@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
320 lines (319 loc) • 15.9 kB
JavaScript
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 },
});
});
}