@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
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)");
@@ -845,9 +845,11 @@ async function runProjectLocalInit(opts = {}) {
845
845
  * Configure MCP for all detected AI coding tools (Claude, VS Code, Cursor, Copilot, Codex).
846
846
  * @param verb - label used in log messages, e.g. "Updated" or "Configured"
847
847
  */
848
- function configureMcpTargets(phrenPath, opts, verb) {
848
+ export function configureMcpTargets(phrenPath, opts, verb = "Configured") {
849
+ let claudeStatus = "no_settings";
849
850
  try {
850
851
  const status = configureClaude(phrenPath, { mcpEnabled: opts.mcpEnabled, hooksEnabled: opts.hooksEnabled });
852
+ claudeStatus = status ?? "installed";
851
853
  if (status === "disabled" || status === "already_disabled") {
852
854
  log(` ${verb} Claude Code hooks (MCP disabled)`);
853
855
  }
@@ -858,31 +860,44 @@ function configureMcpTargets(phrenPath, opts, verb) {
858
860
  catch (e) {
859
861
  log(` Could not configure Claude Code settings (${e}), add manually`);
860
862
  }
863
+ let vsStatus = "no_vscode";
861
864
  try {
862
- const vscodeResult = configureVSCode(phrenPath, { mcpEnabled: opts.mcpEnabled });
863
- logMcpTargetStatus("VS Code", vscodeResult, verb);
865
+ vsStatus = configureVSCode(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_vscode";
866
+ logMcpTargetStatus("VS Code", vsStatus, verb);
864
867
  }
865
868
  catch (err) {
866
869
  debugLog(`configureVSCode failed: ${errorMessage(err)}`);
867
870
  }
871
+ let cursorStatus = "no_cursor";
868
872
  try {
869
- logMcpTargetStatus("Cursor", configureCursorMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }), verb);
873
+ cursorStatus = configureCursorMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_cursor";
874
+ logMcpTargetStatus("Cursor", cursorStatus, verb);
870
875
  }
871
876
  catch (err) {
872
877
  debugLog(`configureCursorMcp failed: ${errorMessage(err)}`);
873
878
  }
879
+ let copilotStatus = "no_copilot";
874
880
  try {
875
- logMcpTargetStatus("Copilot CLI", configureCopilotMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }), verb);
881
+ copilotStatus = configureCopilotMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_copilot";
882
+ logMcpTargetStatus("Copilot CLI", copilotStatus, verb);
876
883
  }
877
884
  catch (err) {
878
885
  debugLog(`configureCopilotMcp failed: ${errorMessage(err)}`);
879
886
  }
887
+ let codexStatus = "no_codex";
880
888
  try {
881
- logMcpTargetStatus("Codex", configureCodexMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }), verb);
889
+ codexStatus = configureCodexMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_codex";
890
+ logMcpTargetStatus("Codex", codexStatus, verb);
882
891
  }
883
892
  catch (err) {
884
893
  debugLog(`configureCodexMcp failed: ${errorMessage(err)}`);
885
894
  }
895
+ const allStatuses = [claudeStatus, vsStatus, cursorStatus, copilotStatus, codexStatus];
896
+ if (allStatuses.some((s) => s === "installed" || s === "already_configured"))
897
+ return "installed";
898
+ if (allStatuses.some((s) => s === "disabled" || s === "already_disabled"))
899
+ return "disabled";
900
+ return claudeStatus;
886
901
  }
