@phren/cli 0.0.10 → 0.0.12

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 (100) hide show
  1. package/README.md +11 -17
  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 +58 -71
  7. package/mcp/dist/cli-config.js +337 -131
  8. package/mcp/dist/cli-extract.js +3 -2
  9. package/mcp/dist/cli-govern.js +35 -63
  10. package/mcp/dist/cli-graph.js +19 -4
  11. package/mcp/dist/cli-hooks-globs.js +2 -1
  12. package/mcp/dist/cli-hooks-output.js +4 -4
  13. package/mcp/dist/cli-hooks-session.js +1 -1
  14. package/mcp/dist/cli-hooks.js +44 -35
  15. package/mcp/dist/cli-namespaces.js +15 -5
  16. package/mcp/dist/cli-search.js +2 -2
  17. package/mcp/dist/cli.js +1 -1
  18. package/mcp/dist/content-archive.js +23 -14
  19. package/mcp/dist/content-citation.js +13 -2
  20. package/mcp/dist/content-dedup.js +9 -9
  21. package/mcp/dist/content-learning.js +6 -4
  22. package/mcp/dist/content-metadata.js +10 -0
  23. package/mcp/dist/core-finding.js +1 -1
  24. package/mcp/dist/data-access.js +10 -31
  25. package/mcp/dist/data-tasks.js +5 -26
  26. package/mcp/dist/embedding.js +7 -8
  27. package/mcp/dist/entrypoint.js +133 -102
  28. package/mcp/dist/finding-impact.js +1 -32
  29. package/mcp/dist/finding-journal.js +1 -1
  30. package/mcp/dist/finding-lifecycle.js +2 -7
  31. package/mcp/dist/governance-locks.js +12 -5
  32. package/mcp/dist/governance-policy.js +156 -9
  33. package/mcp/dist/governance-scores.js +4 -10
  34. package/mcp/dist/hooks.js +62 -18
  35. package/mcp/dist/index.js +4 -4
  36. package/mcp/dist/init-config.js +4 -25
  37. package/mcp/dist/init-preferences.js +1 -1
  38. package/mcp/dist/init-setup.js +6 -55
  39. package/mcp/dist/init-shared.js +53 -1
  40. package/mcp/dist/init.js +191 -29
  41. package/mcp/dist/link-checksums.js +3 -2
  42. package/mcp/dist/link-context.js +2 -2
  43. package/mcp/dist/link-doctor.js +14 -57
  44. package/mcp/dist/link-skills.js +98 -12
  45. package/mcp/dist/link.js +16 -75
  46. package/mcp/dist/machine-identity.js +1 -9
  47. package/mcp/dist/mcp-config.js +247 -42
  48. package/mcp/dist/mcp-data.js +9 -9
  49. package/mcp/dist/mcp-extract-facts.js +12 -7
  50. package/mcp/dist/mcp-extract.js +2 -2
  51. package/mcp/dist/mcp-finding.js +16 -20
  52. package/mcp/dist/mcp-graph.js +12 -12
  53. package/mcp/dist/mcp-hooks.js +1 -1
  54. package/mcp/dist/mcp-ops.js +18 -18
  55. package/mcp/dist/mcp-search.js +11 -16
  56. package/mcp/dist/mcp-session.js +12 -2
  57. package/mcp/dist/memory-ui-assets.js +1 -36
  58. package/mcp/dist/memory-ui-graph.js +152 -50
  59. package/mcp/dist/memory-ui-page.js +30 -5
  60. package/mcp/dist/memory-ui-scripts.js +252 -63
  61. package/mcp/dist/memory-ui-server.js +115 -3
  62. package/mcp/dist/phren-core.js +2 -0
  63. package/mcp/dist/phren-paths.js +8 -9
  64. package/mcp/dist/proactivity.js +5 -5
  65. package/mcp/dist/profile-store.js +2 -2
  66. package/mcp/dist/project-config.js +64 -17
  67. package/mcp/dist/provider-adapters.js +1 -1
  68. package/mcp/dist/query-correlation.js +22 -19
  69. package/mcp/dist/session-checkpoints.js +14 -14
  70. package/mcp/dist/session-utils.js +3 -2
  71. package/mcp/dist/shared-data-utils.js +28 -0
  72. package/mcp/dist/shared-fragment-graph.js +22 -21
  73. package/mcp/dist/shared-governance.js +1 -1
  74. package/mcp/dist/shared-index.js +144 -105
  75. package/mcp/dist/shared-retrieval.js +21 -23
  76. package/mcp/dist/shared-search-fallback.js +15 -25
  77. package/mcp/dist/shared-sqljs.js +3 -2
  78. package/mcp/dist/shared.js +5 -6
  79. package/mcp/dist/shell-entry.js +1 -1
  80. package/mcp/dist/shell-input.js +63 -53
  81. package/mcp/dist/shell-palette.js +6 -1
  82. package/mcp/dist/shell-render.js +9 -5
  83. package/mcp/dist/shell-state-store.js +2 -5
  84. package/mcp/dist/shell-view.js +7 -6
  85. package/mcp/dist/shell.js +5 -55
  86. package/mcp/dist/skill-files.js +4 -10
  87. package/mcp/dist/skill-registry.js +3 -0
  88. package/mcp/dist/status.js +43 -21
  89. package/mcp/dist/task-hygiene.js +1 -1
  90. package/mcp/dist/telemetry.js +5 -4
  91. package/mcp/dist/update.js +1 -1
  92. package/mcp/dist/utils.js +4 -4
  93. package/package.json +2 -3
  94. package/skills/docs.md +11 -11
  95. package/starter/README.md +1 -1
  96. package/starter/global/CLAUDE.md +2 -2
  97. package/starter/global/skills/audit.md +106 -0
  98. package/mcp/dist/cli-hooks-retrieval.js +0 -2
  99. package/mcp/dist/impact-scoring.js +0 -22
  100. package/mcp/dist/shared-paths.js +0 -1
