@phren/cli 0.0.9 → 0.0.11

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 (67) hide show
  1. package/README.md +2 -8
  2. package/mcp/dist/cli-actions.js +5 -5
  3. package/mcp/dist/cli-config.js +334 -127
  4. package/mcp/dist/cli-govern.js +140 -3
  5. package/mcp/dist/cli-graph.js +3 -2
  6. package/mcp/dist/cli-hooks-globs.js +2 -1
  7. package/mcp/dist/cli-hooks-output.js +3 -3
  8. package/mcp/dist/cli-hooks.js +41 -34
  9. package/mcp/dist/cli-namespaces.js +15 -5
  10. package/mcp/dist/cli-search.js +2 -2
  11. package/mcp/dist/content-archive.js +2 -2
  12. package/mcp/dist/content-citation.js +12 -22
  13. package/mcp/dist/content-dedup.js +9 -9
  14. package/mcp/dist/data-access.js +1 -1
  15. package/mcp/dist/data-tasks.js +23 -0
  16. package/mcp/dist/embedding.js +7 -7
  17. package/mcp/dist/entrypoint.js +129 -102
  18. package/mcp/dist/governance-locks.js +6 -5
  19. package/mcp/dist/governance-policy.js +155 -2
  20. package/mcp/dist/governance-scores.js +3 -3
  21. package/mcp/dist/hooks.js +39 -18
  22. package/mcp/dist/index.js +4 -4
  23. package/mcp/dist/init-config.js +3 -24
  24. package/mcp/dist/init-setup.js +5 -5
  25. package/mcp/dist/init.js +170 -23
  26. package/mcp/dist/link-checksums.js +3 -2
  27. package/mcp/dist/link-context.js +1 -1
  28. package/mcp/dist/link-doctor.js +3 -3
  29. package/mcp/dist/link-skills.js +98 -12
  30. package/mcp/dist/link.js +17 -27
  31. package/mcp/dist/machine-identity.js +1 -9
  32. package/mcp/dist/mcp-config.js +247 -42
  33. package/mcp/dist/mcp-data.js +9 -9
  34. package/mcp/dist/mcp-extract-facts.js +1 -1
  35. package/mcp/dist/mcp-extract.js +2 -2
  36. package/mcp/dist/mcp-finding.js +6 -6
  37. package/mcp/dist/mcp-graph.js +11 -11
  38. package/mcp/dist/mcp-ops.js +18 -18
  39. package/mcp/dist/mcp-search.js +8 -8
  40. package/mcp/dist/mcp-tasks.js +21 -1
  41. package/mcp/dist/memory-ui-page.js +23 -0
  42. package/mcp/dist/memory-ui-scripts.js +210 -27
  43. package/mcp/dist/memory-ui-server.js +115 -3
  44. package/mcp/dist/phren-paths.js +7 -7
  45. package/mcp/dist/profile-store.js +2 -2
  46. package/mcp/dist/project-config.js +63 -16
  47. package/mcp/dist/session-utils.js +3 -2
  48. package/mcp/dist/shared-fragment-graph.js +22 -21
  49. package/mcp/dist/shared-index.js +144 -105
  50. package/mcp/dist/shared-retrieval.js +22 -56
  51. package/mcp/dist/shared-search-fallback.js +13 -13
  52. package/mcp/dist/shared-sqljs.js +3 -2
  53. package/mcp/dist/shared.js +3 -3
  54. package/mcp/dist/shell-input.js +1 -1
  55. package/mcp/dist/shell-state-store.js +1 -1
  56. package/mcp/dist/shell-view.js +3 -2
  57. package/mcp/dist/shell.js +1 -1
  58. package/mcp/dist/skill-files.js +4 -10
  59. package/mcp/dist/skill-registry.js +3 -0
  60. package/mcp/dist/status.js +41 -13
  61. package/mcp/dist/task-hygiene.js +1 -1
  62. package/mcp/dist/telemetry.js +5 -4
  63. package/mcp/dist/update.js +1 -1
  64. package/mcp/dist/utils.js +3 -3
  65. package/package.json +2 -2
  66. package/starter/global/skills/audit.md +106 -0
  67. package/mcp/dist/shared-paths.js +0 -1
