@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
839 lines (838 loc) • 46.2 kB
JavaScript
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { mcpResponse, resolveStoreForProject } from "./types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { 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 ─────────────────────────────────────────────────────────────────
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/arc" → store path + "arc")
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,
};
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"]))
continue;
// 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"]))
continue;
const actor = process.env.PHREN_ACTOR || process.env.USER || "unknown";
runStoreGit(["commit", "-m", storeCommitMessage(`phren: ${actor} team sync`)]);
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));
}