@@ -144,7 +144,7 @@ export async function handlePruneMemories(args = []) {
144
144
  .filter((e) => e !== null);
145
145
  }
146
146
  catch (err) {
147
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
147
+ if ((process.env.PHREN_DEBUG))
148
148
  process.stderr.write(`[phren] cli-govern retrievalLog readParse: ${errorMessage(err)}\n`);
149
149
  }
150
150
  }
@@ -255,7 +255,7 @@ export async function handleGcMaintain(args = []) {
255
255
  const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
256
256
  let oldCommits = [];
257
257
  try {
258
- const raw = execSync(`git log --oneline --before="${sevenDaysAgo}" --format="%H %s"`, { cwd: phrenPath, encoding: "utf8" }).trim();
258
+ const raw = execSync(`git log --before="${sevenDaysAgo}" --format="%H %ci %s"`, { cwd: phrenPath, encoding: "utf8" }).trim();
259
259
  if (raw) {
260
260
  oldCommits = raw.split("\n").filter((l) => l.includes("auto-save:") || l.includes("[auto]"));
261
261
  }
@@ -271,23 +271,21 @@ export async function handleGcMaintain(args = []) {
271
271
  report.commitsSquashed = oldCommits.length;
272
272
  }
273
273
  else {
274
- // Group by ISO week (YYYY-Www) based on commit timestamp
274
+ // Group by ISO week based on commit timestamp (already in the log output)
275
275
  const commitsByWeek = new Map();
276
276
  for (const line of oldCommits) {
277
277
  const hash = line.split(" ")[0];
278
- try {
279
- const dateStr = execSync(`git log -1 --format="%ci" ${hash}`, { cwd: phrenPath, encoding: "utf8" }).trim();
280
- const date = new Date(dateStr);
281
- const weekStart = new Date(date);
282
- weekStart.setDate(date.getDate() - date.getDay()); // start of week (Sunday)
283
- const weekKey = weekStart.toISOString().slice(0, 10);
284
- if (!commitsByWeek.has(weekKey))
285
- commitsByWeek.set(weekKey, []);
286
- commitsByWeek.get(weekKey).push(hash);
287
- }
288
- catch {
289
- // Skip commits we can't resolve
290
- }
278
+ // Format: "HASH YYYY-MM-DD HH:MM:SS +ZZZZ subject..."
279
+ const dateMatch = line.match(/^[a-f0-9]+ (\d{4}-\d{2}-\d{2})/);
280
+ if (!dateMatch)
281
+ continue;
282
+ const date = new Date(dateMatch[1]);
283
+ const weekStart = new Date(date);
284
+ weekStart.setDate(date.getDate() - date.getDay());
285
+ const weekKey = weekStart.toISOString().slice(0, 10);
286
+ if (!commitsByWeek.has(weekKey))
287
+ commitsByWeek.set(weekKey, []);
288
+ commitsByWeek.get(weekKey).push(hash);
291
289
  }
292
290
  // For each week with multiple commits, soft-reset to oldest and amend into a summary
293
291
  for (const [weekKey, hashes] of commitsByWeek.entries()) {
@@ -320,64 +318,38 @@ export async function handleGcMaintain(args = []) {
320
318
  console.log("Commit squash: all old auto-save weeks have only one commit, nothing to squash.");
321
319
  }
322
320
  }
323
- // 3. Prune stale session markers from ~/.phren/.sessions/ older than 30 days
324
- const sessionsDir = path.join(phrenPath, ".sessions");
321
+ // 3–4. Prune stale files from .sessions/ and .runtime/
325
322
  const thirtyDaysAgo = Date.now() - 30 * 86400000;
326
- if (fs.existsSync(sessionsDir)) {
327
- const entries = fs.readdirSync(sessionsDir);
328
- for (const entry of entries) {
329
- const fullPath = path.join(sessionsDir, entry);
330
- try {
331
- const stat = fs.statSync(fullPath);
332
- if (stat.mtimeMs < thirtyDaysAgo) {
333
- if (dryRun) {
334
- console.log(`[dry-run] Would remove session marker: .sessions/${entry}`);
335
- }
336
- else {
337
- fs.unlinkSync(fullPath);
338
- }
339
- report.sessionsRemoved++;
340
- }
341
- }
342
- catch {
343
- // Skip unreadable entries
344
- }
345
- }
346
- }
347
- const sessionsVerb = dryRun ? "Would remove" : "Removed";
348
- console.log(`${sessionsVerb} ${report.sessionsRemoved} stale session marker(s) from .sessions/`);
349
- // 4. Trim runtime logs from ~/.phren/.runtime/ older than 30 days
350
- const runtimeDir = path.join(phrenPath, ".runtime");
351
- const logExtensions = new Set([".log", ".jsonl", ".json"]);
352
- if (fs.existsSync(runtimeDir)) {
353
- const entries = fs.readdirSync(runtimeDir);
354
- for (const entry of entries) {
355
- const ext = path.extname(entry);
356
- if (!logExtensions.has(ext))
357
- continue;
358
- // Never trim the active audit log or telemetry config
359
- if (entry === "audit.log" || entry === "telemetry.json")
323
+ function pruneStaleFiles(dir, label, filter) {
324
+ let removed = 0;
325
+ if (!fs.existsSync(dir))
326
+ return removed;
327
+ for (const entry of fs.readdirSync(dir)) {
328
+ if (filter && !filter(entry))
360
329
  continue;
361
- const fullPath = path.join(runtimeDir, entry);
330
+ const fullPath = path.join(dir, entry);
362
331
  try {
363
- const stat = fs.statSync(fullPath);
364
- if (stat.mtimeMs < thirtyDaysAgo) {
332
+ if (fs.statSync(fullPath).mtimeMs < thirtyDaysAgo) {
365
333
  if (dryRun) {
366
- console.log(`[dry-run] Would remove runtime log: .runtime/${entry}`);
334
+ console.log(`[dry-run] Would remove: ${label}/${entry}`);
367
335
  }
368
336
  else {
369
337
  fs.unlinkSync(fullPath);
370
338
  }
371
- report.runtimeLogsRemoved++;
339
+ removed++;
372
340
  }
373
341
  }
374
- catch {
375
- // Skip unreadable entries
376
- }
342
+ catch { /* skip unreadable */ }
377
343
  }
344
+ return removed;
378
345
  }
379
- const logsVerb = dryRun ? "Would remove" : "Removed";
380
- console.log(`${logsVerb} ${report.runtimeLogsRemoved} stale runtime log(s) from .runtime/`);
346
+ const logExtensions = new Set([".log", ".jsonl", ".json"]);
347
+ const protectedFiles = new Set(["audit.log", "telemetry.json"]);
348
+ report.sessionsRemoved = pruneStaleFiles(path.join(phrenPath, ".sessions"), ".sessions");
349
+ report.runtimeLogsRemoved = pruneStaleFiles(path.join(phrenPath, ".runtime"), ".runtime", (entry) => logExtensions.has(path.extname(entry)) && !protectedFiles.has(entry));
350
+ const verb = dryRun ? "Would remove" : "Removed";
351
+ console.log(`${verb} ${report.sessionsRemoved} stale session marker(s) from .sessions/`);
352
+ console.log(`${verb} ${report.runtimeLogsRemoved} stale runtime log(s) from .runtime/`);
381
353
  // 5. Summary
382
354
  if (!dryRun) {
383
355
  appendAuditLog(phrenPath, "maintain_gc", `gitGc=${report.gitGcRan} squashed=${report.commitsSquashed} sessions=${report.sessionsRemoved} logs=${report.runtimeLogsRemoved}`);
@@ -529,7 +501,7 @@ export async function handleBackgroundMaintenance(projectArg) {
529
501
  fs.unlinkSync(markers.lock);
530
502
  }
531
503
  catch (err) {
532
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
504
+ if ((process.env.PHREN_DEBUG))
533
505
  process.stderr.write(`[phren] cli-govern backgroundMaintenance unlockFinal: ${errorMessage(err)}\n`);
534
506
  }
535
507
  }
@@ -1,6 +1,7 @@
1
1
  import { getPhrenPath } from "./shared.js";
2
2
  import { buildIndex, queryRows } from "./shared-index.js";
3
3
  import { resolveRuntimeProfile } from "./runtime-profile.js";
4
+ import { isValidProjectName, errorMessage } from "./utils.js";
4
5
  /**
5
6
  * CLI: phren graph [--project <name>] [--limit <n>]
6
7
  * Displays the fragment knowledge graph as a table.
@@ -18,6 +19,10 @@ export async function handleGraphRead(args) {
18
19
  limit = Math.min(Math.max(parseInt(args[++i], 10) || 20, 1), 200);
19
20
  }
20
21
  }
22
+ if (project && !isValidProjectName(project)) {
23
+ console.error(`Invalid project name: "${project}".`);
24
+ process.exit(1);
25
+ }
21
26
  const db = await buildIndex(phrenPath, profile);
22
27
  let sql;
23
28
  let params;
@@ -74,6 +79,10 @@ export async function handleGraphLink(args) {
74
79
  process.exit(1);
75
80
  }
76
81
  const [project, findingText, fragmentName] = args;
82
+ if (!isValidProjectName(project)) {
83
+ console.error(`Invalid project name: "${project}".`);
84
+ process.exit(1);
85
+ }
77
86
  const phrenPath = getPhrenPath();
78
87
  const profile = resolveRuntimeProfile(phrenPath);
79
88
  const db = await buildIndex(phrenPath, profile);
@@ -94,7 +103,10 @@ export async function handleGraphLink(args) {
94
103
  try {
95
104
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [normalizedFragment, "fragment", new Date().toISOString().slice(0, 10)]);
96
105
  }
97
- catch { /* best effort */ }
106
+ catch (err) {
107
+ if (process.env.PHREN_DEBUG)
108
+ process.stderr.write(`[phren] graph link insert fragment: ${errorMessage(err)}\n`);
109
+ }
98
110
  const fragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [normalizedFragment, "fragment"]);
99
111
  if (!fragmentResult?.length || !fragmentResult[0]?.values?.length) {
100
112
  console.error("Failed to create fragment.");
@@ -104,7 +116,10 @@ export async function handleGraphLink(args) {
104
116
  try {
105
117
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [sourceDoc, "document", new Date().toISOString().slice(0, 10)]);
106
118
  }
107
- catch { /* best effort */ }
119
+ catch (err) {
120
+ if (process.env.PHREN_DEBUG)
121
+ process.stderr.write(`[phren] graph link insert document: ${errorMessage(err)}\n`);
122
+ }
108
123
  const docResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [sourceDoc, "document"]);
109
124
  if (!docResult?.length || !docResult[0]?.values?.length) {
110
125
  console.error("Failed to create document fragment.");
@@ -115,7 +130,7 @@ export async function handleGraphLink(args) {
115
130
  db.run("INSERT OR IGNORE INTO entity_links (source_id, target_id, rel_type, source_doc) VALUES (?, ?, ?, ?)", [sourceId, targetId, "mentions", sourceDoc]);
116
131
  }
117
132
  catch (err) {
118
- console.error(`Failed to link: ${err instanceof Error ? err.message : String(err)}`);
133
+ console.error(`Failed to link: ${errorMessage(err)}`);
119
134
  process.exit(1);
120
135
  }
121
136
  // Persist to manual-links.json
@@ -144,7 +159,7 @@ export async function handleGraphLink(args) {
144
159
  });
145
160
  }
146
161
  catch (err) {
147
- console.error(`Failed to persist manual link: ${err instanceof Error ? err.message : String(err)}`);
162
+ console.error(`Failed to persist manual link: ${errorMessage(err)}`);
148
163
  process.exit(1);
149
164
  }
150
165
  console.log(`Linked "${fragmentName}" to ${sourceDoc}.`);
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { capCache } from "./shared.js";
4
+ import { errorMessage } from "./utils.js";
4
5
  // ── Glob matching and project frontmatter ────────────────────────────────────
5
6
  const projectGlobCache = new Map();
6
7
  export function clearProjectGlobCache() {
@@ -45,7 +46,7 @@ function parseProjectGlobs(phrenPathLocal, project) {
45
46
  }
46
47
  catch (err) {
47
48
  if (process.env.PHREN_DEBUG)
48
- process.stderr.write(`[phren] getProjectGlobs: ${err instanceof Error ? err.message : String(err)}\n`);
49
+ process.stderr.write(`[phren] getProjectGlobs: ${errorMessage(err)}\n`);
49
50
  }
50
51
  projectGlobCache.set(project, globs);
51
52
  capCache(projectGlobCache);
@@ -1,7 +1,7 @@
1
1
  import { recordInjection, recordRetrieval, } from "./shared-governance.js";
2
2
  import { getDocSourceKey, } from "./shared-index.js";
3
3
  import { logImpact, extractFindingIdsFromSnippet, } from "./finding-impact.js";
4
- import { isFeatureEnabled } from "./utils.js";
4
+ import { isFeatureEnabled, errorMessage } from "./utils.js";
5
5
  import { annotateStale } from "./cli-hooks-citations.js";
6
6
  import { approximateTokens, fileRelevanceBoost, branchMatchBoost } from "./shared-retrieval.js";
7
7
  // ── Progressive disclosure helpers ────────────────────────────────────────────
@@ -56,7 +56,7 @@ export function buildHookOutput(selected, usedTokens, intent, gitCtx, detectedPr
56
56
  }
57
57
  catch (err) {
58
58
  if (process.env.PHREN_DEBUG)
59
- process.stderr.write(`[phren] injectContext recordRetrieval: ${err instanceof Error ? err.message : String(err)}\n`);
59
+ process.stderr.write(`[phren] injectContext recordRetrieval: ${errorMessage(err)}\n`);
60
60
  }
61
61
  }
62
62
  }
@@ -108,7 +108,7 @@ export function buildHookOutput(selected, usedTokens, intent, gitCtx, detectedPr
108
108
  }
109
109
  catch (err) {
110
110
  if (process.env.PHREN_DEBUG)
111
- process.stderr.write(`[phren] injectContext recordRetrievalOrdered: ${err instanceof Error ? err.message : String(err)}\n`);
111
+ process.stderr.write(`[phren] injectContext recordRetrievalOrdered: ${errorMessage(err)}\n`);
112
112
  }
113
113
  parts.push(`[${getDocSourceKey(doc, phrenPathLocal)}] (${doc.type})`);
114
114
  parts.push(annotateStale(snippet));
@@ -116,7 +116,7 @@ export function buildHookOutput(selected, usedTokens, intent, gitCtx, detectedPr
116
116
  }
117
117
  }
118
118
  logImpact(phrenPathLocal, impactEntries);
119
- parts.push("<phren-context>");
119
+ parts.push("</phren-context>");
120
120
  const changedCount = gitCtx?.changedFiles.size ?? 0;
121
121
  if (gitCtx) {
122
122
  const fileHits = selected.filter((r) => fileRelevanceBoost(r.doc.path, gitCtx.changedFiles) > 0).length;
@@ -749,7 +749,7 @@ export async function handleHookStop() {
749
749
  }
750
750
  catch (err) {
751
751
  if (process.env.PHREN_DEBUG)
752
- process.stderr.write(`[phren] hookSessionStart transcriptParse: ${errorMessage(err)}\n`);
752
+ process.stderr.write(`[phren] hookStop transcriptParse: ${errorMessage(err)}\n`);
753
753
  }
754
754
  }
755
755
  captureInput = assistantTexts.join("\n");
@@ -5,7 +5,7 @@
5
5
  // cli-hooks-output.ts — hook output formatting
6
6
  // cli-hooks-globs.ts — project glob matching
7
7
  import { debugLog, sessionMarker, sessionsDir, getPhrenPath, } from "./shared.js";
8
- import { getRetentionPolicy, getWorkflowPolicy, } from "./shared-governance.js";
8
+ import { mergeConfig, } from "./shared-governance.js";
9
9
  import { buildIndex, detectProject, } from "./shared-index.js";
10
10
  import { isProjectHookEnabled } from "./project-config.js";
11
11
  import { checkConsolidationNeeded, } from "./shared-content.js";
@@ -126,7 +126,7 @@ export async function handleHookPrompt() {
126
126
  }
127
127
  catch (err) {
128
128
  if (process.env.PHREN_DEBUG)
129
- process.stderr.write(`[phren] hookPrompt stdinRead: ${err instanceof Error ? err.message : String(err)}\n`);
129
+ process.stderr.write(`[phren] hookPrompt stdinRead: ${errorMessage(err)}\n`);
130
130
  process.exit(0);
131
131
  }
132
132
  const input = parseHookInput(raw);
@@ -164,7 +164,9 @@ export async function handleHookPrompt() {
164
164
  for (const kw of keywordEntries) {
165
165
  sessionTopics[kw] = (sessionTopics[kw] ?? 0) + 1;
166
166
  }
167
- fs.writeFileSync(topicFile, JSON.stringify(sessionTopics));
167
+ const topicTmp = `${topicFile}.tmp-${process.pid}`;
168
+ fs.writeFileSync(topicTmp, JSON.stringify(sessionTopics));
169
+ fs.renameSync(topicTmp, topicFile);
168
170
  // Find hot topics (3+ mentions this session)
169
171
  hotTopics = Object.entries(sessionTopics)
170
172
  .filter(([, count]) => count >= 3)
@@ -182,6 +184,7 @@ export async function handleHookPrompt() {
182
184
  appendAuditLog(getPhrenPath(), "hook_prompt", `status=project_disabled project=${detectedProject}`);
183
185
  process.exit(0);
184
186
  }
187
+ const resolvedConfig = mergeConfig(getPhrenPath(), detectedProject ?? undefined);
185
188
  const safeQuery = buildRobustFtsQuery(keywords, detectedProject, getPhrenPath());
186
189
  if (!safeQuery)
187
190
  process.exit(0);
@@ -193,14 +196,17 @@ export async function handleHookPrompt() {
193
196
  process.exit(0);
194
197
  autoLearnQuerySynonyms(getPhrenPath(), detectedProject, keywordEntries, rows);
195
198
  const tTrust0 = Date.now();
196
- const policy = getRetentionPolicy(getPhrenPath());
199
+ const policy = resolvedConfig.retentionPolicy;
197
200
  const memoryTtlDays = Number.parseInt(process.env.PHREN_MEMORY_TTL_DAYS || String(policy.ttlDays), 10);
198
201
  const trustResult = applyTrustFilter(rows, Number.isNaN(memoryTtlDays) ? policy.ttlDays : memoryTtlDays, policy.minInjectConfidence, policy.decay, getPhrenPath());
199
202
  rows = trustResult.rows;
200
203
  stage.trustMs = Date.now() - tTrust0;
201
204
  if (!rows.length)
202
205
  process.exit(0);
203
- if (isFeatureEnabled("PHREN_FEATURE_AUTO_EXTRACT", true) && getProactivityLevelForFindings(getPhrenPath()) !== "low" && sessionId && detectedProject && cwd) {
206
+ const findingsProactivity = resolvedConfig.proactivity.findings
207
+ ?? resolvedConfig.proactivity.base
208
+ ?? getProactivityLevelForFindings(getPhrenPath());
209
+ if (isFeatureEnabled("PHREN_FEATURE_AUTO_EXTRACT", true) && findingsProactivity !== "low" && sessionId && detectedProject && cwd) {
204
210
  const marker = sessionMarker(getPhrenPath(), `extracted-${sessionId}-${detectedProject}`);
205
211
  if (!fs.existsSync(marker)) {
206
212
  try {
@@ -260,7 +266,9 @@ export async function handleHookPrompt() {
260
266
  debugLog(`injection-budget: trimmed ${selected.length} -> ${kept.length} snippets to fit ${maxInjectTokens} token budget`);
261
267
  }
262
268
  const parts = buildHookOutput(budgetSelected, budgetUsedTokens, intent, gitCtx, detectedProject, stage, safeTokenBudget, getPhrenPath(), sessionId);
263
- const taskLevel = getProactivityLevelForTask(getPhrenPath());
269
+ const taskLevel = resolvedConfig.proactivity.tasks
270
+ ?? resolvedConfig.proactivity.base
271
+ ?? getProactivityLevelForTask(getPhrenPath());
264
272
  const taskLifecycle = handleTaskPromptLifecycle({
265
273
  phrenPath: getPhrenPath(),
266
274
  prompt,
@@ -275,8 +283,7 @@ export async function handleHookPrompt() {
275
283
  }
276
284
  // Inject finding sensitivity agent instruction
277
285
  try {
278
- const workflowPolicy = getWorkflowPolicy(getPhrenPath());
279
- const sensitivity = workflowPolicy.findingSensitivity ?? "balanced";
286
+ const sensitivity = resolvedConfig.findingSensitivity ?? "balanced";
280
287
  const sensitivityConfig = FINDING_SENSITIVITY_CONFIG[sensitivity];
281
288
  if (sensitivityConfig) {
282
289
  parts.push("");
@@ -304,36 +311,38 @@ export async function handleHookPrompt() {
304
311
  const noticeFile = sessionId ? sessionMarker(getPhrenPath(), `noticed-${sessionId}`) : null;
305
312
  const alreadyNoticed = noticeFile ? fs.existsSync(noticeFile) : false;
306
313
  if (!alreadyNoticed) {
307
- // Clean up stale session markers (>24h old) from .sessions/ dir
308
- try {
309
- const cutoff = Date.now() - 86400000;
310
- const sessDir = sessionsDir(getPhrenPath());
311
- if (fs.existsSync(sessDir)) {
312
- for (const f of fs.readdirSync(sessDir)) {
313
- if (!f.startsWith("noticed-") && !f.startsWith("extracted-"))
314
+ // Defer stale session marker cleanup to after output — it's I/O-heavy and not needed for results
315
+ setImmediate(() => {
316
+ try {
317
+ const cutoff = Date.now() - 86400000;
318
+ const sessDir = sessionsDir(getPhrenPath());
319
+ if (fs.existsSync(sessDir)) {
320
+ for (const f of fs.readdirSync(sessDir)) {
321
+ if (!f.startsWith("noticed-") && !f.startsWith("extracted-"))
322
+ continue;
323
+ const fp = `${sessDir}/${f}`;
324
+ try {
325
+ if (fs.statSync(fp).mtimeMs < cutoff)
326
+ fs.unlinkSync(fp);
327
+ }
328
+ catch { /* ignore per-file errors */ }
329
+ }
330
+ }
331
+ // Also clean stale markers from the phren root
332
+ for (const f of fs.readdirSync(getPhrenPath())) {
333
+ if (!f.startsWith(".noticed-") && !f.startsWith(".extracted-"))
314
334
  continue;
315
- const fp = `${sessDir}/${f}`;
316
- if (fs.statSync(fp).mtimeMs < cutoff)
335
+ const fp = `${getPhrenPath()}/${f}`;
336
+ try {
317
337
  fs.unlinkSync(fp);
338
+ }
339
+ catch { /* ignore */ }
318
340
  }
319
341
  }
320
- // Also clean stale markers from the phren root
321
- for (const f of fs.readdirSync(getPhrenPath())) {
322
- if (!f.startsWith(".noticed-") && !f.startsWith(".extracted-"))
323
- continue;
324
- const fp = `${getPhrenPath()}/${f}`;
325
- try {
326
- fs.unlinkSync(fp);
327
- }
328
- catch (err) {
329
- if (process.env.PHREN_DEBUG)
330
- process.stderr.write(`[phren] hookPrompt staleNoticeUnlink: ${errorMessage(err)}\n`);
331
- }
342
+ catch (err) {
343
+ debugLog(`stale notice cleanup failed: ${errorMessage(err)}`);
332
344
  }
333
- }
334
- catch (err) {
335
- debugLog(`stale notice cleanup failed: ${errorMessage(err)}`);
336
- }
345
+ });
337
346
  const needed = checkConsolidationNeeded(getPhrenPath(), profile);
338
347
  if (needed.length > 0) {
339
348
  const notices = needed.map((n) => {
@@ -345,7 +354,7 @@ export async function handleHookPrompt() {
345
354
  parts.push(`Findings ready for consolidation:`);
346
355
  parts.push(notices.join("\n"));
347
356
  parts.push(`Run phren-consolidate when ready.`);
348
- parts.push(`<phren-notice>`);
357
+ parts.push(`</phren-notice>`);
349
358
  }
350
359
  if (noticeFile) {
351
360
  try {
@@ -367,7 +376,7 @@ export async function handleHookPrompt() {
367
376
  }
368
377
  catch (err) {
369
378
  const msg = errorMessage(err);
370
- process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.<phren-error>\n`);
379
+ process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.</phren-error>\n`);
371
380
  debugLog(`hook-prompt error: ${msg}`);
372
381
  process.exit(0);
373
382
  }
@@ -5,6 +5,7 @@ import { expandHomePath, findProjectNameCaseInsensitive, getPhrenPath, getProjec
5
5
  import { isValidProjectName, errorMessage } from "./utils.js";
6
6
  import { readInstallPreferences, writeInstallPreferences } from "./init-preferences.js";
7
7
  import { buildSkillManifest, findLocalSkill, findSkill, getAllSkills } from "./skill-registry.js";
8
+ import { detectSkillCollisions } from "./link-skills.js";
8
9
  import { setSkillEnabledAndSync, syncSkillLinksForScope } from "./skill-files.js";
9
10
  import { findProjectDir } from "./project-locator.js";
10
11
  import { TASK_FILE_ALIASES, addTask, completeTask, updateTask, reorderTask, pinTask, removeTask, workNextTask, tidyDoneTasks, linkTaskIssue, promoteTask, resolveTaskItem } from "./data-tasks.js";
@@ -76,7 +77,7 @@ function openInEditor(filePath) {
76
77
  execFileSync(editor, [filePath], { stdio: "inherit" });
77
78
  }
78
79
  catch (err) {
79
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
80
+ if ((process.env.PHREN_DEBUG))
80
81
  process.stderr.write(`[phren] openInEditor: ${errorMessage(err)}\n`);
81
82
  console.error(`Editor "${editor}" failed. Set $EDITOR to your preferred editor.`);
82
83
  process.exit(1);
@@ -145,7 +146,7 @@ export function handleSkillsNamespace(args, profile) {
145
146
  console.log(`Linked skill ${fileName} into ${project}.`);
146
147
  }
147
148
  catch (err) {
148
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
149
+ if ((process.env.PHREN_DEBUG))
149
150
  process.stderr.write(`[phren] skill add symlinkFailed: ${errorMessage(err)}\n`);
150
151
  fs.copyFileSync(source, dest);
151
152
  console.log(`Copied skill ${fileName} into ${project}.`);
@@ -450,7 +451,7 @@ export function handleDetectSkills(args, profile) {
450
451
  continue;
451
452
  }
452
453
  catch (err) {
453
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
454
+ if ((process.env.PHREN_DEBUG))
454
455
  process.stderr.write(`[phren] skillList lstat: ${errorMessage(err)}\n`);
455
456
  }
456
457
  const name = entry.replace(/\.md$/, "");
@@ -548,6 +549,15 @@ function printSkillDoctor(scope, manifest, destDir) {
548
549
  problems.push(`Mirror drift for ${skill.name}: expected ${dest} -> ${skill.root}`);
549
550
  }
550
551
  }
552
+ // Check for user-owned files blocking phren skill links
553
+ const phrenPath = getPhrenPath();
554
+ const srcDir = scope.toLowerCase() === "global"
555
+ ? path.join(phrenPath, "global", "skills")
556
+ : path.join(phrenPath, scope, "skills");
557
+ const collisions = detectSkillCollisions(srcDir, destDir, phrenPath);
558
+ for (const collision of collisions) {
559
+ problems.push(`Skill collision: ${collision.message}`);
560
+ }
551
561
  }
552
562
  if (!manifest.problems.length && !problems.length) {
553
563
  console.log("\nDoctor: no skill pipeline issues detected.");
@@ -853,7 +863,7 @@ function handleProjectsList(profile) {
853
863
  dirFiles = new Set(fs.readdirSync(projectDir));
854
864
  }
855
865
  catch (err) {
856
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
866
+ if ((process.env.PHREN_DEBUG))
857
867
  process.stderr.write(`[phren] projects list readdir: ${errorMessage(err)}\n`);
858
868
  dirFiles = new Set();
859
869
  }
@@ -896,7 +906,7 @@ async function handleProjectsRemove(name, profile) {
896
906
  countFiles(projectDir);
897
907
  }
898
908
  catch (err) {
899
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
909
+ if ((process.env.PHREN_DEBUG))
900
910
  process.stderr.write(`[phren] projects remove countFiles: ${errorMessage(err)}\n`);
901
911
  }
902
912
  const readline = await import("readline");
@@ -34,7 +34,7 @@ export function readSearchHistory(phrenPath) {
34
34
  .map((line) => JSON.parse(line));
35
35
  }
36
36
  catch (err) {
37
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
37
+ if ((process.env.PHREN_DEBUG))
38
38
  process.stderr.write(`[phren] readSearchHistory: ${errorMessage(err)}\n`);
39
39
  return [];
40
40
  }
@@ -253,7 +253,7 @@ export async function runSearch(opts, phrenPath, profile) {
253
253
  logSearchMiss(phrenPath, opts.query, opts.project);
254
254
  }
255
255
  catch (err) {
256
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
256
+ if ((process.env.PHREN_DEBUG))
257
257
  process.stderr.write(`[phren] search logSearchMiss: ${errorMessage(err)}\n`);
258
258
  }
259
259
  }
package/mcp/dist/cli.js CHANGED
@@ -108,7 +108,7 @@ export async function runCliCommand(command, args) {
108
108
  case "session-context":
109
109
  return handleSessionContext();
110
110
  default:
111
- console.error(`Unknown command: ${command}`);
111
+ console.error(`Unknown command: ${command}\nRun 'phren --help' for available commands.`);
112
112
  process.exit(1);
113
113
  }
114
114
  }
@@ -77,12 +77,11 @@ function parseActiveEntries(content) {
77
77
  /**
78
78
  * Check whether a bullet already exists in a reference file (already archived).
79
79
  */
80
- function isAlreadyArchived(referenceDir, bullet) {
80
+ /** Build a Set of normalized bullet strings from all .md files in referenceDir. */
81
+ function buildArchivedBulletSet(referenceDir) {
82
+ const bulletSet = new Set();
81
83
  if (!fs.existsSync(referenceDir))
82
- return false;
83
- const normalizedBullet = stripComments(bullet).replace(/^-\s+/, "").trim().toLowerCase();
84
- if (!normalizedBullet)
85
- return false;
84
+ return bulletSet;
86
85
  try {
87
86
  const stack = [referenceDir];
88
87
  while (stack.length > 0) {
@@ -100,17 +99,23 @@ function isAlreadyArchived(referenceDir, bullet) {
100
99
  if (!line.startsWith("- "))
101
100
  continue;
102
101
  const normalizedLine = stripComments(line).replace(/^-\s+/, "").trim().toLowerCase();
103
- if (normalizedLine === normalizedBullet)
104
- return true;
102
+ if (normalizedLine)
103
+ bulletSet.add(normalizedLine);
105
104
  }
106
105
  }
107
106
  }
108
107
  }
109
108
  catch (err) {
110
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
111
- process.stderr.write(`[phren] isDuplicateInReference: ${errorMessage(err)}\n`);
109
+ if ((process.env.PHREN_DEBUG))
110
+ process.stderr.write(`[phren] buildArchivedBulletSet: ${errorMessage(err)}\n`);
112
111
  }
113
- return false;
112
+ return bulletSet;
113
+ }
114
+ function isAlreadyArchived(archivedSet, bullet) {
115
+ const normalizedBullet = stripComments(bullet).replace(/^-\s+/, "").trim().toLowerCase();
116
+ if (!normalizedBullet)
117
+ return false;
118
+ return archivedSet.has(normalizedBullet);
114
119
  }
115
120
  /**
116
121
  * Archive the oldest entries from FINDINGS.md into reference/{topic}.md files.
@@ -152,8 +157,11 @@ export function autoArchiveToReference(phrenPath, project, keepCount) {
152
157
  throw wxErr;
153
158
  }
154
159
  }
155
- catch {
156
- return phrenErr("Consolidation already running", PhrenError.LOCK_TIMEOUT);
160
+ catch (innerErr) {
161
+ if (innerErr.code === "EEXIST" || innerErr.code === "ENOENT") {
162
+ return phrenErr("Consolidation already running", PhrenError.LOCK_TIMEOUT);
163
+ }
164
+ throw innerErr;
157
165
  }
158
166
  }
159
167
  else {
@@ -173,9 +181,10 @@ export function autoArchiveToReference(phrenPath, project, keepCount) {
173
181
  const referenceDir = path.join(resolvedDir, "reference");
174
182
  const { topics } = readProjectTopics(phrenPath, project);
175
183
  const today = new Date().toISOString().slice(0, 10);
184
+ const archivedSet = buildArchivedBulletSet(referenceDir);
176
185
  const actuallyArchived = [];
177
186
  for (const entry of toArchive) {
178
- if (isAlreadyArchived(referenceDir, entry.bullet)) {
187
+ if (isAlreadyArchived(archivedSet, entry.bullet)) {
179
188
  debugLog(`auto_archive: skipping already-archived entry: "${entry.bullet.slice(0, 60)}"`);
180
189
  continue;
181
190
  }
@@ -271,7 +280,7 @@ export function autoArchiveToReference(phrenPath, project, keepCount) {
271
280
  fs.unlinkSync(lockFile);
272
281
  }
273
282
  catch (err) {
274
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
283
+ if ((process.env.PHREN_DEBUG))
275
284
  process.stderr.write(`[phren] autoArchiveToReference unlockFile: ${errorMessage(err)}\n`);
276
285
  }
277
286
  }