@phren/cli 0.0.11 → 0.0.13

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.
Files changed (76) hide show
  1. package/README.md +9 -9
  2. package/mcp/dist/capabilities/cli.js +1 -1
  3. package/mcp/dist/capabilities/mcp.js +1 -1
  4. package/mcp/dist/capabilities/vscode.js +1 -1
  5. package/mcp/dist/capabilities/web-ui.js +1 -1
  6. package/mcp/dist/cli-actions.js +54 -67
  7. package/mcp/dist/cli-config.js +4 -5
  8. package/mcp/dist/cli-extract.js +3 -2
  9. package/mcp/dist/cli-graph.js +17 -3
  10. package/mcp/dist/cli-hooks-output.js +1 -1
  11. package/mcp/dist/cli-hooks-session.js +1 -1
  12. package/mcp/dist/cli-hooks.js +5 -3
  13. package/mcp/dist/cli.js +1 -1
  14. package/mcp/dist/content-archive.js +21 -12
  15. package/mcp/dist/content-citation.js +13 -2
  16. package/mcp/dist/content-learning.js +6 -4
  17. package/mcp/dist/content-metadata.js +10 -0
  18. package/mcp/dist/core-finding.js +1 -1
  19. package/mcp/dist/data-access.js +10 -31
  20. package/mcp/dist/data-tasks.js +5 -26
  21. package/mcp/dist/embedding.js +0 -1
  22. package/mcp/dist/entrypoint.js +4 -0
  23. package/mcp/dist/finding-impact.js +1 -32
  24. package/mcp/dist/finding-journal.js +1 -1
  25. package/mcp/dist/finding-lifecycle.js +2 -7
  26. package/mcp/dist/governance-locks.js +6 -0
  27. package/mcp/dist/governance-policy.js +1 -7
  28. package/mcp/dist/governance-scores.js +1 -7
  29. package/mcp/dist/hooks.js +23 -0
  30. package/mcp/dist/init-config.js +1 -1
  31. package/mcp/dist/init-preferences.js +1 -1
  32. package/mcp/dist/init-setup.js +1 -50
  33. package/mcp/dist/init-shared.js +53 -1
  34. package/mcp/dist/init.js +21 -6
  35. package/mcp/dist/link-context.js +1 -1
  36. package/mcp/dist/link-doctor.js +11 -54
  37. package/mcp/dist/link.js +4 -53
  38. package/mcp/dist/mcp-extract-facts.js +11 -6
  39. package/mcp/dist/mcp-finding.js +10 -14
  40. package/mcp/dist/mcp-graph.js +6 -6
  41. package/mcp/dist/mcp-hooks.js +1 -1
  42. package/mcp/dist/mcp-search.js +3 -8
  43. package/mcp/dist/mcp-session.js +12 -2
  44. package/mcp/dist/memory-ui-assets.js +1 -36
  45. package/mcp/dist/memory-ui-graph.js +152 -50
  46. package/mcp/dist/memory-ui-page.js +7 -5
  47. package/mcp/dist/memory-ui-scripts.js +42 -36
  48. package/mcp/dist/phren-core.js +2 -0
  49. package/mcp/dist/phren-paths.js +1 -2
  50. package/mcp/dist/proactivity.js +5 -5
  51. package/mcp/dist/project-config.js +1 -1
  52. package/mcp/dist/provider-adapters.js +1 -1
  53. package/mcp/dist/query-correlation.js +22 -19
  54. package/mcp/dist/session-checkpoints.js +14 -14
  55. package/mcp/dist/shared-data-utils.js +28 -0
  56. package/mcp/dist/shared-fragment-graph.js +11 -11
  57. package/mcp/dist/shared-governance.js +1 -1
  58. package/mcp/dist/shared-retrieval.js +2 -10
  59. package/mcp/dist/shared-search-fallback.js +2 -12
  60. package/mcp/dist/shared.js +2 -3
  61. package/mcp/dist/shell-entry.js +1 -1
  62. package/mcp/dist/shell-input.js +62 -52
  63. package/mcp/dist/shell-palette.js +6 -1
  64. package/mcp/dist/shell-render.js +9 -5
  65. package/mcp/dist/shell-state-store.js +1 -4
  66. package/mcp/dist/shell-view.js +4 -4
  67. package/mcp/dist/shell.js +4 -54
  68. package/mcp/dist/status.js +2 -8
  69. package/mcp/dist/utils.js +1 -1
  70. package/package.json +1 -2
  71. package/skills/docs.md +11 -11
  72. package/starter/README.md +1 -1
  73. package/starter/global/CLAUDE.md +2 -2
  74. package/starter/global/skills/audit.md +10 -10
  75. package/mcp/dist/cli-hooks-retrieval.js +0 -2
  76. package/mcp/dist/impact-scoring.js +0 -22
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { execFileSync } from "child_process";
4
4
  import { debugLog, EXEC_TIMEOUT_QUICK_MS, getProjectDirs, isRecord, homeDir, homePath, hookConfigPath, runtimeHealthFile, } from "./shared.js";
