UNPKG

@phren/cli

Version:

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

849 lines (848 loc) • 46.7 kB
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 { logger } from "../logger.js"; import { isValidProjectName, errorMessage } from "../utils.js"; import { storeAwareProjectPath } from "../store-routing.js"; import { removeFinding as removeFindingCore, removeFindings as removeFindingsCore, applyFindingTypePrefix, } from "../core/finding.js"; import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, } from "../shared.js"; import { getCurrentActor, getMachineName, storeCommitMessage } from "../machine-identity.js"; import { loadCodePackage } from "../modules/code-package.js"; import { moduleEnabled } from "../modules/runtime.js"; import { addFindingToFile, addFindingsToFile, checkSemanticConflicts, } from "../shared/content.js"; import { jaccardTokenize, jaccardSimilarity, stripMetadata, detectConflicts, extractDynamicEntities } from "../content/dedup.js"; import { scanForSecrets } from "../content/secrets.js"; import { runCustomHooks } from "../hooks.js"; import { incrementSessionFindings } from "./session.js"; import { extractFragmentNames } from "../shared/fragment-graph.js"; import { extractFactFromFinding } from "./extract-facts.js"; import { appendChildFinding, editFinding as editFindingCore, readFindings, FINDINGS_FILENAME } from "../data/access.js"; import { getActiveTaskForSession } from "../task/lifecycle.js"; import { isInactiveFindingLine, supersedeFinding, retractFinding as retractFindingLifecycle, resolveFindingContradiction, } from "../finding/lifecycle.js"; import { permissionDeniedError } from "../governance/rbac.js"; import { TEAM_STORE_PATHSPECS } from "../cli/session-git.js"; import { withFileLock } from "../governance/locks.js"; import { runtimeFile } from "../phren-paths.js"; import { mergeStoreUpstream } from "../sync/store-merge.js"; const JACCARD_MAYBE_LOW = 0.25; const JACCARD_MAYBE_HIGH = 0.40; // above this isDuplicateFinding already catches it function findJaccardCandidates(phrenPath, project, finding) { try { const findingsPath = path.join(phrenPath, project, FINDINGS_FILENAME); if (!fs.existsSync(findingsPath)) return []; const content = fs.readFileSync(findingsPath, "utf8"); const newClean = stripMetadata(finding).trim(); const newTokens = jaccardTokenize(newClean); if (newTokens.size < 3) return []; const candidates = []; for (const line of content.split("\n")) { if (!line.startsWith("- ") || isInactiveFindingLine(line)) continue; const existingClean = stripMetadata(line).replace(/^-\s+/, "").trim(); const existingTokens = jaccardTokenize(existingClean); if (existingTokens.size < 3) continue; const sim = jaccardSimilarity(newTokens, existingTokens); if (sim >= JACCARD_MAYBE_LOW && sim < JACCARD_MAYBE_HIGH) { candidates.push({ existing: existingClean, similarity: Math.round(sim * 100) / 100 }); } } return candidates; } catch { return []; } } // Heuristic conflict candidates surfaced to the calling agent so it can decide whether a // genuine contradiction exists — no extra LLM/API call, the agent in the loop is the judge. // Mirrors findJaccardCandidates (potential duplicates). Capped to keep the response small. const MAX_CONFLICT_CANDIDATES = 5; export function findConflictCandidates(phrenPath, project, finding) { try { const findingsPath = path.join(phrenPath, project, FINDINGS_FILENAME); if (!fs.existsSync(findingsPath)) return []; const content = fs.readFileSync(findingsPath, "utf8"); const activeBullets = content.split("\n").filter((l) => l.startsWith("- ") && !isInactiveFindingLine(l)); if (activeBullets.length === 0) return []; const dynamicEntities = extractDynamicEntities(phrenPath, project); return detectConflicts(finding, activeBullets, dynamicEntities) .map((line) => stripMetadata(line).replace(/^-\s+/, "").trim().slice(0, 80)) .filter((snippet) => snippet.length > 0) .slice(0, MAX_CONFLICT_CANDIDATES); } catch (err) { logger.debug("add_finding", `findConflictCandidates: ${errorMessage(err)}`); return []; } } function extractConflictsWith(annotations) { return annotations .map((annotation) => annotation.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1]) .filter((value) => Boolean(value)); } function matchesFindingTextSelector(finding, selector) { const query = selector.trim().toLowerCase(); if (!query) return true; const id = finding.id.toLowerCase(); const stableId = finding.stableId?.toLowerCase(); const text = finding.text.toLowerCase(); if (query.startsWith("fid:")) { const normalizedFid = query.slice(4).trim(); if (!normalizedFid) return false; return stableId === normalizedFid || id === query || id === normalizedFid; } return id === query || stableId === query || text === query || text.includes(query); } /** Shared boilerplate for lifecycle mutation tools: validate project → call core fn → update index → map response. */ function withLifecycleMutation(phrenPath, project, writeQueue, updateIndex, handler, mapResponse) { if (!isValidProjectName(project)) return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); return writeQueue(async () => { const result = handler(); if (!result.ok) return mcpResponse({ ok: false, error: result.error }); const resolvedFindingsDir = storeAwareProjectPath(phrenPath, project); if (resolvedFindingsDir) updateIndex(path.join(resolvedFindingsDir, FINDINGS_FILENAME)); const mapped = mapResponse(result.data); return mcpResponse({ ok: true, message: mapped.message, data: mapped.data }); }); } // ── Handlers ───────────────────────────────────────────────────────────────── export async function handleAddFinding(ctx, params) { const { finding, sessionId, findingType, scope } = params; // `name` is the citation's code link (a function, type or variable); // `symbol` is its deprecated spelling, accepted until 0.3.1. The stored // citation keeps its existing key. const { name: citedName, ...citationRest } = params.citation ?? {}; const citation = params.citation ? { ...citationRest, ...(citedName ?? citationRest.symbol ? { symbol: citedName ?? citationRest.symbol } : {}) } : undefined; // Resolve store-qualified project names (e.g., "team/forge" → store path + "forge") let phrenPath; let project; try { const resolved = resolveStoreForProject(ctx, params.project); phrenPath = resolved.phrenPath; project = resolved.project; } catch (err) { return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); } const { withWriteQueue, updateFileInIndex } = ctx; if (!isValidProjectName(project)) return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); const addFindingDenied = permissionDeniedError(phrenPath, "add_finding", project); if (addFindingDenied) return mcpResponse({ ok: false, error: addFindingDenied }); const provenance = { source: "human", machine: getMachineName(), actor: getCurrentActor(), session_id: sessionId, ...(params.tool ? { tool: params.tool } : {}), }; const normalizedScope = normalizeMemoryScope(scope ?? "shared"); if (!normalizedScope) return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` }); // Size caps apply to every store; they used to sit after the team branch. const findingList = Array.isArray(finding) ? finding : [finding]; if (findingList.length > 100) return mcpResponse({ ok: false, error: "Bulk add limited to 100 findings per call." }); if (findingList.some((f) => f.length > 5000)) { return mcpResponse({ ok: false, error: Array.isArray(finding) ? "One or more findings exceed 5000 character limit." : "Finding text exceeds 5000 character limit.", }); } // Team stores: use append-only journal (no FINDINGS.md mutation, no merge conflicts) { const storeResolved = resolveStoreForProject(ctx, params.project); if (storeResolved.storeRole === "team") { const { appendTeamJournal } = await import("../finding/journal.js"); // The journal is pushed to a repo shared with colleagues, so it gets the // same secret scan the personal store applies. const rejected = []; const accepted = []; for (const f of findingList) { const secretType = scanForSecrets(f); if (secretType) rejected.push({ finding: f.slice(0, 60), reason: `Contains ${secretType}` }); else accepted.push(applyFindingTypePrefix(f, findingType)); } return withWriteQueue(async () => { const added = []; const journals = new Set(); for (const taggedFinding of accepted) { const result = appendTeamJournal(phrenPath, project, taggedFinding, provenance.actor, provenance.machine); if (result.ok) { added.push(taggedFinding); journals.add(path.join(phrenPath, project, "journal", result.data)); } else { rejected.push({ finding: taggedFinding.slice(0, 60), reason: result.error }); } } for (const journalPath of journals) updateFileInIndex(journalPath); if (added.length === 0) { return mcpResponse({ ok: false, error: `No findings written to ${params.project} journal: ${rejected.map((r) => `"${r.finding}" ${r.reason}`).join("; ")}`, data: { project: params.project, rejected, journalMode: true }, }); } return mcpResponse({ ok: true, message: `Added ${added.length} finding(s) to ${params.project} journal${rejected.length > 0 ? `, ${rejected.length} rejected` : ""}`, data: { project: params.project, added, journalMode: true, ...(rejected.length > 0 ? { rejected } : {}) }, }); }); } } if (Array.isArray(finding)) { const findings = finding; return withWriteQueue(async () => { runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project }); const allPotentialDuplicates = []; const allPotentialConflicts = []; const extraAnnotationsByFinding = []; for (const f of findings) { const candidates = findJaccardCandidates(phrenPath, project, f); if (candidates.length > 0) allPotentialDuplicates.push({ finding: f, candidates }); const conflictCandidates = findConflictCandidates(phrenPath, project, f); if (conflictCandidates.length > 0) allPotentialConflicts.push({ finding: f, candidates: conflictCandidates }); try { const conflicts = await checkSemanticConflicts(phrenPath, project, f); extraAnnotationsByFinding.push(conflicts.checked && conflicts.annotations.length > 0 ? conflicts.annotations : []); } catch (err) { logger.debug("add_finding", `bulk semanticConflict: ${errorMessage(err)}`); extraAnnotationsByFinding.push([]); } } const result = addFindingsToFile(phrenPath, project, findings, { extraAnnotationsByFinding, sessionId, scope: normalizedScope, provenance, }); if (!result.ok) return mcpResponse({ ok: false, error: result.error }); const { added, skipped, rejected } = result.data; if (added.length > 0) { runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project }); incrementSessionFindings(phrenPath, added.length, sessionId, project); updateFileInIndex(path.join(phrenPath, project, FINDINGS_FILENAME)); } const rejectedMsg = rejected.length > 0 ? `, ${rejected.length} rejected` : ""; return mcpResponse({ ok: true, message: `Added ${added.length}/${findings.length} findings (${skipped.length} duplicates skipped${rejectedMsg})`, data: { project, added, skipped, rejected, ...(allPotentialDuplicates.length > 0 ? { potentialDuplicates: allPotentialDuplicates } : {}), ...(allPotentialConflicts.length > 0 ? { potentialConflicts: allPotentialConflicts } : {}), }, }); }); } return withWriteQueue(async () => { try { const taggedFinding = applyFindingTypePrefix(finding, findingType); // Memory link: link the finding to the one function, type or variable it // names, or validate an explicit citation `name`. Never rewrites the // finding text; only the citation comment gains the link. const code = moduleEnabled(phrenPath, "code") ? await loadCodePackage(phrenPath) : undefined; const symbolCitation = code ? await code.symbolCitationForFinding(phrenPath, project, taggedFinding, citation?.symbol) : citation?.symbol ? { symbol: citation.symbol } : {}; const citationForWrite = (citation || symbolCitation.symbol) ? { ...(citation ?? {}), ...symbolCitation } : undefined; // Jaccard "maybe zone" scan — free, no LLM call. Return candidates so the agent decides. const potentialDuplicates = findJaccardCandidates(phrenPath, project, taggedFinding); // Heuristic contradiction candidates — also free, also agent-decides. No extra API call. const potentialConflicts = findConflictCandidates(phrenPath, project, taggedFinding); const semanticConflicts = await checkSemanticConflicts(phrenPath, project, taggedFinding); runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project }); const result = addFindingToFile(phrenPath, project, taggedFinding, citationForWrite, { sessionId, scope: normalizedScope, extraAnnotations: semanticConflicts.checked ? semanticConflicts.annotations : undefined, provenance, }); if (!result.ok) { return mcpResponse({ ok: false, error: result.error }); } if (result.data.status === "skipped") { return mcpResponse({ ok: true, message: result.data.message, data: { project, finding: taggedFinding, status: "skipped" } }); } updateFileInIndex(path.join(phrenPath, project, FINDINGS_FILENAME)); if (result.data.status === "added" || result.data.status === "created") { runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project }); incrementSessionFindings(phrenPath, 1, sessionId, project); extractFactFromFinding(phrenPath, project, taggedFinding); // Bidirectional link: if there's an active task in this session, append this finding to it. if (sessionId) { const activeTask = getActiveTaskForSession(phrenPath, sessionId, project); if (activeTask) { const taskMatch = activeTask.stableId ? `bid:${activeTask.stableId}` : activeTask.line; // Extract fid from the last written line in FINDINGS.md try { const findingsPath = path.join(phrenPath, project, FINDINGS_FILENAME); const findingsContent = fs.readFileSync(findingsPath, "utf8"); const lines = findingsContent.split("\n"); const taggedText = taggedFinding.replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase(); for (let li = lines.length - 1; li >= 0; li--) { const l = lines[li]; if (!l.startsWith("- ")) continue; const lineText = l.replace(/<!--.*?-->/g, "").replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase(); if (lineText === taggedText || l.toLowerCase().includes(taggedText.slice(0, 30))) { const fidMatch = l.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/); if (fidMatch) { appendChildFinding(phrenPath, project, taskMatch, `fid:${fidMatch[1]}`); } break; } } } catch { // Non-fatal: task-finding linkage is best-effort } } } } const conflictsWithList = semanticConflicts.checked ? extractConflictsWith(semanticConflicts.annotations) : (result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1] ? [result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)[1]] : []); const conflictsWith = conflictsWithList[0]; // Extract fragment hints synchronously from the finding text (regex only, no DB). // Full DB fragment linking happens on the next index rebuild via updateFileInIndex → // extractAndLinkFragments. We surface hints here so callers can see what was detected. const detectedFragments = extractFragmentNames(taggedFinding); // Surface heuristic conflict candidates for the agent to judge (only when the LLM path // didn't already confirm a conflict, to avoid double-reporting the same relationship). const conflictNote = (potentialConflicts.length > 0 && conflictsWithList.length === 0) ? ` ⚠ Possible contradiction with existing finding(s): ${potentialConflicts.map((c) => `"${c}"`).join("; ")}.` + ` If this genuinely contradicts one, resolve it with resolve_contradiction (or supersede_finding); if they're unrelated, ignore this note.` : ""; return mcpResponse({ ok: true, message: result.data.message + conflictNote, data: { project, finding: taggedFinding, status: result.data.status, ...(conflictsWith ? { conflictsWith } : {}), ...(conflictsWithList.length > 0 ? { conflicts: conflictsWithList } : {}), ...(detectedFragments.length > 0 ? { detectedFragments } : {}), ...(potentialDuplicates.length > 0 ? { potentialDuplicates } : {}), ...(potentialConflicts.length > 0 ? { potentialConflicts } : {}), scope: normalizedScope, } }); } catch (err) { if (err instanceof Error && err.message.includes("Rejected:")) { return mcpResponse({ ok: false, error: errorMessage(err), errorCode: "VALIDATION_ERROR" }); } return mcpResponse({ ok: false, error: `Unexpected error saving finding: ${errorMessage(err)}` }); } }); } async function handleSupersedeFinding(ctx, { project: projectInput, finding_text, superseded_by }) { let phrenPath; let project; try { const resolved = resolveStoreForProject(ctx, projectInput); phrenPath = resolved.phrenPath; project = resolved.project; } catch (err) { return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); } const { withWriteQueue, updateFileInIndex } = ctx; return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => supersedeFinding(phrenPath, project, finding_text, superseded_by), (data) => ({ message: `Marked finding as superseded in ${project}.`, data: { project, finding: data.finding, status: data.status, superseded_by: data.superseded_by }, })); } async function handleRetractFinding(ctx, { project: projectInput, finding_text, reason }) { let phrenPath; let project; try { const resolved = resolveStoreForProject(ctx, projectInput); phrenPath = resolved.phrenPath; project = resolved.project; } catch (err) { return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); } const { withWriteQueue, updateFileInIndex } = ctx; return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => retractFindingLifecycle(phrenPath, project, finding_text, reason), (data) => ({ message: `Retracted finding in ${project}.`, data: { project, finding: data.finding, status: data.status, reason: data.reason }, })); } async function handleResolveContradiction(ctx, { project: projectInput, finding_text, finding_text_other, finding_a, finding_b, resolution }) { let phrenPath; let project; try { const resolved = resolveStoreForProject(ctx, projectInput); phrenPath = resolved.phrenPath; project = resolved.project; } catch (err) { return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); } const { withWriteQueue, updateFileInIndex } = ctx; const findingText = (finding_text ?? finding_a)?.trim(); const findingTextOther = (finding_text_other ?? finding_b)?.trim(); if (!findingText || !findingTextOther) { return mcpResponse({ ok: false, error: "Both finding_text and finding_text_other are required.", }); } return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => resolveFindingContradiction(phrenPath, project, findingText, findingTextOther, resolution), (data) => ({ message: `Resolved contradiction in ${project} with "${resolution}".`, data: { project, resolution: data.resolution, finding_text: data.finding_a, finding_text_other: data.finding_b, finding_a: data.finding_a, finding_b: data.finding_b, }, })); } async function handleGetContradictions(ctx, { project, finding_text }) { if (project && !isValidProjectName(project)) return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); // Build project list from all stores (primary + team) let projectsWithPaths; if (project) { const resolved = resolveStoreForProject(ctx, project, "read"); projectsWithPaths = [{ project: resolved.project, storePath: resolved.phrenPath }]; } else { const { listAllProjects } = await import("../store-routing.js"); projectsWithPaths = listAllProjects(ctx.phrenPath, ctx.profile) .map((p) => ({ project: p.projectName, storePath: p.store.path })); } const contradictions = []; for (const { project: p, storePath } of projectsWithPaths) { const result = readFindings(storePath, p); if (!result.ok) continue; for (const finding of result.data) { if (finding.status !== "contradicted") continue; if (finding_text && !matchesFindingTextSelector(finding, finding_text)) continue; contradictions.push({ project: p, id: finding.id, stableId: finding.stableId, text: finding.text, date: finding.date, status_updated: finding.status_updated, status_reason: finding.status_reason, status_ref: finding.status_ref, }); } } return mcpResponse({ ok: true, message: contradictions.length ? `Found ${contradictions.length} unresolved contradiction${contradictions.length === 1 ? "" : "s"}.` : "No unresolved contradictions found.", data: { project: project ?? null, finding_text: finding_text ?? null, contradictions, }, }); } async function handleEditFinding(ctx, { project: projectInput, old_text, new_text }) { let phrenPath; let project; try { const resolved = resolveStoreForProject(ctx, projectInput); phrenPath = resolved.phrenPath; project = resolved.project; } catch (err) { return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); } const { withWriteQueue, updateFileInIndex } = ctx; if (!isValidProjectName(project)) return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); const editDenied = permissionDeniedError(phrenPath, "edit_finding", project); if (editDenied) return mcpResponse({ ok: false, error: editDenied }); return withWriteQueue(async () => { const result = editFindingCore(phrenPath, project, old_text, new_text); if (!result.ok) return mcpResponse({ ok: false, error: result.error }); const resolvedFindingsDir = storeAwareProjectPath(phrenPath, project); if (resolvedFindingsDir) updateFileInIndex(path.join(resolvedFindingsDir, FINDINGS_FILENAME)); return mcpResponse({ ok: true, message: result.data, data: { project, old_text, new_text }, }); }); } async function handleRemoveFinding(ctx, { project: projectInput, finding }) { let phrenPath; let project; try { const resolved = resolveStoreForProject(ctx, projectInput); phrenPath = resolved.phrenPath; project = resolved.project; } catch (err) { return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }); } const { withWriteQueue, updateFileInIndex } = ctx; if (!isValidProjectName(project)) return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); const removeDenied = permissionDeniedError(phrenPath, "remove_finding", project); if (removeDenied) return mcpResponse({ ok: false, error: removeDenied }); if (Array.isArray(finding)) { return withWriteQueue(async () => { const result = removeFindingsCore(phrenPath, project, finding); if (!result.ok) return mcpResponse({ ok: false, error: result.message }); const resolvedFindingsDir = storeAwareProjectPath(phrenPath, project); if (resolvedFindingsDir) updateFileInIndex(path.join(resolvedFindingsDir, FINDINGS_FILENAME)); return mcpResponse({ ok: true, message: result.message, data: result.data }); }); } return withWriteQueue(async () => { const result = removeFindingCore(phrenPath, project, finding); if (!result.ok) return mcpResponse({ ok: false, error: result.message }); const resolvedFindingsDir = storeAwareProjectPath(phrenPath, project); if (resolvedFindingsDir) updateFileInIndex(path.join(resolvedFindingsDir, FINDINGS_FILENAME)); return mcpResponse({ ok: true, message: result.message, data: result.data }); }); } /** * Commit and push the team-safe pathspecs of every attached team store. Team * stores are independent repos, so push_changes runs this whatever happens to * the primary store. */ export async function syncTeamStores(phrenPath) { const teamResults = []; try { const { execFileSync } = await import("child_process"); const { getNonPrimaryStores } = await import("../store-registry.js"); const teamStores = getNonPrimaryStores(phrenPath).filter((s) => s.role === "team"); for (const store of teamStores) { if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git"))) continue; const runStoreGit = (args, opts = {}) => execFileSync("git", args, { cwd: store.path, encoding: "utf8", timeout: opts.timeout ?? EXEC_TIMEOUT_MS, env: nonInteractiveGitEnv(opts.env), stdio: ["ignore", "pipe", "pipe"], }).trim(); const mergeStoreGit = async (_cwd, gitArgs) => { try { return { ok: true, output: runStoreGit(gitArgs) }; } catch (err) { return { ok: false, output: "", error: errorMessage(err) }; } }; try { if (runStoreGit(["status", "--porcelain"])) { // Stage each team-safe pathspec individually — a single no-match // (e.g. no */truths.md in this store) used to abort the whole add. for (const spec of TEAM_STORE_PATHSPECS) { try { runStoreGit(["add", "--sparse", "--", spec]); } catch { /* best-effort */ } } if (runStoreGit(["diff", "--cached", "--name-only"])) { const actor = process.env.PHREN_ACTOR || process.env.USER || "unknown"; runStoreGit(["commit", "-m", storeCommitMessage(`phren: ${actor} team sync`)]); } } // A clean tree can still hold commits the remote lacks: the session-start // pull commits local writes before merging, and nothing else pushed them. let ahead = 1; try { ahead = Number(runStoreGit(["rev-list", "--count", "@{u}..HEAD"])); } catch { /* no upstream: let push decide */ } if (ahead === 0) continue; try { runStoreGit(["push"], { timeout: 15000 }); teamResults.push({ store: store.name, pushed: true }); } catch { try { const merged = await mergeStoreUpstream(store.path, { git: mergeStoreGit, commitLocalWrites: false }); if (merged.status !== "updated" && merged.status !== "unchanged") throw new Error(merged.detail); runStoreGit(["push"], { timeout: 15000 }); teamResults.push({ store: store.name, pushed: true }); } catch (retryErr) { teamResults.push({ store: store.name, pushed: false, error: errorMessage(retryErr) }); } } } catch (storeErr) { teamResults.push({ store: store.name, pushed: false, error: errorMessage(storeErr) }); } } } catch (err) { debugLog(`syncTeamStores: ${errorMessage(err)}`); } return teamResults; } /** Team-store outcomes appended to a push_changes message, so a failure is visible. */ function describeTeamSync(results) { if (results.length === 0) return ""; const pushed = results.filter((r) => r.pushed).map((r) => r.store); const parts = pushed.length > 0 ? [`Pushed ${pushed.length} team store(s): ${pushed.join(", ")}.`] : []; for (const failure of results.filter((r) => !r.pushed)) parts.push(`Team store "${failure.store}" failed: ${failure.error ?? "unknown error"}.`); return ` ${parts.join(" ")}`; } async function handlePushChanges(ctx, { message }) { const { phrenPath, withWriteQueue } = ctx; return withWriteQueue(async () => withFileLock(runtimeFile(phrenPath, "git-op"), async () => { const { execFileSync } = await import("child_process"); const runGit = (args, opts = {}) => execFileSync("git", args, { cwd: phrenPath, encoding: "utf8", timeout: opts.timeout ?? EXEC_TIMEOUT_MS, env: nonInteractiveGitEnv(opts.env), stdio: ["ignore", "pipe", "pipe"], }).trim(); const mergeGit = async (_cwd, args) => { try { return { ok: true, output: runGit(args) }; } catch (err) { return { ok: false, output: "", error: errorMessage(err) }; } }; const teamResults = await syncTeamStores(phrenPath); const teamSuffix = describeTeamSync(teamResults); const teamData = teamResults.length > 0 ? { teamStores: teamResults } : {}; try { const status = runGit(["status", "--porcelain"]); if (!status) return mcpResponse({ ok: true, message: `Nothing to save. Phren is up to date.${teamSuffix}`, data: { files: 0, pushed: false, ...teamData } }); const files = status.split("\n").filter(Boolean); const projectNames = Array.from(new Set(files .map((line) => line.slice(3).trim().split("/")[0]) .filter((name) => name && !name.startsWith(".") && name !== "profiles"))); const commitMsg = message || `phren: save ${files.length} file(s) across ${projectNames.length} project(s)`; runCustomHooks(phrenPath, "pre-save"); // Stage all files including untracked (new project dirs, first FINDINGS.md, etc.) runGit(["add", "--sparse", "-A"]); // Belt-and-suspenders: unstage sensitive files that .gitignore should // already block (mirrors the same defensive reset in cli/session-stop.ts's // auto-save). .config/auth-profiles.json is the legacy path for // auth/profiles.ts's credential store (API keys, OpenAI Codex OAuth // tokens) — this catches a store where the file was already tracked // before it was gitignored, so a later token refresh doesn't get // re-staged and pushed. Failures here are non-fatal (files may not exist). try { runGit(["reset", "HEAD", "--", ".env", "**/.env", "*.pem", "*.key", ".config/auth-profiles.json"]); } catch { /* best effort */ } runGit(["commit", "-m", storeCommitMessage(commitMsg)]); let hasRemote = false; try { const remotes = runGit(["remote"]); hasRemote = remotes.length > 0; } catch (err) { logger.warn("push_changes", `remoteCheck: ${errorMessage(err)}`); } if (!hasRemote) { const changedFiles = status.split("\n").filter(Boolean).length; return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). No remote configured, skipping push.${teamSuffix}`, data: { files: changedFiles, pushed: false, ...teamData } }); } let pushed = false; let lastPushError = ""; const delays = [2000, 4000, 8000]; for (let attempt = 0; attempt <= 3; attempt++) { try { runGit(["push"], { timeout: 15000 }); pushed = true; break; } catch (pushErr) { lastPushError = pushErr instanceof Error ? pushErr.message : String(pushErr); debugLog(`Push attempt ${attempt + 1} failed: ${lastPushError}`); if (attempt < 3) { const merged = await mergeStoreUpstream(phrenPath, { git: mergeGit, commitLocalWrites: false }); if (merged.status !== "updated" && merged.status !== "unchanged") { logger.warn("push_changes", `mergeRemote: ${merged.detail}`); break; } await new Promise(r => setTimeout(r, delays[attempt])); } } } const changedFiles = status.split("\n").filter(Boolean).length; runCustomHooks(phrenPath, "post-save", { PHREN_FILES_CHANGED: String(changedFiles), PHREN_PUSHED: String(pushed) }); if (pushed) { return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). Pushed to remote.${teamSuffix}`, data: { files: changedFiles, pushed: true, ...teamData } }); } else { return mcpResponse({ ok: true, message: `Changes were committed but push failed.\n\nGit error: ${lastPushError}\n\nRun 'git push' manually from your phren directory.${teamSuffix}`, data: { files: changedFiles, pushed: false, pushError: lastPushError, ...teamData }, }); } } catch (err) { return mcpResponse({ ok: false, error: `Save failed: ${errorMessage(err)}${teamSuffix}`, errorCode: "INTERNAL_ERROR" }); } })); } // ── Registration ───────────────────────────────────────────────────────────── export function register(server, ctx) { server.registerTool("add_finding", { title: "◆ phren · save finding", description: "Tell phren one or more insights for a project's FINDINGS.md. Call this the moment you discover " + "a non-obvious pattern, hit a subtle bug, find a workaround, or learn something that would " + "save time in a future session. Do not wait until the end of the session." + " Pass a single string or an array of strings." + " Optionally classify with findingType: decision, pitfall, pattern, tradeoff, architecture, or bug.", inputSchema: z.object({ project: z.string().describe("Project name (must match a directory in your phren store)."), finding: z.preprocess((v) => (typeof v === "string" ? [v] : v), z.array(z.string()).min(1)).describe("The insight(s) to save. Always pass as an array of strings — use a single-element array for one finding."), citation: z.object({ file: z.string().optional().describe("Source file path that supports this finding."), line: z.number().int().positive().optional().describe("1-based line number in file."), repo: z.string().optional().describe("Git repository root path for citation validation."), commit: z.string().optional().describe("Git commit SHA that supports this finding."), name: z.string().optional().describe("The function, type or variable this finding is about: Name, Type.member or name(). Validated against the project's code index; stored unresolved when it does not resolve."), symbol: z.string().optional().describe("Deprecated: use name. Accepted until @phren/cli 0.3.1."), supersedes: z.string().optional().describe("First 60 chars of the old finding this one replaces. The old entry will be marked as superseded."), task_item: z.string().optional().describe("Task item stable ID like bid:abcd1234, positional ID like A1, or item text to link this finding to."), }).optional().describe("Optional source citation for traceability (only used when finding is a single string)."), sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this if you want session metrics to include this write."), findingType: z.enum(FINDING_TYPES) .optional() .describe("Classify this finding: 'decision' (architectural choice with rationale), 'pitfall' (bug or failure mode to avoid), 'pattern' (reusable approach that works well), 'bug' (confirmed defect or failure)."), scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."), }), }, (params) => handleAddFinding(ctx, params)); server.registerTool("supersede_finding", { title: "◆ phren · supersede finding", description: "Mark an existing finding as superseded and link it to the newer finding text.", inputSchema: z.object({ project: z.string().describe("Project name."), finding_text: z.string().describe("Finding to supersede (supports fid, exact text, or partial match)."), superseded_by: z.string().describe("Text of the new finding that supersedes this one."), }), }, (params) => handleSupersedeFinding(ctx, params)); server.registerTool("retract_finding", { title: "◆ phren · retract finding", description: "Mark an existing finding as retracted and store the reason in lifecycle metadata.", inputSchema: z.object({ project: z.string().describe("Project name."), finding_text: z.string().describe("Finding to retract (supports fid, exact text, or partial match)."), reason: z.string().describe("Reason for retraction."), }), }, (params) => handleRetractFinding(ctx, params)); server.registerTool("resolve_contradiction", { title: "◆ phren · resolve contradiction", description: "Resolve a contradiction between two findings and update lifecycle status based on the chosen resolution.", inputSchema: z.object({ project: z.string().describe("Project name."), finding_text: z.string().optional().describe("First finding (supports fid, exact text, or partial match)."), finding_text_other: z.string().optional().describe("Second finding (supports fid, exact text, or partial match)."), finding_a: z.string().optional().describe("Deprecated alias for finding_text."), finding_b: z.string().optional().describe("Deprecated alias for finding_text_other."), resolution: z.enum(["keep_a", "keep_b", "keep_both", "retract_both"]).describe("Resolution strategy."), }).superRefine((value, zodCtx) => { if (!(value.finding_text ?? value.finding_a)) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, path: ["finding_text"], message: "finding_text is required.", }); } if (!(value.finding_text_other ?? value.finding_b)) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, path: ["finding_text_other"], message: "finding_text_other is required.", }); } }), }, (params) => handleResolveContradiction(ctx, params)); server.registerTool("get_contradictions", { title: "◆ phren · contradictions", description: "List unresolved contradictions (findings currently marked with status contradicted).", inputSchema: z.object({ project: z.string().optional().describe("Optional project filter. When omitted, scans all projects."), finding_text: z.string().optional().describe("Optional finding selector (supports fid, exact text, or partial match)."), }), }, (params) => handleGetContradictions(ctx, params)); server.registerTool("edit_finding", { title: "◆ phren · edit finding", description: "Edit a finding in place while preserving its metadata and history.", inputSchema: z.object({ project: z.string().describe("Project name."), old_text: z.string().describe("Existing finding text to match."), new_text: z.string().describe("Replacement finding text."), }), }, (params) => handleEditFinding(ctx, params)); server.registerTool("remove_finding", { title: "◆ phren · remove finding", description: "Remove one or more findings from a project's FINDINGS.md by matching text. Use this when a " + "previously captured insight turns out to be wrong, outdated, or no longer relevant." + " Pass a single string or an array of strings.", inputSchema: z.object({ project: z.string().describe("Project name."), finding: z.preprocess((v) => (typeof v === "string" ? [v] : v), z.array(z.string()).min(1)).describe("Text(s) to match and remove. Always pass as an array of strings — use a single-element array for one."), }), }, (params) => handleRemoveFinding(ctx, params)); server.registerTool("push_changes", { title: "◆ phren · push", description: "Commit and push any changes in the phren store. Call this at the end of a session " + "or after adding multiple findings/tasks items. Commits all modified files in the " + "phren directory and pushes if a remote is configured.", inputSchema: z.object({ message: z.string().optional().describe("Commit message. Defaults to 'update phren'."), }), }, (params) => handlePushChanges(ctx, params)); }