package/mcp/dist/hooks.js CHANGED
@@ -1,18 +1,12 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { createHmac, randomUUID } from "crypto";
3
+ import { createHmac } from "crypto";
4
4
  import { execFileSync } from "child_process";
5
5
  import { fileURLToPath } from "url";
6
- import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile } from "./shared.js";
6
+ import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile, atomicWriteText } from "./shared.js";
7
7
  import { errorMessage } from "./utils.js";
8
8
  import { hookConfigPath } from "./provider-adapters.js";
9
9
  import { PACKAGE_SPEC } from "./package-metadata.js";
10
- function atomicWriteText(filePath, content) {
11
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
12
- const tmpPath = `${filePath}.tmp-${randomUUID()}`;
13
- fs.writeFileSync(tmpPath, content);
14
- fs.renameSync(tmpPath, filePath);
15
- }
16
10
  export function commandExists(cmd) {
17
11
  try {
18
12
  const whichCmd = process.platform === "win32" ? "where.exe" : "which";
@@ -215,10 +209,34 @@ function validateCodexConfig(config) {
215
209
  Array.isArray(config.hooks?.UserPromptSubmit) &&
216
210
  Array.isArray(config.hooks?.Stop));
217
211
  }
212
+ // ── mtime-based install-preferences cache (shared by readHookPreferences + readCustomHooks) ──
213
+ const _installPrefsJsonCache = new Map();
214
+ export function clearHookPrefsCache() {
215
+ _installPrefsJsonCache.clear();
216
+ }
217
+ function cachedReadInstallPrefsJson(phrenPath) {
218
+ const prefsPath = installPreferencesFile(phrenPath);
219
+ let mtimeMs;
220
+ try {
221
+ mtimeMs = fs.statSync(prefsPath).mtimeMs;
222
+ }
223
+ catch {
224
+ _installPrefsJsonCache.delete(prefsPath);
225
+ return null;
226
+ }
227
+ const cached = _installPrefsJsonCache.get(prefsPath);
228
+ if (cached && cached.mtimeMs === mtimeMs) {
229
+ return cached.parsed;
230
+ }
231
+ const parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
232
+ _installPrefsJsonCache.set(prefsPath, { mtimeMs, parsed });
233
+ return parsed;
234
+ }
218
235
  function readHookPreferences(phrenPath) {
219
236
  try {
220
- const prefsPath = installPreferencesFile(phrenPath);
221
- const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
237
+ const prefs = cachedReadInstallPrefsJson(phrenPath);
238
+ if (!prefs)
239
+ return { enabled: true, toolPrefs: {} };
222
240
  const enabled = prefs.hooksEnabled !== false;
223
241
  const toolPrefs = prefs.hookTools && typeof prefs.hookTools === "object"
224
242
  ? prefs.hookTools
@@ -255,15 +273,18 @@ const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 1
255
273
  const HOOK_ERROR_LOG_MAX_LINES = 1000;
256
274
  export function readCustomHooks(phrenPath) {
257
275
  try {
258
- const prefsPath = installPreferencesFile(phrenPath);
259
- const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
260
- if (!Array.isArray(prefs.customHooks))
276
+ const prefs = cachedReadInstallPrefsJson(phrenPath);
277
+ if (!prefs || !Array.isArray(prefs.customHooks))
261
278
  return [];
262
- return prefs.customHooks.filter((h) => h &&
263
- typeof h.event === "string" &&
264
- VALID_HOOK_EVENTS.has(h.event) &&
265
- ((typeof h.command === "string" && h.command.trim().length > 0) ||
266
- (typeof h.webhook === "string" && h.webhook.trim().length > 0)));
279
+ return prefs.customHooks.filter((h) => {
280
+ if (!h || typeof h !== "object")
281
+ return false;
282
+ const rec = h;
283
+ return (typeof rec.event === "string" &&
284
+ VALID_HOOK_EVENTS.has(rec.event) &&
285
+ ((typeof rec.command === "string" && rec.command.trim().length > 0) ||
286
+ (typeof rec.webhook === "string" && rec.webhook.trim().length > 0)));
287
+ });
267
288
  }
268
289
  catch (err) {
269
290
  debugLog(`readCustomHooks: ${errorMessage(err)}`);
package/mcp/dist/index.js CHANGED
@@ -48,13 +48,13 @@ function cleanStaleLocks(phrenPath) {
48
48
  }
49
49
  }
50
50
  catch (err) {
51
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
51
+ if ((process.env.PHREN_DEBUG))
52
52
  process.stderr.write(`[phren] cleanStaleLocks statFile: ${errorMessage(err)}\n`);
53
53
  }
54
54
  }
55
55
  }
56
56
  catch (err) {
57
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
57
+ if ((process.env.PHREN_DEBUG))
58
58
  process.stderr.write(`[phren] cleanStaleLocks readdir: ${errorMessage(err)}\n`);
59
59
  }
60
60
  }
@@ -88,7 +88,7 @@ async function main() {
88
88
  db?.close();
89
89
  }
90
90
  catch (err) {
91
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
91
+ if ((process.env.PHREN_DEBUG))
92
92
  process.stderr.write(`[phren] rebuildIndex dbClose: ${errorMessage(err)}\n`);
93
93
  }
94
94
  db = await buildIndex(phrenPath, profile);
@@ -158,7 +158,7 @@ async function main() {
158
158
  trackToolCall(phrenPath, registeredName);
159
159
  }
160
160
  catch (err) {
161
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
161
+ if ((process.env.PHREN_DEBUG))
162
162
  process.stderr.write(`[phren] trackToolCall: ${errorMessage(err)}\n`);
163
163
  }
164
164
  return handler(...args);
@@ -4,27 +4,16 @@
4
4
  */
5
5
  import * as fs from "fs";
6
6
  import * as path from "path";
7
- import { randomUUID } from "crypto";
8
- import { execFileSync } from "child_process";
9
- import { buildLifecycleCommands } from "./hooks.js";
10
- import { EXEC_TIMEOUT_QUICK_MS, isRecord, hookConfigPath, homePath, readRootManifest, } from "./shared.js";
7
+ import { buildLifecycleCommands, commandExists } from "./hooks.js";
8
+ import { isRecord, hookConfigPath, homePath, readRootManifest, atomicWriteText, } from "./shared.js";
11
9
  import { isFeatureEnabled, errorMessage } from "./utils.js";
12
10
  import { probeVsCodeConfig, resolveCodexMcpConfig, resolveCopilotMcpConfig, resolveCursorMcpConfig, } from "./provider-adapters.js";
13
11
  import { getMcpEnabledPreference, getHooksEnabledPreference } from "./init-preferences.js";
14
- import { resolveEntryScript, VERSION } from "./init-shared.js";
15
- function log(msg) {
16
- process.stdout.write(msg + "\n");
17
- }
12
+ import { resolveEntryScript, log, VERSION } from "./init-shared.js";
18
13
  function getObjectProp(value, key) {
19
14
  const candidate = value[key];
20
15
  return isRecord(candidate) ? candidate : undefined;
21
16
  }
22
- function atomicWriteText(filePath, content) {
23
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
- const tmpPath = `${filePath}.tmp-${randomUUID()}`;
25
- fs.writeFileSync(tmpPath, content);
26
- fs.renameSync(tmpPath, filePath);
27
- }
28
17
  export function patchJsonFile(filePath, patch) {
29
18
  let data = {};
30
19
  if (fs.existsSync(filePath)) {
@@ -44,16 +33,6 @@ export function patchJsonFile(filePath, patch) {
44
33
  patch(data);
45
34
  atomicWriteText(filePath, JSON.stringify(data, null, 2) + "\n");
46
35
  }
47
- function commandExists(cmd) {
48
- try {
49
- const whichCmd = process.platform === "win32" ? "where.exe" : "which";
50
- execFileSync(whichCmd, [cmd], { stdio: ["ignore", "ignore", "ignore"], timeout: EXEC_TIMEOUT_QUICK_MS });
51
- return true;
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
36
  function buildMcpServerConfig(phrenPath) {
58
37
  const entryScript = resolveEntryScript();
59
38
  if (entryScript && fs.existsSync(entryScript)) {
@@ -1087,14 +1087,14 @@ export function updateMachinesYaml(phrenPath, machine, profile) {
1087
1087
  }
1088
1088
  }
1089
1089
  catch (err) {
1090
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1091
- process.stderr.write(`[phren] updateMachinesYaml parse: ${err instanceof Error ? err.message : String(err)}\n`);
1090
+ if ((process.env.PHREN_DEBUG))
1091
+ process.stderr.write(`[phren] updateMachinesYaml parse: ${errorMessage(err)}\n`);
1092
1092
  }
1093
1093
  // Passive init/link refreshes should keep an existing mapping; explicit overrides can remap.
1094
1094
  if (hasExistingMapping && !machine && !profile)
1095
1095
  return;
1096
1096
  const mapping = setMachineProfile(phrenPath, machineName, profileName);
1097
- if (!mapping.ok && (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG)) {
1097
+ if (!mapping.ok && (process.env.PHREN_DEBUG)) {
1098
1098
  process.stderr.write(`[phren] updateMachinesYaml setMachineProfile: ${mapping.error}\n`);
1099
1099
  }
1100
1100
  }
@@ -1289,8 +1289,8 @@ export function runPostInitVerify(phrenPath) {
1289
1289
  ftsOk = entries.some(d => d.isDirectory() && !d.name.startsWith("."));
1290
1290
  }
1291
1291
  catch (err) {
1292
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1293
- process.stderr.write(`[phren] runPostInitVerify projectScan: ${err instanceof Error ? err.message : String(err)}\n`);
1292
+ if ((process.env.PHREN_DEBUG))
1293
+ process.stderr.write(`[phren] runPostInitVerify projectScan: ${errorMessage(err)}\n`);
1294
1294
  ftsOk = false;
1295
1295
  }
1296
1296
  checks.push({
package/mcp/dist/init.js CHANGED
@@ -8,7 +8,7 @@ import * as crypto from "crypto";
8
8
  import { execFileSync } from "child_process";
9
9
  import { configureAllHooks } from "./hooks.js";
10
10
  import { getMachineName, machineFilePath, persistMachineName } from "./machine-identity.js";
11
- import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, readRootManifest, writeRootManifest, } from "./shared.js";
11
+ import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, getProjectDirs, readRootManifest, writeRootManifest, } from "./shared.js";
12
12
  import { isValidProjectName, errorMessage } from "./utils.js";
13
13
  import { codexJsonCandidates, copilotMcpCandidates, cursorMcpCandidates, vscodeMcpCandidates, } from "./provider-adapters.js";
14
14
  export { configureClaude, configureVSCode, configureCursorMcp, configureCopilotMcp, configureCodexMcp, logMcpTargetStatus, resetVSCodeProbeCache, patchJsonFile, } from "./init-config.js";
@@ -406,7 +406,7 @@ async function runWalkthrough(phrenPath) {
406
406
  }
407
407
  }
408
408
  catch (err) {
409
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
409
+ if ((process.env.PHREN_DEBUG))
410
410
  process.stderr.write(`[phren] init ollamaCheck: ${errorMessage(err)}\n`);
411
411
  }
412
412
  printSection("Auto-Capture (Optional)");
@@ -910,32 +910,36 @@ export async function runInit(opts = {}) {
910
910
  }
911
911
  let phrenPath = resolveInitPhrenPath(opts);
912
912
  const dryRun = Boolean(opts.dryRun);
913
- // Migrate ~/.cortex ~/.phren when upgrading from the old name.
914
- // Only runs when the resolved phrenPath doesn't exist yet but ~/.cortex does.
913
+ // Migrate the legacy hidden store directory into ~/.phren when upgrading
914
+ // from the previous product name. Only runs when the resolved phrenPath
915
+ // doesn't exist yet but the legacy directory does.
915
916
  if (!opts._walkthroughStoragePath && !fs.existsSync(phrenPath)) {
916
- const cortexPath = path.resolve(homePath(".cortex"));
917
- if (cortexPath !== phrenPath && fs.existsSync(cortexPath) && hasInstallMarkers(cortexPath)) {
917
+ // Pre-rebrand directory name — kept as literal for migration
918
+ const legacyPath = path.resolve(homePath(".cortex"));
919
+ if (legacyPath !== phrenPath && fs.existsSync(legacyPath) && hasInstallMarkers(legacyPath)) {
918
920
  if (!dryRun) {
919
- fs.renameSync(cortexPath, phrenPath);
921
+ fs.renameSync(legacyPath, phrenPath);
920
922
  }
921
- console.log(`Migrated ~/.cortex → ~/.phren`);
923
+ console.log(`Migrated legacy store → ~/.phren`);
922
924
  }
923
925
  }
924
- // Rename stale cortex-*.md skill files left over from the rebrand.
925
- // Runs on every init so users who already migrated the directory still get the fix.
926
+ // Rename stale legacy skill names left over from the rebrand. Runs on every
927
+ // init so users who already migrated the directory still get the fix.
926
928
  const skillsMigrateDir = path.join(phrenPath, "global", "skills");
927
929
  if (!dryRun && fs.existsSync(skillsMigrateDir)) {
930
+ const legacySkillName = "cortex.md";
931
+ const legacySkillPrefix = "cortex-";
928
932
  for (const entry of fs.readdirSync(skillsMigrateDir)) {
929
933
  if (!entry.endsWith(".md"))
930
934
  continue;
931
- if (entry === "cortex.md") {
935
+ if (entry === legacySkillName) {
932
936
  const dest = path.join(skillsMigrateDir, "phren.md");
933
937
  if (!fs.existsSync(dest)) {
934
938
  fs.renameSync(path.join(skillsMigrateDir, entry), dest);
935
939
  }
936
940
  }
937
- else if (entry.startsWith("cortex-")) {
938
- const newName = entry.replace(/^cortex-/, "phren-");
941
+ else if (entry.startsWith(legacySkillPrefix)) {
942
+ const newName = `phren-${entry.slice(legacySkillPrefix.length)}`;
939
943
  const dest = path.join(skillsMigrateDir, newName);
940
944
  if (!fs.existsSync(dest)) {
941
945
  fs.renameSync(path.join(skillsMigrateDir, entry), dest);
@@ -1381,7 +1385,7 @@ export async function runInit(opts = {}) {
1381
1385
  }
1382
1386
  }
1383
1387
  catch (err) {
1384
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1388
+ if ((process.env.PHREN_DEBUG))
1385
1389
  process.stderr.write(`[phren] init ollamaInstallHint: ${errorMessage(err)}\n`);
1386
1390
  }
1387
1391
  }
@@ -1554,7 +1558,122 @@ export async function runHooksMode(modeArg) {
1554
1558
  log(`Claude status: ${claudeStatus}`);
1555
1559
  log(`Restart your agent to apply changes.`);
1556
1560
  }
1557
- export async function runUninstall() {
1561
+ // Agent skill directories to sweep for symlinks during uninstall
1562
+ function agentSkillDirs() {
1563
+ const home = homeDir();
1564
+ return [
1565
+ homePath(".claude", "skills"),
1566
+ path.join(home, ".cursor", "skills"),
1567
+ path.join(home, ".copilot", "skills"),
1568
+ path.join(home, ".codex", "skills"),
1569
+ ];
1570
+ }
1571
+ // Remove skill symlinks that resolve inside phrenPath. Only touches symlinks, never regular files.
1572
+ function sweepSkillSymlinks(phrenPath) {
1573
+ const resolvedPhren = path.resolve(phrenPath);
1574
+ for (const dir of agentSkillDirs()) {
1575
+ if (!fs.existsSync(dir))
1576
+ continue;
1577
+ let entries;
1578
+ try {
1579
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1580
+ }
1581
+ catch (err) {
1582
+ debugLog(`sweepSkillSymlinks: readdirSync failed for ${dir}: ${errorMessage(err)}`);
1583
+ continue;
1584
+ }
1585
+ for (const entry of entries) {
1586
+ if (!entry.isSymbolicLink())
1587
+ continue;
1588
+ const fullPath = path.join(dir, entry.name);
1589
+ try {
1590
+ const target = fs.realpathSync(fullPath);
1591
+ if (target.startsWith(resolvedPhren + path.sep) || target === resolvedPhren) {
1592
+ fs.unlinkSync(fullPath);
1593
+ log(` Removed skill symlink: ${fullPath}`);
1594
+ }
1595
+ }
1596
+ catch (err) {
1597
+ debugLog(`sweepSkillSymlinks: could not check/remove ${fullPath}: ${errorMessage(err)}`);
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ // Filter phren hook entries from an agent hooks file. Returns true if the file was changed.
1603
+ // Deletes the file if no hooks remain. `commandField` is the JSON key holding the command
1604
+ // string in each hook entry (e.g. "bash" for Copilot, "command" for Codex).
1605
+ function filterAgentHooks(filePath, commandField) {
1606
+ if (!fs.existsSync(filePath))
1607
+ return false;
1608
+ try {
1609
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
1610
+ if (!isRecord(raw) || !isRecord(raw.hooks))
1611
+ return false;
1612
+ const hooks = raw.hooks;
1613
+ let changed = false;
1614
+ for (const event of Object.keys(hooks)) {
1615
+ const entries = hooks[event];
1616
+ if (!Array.isArray(entries))
1617
+ continue;
1618
+ const filtered = entries.filter((e) => !(isRecord(e) && typeof e[commandField] === "string" && isPhrenCommand(e[commandField])));
1619
+ if (filtered.length !== entries.length) {
1620
+ hooks[event] = filtered;
1621
+ changed = true;
1622
+ }
1623
+ }
1624
+ if (!changed)
1625
+ return false;
1626
+ // Remove empty hook event keys
1627
+ for (const event of Object.keys(hooks)) {
1628
+ if (Array.isArray(hooks[event]) && hooks[event].length === 0) {
1629
+ delete hooks[event];
1630
+ }
1631
+ }
1632
+ if (Object.keys(hooks).length === 0) {
1633
+ fs.unlinkSync(filePath);
1634
+ }
1635
+ else {
1636
+ atomicWriteText(filePath, JSON.stringify(raw, null, 2));
1637
+ }
1638
+ return true;
1639
+ }
1640
+ catch (err) {
1641
+ debugLog(`filterAgentHooks: failed for ${filePath}: ${errorMessage(err)}`);
1642
+ return false;
1643
+ }
1644
+ }
1645
+ async function promptUninstallConfirm(phrenPath) {
1646
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1647
+ return true;
1648
+ // Show summary of what will be deleted
1649
+ try {
1650
+ const projectDirs = getProjectDirs(phrenPath);
1651
+ const projectCount = projectDirs.length;
1652
+ let findingCount = 0;
1653
+ for (const dir of projectDirs) {
1654
+ const findingsFile = path.join(dir, "FINDINGS.md");
1655
+ if (fs.existsSync(findingsFile)) {
1656
+ const content = fs.readFileSync(findingsFile, "utf8");
1657
+ findingCount += content.split("\n").filter((l) => l.startsWith("- ")).length;
1658
+ }
1659
+ }
1660
+ log(`\n Will delete: ${phrenPath}`);
1661
+ log(` Contains: ${projectCount} project(s), ~${findingCount} finding(s)`);
1662
+ }
1663
+ catch (err) {
1664
+ debugLog(`promptUninstallConfirm: summary failed: ${errorMessage(err)}`);
1665
+ log(`\n Will delete: ${phrenPath}`);
1666
+ }
1667
+ const readline = await import("readline");
1668
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1669
+ return new Promise((resolve) => {
1670
+ rl.question(`\nThis will permanently delete ${phrenPath} and all phren data. Type 'yes' to confirm: `, (answer) => {
1671
+ rl.close();
1672
+ resolve(answer.trim().toLowerCase() === "yes");
1673
+ });
1674
+ });
1675
+ }
1676
+ export async function runUninstall(opts = {}) {
1558
1677
  const phrenPath = findPhrenPath();
1559
1678
  const manifest = phrenPath ? readRootManifest(phrenPath) : null;
1560
1679
  if (manifest?.installMode === "project-local" && phrenPath) {
@@ -1575,6 +1694,27 @@ export async function runUninstall() {
1575
1694
  return;
1576
1695
  }
1577
1696
  log("\nUninstalling phren...\n");
1697
+ // Confirmation prompt (shared-mode only — project-local is low-stakes)
1698
+ if (!opts.yes) {
1699
+ const confirmed = phrenPath
1700
+ ? await promptUninstallConfirm(phrenPath)
1701
+ : (process.stdin.isTTY && process.stdout.isTTY
1702
+ ? await (async () => {
1703
+ const readline = await import("readline");
1704
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1705
+ return new Promise((resolve) => {
1706
+ rl.question("This will remove all phren config and hooks. Type 'yes' to confirm: ", (answer) => {
1707
+ rl.close();
1708
+ resolve(answer.trim().toLowerCase() === "yes");
1709
+ });
1710
+ });
1711
+ })()
1712
+ : true);
1713
+ if (!confirmed) {
1714
+ log("Uninstall cancelled.");
1715
+ return;
1716
+ }
1717
+ }
1578
1718
  const home = homeDir();
1579
1719
  const machineFile = machineFilePath();
1580
1720
  const settingsPath = hookConfigPath("claude");
@@ -1677,12 +1817,11 @@ export async function runUninstall() {
1677
1817
  debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
1678
1818
  }
1679
1819
  }
1680
- // Remove Copilot hooks file (written by configureAllHooks)
1820
+ // Remove phren entries from Copilot hooks file (filter, don't bulk-delete)
1681
1821
  const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1682
1822
  try {
1683
- if (fs.existsSync(copilotHooksFile)) {
1684
- fs.unlinkSync(copilotHooksFile);
1685
- log(` Removed Copilot hooks file (${copilotHooksFile})`);
1823
+ if (filterAgentHooks(copilotHooksFile, "bash")) {
1824
+ log(` Removed phren entries from Copilot hooks (${copilotHooksFile})`);
1686
1825
  }
1687
1826
  }
1688
1827
  catch (err) {
@@ -1709,13 +1848,12 @@ export async function runUninstall() {
1709
1848
  catch (err) {
1710
1849
  debugLog(`uninstall: cleanup failed for ${cursorHooksFile}: ${errorMessage(err)}`);
1711
1850
  }
1712
- // Remove Codex hooks file in phren path
1851
+ // Remove phren entries from Codex hooks file (filter, don't bulk-delete)
1713
1852
  const uninstallPhrenPath = (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1714
1853
  const codexHooksFile = hookConfigPath("codex", uninstallPhrenPath);
1715
1854
  try {
1716
- if (fs.existsSync(codexHooksFile)) {
1717
- fs.unlinkSync(codexHooksFile);
1718
- log(` Removed Codex hooks file (${codexHooksFile})`);
1855
+ if (filterAgentHooks(codexHooksFile, "command")) {
1856
+ log(` Removed phren entries from Codex hooks (${codexHooksFile})`);
1719
1857
  }
1720
1858
  }
1721
1859
  catch (err) {
@@ -1748,6 +1886,15 @@ export async function runUninstall() {
1748
1886
  catch (err) {
1749
1887
  debugLog(`uninstall: cleanup failed for ${machineFile}: ${errorMessage(err)}`);
1750
1888
  }
1889
+ // Sweep agent skill directories for symlinks pointing into the phren store
1890
+ if (phrenPath) {
1891
+ try {
1892
+ sweepSkillSymlinks(phrenPath);
1893
+ }
1894
+ catch (err) {
1895
+ debugLog(`uninstall: skill symlink sweep failed: ${errorMessage(err)}`);
1896
+ }
1897
+ }
1751
1898
  if (phrenPath && fs.existsSync(phrenPath)) {
1752
1899
  try {
1753
1900
  fs.rmSync(phrenPath, { recursive: true, force: true });
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import * as crypto from "crypto";
4
4
  import { getProjectDirs } from "./shared.js";
5
5
  import { TASK_FILE_ALIASES } from "./data-tasks.js";
6
+ import { errorMessage } from "./utils.js";
6
7
  function fileChecksum(filePath) {
7
8
  const content = fs.readFileSync(filePath);
8
9
  return crypto.createHash("sha256").update(content).digest("hex");
@@ -18,8 +19,8 @@ function loadChecksums(phrenPath) {
18
19
  return JSON.parse(fs.readFileSync(file, "utf8"));
19
20
  }
20
21
  catch (err) {
21
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
22
- process.stderr.write(`[phren] loadChecksums: ${err instanceof Error ? err.message : String(err)}\n`);
22
+ if ((process.env.PHREN_DEBUG))
23
+ process.stderr.write(`[phren] loadChecksums: ${errorMessage(err)}\n`);
23
24
  return {};
24
25
  }
25
26
  }
@@ -4,7 +4,7 @@ import * as yaml from "js-yaml";
4
4
  import { isValidProjectName } from "./utils.js";
5
5
  import { homeDir, homePath } from "./shared.js";
6
6
  import { resolveTaskFilePath } from "./data-tasks.js";
7
- function log(msg) { process.stdout.write(msg + "\n"); }
7
+ import { log } from "./init-shared.js";
8
8
  function contextFilePath() {
9
9
  return homePath(".phren-context.md");
10
10
  }
@@ -199,14 +199,14 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
199
199
  fsMs = Date.now() - t0;
200
200
  }
201
201
  catch (err) {
202
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
202
+ if ((process.env.PHREN_DEBUG))
203
203
  process.stderr.write(`[phren] doctor fsBenchmark: ${errorMessage(err)}\n`);
204
204
  fsMs = -1;
205
205
  try {
206
206
  fs.unlinkSync(fsBenchFile);
207
207
  }
208
208
  catch (e2) {
209
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
209
+ if ((process.env.PHREN_DEBUG))
210
210
  process.stderr.write(`[phren] doctor fsBenchmarkCleanup: ${e2 instanceof Error ? e2.message : String(e2)}\n`);
211
211
  }
212
212
  }
@@ -329,7 +329,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
329
329
  runtime = JSON.parse(fs.readFileSync(runtimeHealthPath, "utf8"));
330
330
  }
331
331
  catch (err) {
332
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
332
+ if ((process.env.PHREN_DEBUG))
333
333
  process.stderr.write(`[phren] doctor runtimeHealth: ${errorMessage(err)}\n`);
334
334
  runtime = null;
335
335
  }