5
+ import { commandVersion, versionAtLeast, nearestWritableTarget } from "./init-shared.js";
5
6
  import { validateGovernanceJson } from "./shared-governance.js";
6
7
  import { errorMessage } from "./utils.js";
7
8
  import { buildIndex, queryRows } from "./shared-index.js";
@@ -34,53 +35,6 @@ function isWrapperActive(tool) {
34
35
  return false;
35
36
  }
36
37
  }
37
- function commandVersion(cmd, args = ["--version"]) {
38
- try {
39
- return execFileSync(cmd, args, {
40
- encoding: "utf8",
41
- stdio: ["ignore", "pipe", "ignore"],
42
- timeout: EXEC_TIMEOUT_QUICK_MS,
43
- }).trim();
44
- }
45
- catch (err) {
46
- debugLog(`doctor: commandVersion ${cmd} failed: ${errorMessage(err)}`);
47
- return null;
48
- }
49
- }
50
- function parseSemverTriple(raw) {
51
- const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
52
- if (!match)
53
- return null;
54
- return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)];
55
- }
56
- function versionAtLeast(raw, major, minor = 0) {
57
- if (!raw)
58
- return false;
59
- const parsed = parseSemverTriple(raw);
60
- if (!parsed)
61
- return false;
62
- const [m, n] = parsed;
63
- if (m !== major)
64
- return m > major;
65
- return n >= minor;
66
- }
67
- function nearestWritableTarget(filePath) {
68
- let probe = fs.existsSync(filePath) ? filePath : path.dirname(filePath);
69
- while (!fs.existsSync(probe)) {
70
- const parent = path.dirname(probe);
71
- if (parent === probe)
72
- return false;
73
- probe = parent;
74
- }
75
- try {
76
- fs.accessSync(probe, fs.constants.W_OK);
77
- return true;
78
- }
79
- catch (err) {
80
- debugLog(`doctor: writable check failed for ${filePath}: ${errorMessage(err)}`);
81
- return false;
82
- }
83
- }
84
38
  function gitRemoteStatus(phrenPath) {
85
39
  try {
86
40
  execFileSync("git", ["-C", phrenPath, "rev-parse", "--is-inside-work-tree"], {
@@ -375,6 +329,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
375
329
  const detected = detectInstalledTools();
376
330
  if (detected.has("copilot")) {
377
331
  const copilotHooks = hookConfigPath("copilot", phrenPath);
332
+ const copilotWritable = nearestWritableTarget(copilotHooks);
378
333
  checks.push({
379
334
  name: "copilot-hooks",
380
335
  ok: fs.existsSync(copilotHooks),
@@ -382,12 +337,13 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
382
337
  });
383
338
  checks.push({
384
339
  name: "copilot-config-writable",
385
- ok: nearestWritableTarget(copilotHooks),
386
- detail: nearestWritableTarget(copilotHooks) ? `writable: ${copilotHooks}` : `not writable: ${copilotHooks}`,
340
+ ok: copilotWritable,
341
+ detail: copilotWritable ? `writable: ${copilotHooks}` : `not writable: ${copilotHooks}`,
387
342
  });
388
343
  }
389
344
  if (detected.has("cursor")) {
390
345
  const cursorHooks = hookConfigPath("cursor", phrenPath);
346
+ const cursorWritable = nearestWritableTarget(cursorHooks);
391
347
  checks.push({
392
348
  name: "cursor-hooks",
393
349
  ok: fs.existsSync(cursorHooks),
@@ -395,12 +351,13 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
395
351
  });
396
352
  checks.push({
397
353
  name: "cursor-config-writable",
398
- ok: nearestWritableTarget(cursorHooks),
399
- detail: nearestWritableTarget(cursorHooks) ? `writable: ${cursorHooks}` : `not writable: ${cursorHooks}`,
354
+ ok: cursorWritable,
355
+ detail: cursorWritable ? `writable: ${cursorHooks}` : `not writable: ${cursorHooks}`,
400
356
  });
401
357
  }
402
358
  if (detected.has("codex")) {
403
359
  const codexHooks = hookConfigPath("codex", phrenPath);
360
+ const codexWritable = nearestWritableTarget(codexHooks);
404
361
  checks.push({
405
362
  name: "codex-hooks",
406
363
  ok: fs.existsSync(codexHooks),
@@ -408,8 +365,8 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
408
365
  });
409
366
  checks.push({
410
367
  name: "codex-config-writable",
411
- ok: nearestWritableTarget(codexHooks),
412
- detail: nearestWritableTarget(codexHooks) ? `writable: ${codexHooks}` : `not writable: ${codexHooks}`,
368
+ ok: codexWritable,
369
+ detail: codexWritable ? `writable: ${codexHooks}` : `not writable: ${codexHooks}`,
413
370
  });
414
371
  }
415
372
  for (const tool of ["copilot", "cursor", "codex"]) {
@@ -446,7 +403,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
446
403
  }
447
404
  else {
448
405
  // Read-only mode: just check if hook configs exist, don't write anything
449
- const detectedTools = detectInstalledTools();
406
+ const detectedTools = detected;
450
407
  const hookChecks = [];
451
408
  const missing = [];
452
409
  for (const tool of detectedTools) {
package/mcp/dist/link.js CHANGED
@@ -4,7 +4,7 @@ import * as readline from "readline";
4
4
  import * as yaml from "js-yaml";
5
5
  import { execFileSync } from "child_process";
6
6
  import { ROOT } from "./package-metadata.js";
7
- import { configureClaude, configureCodexMcp, configureCopilotMcp, configureCursorMcp, configureVSCode, ensureGovernanceFiles, getHooksEnabledPreference, getMcpEnabledPreference, isVersionNewer, logMcpTargetStatus, patchJsonFile, setMcpEnabledPreference, } from "./init.js";
7
+ import { configureMcpTargets, ensureGovernanceFiles, getHooksEnabledPreference, getMcpEnabledPreference, isVersionNewer, patchJsonFile, setMcpEnabledPreference, } from "./init.js";
8
8
  import { configureAllHooks, detectInstalledTools } from "./hooks.js";
9
9
  import { getMachineName, persistMachineName } from "./machine-identity.js";
10
10
  import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, isRecord, homePath, hookConfigPath, installPreferencesFile, atomicWriteText, } from "./shared.js";
@@ -34,7 +34,7 @@ function listProfiles(phrenPath) {
34
34
  const listed = listProfilesShared(phrenPath);
35
35
  if (!listed.ok)
36
36
  return [];
37
- return listed.data.map((profile) => ({ name: profile.name, description: "" }));
37
+ return listed.data.map((profile) => ({ name: profile.name }));
38
38
  }
39
39
  export function findProfileFile(phrenPath, profileName) {
40
40
  const profilesDir = path.join(phrenPath, "profiles");
@@ -94,7 +94,7 @@ async function registerMachine(phrenPath) {
94
94
  }
95
95
  log("\nAvailable profiles:");
96
96
  for (const p of listProfiles(phrenPath))
97
- log(` ${p.name} (${p.description})`);
97
+ log(` ${p.name}`);
98
98
  log("");
99
99
  const profile = (await ask("Which profile? ")).trim();
100
100
  rl.close();
@@ -491,56 +491,7 @@ export async function runLink(phrenPath, opts = {}) {
491
491
  log(` MCP mode: ${mcpEnabled ? "ON (recommended)" : "OFF (hooks-only fallback)"}`);
492
492
  log(` Hooks mode: ${hooksEnabled ? "ON (active)" : "OFF (disabled)"}`);
493
493
  maybeOfferStarterTemplateUpdate(phrenPath);
494
- let mcpStatus = "no_settings";
495
- try {
496
- mcpStatus = configureClaude(phrenPath, { mcpEnabled, hooksEnabled }) ?? "installed";
497
- }
498
- catch (err) {
499
- if ((process.env.PHREN_DEBUG))
500
- process.stderr.write(`[phren] link configureClaude: ${errorMessage(err)}\n`);
501
- }
502
- logMcpTargetStatus("Claude", mcpStatus);
503
- let vsStatus = "no_vscode";
504
- try {
505
- vsStatus = configureVSCode(phrenPath, { mcpEnabled }) ?? "no_vscode";
506
- }
507
- catch (err) {
508
- if ((process.env.PHREN_DEBUG))
509
- process.stderr.write(`[phren] link configureVSCode: ${errorMessage(err)}\n`);
510
- }
511
- logMcpTargetStatus("VS Code", vsStatus);
512
- let cursorStatus = "no_cursor";
513
- try {
514
- cursorStatus = configureCursorMcp(phrenPath, { mcpEnabled }) ?? "no_cursor";
515
- }
516
- catch (err) {
517
- if ((process.env.PHREN_DEBUG))
518
- process.stderr.write(`[phren] link configureCursorMcp: ${errorMessage(err)}\n`);
519
- }
520
- logMcpTargetStatus("Cursor", cursorStatus);
521
- let copilotStatus = "no_copilot";
522
- try {
523
- copilotStatus = configureCopilotMcp(phrenPath, { mcpEnabled }) ?? "no_copilot";
524
- }
525
- catch (err) {
526
- if ((process.env.PHREN_DEBUG))
527
- process.stderr.write(`[phren] link configureCopilotMcp: ${errorMessage(err)}\n`);
528
- }
529
- logMcpTargetStatus("Copilot CLI", copilotStatus);
530
- let codexStatus = "no_codex";
531
- try {
532
- codexStatus = configureCodexMcp(phrenPath, { mcpEnabled }) ?? "no_codex";
533
- }
534
- catch (err) {
535
- if ((process.env.PHREN_DEBUG))
536
- process.stderr.write(`[phren] link configureCodexMcp: ${errorMessage(err)}\n`);
537
- }
538
- logMcpTargetStatus("Codex", codexStatus);
539
- const mcpStatusForContext = [mcpStatus, vsStatus, cursorStatus, copilotStatus, codexStatus].some((s) => s === "installed" || s === "already_configured")
540
- ? "installed"
541
- : [mcpStatus, vsStatus, cursorStatus, copilotStatus, codexStatus].some((s) => s === "disabled" || s === "already_disabled")
542
- ? "disabled"
543
- : mcpStatus;
494
+ const mcpStatusForContext = configureMcpTargets(phrenPath, { mcpEnabled, hooksEnabled });
544
495
  // Register hooks for Copilot CLI, Cursor, Codex
545
496
  if (hooksEnabled) {
546
497
  const hookedTools = configureAllHooks(phrenPath, { tools: detectedTools });
@@ -9,6 +9,7 @@ import * as path from "path";
9
9
  import { debugLog } from "./shared.js";
10
10
  import { safeProjectPath, isFeatureEnabled, errorMessage } from "./utils.js";
11
11
  import { callLlm } from "./content-dedup.js";
12
+ import { withFileLock } from "./shared-governance.js";
12
13
  const FACT_EXTRACT_FLAG = "PHREN_FEATURE_FACT_EXTRACT";
13
14
  const MAX_FACTS = 50;
14
15
  function preferencesPath(phrenPath, project) {
@@ -64,13 +65,17 @@ export function extractFactFromFinding(phrenPath, project, finding) {
64
65
  const fact = raw.replace(/[\r\n]+/g, " ").trim().slice(0, 200);
65
66
  if (!fact)
66
67
  return;
67
- // Re-read inside the callback to minimize race window (best-effort; not locked)
68
- const existing = readExtractedFacts(phrenPath, project);
69
- const normalized = fact.toLowerCase();
70
- if (existing.some(f => f.fact.toLowerCase() === normalized))
68
+ const p = preferencesPath(phrenPath, project);
69
+ if (!p)
71
70
  return;
72
- existing.push({ fact, source: finding.slice(0, 120), at: new Date().toISOString() });
73
- writeExtractedFacts(phrenPath, project, existing);
71
+ withFileLock(p, () => {
72
+ const existing = readExtractedFacts(phrenPath, project);
73
+ const normalized = fact.toLowerCase();
74
+ if (existing.some(f => f.fact.toLowerCase() === normalized))
75
+ return;
76
+ existing.push({ fact, source: finding.slice(0, 120), at: new Date().toISOString() });
77
+ writeExtractedFacts(phrenPath, project, existing);
78
+ });
74
79
  })
75
80
  .catch((err) => {
76
81
  debugLog(`extractFactFromFinding: ${errorMessage(err)}`);
@@ -4,7 +4,7 @@ import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { isValidProjectName, safeProjectPath, errorMessage } from "./utils.js";
6
6
  import { removeFinding as removeFindingCore, removeFindings as removeFindingsCore, } from "./core-finding.js";
7
- import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, } from "./shared.js";
7
+ import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, RESERVED_PROJECT_DIR_NAMES, } from "./shared.js";
8
8
  import { addFindingToFile, addFindingsToFile, checkSemanticConflicts, autoMergeConflicts, } from "./shared-content.js";
9
9
  import { jaccardTokenize, jaccardSimilarity, stripMetadata } from "./content-dedup.js";
10
10
  import { runCustomHooks } from "./hooks.js";
@@ -17,7 +17,6 @@ import { FINDING_PROVENANCE_SOURCES } from "./content-citation.js";
17
17
  import { isInactiveFindingLine, supersedeFinding, retractFinding as retractFindingLifecycle, resolveFindingContradiction, } from "./finding-lifecycle.js";
18
18
  const JACCARD_MAYBE_LOW = 0.30;
19
19
  const JACCARD_MAYBE_HIGH = 0.55; // above this isDuplicateFinding already catches it
20
- const RESERVED_PROJECT_DIRS = new Set(["global", ".runtime", ".sessions", ".governance"]);
21
20
  function findJaccardCandidates(phrenPath, project, finding) {
22
21
  try {
23
22
  const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
@@ -134,14 +133,11 @@ export function register(server, ctx) {
134
133
  if (!result.ok) {
135
134
  return mcpResponse({ ok: false, error: result.error });
136
135
  }
137
- // Determine status from the returned message string
138
- const isSkipped = result.data.startsWith("Skipped duplicate");
139
- const isAdded = !isSkipped;
140
- if (isSkipped) {
141
- return mcpResponse({ ok: true, message: result.data, data: { project, finding: taggedFinding, status: "skipped" } });
136
+ if (result.data.status === "skipped") {
137
+ return mcpResponse({ ok: true, message: result.data.message, data: { project, finding: taggedFinding, status: "skipped" } });
142
138
  }
143
139
  updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
144
- if (isAdded) {
140
+ if (result.data.status === "added" || result.data.status === "created") {
145
141
  runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
146
142
  incrementSessionFindings(phrenPath, 1, sessionId, project);
147
143
  extractFactFromFinding(phrenPath, project, taggedFinding);
@@ -178,7 +174,7 @@ export function register(server, ctx) {
178
174
  }
179
175
  const conflictsWithList = semanticConflicts.checked
180
176
  ? extractConflictsWith(semanticConflicts.annotations)
181
- : (result.data.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1] ? [result.data.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)[1]] : []);
177
+ : (result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1] ? [result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)[1]] : []);
182
178
  const conflictsWith = conflictsWithList[0];
183
179
  // Extract fragment hints synchronously from the finding text (regex only, no DB).
184
180
  // Full DB fragment linking happens on the next index rebuild via updateFileInIndex →
@@ -186,11 +182,11 @@ export function register(server, ctx) {
186
182
  const detectedFragments = extractFragmentNames(taggedFinding);
187
183
  return mcpResponse({
188
184
  ok: true,
189
- message: result.data,
185
+ message: result.data.message,
190
186
  data: {
191
187
  project,
192
188
  finding: taggedFinding,
193
- status: "added",
189
+ status: result.data.status,
194
190
  ...(conflictsWith ? { conflictsWith } : {}),
195
191
  ...(conflictsWithList.length > 0 ? { conflicts: conflictsWithList } : {}),
196
192
  ...(detectedFragments.length > 0 ? { detectedFragments } : {}),
@@ -356,7 +352,7 @@ export function register(server, ctx) {
356
352
  const projects = project
357
353
  ? [project]
358
354
  : fs.readdirSync(phrenPath, { withFileTypes: true })
359
- .filter((entry) => entry.isDirectory() && !RESERVED_PROJECT_DIRS.has(entry.name) && isValidProjectName(entry.name))
355
+ .filter((entry) => entry.isDirectory() && !RESERVED_PROJECT_DIR_NAMES.has(entry.name) && isValidProjectName(entry.name))
360
356
  .map((entry) => entry.name);
361
357
  const contradictions = [];
362
358
  for (const p of projects) {
@@ -490,8 +486,8 @@ export function register(server, ctx) {
490
486
  .filter((name) => name && !name.startsWith(".") && name !== "profiles")));
491
487
  const commitMsg = message || `phren: save ${files.length} file(s) across ${projectNames.length} project(s)`;
492
488
  runCustomHooks(phrenPath, "pre-save");
493
- // Restrict to known phren file types to avoid staging .env or credential files
494
- runGit(["add", "--", "*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]);
489
+ // Stage all files including untracked (new project dirs, first FINDINGS.md, etc.)
490
+ runGit(["add", "-A"]);
495
491
  runGit(["commit", "-m", commitMsg]);
496
492
  let hasRemote = false;
497
493
  try {
@@ -9,7 +9,7 @@ import { withFileLock } from "./shared-governance.js";
9
9
  export function register(server, ctx) {
10
10
  // ── search_fragments ──────────────────────────────────────────────────
11
11
  server.registerTool("search_fragments", {
12
- title: "phren : search fragments",
12
+ title: "phren · search fragments",
13
13
  description: "Search named fragments in the knowledge graph (libraries, tools, concepts mentioned in findings). " +
14
14
  "Returns matching fragment names and how many findings reference each.",
15
15
  inputSchema: z.object({
@@ -209,7 +209,7 @@ export function register(server, ctx) {
209
209
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [fragmentName, resolvedFragmentType, new Date().toISOString().slice(0, 10)]);
210
210
  }
211
211
  catch (err) {
212
- if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG))
212
+ if (process.env.PHREN_DEBUG)
213
213
  process.stderr.write(`[phren] link_findings fragmentInsert: ${errorMessage(err)}\n`);
214
214
  }
215
215
  const fragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [fragmentName, resolvedFragmentType]);
@@ -232,7 +232,7 @@ export function register(server, ctx) {
232
232
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [sourceDoc, "document", new Date().toISOString().slice(0, 10)]);
233
233
  }
234
234
  catch (err) {
235
- if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG))
235
+ if (process.env.PHREN_DEBUG)
236
236
  process.stderr.write(`[phren] link_findings docFragmentInsert: ${errorMessage(err)}\n`);
237
237
  }
238
238
  const docFragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [sourceDoc, "document"]);
@@ -245,7 +245,7 @@ export function register(server, ctx) {
245
245
  db.run("INSERT OR IGNORE INTO entity_links (source_id, target_id, rel_type, source_doc) VALUES (?, ?, ?, ?)", [sourceId, targetId, relType, sourceDoc]);
246
246
  }
247
247
  catch (err) {
248
- if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG))
248
+ if (process.env.PHREN_DEBUG)
249
249
  process.stderr.write(`[phren] link_findings linkInsert: ${errorMessage(err)}\n`);
250
250
  return mcpResponse({ ok: false, error: "Failed to insert fragment link." });
251
251
  }
@@ -255,7 +255,7 @@ export function register(server, ctx) {
255
255
  db.run("INSERT OR IGNORE INTO global_entities (entity, project, doc_key) VALUES (?, ?, ?)", [fragmentName, project, sourceDoc]);
256
256
  }
257
257
  catch (err) {
258
- if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG))
258
+ if (process.env.PHREN_DEBUG)
259
259
  process.stderr.write(`[phren] link_findings globalFragments: ${errorMessage(err)}\n`);
260
260
  }
261
261
  // 4b. Persist manual link so it survives index rebuilds (mandatory — failure aborts the operation)
@@ -268,7 +268,7 @@ export function register(server, ctx) {
268
268
  existing = JSON.parse(fs.readFileSync(manualLinksPath, "utf8"));
269
269
  }
270
270
  catch (err) {
271
- if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG))
271
+ if (process.env.PHREN_DEBUG)
272
272
  process.stderr.write(`[phren] link_findings manualLinksRead: ${errorMessage(err)}\n`);
273
273
  }
274
274
  }
@@ -23,7 +23,7 @@ function validateHookCommand(command) {
23
23
  return "Command too long (max 1000 characters).";
24
24
  // Reject shell metacharacters that allow injection or arbitrary execution
25
25
  // when the command is later run via `sh -c`.
26
- if (/[`$(){}&|;<>]/.test(trimmed)) {
26
+ if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
27
27
  return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < >";
28
28
  }
29
29
  // eval and source can execute arbitrary code
@@ -567,17 +567,12 @@ export function register(server, ctx) {
567
567
  if (!isValidProjectName(project))
568
568
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
569
569
  const includeHistory = include_history ?? include_superseded ?? false;
570
- const result = readFindings(phrenPath, project, { includeArchived: includeHistory });
570
+ // Always read with archive so we can compute historyCount without a second read
571
+ const result = readFindings(phrenPath, project, { includeArchived: true });
571
572
  if (!result.ok)
572
573
  return mcpResponse({ ok: false, error: result.error });
573
574
  const allItems = result.data;
574
- let historyCount = allItems.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
575
- if (!includeHistory) {
576
- const withArchive = readFindings(phrenPath, project, { includeArchived: true });
577
- if (withArchive.ok) {
578
- historyCount = withArchive.data.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
579
- }
580
- }
575
+ const historyCount = allItems.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length;
581
576
  const visibleItems = includeHistory
582
577
  ? allItems
583
578
  : allItems.filter(f => f.tier !== "archived" && !HISTORY_FINDING_STATUSES.has(f.status));
@@ -90,6 +90,7 @@ function extractResumptionHint(summary, fallbackNextStep, fallbackLastAttempt) {
90
90
  };
91
91
  }
92
92
  /** Per-connection session map keyed by arbitrary connection ID (if provided). */
93
+ const MAX_SESSION_MAP_ENTRIES = 200;
93
94
  const _sessionMap = new Map();
94
95
  function sessionsDir(phrenPath) {
95
96
  const dir = path.join(phrenPath, ".runtime", "sessions");
@@ -151,7 +152,10 @@ function lastSummaryPath(phrenPath) {
151
152
  function writeLastSummary(phrenPath, summary, sessionId, project) {
152
153
  try {
153
154
  const data = { summary, sessionId, project, endedAt: new Date().toISOString() };
154
- fs.writeFileSync(lastSummaryPath(phrenPath), JSON.stringify(data, null, 2));
155
+ const summaryFile = lastSummaryPath(phrenPath);
156
+ const tmpPath = `${summaryFile}.tmp-${crypto.randomUUID()}`;
157
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
158
+ fs.renameSync(tmpPath, summaryFile);
155
159
  }
156
160
  catch (err) {
157
161
  debugError("writeLastSummary", err);
@@ -417,8 +421,14 @@ export function register(server, ctx) {
417
421
  };
418
422
  const newFile = sessionFileForId(phrenPath, sessionId);
419
423
  writeSessionStateFile(newFile, next);
420
- if (connectionId)
424
+ if (connectionId) {
421
425
  _sessionMap.set(connectionId, sessionId);
426
+ if (_sessionMap.size > MAX_SESSION_MAP_ENTRIES) {
427
+ const oldest = _sessionMap.keys().next().value;
428
+ if (oldest !== undefined)
429
+ _sessionMap.delete(oldest);
430
+ }
431
+ }
422
432
  const parts = [];
423
433
  if (priorSummary) {
424
434
  parts.push(`## Last session\n${priorSummary}`);