@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 CHANGED
@@ -8,7 +8,7 @@
8
8
  </p>
9
9
 
10
10
  <p align="center">
11
- Phren is a git-backed knowledge layer that gives AI agents persistent memory across sessions, projects, and machines. Findings, decisions, and patterns are captured as markdown in a repo you control no hosted service, no vendor lock-in.
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
- // Exclude sensitive files from staging: .env files and private keys should
871
- // never be committed to the phren git repository.
872
- const add = await runBestEffortGit(["add", "-A", "--", ":(exclude).env", ":(exclude)**/.env", ":(exclude)*.pem", ":(exclude)*.key"], phrenPath);
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);
@@ -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
- const safeTokenBudget = clampInt(process.env.PHREN_CONTEXT_TOKEN_BUDGET, 550, 180, 10000);
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(`<phren-notice>`);
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.<phren-error>\n`);
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
- confidence *= 1.15;
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 ((process.env.PHREN_ACTOR || process.env.PHREN_ACTOR)?.trim())
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 = (process.env.PHREN_ACTOR || process.env.PHREN_ACTOR)?.trim() || undefined;
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
- const normalizedLearning = resolveCoref(tagNormalized, {
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") && !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
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, } from "./shared.js";
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.map((line, i) => ({ line, i })).filter(({ line }) => line.startsWith("- "));
214
- // 0) Stable finding ID match (fid:XXXXXXXX or just the 8-char hex)
215
- const fidNeedle = needle.replace(/^fid:/, "");
216
- const fidMatch = /^[a-z0-9]{8}$/.test(fidNeedle)
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
- else if (partialMatches.length === 1) {
234
- idx = partialMatches[0].i;
235
- }
236
- else if (partialMatches.length > 1) {
237
- return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
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.map((line, i) => ({ line, i })).filter(({ line }) => line.startsWith("- "));
264
- // Stable finding ID match
265
- const fidNeedle = needle.replace(/^fid:/, "");
266
- const fidMatch = /^[a-z0-9]{8}$/.test(fidNeedle)
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
- else if (exactMatches.length > 1) {
279
- return phrenErr(`"${oldText}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
280
- }
281
- else if (partialMatches.length === 1) {
282
- idx = partialMatches[0].i;
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
- .map((line, index) => ({ line, index }))
134
- .filter(({ line }) => line.startsWith("- "));
135
- const fidNeedle = needle.replace(/^fid:/, "");
136
- const fidMatches = /^[a-z0-9]{8}$/.test(fidNeedle)
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 || process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000;
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
- try {
33
- process.kill(lockPid, 0); // signal 0 = check if alive
34
- ownerDead = false; // PID is still alive, don't steal the lock
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
- catch {
37
- ownerDead = true; // PID is dead, safe to remove
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
  }