@phren/cli 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/mcp/dist/cli-hooks-session.js +9 -3
- package/mcp/dist/cli-hooks.js +27 -9
- package/mcp/dist/content-citation.js +24 -3
- package/mcp/dist/content-learning.js +28 -5
- package/mcp/dist/data-access.js +96 -53
- package/mcp/dist/finding-impact.js +23 -0
- package/mcp/dist/finding-lifecycle.js +56 -29
- package/mcp/dist/governance-locks.js +11 -6
- package/mcp/dist/index.js +2 -1
- package/mcp/dist/init-preferences.js +18 -3
- package/mcp/dist/init.js +11 -0
- package/mcp/dist/mcp-config.js +0 -8
- package/mcp/dist/mcp-data.js +22 -3
- package/mcp/dist/mcp-extract.js +1 -0
- package/mcp/dist/mcp-finding.js +1 -1
- package/mcp/dist/mcp-hooks.js +36 -16
- package/mcp/dist/mcp-memory.js +0 -1
- package/mcp/dist/mcp-ops.js +5 -10
- package/mcp/dist/mcp-search.js +7 -1
- package/mcp/dist/mcp-session.js +66 -6
- package/mcp/dist/mcp-skills.js +5 -2
- package/mcp/dist/mcp-tasks.js +7 -4
- package/mcp/dist/memory-ui-assets.js +2 -2
- package/mcp/dist/memory-ui-data.js +7 -7
- package/mcp/dist/memory-ui-graph.js +178 -23
- package/mcp/dist/project-config.js +37 -18
- package/mcp/dist/shared-content.js +1 -1
- package/mcp/dist/shared-index.js +16 -3
- package/mcp/dist/shared-retrieval.js +64 -34
- package/mcp/dist/shared.js +1 -10
- package/mcp/dist/test-global-setup.js +3 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
|
|
11
|
+
Every time you start a new session, your AI agent forgets everything it learned. Phren fixes that — findings, decisions, and patterns persist as markdown in a git repo you control. No database, no hosted service, no vendor lock-in. Works across sessions, projects, and machines.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
---
|
|
@@ -51,8 +51,8 @@ To add a project later, run `phren add` from that directory. To browse what phre
|
|
|
51
51
|
|
|
52
52
|
- [Documentation site](https://alaarab.github.io/phren/)
|
|
53
53
|
- [Whitepaper (PDF)](https://alaarab.github.io/phren/whitepaper.pdf)
|
|
54
|
-
- [Architecture](docs/architecture.md)
|
|
55
|
-
- [Contributing](CONTRIBUTING.md)
|
|
54
|
+
- [Architecture](docs/architecture.md) — how retrieval, governance, and persistence fit together
|
|
55
|
+
- [Contributing](CONTRIBUTING.md) — how to add tools, skills, and tests
|
|
56
56
|
- [Security](SECURITY.md)
|
|
57
57
|
|
|
58
58
|
---
|
|
@@ -867,9 +867,15 @@ export async function handleHookStop() {
|
|
|
867
867
|
appendAuditLog(phrenPath, "hook_stop", "status=clean");
|
|
868
868
|
return;
|
|
869
869
|
}
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
|
|
870
|
+
// Stage all changes first, then unstage any sensitive files that slipped
|
|
871
|
+
// through. Using pathspec exclusions with `git add -A` can fail when
|
|
872
|
+
// excluded paths are also gitignored (git treats the pathspec as an error).
|
|
873
|
+
let add = await runBestEffortGit(["add", "-A"], phrenPath);
|
|
874
|
+
if (add.ok) {
|
|
875
|
+
// Belt-and-suspenders: unstage sensitive files that .gitignore should
|
|
876
|
+
// already block. Failures here are non-fatal (files may not exist).
|
|
877
|
+
await runBestEffortGit(["reset", "HEAD", "--", ".env", "**/.env", "*.pem", "*.key"], phrenPath);
|
|
878
|
+
}
|
|
873
879
|
let commitMsg = "auto-save phren";
|
|
874
880
|
if (add.ok) {
|
|
875
881
|
const diff = await runBestEffortGit(["diff", "--cached", "--stat", "--no-color"], phrenPath);
|
package/mcp/dist/cli-hooks.js
CHANGED
|
@@ -150,6 +150,26 @@ export async function handleHookPrompt() {
|
|
|
150
150
|
if (!keywords)
|
|
151
151
|
process.exit(0);
|
|
152
152
|
debugLog(`hook-prompt keywords: "${keywords}"`);
|
|
153
|
+
// Session momentum: track topic frequencies within the session
|
|
154
|
+
let hotTopics = [];
|
|
155
|
+
if (sessionId) {
|
|
156
|
+
const topicFile = sessionMarker(getPhrenPath(), `topics-${sessionId}.json`);
|
|
157
|
+
let sessionTopics = {};
|
|
158
|
+
try {
|
|
159
|
+
if (fs.existsSync(topicFile)) {
|
|
160
|
+
sessionTopics = JSON.parse(fs.readFileSync(topicFile, 'utf8'));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch { /* ignore parse errors */ }
|
|
164
|
+
for (const kw of keywordEntries) {
|
|
165
|
+
sessionTopics[kw] = (sessionTopics[kw] ?? 0) + 1;
|
|
166
|
+
}
|
|
167
|
+
fs.writeFileSync(topicFile, JSON.stringify(sessionTopics));
|
|
168
|
+
// Find hot topics (3+ mentions this session)
|
|
169
|
+
hotTopics = Object.entries(sessionTopics)
|
|
170
|
+
.filter(([, count]) => count >= 3)
|
|
171
|
+
.map(([topic]) => topic);
|
|
172
|
+
}
|
|
153
173
|
const tIndex0 = Date.now();
|
|
154
174
|
const db = await buildIndex(getPhrenPath(), profile);
|
|
155
175
|
stage.indexMs = Date.now() - tIndex0;
|
|
@@ -197,20 +217,18 @@ export async function handleHookPrompt() {
|
|
|
197
217
|
stage.rankMs = Date.now() - tRank0;
|
|
198
218
|
if (!rows.length)
|
|
199
219
|
process.exit(0);
|
|
200
|
-
|
|
220
|
+
let safeTokenBudget = clampInt(process.env.PHREN_CONTEXT_TOKEN_BUDGET, 550, 180, 10000);
|
|
201
221
|
const safeLineBudget = clampInt(process.env.PHREN_CONTEXT_SNIPPET_LINES, 6, 2, 100);
|
|
202
222
|
const safeCharBudget = clampInt(process.env.PHREN_CONTEXT_SNIPPET_CHARS, 520, 120, 10000);
|
|
223
|
+
// Session momentum: boost token budget for hot topics
|
|
224
|
+
if (hotTopics.length > 0) {
|
|
225
|
+
safeTokenBudget = Math.min(Math.floor(safeTokenBudget * 1.3), parseInt(process.env.PHREN_MAX_INJECT_TOKENS ?? '2000', 10));
|
|
226
|
+
}
|
|
203
227
|
const tSelect0 = Date.now();
|
|
204
228
|
const { selected, usedTokens } = selectSnippets(rows, keywords, safeTokenBudget, safeLineBudget, safeCharBudget);
|
|
205
229
|
stage.selectMs = Date.now() - tSelect0;
|
|
206
230
|
if (!selected.length)
|
|
207
231
|
process.exit(0);
|
|
208
|
-
// Log query-to-finding correlations for future pre-warming (gated by env var)
|
|
209
|
-
try {
|
|
210
|
-
const { logCorrelations: logCorr } = await import("./query-correlation.js");
|
|
211
|
-
logCorr(getPhrenPath(), keywords, selected, sessionId);
|
|
212
|
-
}
|
|
213
|
-
catch { /* non-fatal */ }
|
|
214
232
|
// Injection budget: cap total injected tokens across all content
|
|
215
233
|
const maxInjectTokens = clampInt(process.env.PHREN_MAX_INJECT_TOKENS, 2000, 200, 20000);
|
|
216
234
|
let budgetSelected = selected;
|
|
@@ -327,7 +345,7 @@ export async function handleHookPrompt() {
|
|
|
327
345
|
parts.push(`Findings ready for consolidation:`);
|
|
328
346
|
parts.push(notices.join("\n"));
|
|
329
347
|
parts.push(`Run phren-consolidate when ready.`);
|
|
330
|
-
parts.push(
|
|
348
|
+
parts.push(`</phren-notice>`);
|
|
331
349
|
}
|
|
332
350
|
if (noticeFile) {
|
|
333
351
|
try {
|
|
@@ -349,7 +367,7 @@ export async function handleHookPrompt() {
|
|
|
349
367
|
}
|
|
350
368
|
catch (err) {
|
|
351
369
|
const msg = errorMessage(err);
|
|
352
|
-
process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details
|
|
370
|
+
process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.</phren-error>\n`);
|
|
353
371
|
debugLog(`hook-prompt error: ${msg}`);
|
|
354
372
|
process.exit(0);
|
|
355
373
|
}
|
|
@@ -5,7 +5,7 @@ import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS } from "./shared.js";
|
|
|
5
5
|
import { errorMessage, runGitOrThrow } from "./utils.js";
|
|
6
6
|
import { findingIdFromLine } from "./finding-impact.js";
|
|
7
7
|
import { METADATA_REGEX, isArchiveStart, isArchiveEnd } from "./content-metadata.js";
|
|
8
|
-
import { FINDING_TYPE_DECAY, extractFindingType } from "./finding-lifecycle.js";
|
|
8
|
+
import { FINDING_TYPE_DECAY, extractFindingType, parseFindingLifecycle } from "./finding-lifecycle.js";
|
|
9
9
|
export const FINDING_PROVENANCE_SOURCES = [
|
|
10
10
|
"human",
|
|
11
11
|
"agent",
|
|
@@ -294,6 +294,7 @@ export function filterTrustedFindingsDetailed(content, opts) {
|
|
|
294
294
|
...(options.decay || {}),
|
|
295
295
|
};
|
|
296
296
|
const highImpactFindingIds = options.highImpactFindingIds;
|
|
297
|
+
const impactCounts = options.impactCounts;
|
|
297
298
|
const project = options.project;
|
|
298
299
|
const lines = content.split("\n");
|
|
299
300
|
const out = [];
|
|
@@ -412,9 +413,29 @@ export function filterTrustedFindingsDetailed(content, opts) {
|
|
|
412
413
|
confidence *= 0.9;
|
|
413
414
|
if (project && highImpactFindingIds?.size) {
|
|
414
415
|
const findingId = findingIdFromLine(line);
|
|
415
|
-
if (highImpactFindingIds.has(findingId))
|
|
416
|
-
|
|
416
|
+
if (highImpactFindingIds.has(findingId)) {
|
|
417
|
+
// Get surface count for graduated boost
|
|
418
|
+
const surfaceCount = impactCounts?.get(findingId) ?? 3;
|
|
419
|
+
// Log-scaled: 3→1.15x, 10→1.28x, 30→1.38x, capped at 1.4x
|
|
420
|
+
const boost = Math.min(1.4, 1 + 0.1 * Math.log2(Math.max(3, surfaceCount)));
|
|
421
|
+
confidence *= boost;
|
|
422
|
+
// Decay resistance: confirmed findings decay 3x slower
|
|
423
|
+
if (effectiveDate) {
|
|
424
|
+
const realAge = ageDaysForDate(effectiveDate);
|
|
425
|
+
if (realAge !== null) {
|
|
426
|
+
const slowedAge = Math.floor(realAge / 3);
|
|
427
|
+
confidence = Math.max(confidence, confidenceForAge(slowedAge, decay));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
417
431
|
}
|
|
432
|
+
const lifecycle = parseFindingLifecycle(line);
|
|
433
|
+
if (lifecycle?.status === "superseded")
|
|
434
|
+
confidence *= 0.25;
|
|
435
|
+
if (lifecycle?.status === "retracted")
|
|
436
|
+
confidence *= 0.1;
|
|
437
|
+
if (lifecycle?.status === "contradicted")
|
|
438
|
+
confidence *= 0.4;
|
|
418
439
|
confidence = Math.max(0, Math.min(1, confidence));
|
|
419
440
|
if (confidence < minConfidence) {
|
|
420
441
|
issues.push({ date: effectiveDate || "unknown", bullet: line, reason: "stale" });
|
|
@@ -11,7 +11,7 @@ import { isDuplicateFinding, scanForSecrets, normalizeObservationTags, resolveCo
|
|
|
11
11
|
import { validateFindingsFormat, validateFinding } from "./content-validate.js";
|
|
12
12
|
import { countActiveFindings, autoArchiveToReference } from "./content-archive.js";
|
|
13
13
|
import { resolveAutoFindingTaskItem, resolveFindingTaskReference, resolveFindingSessionId, } from "./finding-context.js";
|
|
14
|
-
import { buildLifecycleComments, parseFindingLifecycle, stripLifecycleComments, } from "./finding-lifecycle.js";
|
|
14
|
+
import { buildLifecycleComments, extractFindingType, parseFindingLifecycle, stripLifecycleComments, } from "./finding-lifecycle.js";
|
|
15
15
|
import { METADATA_REGEX, } from "./content-metadata.js";
|
|
16
16
|
/** Default cap for active findings before auto-archiving is triggered. */
|
|
17
17
|
const DEFAULT_FINDINGS_CAP = 20;
|
|
@@ -71,12 +71,12 @@ function detectFindingProvenanceSource(explicitSource) {
|
|
|
71
71
|
return "extract";
|
|
72
72
|
if ((process.env.PHREN_HOOK_TOOL))
|
|
73
73
|
return "hook";
|
|
74
|
-
if (
|
|
74
|
+
if (process.env.PHREN_ACTOR?.trim())
|
|
75
75
|
return "agent";
|
|
76
76
|
return "human";
|
|
77
77
|
}
|
|
78
78
|
function buildFindingSource(sessionId, explicitSource, scope) {
|
|
79
|
-
const actor =
|
|
79
|
+
const actor = process.env.PHREN_ACTOR?.trim() || undefined;
|
|
80
80
|
const source = {
|
|
81
81
|
source: detectFindingProvenanceSource(explicitSource),
|
|
82
82
|
machine: getMachineName(),
|
|
@@ -107,6 +107,22 @@ function resolveFindingCitationInput(phrenPath, project, citationInput) {
|
|
|
107
107
|
}
|
|
108
108
|
return phrenOk(Object.keys(resolved).length > 0 ? resolved : undefined);
|
|
109
109
|
}
|
|
110
|
+
export function autoDetectFindingType(text) {
|
|
111
|
+
const lower = text.toLowerCase();
|
|
112
|
+
if (/\b(we decided|decision:|chose .+ over|went with)\b/.test(lower))
|
|
113
|
+
return 'decision';
|
|
114
|
+
if (/\b(bug:|bug in|found a bug|broken|crashes|fails when)\b/.test(lower))
|
|
115
|
+
return 'bug';
|
|
116
|
+
if (/\b(workaround:|work around|temporary fix|hack:)\b/.test(lower))
|
|
117
|
+
return 'workaround';
|
|
118
|
+
if (/\b(pattern:|always .+ before|never .+ without|best practice)\b/.test(lower))
|
|
119
|
+
return 'pattern';
|
|
120
|
+
if (/\b(pitfall:|gotcha:|watch out|careful with|trap:)\b/.test(lower))
|
|
121
|
+
return 'pitfall';
|
|
122
|
+
if (/\b(currently|as of|right now|at the moment|observation:)\b/.test(lower))
|
|
123
|
+
return 'context';
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
110
126
|
function prepareFinding(learning, project, fullHistory, extraAnnotations, citationInput, source, nowIso, inferredRepo, headCommit, phrenPath) {
|
|
111
127
|
const secretType = scanForSecrets(learning);
|
|
112
128
|
if (secretType) {
|
|
@@ -114,10 +130,17 @@ function prepareFinding(learning, project, fullHistory, extraAnnotations, citati
|
|
|
114
130
|
}
|
|
115
131
|
const today = (nowIso ?? new Date().toISOString()).slice(0, 10);
|
|
116
132
|
const { text: tagNormalized, warning: tagWarning } = normalizeObservationTags(learning);
|
|
117
|
-
|
|
133
|
+
let normalizedLearning = resolveCoref(tagNormalized, {
|
|
118
134
|
project,
|
|
119
135
|
file: citationInput?.file,
|
|
120
136
|
});
|
|
137
|
+
const existingType = extractFindingType('- ' + normalizedLearning);
|
|
138
|
+
if (!existingType) {
|
|
139
|
+
const detected = autoDetectFindingType(normalizedLearning);
|
|
140
|
+
if (detected) {
|
|
141
|
+
normalizedLearning = `[${detected}] ${normalizedLearning}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
121
144
|
const fid = crypto.randomBytes(4).toString("hex");
|
|
122
145
|
const fidComment = `<!-- fid:${fid} -->`;
|
|
123
146
|
const createdComment = `<!-- created: ${today} -->`;
|
|
@@ -332,7 +355,7 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
|
|
|
332
355
|
if (!newLines[i].startsWith("- "))
|
|
333
356
|
continue;
|
|
334
357
|
if (newLines[i].includes(prepared.finding.bullet.slice(0, 40))) {
|
|
335
|
-
if (!newLines[i].includes("phren:supersedes")
|
|
358
|
+
if (!newLines[i].includes("phren:supersedes")) {
|
|
336
359
|
const supersedesFirst60 = supersedesText.slice(0, 60);
|
|
337
360
|
newLines[i] = `${newLines[i]} <!-- phren:supersedes "${supersedesFirst60}" -->`;
|
|
338
361
|
}
|
package/mcp/dist/data-access.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import
|
|
3
|
+
import * as yaml from "js-yaml";
|
|
4
|
+
import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, isRecord, } from "./shared.js";
|
|
4
5
|
import { normalizeQueueEntryText, withFileLock as withFileLockRaw, } from "./shared-governance.js";
|
|
5
6
|
import { addFindingToFile, } from "./shared-content.js";
|
|
6
7
|
import { isValidProjectName, queueFilePath, safeProjectPath, errorMessage } from "./utils.js";
|
|
@@ -54,6 +55,69 @@ function normalizeFindingGroupKey(item) {
|
|
|
54
55
|
function findingTimelineDate(item) {
|
|
55
56
|
return item.status_updated || item.date || "0000-00-00";
|
|
56
57
|
}
|
|
58
|
+
function collectFindingBulletLines(lines) {
|
|
59
|
+
const bulletLines = [];
|
|
60
|
+
let inArchiveBlock = false;
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (isArchiveStart(line)) {
|
|
64
|
+
inArchiveBlock = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (isArchiveEnd(line)) {
|
|
68
|
+
inArchiveBlock = false;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!line.startsWith("- "))
|
|
72
|
+
continue;
|
|
73
|
+
bulletLines.push({ line, i, archived: inArchiveBlock });
|
|
74
|
+
}
|
|
75
|
+
return bulletLines;
|
|
76
|
+
}
|
|
77
|
+
function findMatchingFindingBullet(bulletLines, needle, match) {
|
|
78
|
+
const fidNeedle = needle.replace(/^fid:/, "");
|
|
79
|
+
const fidMatch = /^[a-z0-9]{8}$/.test(fidNeedle)
|
|
80
|
+
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`).test(line))
|
|
81
|
+
: [];
|
|
82
|
+
const exactMatches = bulletLines.filter(({ line }) => line.replace(/^-\s+/, "").replace(/<!--.*?-->/g, "").trim().toLowerCase() === needle);
|
|
83
|
+
const partialMatches = bulletLines.filter(({ line }) => line.toLowerCase().includes(needle));
|
|
84
|
+
if (fidMatch.length === 1)
|
|
85
|
+
return { kind: "found", idx: fidMatch[0].i };
|
|
86
|
+
if (exactMatches.length === 1)
|
|
87
|
+
return { kind: "found", idx: exactMatches[0].i };
|
|
88
|
+
if (exactMatches.length > 1) {
|
|
89
|
+
return { kind: "ambiguous", error: `"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.` };
|
|
90
|
+
}
|
|
91
|
+
if (partialMatches.length === 1)
|
|
92
|
+
return { kind: "found", idx: partialMatches[0].i };
|
|
93
|
+
if (partialMatches.length > 1) {
|
|
94
|
+
return { kind: "ambiguous", error: `"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.` };
|
|
95
|
+
}
|
|
96
|
+
return { kind: "not_found" };
|
|
97
|
+
}
|
|
98
|
+
function validateAggregateQueueProfile(phrenPath, profile) {
|
|
99
|
+
if (!profile)
|
|
100
|
+
return phrenOk(undefined);
|
|
101
|
+
if (!isValidProjectName(profile)) {
|
|
102
|
+
return phrenErr(`Invalid PHREN_PROFILE value: ${profile}`, PhrenError.VALIDATION_ERROR);
|
|
103
|
+
}
|
|
104
|
+
const profilePath = path.join(phrenPath, "profiles", `${profile}.yaml`);
|
|
105
|
+
if (!fs.existsSync(profilePath)) {
|
|
106
|
+
return phrenErr(`Profile file not found: ${profilePath}`, PhrenError.FILE_NOT_FOUND);
|
|
107
|
+
}
|
|
108
|
+
let data;
|
|
109
|
+
try {
|
|
110
|
+
data = yaml.load(fs.readFileSync(profilePath, "utf-8"), { schema: yaml.CORE_SCHEMA });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return phrenErr(`Malformed profile YAML: ${profilePath}`, PhrenError.MALFORMED_YAML);
|
|
114
|
+
}
|
|
115
|
+
const projects = isRecord(data) ? data.projects : undefined;
|
|
116
|
+
if (!Array.isArray(projects)) {
|
|
117
|
+
return phrenErr(`Profile YAML missing valid "projects" array: ${profilePath}`, PhrenError.MALFORMED_YAML);
|
|
118
|
+
}
|
|
119
|
+
return phrenOk(undefined);
|
|
120
|
+
}
|
|
57
121
|
export function readFindings(phrenPath, project, opts = {}) {
|
|
58
122
|
const ensured = ensureProject(phrenPath, project);
|
|
59
123
|
if (!ensured.ok)
|
|
@@ -203,42 +267,29 @@ export function removeFinding(phrenPath, project, match) {
|
|
|
203
267
|
const ensured = ensureProject(phrenPath, project);
|
|
204
268
|
if (!ensured.ok)
|
|
205
269
|
return forwardErr(ensured);
|
|
206
|
-
const findingsPath = path.join(ensured.data, 'FINDINGS.md');
|
|
270
|
+
const findingsPath = path.resolve(path.join(ensured.data, 'FINDINGS.md'));
|
|
271
|
+
if (!findingsPath.startsWith(phrenPath + path.sep) && findingsPath !== phrenPath) {
|
|
272
|
+
return phrenErr(`FINDINGS.md path escapes phren store`, PhrenError.VALIDATION_ERROR);
|
|
273
|
+
}
|
|
207
274
|
const filePath = findingsPath;
|
|
208
275
|
if (!fs.existsSync(filePath))
|
|
209
276
|
return phrenErr(`No FINDINGS.md file found for "${project}". Add a finding first with add_finding or :find add.`, PhrenError.FILE_NOT_FOUND);
|
|
210
277
|
return withSafeLock(filePath, () => {
|
|
211
278
|
const lines = fs.readFileSync(filePath, "utf8").split("\n");
|
|
212
279
|
const needle = match.trim().toLowerCase();
|
|
213
|
-
const bulletLines = lines
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`).test(line))
|
|
218
|
-
: [];
|
|
219
|
-
// 1) Exact text match (strip bullet prefix + metadata for comparison)
|
|
220
|
-
const exactMatches = bulletLines.filter(({ line }) => line.replace(/^-\s+/, "").replace(/<!--.*?-->/g, "").trim().toLowerCase() === needle);
|
|
221
|
-
// 2) Unique partial substring match
|
|
222
|
-
const partialMatches = bulletLines.filter(({ line }) => line.toLowerCase().includes(needle));
|
|
223
|
-
let idx;
|
|
224
|
-
if (fidMatch.length === 1) {
|
|
225
|
-
idx = fidMatch[0].i;
|
|
226
|
-
}
|
|
227
|
-
else if (exactMatches.length === 1) {
|
|
228
|
-
idx = exactMatches[0].i;
|
|
229
|
-
}
|
|
230
|
-
else if (exactMatches.length > 1) {
|
|
231
|
-
return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
280
|
+
const bulletLines = collectFindingBulletLines(lines);
|
|
281
|
+
const activeMatch = findMatchingFindingBullet(bulletLines.filter(({ archived }) => !archived), needle, match);
|
|
282
|
+
if (activeMatch.kind === "ambiguous") {
|
|
283
|
+
return phrenErr(activeMatch.error, PhrenError.AMBIGUOUS_MATCH);
|
|
232
284
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
285
|
+
if (activeMatch.kind === "not_found") {
|
|
286
|
+
const archivedMatch = findMatchingFindingBullet(bulletLines.filter(({ archived }) => archived), needle, match);
|
|
287
|
+
if (archivedMatch.kind === "ambiguous" || archivedMatch.kind === "found") {
|
|
288
|
+
return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR);
|
|
289
|
+
}
|
|
240
290
|
return phrenErr(`No finding matching "${match}" in project "${project}". Try a different search term or check :findings view.`, PhrenError.NOT_FOUND);
|
|
241
291
|
}
|
|
292
|
+
const idx = activeMatch.idx;
|
|
242
293
|
const removeCount = isCitationLine(lines[idx + 1] || "") ? 2 : 1;
|
|
243
294
|
const matched = lines[idx];
|
|
244
295
|
lines.splice(idx, removeCount);
|
|
@@ -254,39 +305,28 @@ export function editFinding(phrenPath, project, oldText, newText) {
|
|
|
254
305
|
const newTextTrimmed = newText.trim();
|
|
255
306
|
if (!newTextTrimmed)
|
|
256
307
|
return phrenErr("New finding text cannot be empty.", PhrenError.EMPTY_INPUT);
|
|
257
|
-
const findingsPath = path.join(ensured.data, "FINDINGS.md");
|
|
308
|
+
const findingsPath = path.resolve(path.join(ensured.data, "FINDINGS.md"));
|
|
309
|
+
if (!findingsPath.startsWith(phrenPath + path.sep) && findingsPath !== phrenPath) {
|
|
310
|
+
return phrenErr(`FINDINGS.md path escapes phren store`, PhrenError.VALIDATION_ERROR);
|
|
311
|
+
}
|
|
258
312
|
if (!fs.existsSync(findingsPath))
|
|
259
313
|
return phrenErr(`No FINDINGS.md file found for "${project}".`, PhrenError.FILE_NOT_FOUND);
|
|
260
314
|
return withSafeLock(findingsPath, () => {
|
|
261
315
|
const lines = fs.readFileSync(findingsPath, "utf8").split("\n");
|
|
262
316
|
const needle = oldText.trim().toLowerCase();
|
|
263
|
-
const bulletLines = lines
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`).test(line))
|
|
268
|
-
: [];
|
|
269
|
-
const exactMatches = bulletLines.filter(({ line }) => line.replace(/^-\s+/, "").replace(/<!--.*?-->/g, "").trim().toLowerCase() === needle);
|
|
270
|
-
const partialMatches = bulletLines.filter(({ line }) => line.toLowerCase().includes(needle));
|
|
271
|
-
let idx;
|
|
272
|
-
if (fidMatch.length === 1) {
|
|
273
|
-
idx = fidMatch[0].i;
|
|
274
|
-
}
|
|
275
|
-
else if (exactMatches.length === 1) {
|
|
276
|
-
idx = exactMatches[0].i;
|
|
317
|
+
const bulletLines = collectFindingBulletLines(lines);
|
|
318
|
+
const activeMatch = findMatchingFindingBullet(bulletLines.filter(({ archived }) => !archived), needle, oldText);
|
|
319
|
+
if (activeMatch.kind === "ambiguous") {
|
|
320
|
+
return phrenErr(activeMatch.error, PhrenError.AMBIGUOUS_MATCH);
|
|
277
321
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
else if (partialMatches.length > 1) {
|
|
285
|
-
return phrenErr(`"${oldText}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
322
|
+
if (activeMatch.kind === "not_found") {
|
|
323
|
+
const archivedMatch = findMatchingFindingBullet(bulletLines.filter(({ archived }) => archived), needle, oldText);
|
|
324
|
+
if (archivedMatch.kind === "ambiguous" || archivedMatch.kind === "found") {
|
|
325
|
+
return phrenErr(`Finding "${oldText}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR);
|
|
326
|
+
}
|
|
288
327
|
return phrenErr(`No finding matching "${oldText}" in project "${project}".`, PhrenError.NOT_FOUND);
|
|
289
328
|
}
|
|
329
|
+
const idx = activeMatch.idx;
|
|
290
330
|
// Preserve existing metadata comment (fid, citations, etc.)
|
|
291
331
|
const existing = lines[idx];
|
|
292
332
|
const metaMatch = existing.match(/(<!--.*?-->)/g);
|
|
@@ -366,6 +406,9 @@ export function readReviewQueue(phrenPath, project) {
|
|
|
366
406
|
return phrenOk(items);
|
|
367
407
|
}
|
|
368
408
|
export function readReviewQueueAcrossProjects(phrenPath, profile) {
|
|
409
|
+
const validation = validateAggregateQueueProfile(phrenPath, profile);
|
|
410
|
+
if (!validation.ok)
|
|
411
|
+
return validation;
|
|
369
412
|
const projects = getProjectDirs(phrenPath, profile)
|
|
370
413
|
.map((dir) => path.basename(dir))
|
|
371
414
|
.filter((project) => project !== "global")
|
|
@@ -144,6 +144,29 @@ export function getHighImpactFindings(phrenPath, minSurfaceCount = 3) {
|
|
|
144
144
|
};
|
|
145
145
|
return new Set(ids);
|
|
146
146
|
}
|
|
147
|
+
export function getImpactSurfaceCounts(phrenPath, minSurfaces = 1) {
|
|
148
|
+
const file = impactLogFile(phrenPath);
|
|
149
|
+
if (!fs.existsSync(file))
|
|
150
|
+
return new Map();
|
|
151
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
152
|
+
const counts = new Map();
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
try {
|
|
155
|
+
const entry = JSON.parse(line);
|
|
156
|
+
if (entry.findingId) {
|
|
157
|
+
counts.set(entry.findingId, (counts.get(entry.findingId) ?? 0) + 1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
}
|
|
162
|
+
// Filter by minimum
|
|
163
|
+
const filtered = new Map();
|
|
164
|
+
for (const [id, count] of counts) {
|
|
165
|
+
if (count >= minSurfaces)
|
|
166
|
+
filtered.set(id, count);
|
|
167
|
+
}
|
|
168
|
+
return filtered;
|
|
169
|
+
}
|
|
147
170
|
export function markImpactEntriesCompletedForSession(phrenPath, sessionId, project) {
|
|
148
171
|
if (!sessionId)
|
|
149
172
|
return 0;
|
|
@@ -5,7 +5,7 @@ import { PhrenError, phrenErr, phrenOk } from "./phren-core.js";
|
|
|
5
5
|
const LIFECYCLE_PREFIX = "phren";
|
|
6
6
|
import { withFileLock } from "./governance-locks.js";
|
|
7
7
|
import { isValidProjectName, safeProjectPath } from "./utils.js";
|
|
8
|
-
import { parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
|
|
8
|
+
import { isArchiveEnd, isArchiveStart, parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
|
|
9
9
|
export const FINDING_TYPE_DECAY = {
|
|
10
10
|
'pattern': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay, long-lived
|
|
11
11
|
'decision': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
|
|
@@ -109,6 +109,49 @@ function normalizeFindingText(value) {
|
|
|
109
109
|
function removeRelationComments(line) {
|
|
110
110
|
return stripRelationMetadata(line);
|
|
111
111
|
}
|
|
112
|
+
function collectFindingBulletLines(lines) {
|
|
113
|
+
const bulletLines = [];
|
|
114
|
+
let inArchiveBlock = false;
|
|
115
|
+
for (let i = 0; i < lines.length; i++) {
|
|
116
|
+
const line = lines[i];
|
|
117
|
+
if (isArchiveStart(line)) {
|
|
118
|
+
inArchiveBlock = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (isArchiveEnd(line)) {
|
|
122
|
+
inArchiveBlock = false;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!line.startsWith("- "))
|
|
126
|
+
continue;
|
|
127
|
+
bulletLines.push({ line, index: i, archived: inArchiveBlock });
|
|
128
|
+
}
|
|
129
|
+
return bulletLines;
|
|
130
|
+
}
|
|
131
|
+
function selectMatchingFinding(bulletLines, needle, match) {
|
|
132
|
+
const fidNeedle = needle.replace(/^fid:/, "");
|
|
133
|
+
const fidMatches = /^[a-z0-9]{8}$/.test(fidNeedle)
|
|
134
|
+
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`, "i").test(line))
|
|
135
|
+
: [];
|
|
136
|
+
const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle);
|
|
137
|
+
const partialMatches = bulletLines.filter(({ line }) => {
|
|
138
|
+
const clean = normalizeFindingText(line);
|
|
139
|
+
return clean.includes(needle) || line.toLowerCase().includes(needle);
|
|
140
|
+
});
|
|
141
|
+
if (fidMatches.length === 1)
|
|
142
|
+
return phrenOk(fidMatches[0]);
|
|
143
|
+
if (exactMatches.length === 1)
|
|
144
|
+
return phrenOk(exactMatches[0]);
|
|
145
|
+
if (exactMatches.length > 1) {
|
|
146
|
+
return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
147
|
+
}
|
|
148
|
+
if (partialMatches.length === 1)
|
|
149
|
+
return phrenOk(partialMatches[0]);
|
|
150
|
+
if (partialMatches.length > 1) {
|
|
151
|
+
return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
152
|
+
}
|
|
153
|
+
return phrenOk(null);
|
|
154
|
+
}
|
|
112
155
|
function applyLifecycle(line, lifecycle, today, opts) {
|
|
113
156
|
let updated = stripLifecycleComments(removeRelationComments(line)).trimEnd();
|
|
114
157
|
if (opts?.supersededBy) {
|
|
@@ -129,35 +172,19 @@ function matchFinding(lines, match) {
|
|
|
129
172
|
if (!needleRaw)
|
|
130
173
|
return phrenErr("Finding text cannot be empty.", PhrenError.EMPTY_INPUT);
|
|
131
174
|
const needle = normalizeFindingText(needleRaw);
|
|
132
|
-
const bulletLines = lines
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`, "i").test(line))
|
|
138
|
-
: [];
|
|
139
|
-
const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle);
|
|
140
|
-
const partialMatches = bulletLines.filter(({ line }) => {
|
|
141
|
-
const clean = normalizeFindingText(line);
|
|
142
|
-
return clean.includes(needle) || line.toLowerCase().includes(needle);
|
|
143
|
-
});
|
|
144
|
-
let selected;
|
|
145
|
-
if (fidMatches.length === 1) {
|
|
146
|
-
selected = fidMatches[0];
|
|
147
|
-
}
|
|
148
|
-
else if (exactMatches.length === 1) {
|
|
149
|
-
selected = exactMatches[0];
|
|
150
|
-
}
|
|
151
|
-
else if (exactMatches.length > 1) {
|
|
152
|
-
return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
153
|
-
}
|
|
154
|
-
else if (partialMatches.length === 1) {
|
|
155
|
-
selected = partialMatches[0];
|
|
156
|
-
}
|
|
157
|
-
else if (partialMatches.length > 1) {
|
|
158
|
-
return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
159
|
-
}
|
|
175
|
+
const bulletLines = collectFindingBulletLines(lines);
|
|
176
|
+
const activeMatch = selectMatchingFinding(bulletLines.filter(({ archived }) => !archived), needle, match);
|
|
177
|
+
if (!activeMatch.ok)
|
|
178
|
+
return activeMatch;
|
|
179
|
+
const selected = activeMatch.data;
|
|
160
180
|
if (!selected) {
|
|
181
|
+
const archivedMatch = selectMatchingFinding(bulletLines.filter(({ archived }) => archived), needle, match);
|
|
182
|
+
if (!archivedMatch.ok) {
|
|
183
|
+
return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR);
|
|
184
|
+
}
|
|
185
|
+
if (archivedMatch.data) {
|
|
186
|
+
return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR);
|
|
187
|
+
}
|
|
161
188
|
return phrenErr(`No finding matching "${match}".`, PhrenError.NOT_FOUND);
|
|
162
189
|
}
|
|
163
190
|
const stableId = parseFindingIdMeta(selected.line);
|
|
@@ -3,7 +3,7 @@ import * as path from "path";
|
|
|
3
3
|
import { debugLog } from "./shared.js";
|
|
4
4
|
// Acquire the file lock, returning true on success or throwing on timeout.
|
|
5
5
|
function acquireFileLock(lockPath) {
|
|
6
|
-
const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS ||
|
|
6
|
+
const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000;
|
|
7
7
|
const pollInterval = Number.parseInt((process.env.PHREN_FILE_LOCK_POLL_MS) || "100", 10) || 100;
|
|
8
8
|
const staleThreshold = Number.parseInt((process.env.PHREN_FILE_LOCK_STALE_MS) || "30000", 10) || 30000;
|
|
9
9
|
const waiter = new Int32Array(new SharedArrayBuffer(4));
|
|
@@ -29,12 +29,17 @@ function acquireFileLock(lockPath) {
|
|
|
29
29
|
const lockContent = fs.readFileSync(lockPath, "utf8");
|
|
30
30
|
const lockPid = Number.parseInt(lockContent.split("\n")[0], 10);
|
|
31
31
|
if (Number.isFinite(lockPid) && lockPid > 0) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
if (process.platform !== 'win32') {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(lockPid, 0);
|
|
35
|
+
ownerDead = false;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
ownerDead = true;
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
|
-
|
|
37
|
-
ownerDead = true;
|
|
41
|
+
else {
|
|
42
|
+
ownerDead = true;
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
}
|
package/mcp/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { register as registerSkills } from "./mcp-skills.js";
|
|
|
19
19
|
import { register as registerHooks } from "./mcp-hooks.js";
|
|
20
20
|
import { register as registerExtract } from "./mcp-extract.js";
|
|
21
21
|
import { register as registerConfig } from "./mcp-config.js";
|
|
22
|
+
import { mcpResponse } from "./mcp-types.js";
|
|
22
23
|
import { errorMessage } from "./utils.js";
|
|
23
24
|
import { runTopLevelCommand } from "./entrypoint.js";
|
|
24
25
|
import { startEmbeddingWarmup } from "./startup-embedding.js";
|
|
@@ -110,7 +111,7 @@ async function main() {
|
|
|
110
111
|
const message = errorMessage(err);
|
|
111
112
|
if (message.includes("Write timeout") || message.includes("Write queue full")) {
|
|
112
113
|
debugLog(`Write queue timeout: ${message}`);
|
|
113
|
-
return { ok: false, error: `Write queue timeout: ${message}`, errorCode: "TIMEOUT" };
|
|
114
|
+
return mcpResponse({ ok: false, error: `Write queue timeout: ${message}`, errorCode: "TIMEOUT" });
|
|
114
115
|
}
|
|
115
116
|
throw err;
|
|
116
117
|
}
|