887
902
  /**
888
903
  * Configure hooks if enabled, or log a disabled message.
@@ -910,32 +925,36 @@ export async function runInit(opts = {}) {
910
925
  }
911
926
  let phrenPath = resolveInitPhrenPath(opts);
912
927
  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.
928
+ // Migrate the legacy hidden store directory into ~/.phren when upgrading
929
+ // from the previous product name. Only runs when the resolved phrenPath
930
+ // doesn't exist yet but the legacy directory does.
915
931
  if (!opts._walkthroughStoragePath && !fs.existsSync(phrenPath)) {
916
- const cortexPath = path.resolve(homePath(".cortex"));
917
- if (cortexPath !== phrenPath && fs.existsSync(cortexPath) && hasInstallMarkers(cortexPath)) {
932
+ // Pre-rebrand directory name — kept as literal for migration
933
+ const legacyPath = path.resolve(homePath(".cortex"));
934
+ if (legacyPath !== phrenPath && fs.existsSync(legacyPath) && hasInstallMarkers(legacyPath)) {
918
935
  if (!dryRun) {
919
- fs.renameSync(cortexPath, phrenPath);
936
+ fs.renameSync(legacyPath, phrenPath);
920
937
  }
921
- console.log(`Migrated ~/.cortex → ~/.phren`);
938
+ console.log(`Migrated legacy store → ~/.phren`);
922
939
  }
923
940
  }
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.
941
+ // Rename stale legacy skill names left over from the rebrand. Runs on every
942
+ // init so users who already migrated the directory still get the fix.
926
943
  const skillsMigrateDir = path.join(phrenPath, "global", "skills");
927
944
  if (!dryRun && fs.existsSync(skillsMigrateDir)) {
945
+ const legacySkillName = "cortex.md";
946
+ const legacySkillPrefix = "cortex-";
928
947
  for (const entry of fs.readdirSync(skillsMigrateDir)) {
929
948
  if (!entry.endsWith(".md"))
930
949
  continue;
931
- if (entry === "cortex.md") {
950
+ if (entry === legacySkillName) {
932
951
  const dest = path.join(skillsMigrateDir, "phren.md");
933
952
  if (!fs.existsSync(dest)) {
934
953
  fs.renameSync(path.join(skillsMigrateDir, entry), dest);
935
954
  }
936
955
  }
937
- else if (entry.startsWith("cortex-")) {
938
- const newName = entry.replace(/^cortex-/, "phren-");
956
+ else if (entry.startsWith(legacySkillPrefix)) {
957
+ const newName = `phren-${entry.slice(legacySkillPrefix.length)}`;
939
958
  const dest = path.join(skillsMigrateDir, newName);
940
959
  if (!fs.existsSync(dest)) {
941
960
  fs.renameSync(path.join(skillsMigrateDir, entry), dest);
@@ -1381,7 +1400,7 @@ export async function runInit(opts = {}) {
1381
1400
  }
1382
1401
  }
1383
1402
  catch (err) {
1384
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1403
+ if ((process.env.PHREN_DEBUG))
1385
1404
  process.stderr.write(`[phren] init ollamaInstallHint: ${errorMessage(err)}\n`);
1386
1405
  }
1387
1406
  }
@@ -1554,7 +1573,122 @@ export async function runHooksMode(modeArg) {
1554
1573
  log(`Claude status: ${claudeStatus}`);
1555
1574
  log(`Restart your agent to apply changes.`);
1556
1575
  }
1557
- export async function runUninstall() {
1576
+ // Agent skill directories to sweep for symlinks during uninstall
1577
+ function agentSkillDirs() {
1578
+ const home = homeDir();
1579
+ return [
1580
+ homePath(".claude", "skills"),
1581
+ path.join(home, ".cursor", "skills"),
1582
+ path.join(home, ".copilot", "skills"),
1583
+ path.join(home, ".codex", "skills"),
1584
+ ];
1585
+ }
1586
+ // Remove skill symlinks that resolve inside phrenPath. Only touches symlinks, never regular files.
1587
+ function sweepSkillSymlinks(phrenPath) {
1588
+ const resolvedPhren = path.resolve(phrenPath);
1589
+ for (const dir of agentSkillDirs()) {
1590
+ if (!fs.existsSync(dir))
1591
+ continue;
1592
+ let entries;
1593
+ try {
1594
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1595
+ }
1596
+ catch (err) {
1597
+ debugLog(`sweepSkillSymlinks: readdirSync failed for ${dir}: ${errorMessage(err)}`);
1598
+ continue;
1599
+ }
1600
+ for (const entry of entries) {
1601
+ if (!entry.isSymbolicLink())
1602
+ continue;
1603
+ const fullPath = path.join(dir, entry.name);
1604
+ try {
1605
+ const target = fs.realpathSync(fullPath);
1606
+ if (target.startsWith(resolvedPhren + path.sep) || target === resolvedPhren) {
1607
+ fs.unlinkSync(fullPath);
1608
+ log(` Removed skill symlink: ${fullPath}`);
1609
+ }
1610
+ }
1611
+ catch (err) {
1612
+ debugLog(`sweepSkillSymlinks: could not check/remove ${fullPath}: ${errorMessage(err)}`);
1613
+ }
1614
+ }
1615
+ }
1616
+ }
1617
+ // Filter phren hook entries from an agent hooks file. Returns true if the file was changed.
1618
+ // Deletes the file if no hooks remain. `commandField` is the JSON key holding the command
1619
+ // string in each hook entry (e.g. "bash" for Copilot, "command" for Codex).
1620
+ function filterAgentHooks(filePath, commandField) {
1621
+ if (!fs.existsSync(filePath))
1622
+ return false;
1623
+ try {
1624
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
1625
+ if (!isRecord(raw) || !isRecord(raw.hooks))
1626
+ return false;
1627
+ const hooks = raw.hooks;
1628
+ let changed = false;
1629
+ for (const event of Object.keys(hooks)) {
1630
+ const entries = hooks[event];
1631
+ if (!Array.isArray(entries))
1632
+ continue;
1633
+ const filtered = entries.filter((e) => !(isRecord(e) && typeof e[commandField] === "string" && isPhrenCommand(e[commandField])));
1634
+ if (filtered.length !== entries.length) {
1635
+ hooks[event] = filtered;
1636
+ changed = true;
1637
+ }
1638
+ }
1639
+ if (!changed)
1640
+ return false;
1641
+ // Remove empty hook event keys
1642
+ for (const event of Object.keys(hooks)) {
1643
+ if (Array.isArray(hooks[event]) && hooks[event].length === 0) {
1644
+ delete hooks[event];
1645
+ }
1646
+ }
1647
+ if (Object.keys(hooks).length === 0) {
1648
+ fs.unlinkSync(filePath);
1649
+ }
1650
+ else {
1651
+ atomicWriteText(filePath, JSON.stringify(raw, null, 2));
1652
+ }
1653
+ return true;
1654
+ }
1655
+ catch (err) {
1656
+ debugLog(`filterAgentHooks: failed for ${filePath}: ${errorMessage(err)}`);
1657
+ return false;
1658
+ }
1659
+ }
1660
+ async function promptUninstallConfirm(phrenPath) {
1661
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1662
+ return true;
1663
+ // Show summary of what will be deleted
1664
+ try {
1665
+ const projectDirs = getProjectDirs(phrenPath);
1666
+ const projectCount = projectDirs.length;
1667
+ let findingCount = 0;
1668
+ for (const dir of projectDirs) {
1669
+ const findingsFile = path.join(dir, "FINDINGS.md");
1670
+ if (fs.existsSync(findingsFile)) {
1671
+ const content = fs.readFileSync(findingsFile, "utf8");
1672
+ findingCount += content.split("\n").filter((l) => l.startsWith("- ")).length;
1673
+ }
1674
+ }
1675
+ log(`\n Will delete: ${phrenPath}`);
1676
+ log(` Contains: ${projectCount} project(s), ~${findingCount} finding(s)`);
1677
+ }
1678
+ catch (err) {
1679
+ debugLog(`promptUninstallConfirm: summary failed: ${errorMessage(err)}`);
1680
+ log(`\n Will delete: ${phrenPath}`);
1681
+ }
1682
+ const readline = await import("readline");
1683
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1684
+ return new Promise((resolve) => {
1685
+ rl.question(`\nThis will permanently delete ${phrenPath} and all phren data. Type 'yes' to confirm: `, (answer) => {
1686
+ rl.close();
1687
+ resolve(answer.trim().toLowerCase() === "yes");
1688
+ });
1689
+ });
1690
+ }
1691
+ export async function runUninstall(opts = {}) {
1558
1692
  const phrenPath = findPhrenPath();
1559
1693
  const manifest = phrenPath ? readRootManifest(phrenPath) : null;
1560
1694
  if (manifest?.installMode === "project-local" && phrenPath) {
@@ -1575,6 +1709,27 @@ export async function runUninstall() {
1575
1709
  return;
1576
1710
  }
1577
1711
  log("\nUninstalling phren...\n");
1712
+ // Confirmation prompt (shared-mode only — project-local is low-stakes)
1713
+ if (!opts.yes) {
1714
+ const confirmed = phrenPath
1715
+ ? await promptUninstallConfirm(phrenPath)
1716
+ : (process.stdin.isTTY && process.stdout.isTTY
1717
+ ? await (async () => {
1718
+ const readline = await import("readline");
1719
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1720
+ return new Promise((resolve) => {
1721
+ rl.question("This will remove all phren config and hooks. Type 'yes' to confirm: ", (answer) => {
1722
+ rl.close();
1723
+ resolve(answer.trim().toLowerCase() === "yes");
1724
+ });
1725
+ });
1726
+ })()
1727
+ : true);
1728
+ if (!confirmed) {
1729
+ log("Uninstall cancelled.");
1730
+ return;
1731
+ }
1732
+ }
1578
1733
  const home = homeDir();
1579
1734
  const machineFile = machineFilePath();
1580
1735
  const settingsPath = hookConfigPath("claude");
@@ -1677,12 +1832,11 @@ export async function runUninstall() {
1677
1832
  debugLog(`uninstall: cleanup failed for ${mcpFile}: ${errorMessage(err)}`);
1678
1833
  }
1679
1834
  }
1680
- // Remove Copilot hooks file (written by configureAllHooks)
1835
+ // Remove phren entries from Copilot hooks file (filter, don't bulk-delete)
1681
1836
  const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1682
1837
  try {
1683
- if (fs.existsSync(copilotHooksFile)) {
1684
- fs.unlinkSync(copilotHooksFile);
1685
- log(` Removed Copilot hooks file (${copilotHooksFile})`);
1838
+ if (filterAgentHooks(copilotHooksFile, "bash")) {
1839
+ log(` Removed phren entries from Copilot hooks (${copilotHooksFile})`);
1686
1840
  }
1687
1841
  }
1688
1842
  catch (err) {
@@ -1709,13 +1863,12 @@ export async function runUninstall() {
1709
1863
  catch (err) {
1710
1864
  debugLog(`uninstall: cleanup failed for ${cursorHooksFile}: ${errorMessage(err)}`);
1711
1865
  }
1712
- // Remove Codex hooks file in phren path
1866
+ // Remove phren entries from Codex hooks file (filter, don't bulk-delete)
1713
1867
  const uninstallPhrenPath = (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1714
1868
  const codexHooksFile = hookConfigPath("codex", uninstallPhrenPath);
1715
1869
  try {
1716
- if (fs.existsSync(codexHooksFile)) {
1717
- fs.unlinkSync(codexHooksFile);
1718
- log(` Removed Codex hooks file (${codexHooksFile})`);
1870
+ if (filterAgentHooks(codexHooksFile, "command")) {
1871
+ log(` Removed phren entries from Codex hooks (${codexHooksFile})`);
1719
1872
  }
1720
1873
  }
1721
1874
  catch (err) {
@@ -1748,6 +1901,15 @@ export async function runUninstall() {
1748
1901
  catch (err) {
1749
1902
  debugLog(`uninstall: cleanup failed for ${machineFile}: ${errorMessage(err)}`);
1750
1903
  }
1904
+ // Sweep agent skill directories for symlinks pointing into the phren store
1905
+ if (phrenPath) {
1906
+ try {
1907
+ sweepSkillSymlinks(phrenPath);
1908
+ }
1909
+ catch (err) {
1910
+ debugLog(`uninstall: skill symlink sweep failed: ${errorMessage(err)}`);
1911
+ }
1912
+ }
1751
1913
  if (phrenPath && fs.existsSync(phrenPath)) {
1752
1914
  try {
1753
1915
  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
  }
@@ -38,7 +38,7 @@ function writeContextFile(managedContent) {
38
38
  const existing = fs.readFileSync(contextFile, "utf8");
39
39
  if (existing.includes("<!-- phren-managed -->")) {
40
40
  const startIdx = existing.indexOf("<!-- phren-managed -->");
41
- const endIdx = existing.indexOf("<!-- phren-managed -->");
41
+ const endIdx = existing.indexOf("<!-- phren-managed -->", startIdx + "<!-- phren-managed -->".length);
42
42
  const before = startIdx > 0 ? existing.slice(0, startIdx).trimEnd() : "";
43
43
  const after = endIdx !== -1 ? existing.slice(endIdx + "<!-- phren-managed -->".length).trimStart() : "";
44
44
  const parts = [before, wrapped, after].filter(Boolean);
@@ -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"], {
@@ -199,14 +153,14 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
199
153
  fsMs = Date.now() - t0;
200
154
  }
201
155
  catch (err) {
202
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
156
+ if ((process.env.PHREN_DEBUG))
203
157
  process.stderr.write(`[phren] doctor fsBenchmark: ${errorMessage(err)}\n`);
204
158
  fsMs = -1;
205
159
  try {
206
160
  fs.unlinkSync(fsBenchFile);
207
161
  }
208
162
  catch (e2) {
209
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
163
+ if ((process.env.PHREN_DEBUG))
210
164
  process.stderr.write(`[phren] doctor fsBenchmarkCleanup: ${e2 instanceof Error ? e2.message : String(e2)}\n`);
211
165
  }
212
166
  }
@@ -329,7 +283,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
329
283
  runtime = JSON.parse(fs.readFileSync(runtimeHealthPath, "utf8"));
330
284
  }
331
285
  catch (err) {
332
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
286
+ if ((process.env.PHREN_DEBUG))
333
287
  process.stderr.write(`[phren] doctor runtimeHealth: ${errorMessage(err)}\n`);
334
288
  runtime = null;
335
289
  }
@@ -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) {
@@ -108,7 +108,75 @@ export function readSkillManifestHooks(phrenPath) {
108
108
  }
109
109
  return Object.keys(result).length > 0 ? result : null;
110
110
  }
111
- // ── Skill linking helpers ───────────────────────────────────────────────────
111
+ /**
112
+ * Returns true if `destPath` is a symlink whose resolved target lives under
113
+ * `managedRoot`. Used to decide whether phren owns a symlink.
114
+ */
115
+ export function isManagedSymlink(destPath, managedRoot) {
116
+ try {
117
+ const stat = fs.lstatSync(destPath);
118
+ if (!stat.isSymbolicLink())
119
+ return false;
120
+ const target = fs.readlinkSync(destPath);
121
+ const resolvedTarget = path.resolve(path.dirname(destPath), target);
122
+ const managedPrefix = path.resolve(managedRoot) + path.sep;
123
+ return resolvedTarget.startsWith(managedPrefix);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * Returns true if `destPath` exists and is NOT a symlink that points into
131
+ * managedRoot — i.e. it's a file/dir the user owns.
132
+ */
133
+ function isUserOwnedFile(destPath, managedRoot) {
134
+ try {
135
+ fs.lstatSync(destPath);
136
+ }
137
+ catch {
138
+ return false; // doesn't exist
139
+ }
140
+ return !isManagedSymlink(destPath, managedRoot);
141
+ }
142
+ /**
143
+ * Scan destDir for files that phren would want to link (based on srcDir) but
144
+ * can't because a user-owned file already occupies the destination slot.
145
+ */
146
+ export function detectSkillCollisions(srcDir, destDir, managedRoot) {
147
+ if (!fs.existsSync(srcDir) || !fs.existsSync(destDir))
148
+ return [];
149
+ const collisions = [];
150
+ for (const entry of fs.readdirSync(srcDir)) {
151
+ const srcPath = path.join(srcDir, entry);
152
+ const stat = fs.statSync(srcPath);
153
+ if (stat.isFile() && entry.endsWith(".md")) {
154
+ const destPath = path.join(destDir, entry);
155
+ if (isUserOwnedFile(destPath, managedRoot)) {
156
+ const skillName = entry.replace(/\.md$/, "");
157
+ collisions.push({
158
+ skillName,
159
+ destPath,
160
+ message: `Skill '${skillName}' — user file already exists at ${destPath}. Rename or remove it to use phren's version.`,
161
+ });
162
+ }
163
+ }
164
+ else if (stat.isDirectory()) {
165
+ const skillFile = path.join(srcPath, "SKILL.md");
166
+ if (fs.existsSync(skillFile)) {
167
+ const destPath = path.join(destDir, entry);
168
+ if (isUserOwnedFile(destPath, managedRoot)) {
169
+ collisions.push({
170
+ skillName: entry,
171
+ destPath,
172
+ message: `Skill '${entry}' — user directory already exists at ${destPath}. Rename or remove it to use phren's version.`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ }
178
+ return collisions;
179
+ }
112
180
  function cleanupManagedSkillLinks(destDir, expectedNames, managedRoot) {
113
181
  if (!fs.existsSync(destDir))
114
182
  return;
@@ -117,27 +185,22 @@ function cleanupManagedSkillLinks(destDir, expectedNames, managedRoot) {
117
185
  continue;
118
186
  const destPath = path.join(destDir, entry);
119
187
  try {
120
- const stat = fs.lstatSync(destPath);
121
- if (!stat.isSymbolicLink())
122
- continue;
123
- const target = fs.readlinkSync(destPath);
124
- const resolvedTarget = path.resolve(path.dirname(destPath), target);
125
- const managedPrefix = path.resolve(managedRoot) + path.sep;
126
- if (!resolvedTarget.startsWith(managedPrefix))
188
+ if (!isManagedSymlink(destPath, managedRoot))
127
189
  continue;
128
190
  fs.unlinkSync(destPath);
129
191
  }
130
192
  catch (err) {
131
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
193
+ if ((process.env.PHREN_DEBUG))
132
194
  process.stderr.write(`[phren] cleanupManagedSkillLinks: ${errorMessage(err)}\n`);
133
195
  }
134
196
  }
135
197
  }
136
198
  export function linkSkillsDir(srcDir, destDir, managedRoot, symlinkFile, opts) {
137
199
  if (!fs.existsSync(srcDir))
138
- return;
200
+ return [];
139
201
  fs.mkdirSync(destDir, { recursive: true });
140
202
  const expectedNames = new Set();
203
+ const collisions = [];
141
204
  for (const entry of fs.readdirSync(srcDir)) {
142
205
  const srcPath = path.join(srcDir, entry);
143
206
  const stat = fs.statSync(srcPath);
@@ -146,20 +209,43 @@ export function linkSkillsDir(srcDir, destDir, managedRoot, symlinkFile, opts) {
146
209
  continue;
147
210
  }
148
211
  if (stat.isFile() && entry.endsWith(".md")) {
212
+ const destPath = path.join(destDir, entry);
213
+ if (isUserOwnedFile(destPath, managedRoot)) {
214
+ const collision = {
215
+ skillName,
216
+ destPath,
217
+ message: `Skipping skill '${skillName}' — user skill already exists at ${destPath}. To use phren's version, rename or remove your skill first.`,
218
+ };
219
+ collisions.push(collision);
220
+ process.stderr.write(`[phren] ${collision.message}\n`);
221
+ continue;
222
+ }
149
223
  expectedNames.add(entry);
150
- symlinkFile(srcPath, path.join(destDir, entry), managedRoot);
224
+ symlinkFile(srcPath, destPath, managedRoot);
151
225
  }
152
226
  else if (stat.isDirectory()) {
153
227
  const skillFile = path.join(srcPath, "SKILL.md");
154
228
  if (fs.existsSync(skillFile)) {
229
+ const destPath = path.join(destDir, entry);
230
+ if (isUserOwnedFile(destPath, managedRoot)) {
231
+ const collision = {
232
+ skillName,
233
+ destPath,
234
+ message: `Skipping skill '${skillName}' — user skill already exists at ${destPath}. To use phren's version, rename or remove your skill first.`,
235
+ };
236
+ collisions.push(collision);
237
+ process.stderr.write(`[phren] ${collision.message}\n`);
238
+ continue;
239
+ }
155
240
  expectedNames.add(entry);
156
241
  // Symlink the entire skill directory so bundled scripts and assets are accessible.
157
242
  // Relative paths in the skill body remain valid because the directory structure is preserved.
158
- symlinkFile(srcPath, path.join(destDir, entry), managedRoot);
243
+ symlinkFile(srcPath, destPath, managedRoot);
159
244
  }
160
245
  }
161
246
  }
162
247
  cleanupManagedSkillLinks(destDir, expectedNames, managedRoot);
248
+ return collisions;
163
249
  }
164
250
  export function writeSkillMd(phrenPath) {
165
251
  const lifecycle = buildSharedLifecycleCommands();