UNPKG

@phren/cli

Version:

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

320 lines (319 loc) • 15.9 kB
import * as fs from "fs"; import * as path from "path"; import { PhrenError, phrenErr, phrenOk } from "../phren-core.js"; // Phren lifecycle comment prefix. No backward compat. const LIFECYCLE_PREFIX = "phren"; import { withFileLock } from "../shared/governance.js"; import { isValidProjectName } from "../utils.js"; import { storeAwareProjectPath } from "../store-routing.js"; import { isArchiveEnd, isArchiveStart, parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, normalizeFindingText, } from "../content/metadata.js"; import { FINDINGS_FILENAME } from "../data/access.js"; /** * Decay rule per finding tag. Keyed with `satisfies Record<FindingTag, ...>` * so this object is required to have exactly one row per tag in FINDING_TAGS * (phren-core.ts) — no more, no less. Add a tag there and TypeScript forces a * decay row here; remove one and the corresponding row becomes a compile * error instead of silently-dead data. * * Previously this table had 9 rows against a 6-entry offered enum that * disagreed with it in both directions: `anti-pattern`, `observation`, and * `tooling` had rows here but nothing ever wrote those tags, while * `tradeoff` and `architecture` were offered/writable but had no row here * (so they never decayed and had no max-age). The exported type stays * `Record<string, FindingDecayConfig>` so existing string-keyed lookups * (e.g. content/citation.ts) don't need to change. */ export const FINDING_TYPE_DECAY = { 'pattern': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay, long-lived 'decision': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays 'pitfall': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay 'workaround': { maxAgeDays: 60, decayMultiplier: 0.85 }, // Medium decay 'bug': { maxAgeDays: 30, decayMultiplier: 0.8 }, // Medium-fast decay 'context': { maxAgeDays: 30, decayMultiplier: 0.75 }, // Fast decay (contextual facts) }; export function extractFindingType(line) { const match = line.match(/\[(\w[\w-]*)\]/); if (!match) return null; const tag = match[1].toLowerCase(); return tag in FINDING_TYPE_DECAY ? tag : null; } export const FINDING_LIFECYCLE_STATUSES = [ "active", "superseded", "contradicted", "stale", "invalid_citation", "retracted", ]; function cleanCommentValue(value) { return value.replace(/\s+/g, " ").trim(); } function serializeCommentValue(value) { return cleanCommentValue(value).replace(/"/g, "'"); } function parseCreatedDate(line) { const created = parseCreatedDateMeta(line); return created ? cleanCommentValue(created) : undefined; } function matchField(line, field) { return parseStatusField(line, field); } export function parseFindingLifecycle(line) { const created = parseCreatedDate(line); const normalizedStatus = parseStatus(line); const normalized = { status: normalizedStatus ?? "active", status_updated: matchField(line, "status_updated") ?? created, status_reason: matchField(line, "status_reason"), status_ref: matchField(line, "status_ref"), }; if (normalizedStatus) return normalized; const supersession = parseSupersession(line); if (supersession) { const updated = supersession.date || normalized.status_updated; return { status: "superseded", status_updated: updated ? cleanCommentValue(updated) : undefined, status_reason: normalized.status_reason ?? "superseded_by", status_ref: normalized.status_ref ?? cleanCommentValue(supersession.ref || ""), }; } const contradictionRef = parseContradiction(line); if (contradictionRef) { return { status: "contradicted", status_updated: normalized.status_updated, status_reason: normalized.status_reason ?? "conflicts_with", status_ref: normalized.status_ref ?? cleanCommentValue(contradictionRef), }; } return normalized; } export function buildLifecycleComments(lifecycle, fallbackDate) { const status = lifecycle?.status ?? "active"; const statusUpdated = lifecycle?.status_updated ?? fallbackDate; const parts = [`<!-- ${LIFECYCLE_PREFIX}:status "${status}" -->`]; if (statusUpdated) parts.push(`<!-- ${LIFECYCLE_PREFIX}:status_updated "${serializeCommentValue(statusUpdated)}" -->`); if (lifecycle?.status_reason) parts.push(`<!-- ${LIFECYCLE_PREFIX}:status_reason "${serializeCommentValue(lifecycle.status_reason)}" -->`); if (lifecycle?.status_ref) parts.push(`<!-- ${LIFECYCLE_PREFIX}:status_ref "${serializeCommentValue(lifecycle.status_ref)}" -->`); return parts.join(" "); } export function stripLifecycleComments(line) { return stripLifecycleMetadata(line); } export function isInactiveFindingLine(line) { return parseFindingLifecycle(line).status !== "active"; } function findingTextFromLine(line) { return line .replace(/^-\s+/, "") .replace(/<!--.*?-->/g, "") .trim(); } function removeRelationComments(line) { return stripRelationMetadata(line); } function collectFindingBulletLines(lines) { const bulletLines = []; let inArchiveBlock = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (isArchiveStart(line)) { inArchiveBlock = true; continue; } if (isArchiveEnd(line)) { inArchiveBlock = false; continue; } if (!line.startsWith("- ")) continue; bulletLines.push({ line, index: i, archived: inArchiveBlock }); } return bulletLines; } function selectMatchingFinding(bulletLines, needle, match) { const fidNeedle = needle.replace(/^fid:/, ""); const fidMatches = /^[a-z0-9]{8}$/.test(fidNeedle) ? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`, "i").test(line)) : []; const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle); const partialMatches = bulletLines.filter(({ line }) => { const clean = normalizeFindingText(line); return clean.includes(needle) || line.toLowerCase().includes(needle); }); if (fidMatches.length === 1) return phrenOk(fidMatches[0]); if (exactMatches.length === 1) return phrenOk(exactMatches[0]); if (exactMatches.length > 1) { return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH); } if (partialMatches.length === 1) return phrenOk(partialMatches[0]); if (partialMatches.length > 1) { return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH); } return phrenOk(null); } function applyLifecycle(line, lifecycle, today, opts) { let updated = stripLifecycleComments(removeRelationComments(line)).trimEnd(); if (opts?.supersededBy) { updated += ` <!-- ${LIFECYCLE_PREFIX}:superseded_by "${serializeCommentValue(opts.supersededBy)}" ${today} -->`; } if (opts?.supersedes) { updated += ` <!-- ${LIFECYCLE_PREFIX}:supersedes "${serializeCommentValue(opts.supersedes)}" -->`; } if (opts?.contradicts) { const contradictionRef = serializeCommentValue(opts.contradicts); updated += ` <!-- conflicts_with: "${contradictionRef}" --> <!-- ${LIFECYCLE_PREFIX}:contradicts "${contradictionRef}" -->`; } updated += ` ${buildLifecycleComments(lifecycle, today)}`; return updated; } function matchFinding(lines, match) { const needleRaw = match.trim(); if (!needleRaw) return phrenErr("Finding text cannot be empty.", PhrenError.EMPTY_INPUT); const needle = normalizeFindingText(needleRaw); const bulletLines = collectFindingBulletLines(lines); const activeMatch = selectMatchingFinding(bulletLines.filter(({ archived }) => !archived), needle, match); if (!activeMatch.ok) return activeMatch; const selected = activeMatch.data; if (!selected) { const archivedMatch = selectMatchingFinding(bulletLines.filter(({ archived }) => archived), needle, match); if (!archivedMatch.ok) { return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR); } if (archivedMatch.data) { return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR); } return phrenErr(`No finding matching "${match}".`, PhrenError.NOT_FOUND); } const stableId = parseFindingIdMeta(selected.line); return phrenOk({ index: selected.index, line: selected.line, text: findingTextFromLine(selected.line), stableId, }); } function findingsPathForProject(phrenPath, project) { if (!isValidProjectName(project)) return phrenErr(`Invalid project name: "${project}"`, PhrenError.INVALID_PROJECT_NAME); const projectDir = storeAwareProjectPath(phrenPath, project); if (!projectDir) return phrenErr(`Invalid project name: "${project}"`, PhrenError.INVALID_PROJECT_NAME); if (!fs.existsSync(projectDir)) return phrenErr(`Project "${project}" not found.`, PhrenError.PROJECT_NOT_FOUND); const findingsPath = path.join(projectDir, FINDINGS_FILENAME); if (!fs.existsSync(findingsPath)) return phrenErr(`No FINDINGS.md found for "${project}".`, PhrenError.FILE_NOT_FOUND); return phrenOk(findingsPath); } export function supersedeFinding(phrenPath, project, findingText, supersededBy) { const pathResult = findingsPathForProject(phrenPath, project); if (!pathResult.ok) return pathResult; const findingsPath = pathResult.data; const ref = supersededBy.trim().slice(0, 60); if (!ref) return phrenErr("superseded_by cannot be empty.", PhrenError.EMPTY_INPUT); return withFileLock(findingsPath, () => { const lines = fs.readFileSync(findingsPath, "utf8").split("\n"); const matched = matchFinding(lines, findingText); if (!matched.ok) return matched; const today = new Date().toISOString().slice(0, 10); lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "superseded", status_updated: today, status_reason: "superseded_by", status_ref: ref }, today, { supersededBy: ref }); const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; const tmpPath = findingsPath + ".tmp." + process.pid; fs.writeFileSync(tmpPath, normalized); fs.renameSync(tmpPath, findingsPath); return phrenOk({ finding: matched.data.text, superseded_by: ref, status: "superseded" }); }); } export function retractFinding(phrenPath, project, findingText, reason) { const pathResult = findingsPathForProject(phrenPath, project); if (!pathResult.ok) return pathResult; const findingsPath = pathResult.data; const reasonText = reason.trim(); if (!reasonText) return phrenErr("reason cannot be empty.", PhrenError.EMPTY_INPUT); return withFileLock(findingsPath, () => { const lines = fs.readFileSync(findingsPath, "utf8").split("\n"); const matched = matchFinding(lines, findingText); if (!matched.ok) return matched; const today = new Date().toISOString().slice(0, 10); lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "retracted", status_updated: today, status_reason: reasonText }, today); const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; const tmpPath = findingsPath + ".tmp." + process.pid; fs.writeFileSync(tmpPath, normalized); fs.renameSync(tmpPath, findingsPath); return phrenOk({ finding: matched.data.text, reason: reasonText, status: "retracted" }); }); } export function resolveFindingContradiction(phrenPath, project, findingA, findingB, resolution) { const pathResult = findingsPathForProject(phrenPath, project); if (!pathResult.ok) return pathResult; const findingsPath = pathResult.data; return withFileLock(findingsPath, () => { const lines = fs.readFileSync(findingsPath, "utf8").split("\n"); const matchedA = matchFinding(lines, findingA); if (!matchedA.ok) return matchedA; const matchedB = matchFinding(lines, findingB); if (!matchedB.ok) return matchedB; if (matchedA.data.index === matchedB.data.index) { return phrenErr("finding_a and finding_b refer to the same finding.", PhrenError.VALIDATION_ERROR); } const today = new Date().toISOString().slice(0, 10); const refA = matchedA.data.text.slice(0, 60); const refB = matchedB.data.text.slice(0, 60); let statusA = "active"; let statusB = "active"; if (resolution === "keep_a") { lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_a", status_ref: refB }, today); lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "superseded", status_updated: today, status_reason: "contradiction_resolved_keep_a", status_ref: refA }, today, { supersededBy: refA }); statusA = "active"; statusB = "superseded"; } else if (resolution === "keep_b") { lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "superseded", status_updated: today, status_reason: "contradiction_resolved_keep_b", status_ref: refB }, today, { supersededBy: refB }); lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_b", status_ref: refA }, today); statusA = "superseded"; statusB = "active"; } else if (resolution === "keep_both") { lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_both", status_ref: refB }, today); lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_both", status_ref: refA }, today); statusA = "active"; statusB = "active"; } else { lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "retracted", status_updated: today, status_reason: "contradiction_retracted_both", status_ref: refB }, today); lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "retracted", status_updated: today, status_reason: "contradiction_retracted_both", status_ref: refA }, today); statusA = "retracted"; statusB = "retracted"; } const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; const tmpPath = findingsPath + ".tmp." + process.pid; fs.writeFileSync(tmpPath, normalized); fs.renameSync(tmpPath, findingsPath); return phrenOk({ resolution, finding_a: { text: matchedA.data.text, status: statusA }, finding_b: { text: matchedB.data.text, status: statusB }, }); }); }