@phren/cli 0.0.1

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,449 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { createHmac, randomUUID } from "crypto";
4
+ import { execFileSync } from "child_process";
5
+ import { fileURLToPath } from "url";
6
+ import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile } from "./shared.js";
7
+ import { errorMessage } from "./utils.js";
8
+ import { hookConfigPath } from "./provider-adapters.js";
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
+ export function commandExists(cmd) {
17
+ try {
18
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
19
+ execFileSync(whichCmd, [cmd], { stdio: ["ignore", "ignore", "ignore"], timeout: EXEC_TIMEOUT_QUICK_MS });
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ debugLog(`commandExists: ${cmd} not found: ${errorMessage(err)}`);
24
+ return false;
25
+ }
26
+ }
27
+ export function detectInstalledTools() {
28
+ const tools = new Set();
29
+ if (commandExists("github-copilot-cli") || fs.existsSync(homePath(".local", "share", "gh", "extensions", "gh-copilot"))) {
30
+ tools.add("copilot");
31
+ }
32
+ if (commandExists("cursor")) {
33
+ tools.add("cursor");
34
+ }
35
+ if (commandExists("codex") || fs.existsSync(homePath(".codex"))) {
36
+ tools.add("codex");
37
+ }
38
+ return tools;
39
+ }
40
+ function resolveToolBinary(tool) {
41
+ try {
42
+ const wrapperPath = path.resolve(homePath(".local", "bin", tool));
43
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
44
+ const whichArgs = process.platform === "win32" ? [tool] : ["-a", tool];
45
+ const raw = execFileSync(whichCmd, whichArgs, {
46
+ encoding: "utf8",
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ timeout: EXEC_TIMEOUT_QUICK_MS,
49
+ });
50
+ const candidates = raw.split("\n").map((line) => line.trim()).filter(Boolean);
51
+ for (const candidate of candidates) {
52
+ const resolved = path.resolve(candidate);
53
+ if (resolved !== wrapperPath)
54
+ return candidate;
55
+ }
56
+ }
57
+ catch (err) {
58
+ debugLog(`resolveToolBinary: failed for ${tool}: ${errorMessage(err)}`);
59
+ return null;
60
+ }
61
+ return null;
62
+ }
63
+ function resolveCliEntryScript() {
64
+ const local = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
65
+ return fs.existsSync(local) ? local : null;
66
+ }
67
+ function phrenPackageSpec() {
68
+ return PACKAGE_SPEC;
69
+ }
70
+ function buildPackageLifecycleCommands() {
71
+ const packageSpec = phrenPackageSpec();
72
+ return {
73
+ sessionStart: `npx -y ${packageSpec} hook-session-start`,
74
+ userPromptSubmit: `npx -y ${packageSpec} hook-prompt`,
75
+ stop: `npx -y ${packageSpec} hook-stop`,
76
+ hookTool: `npx -y ${packageSpec} hook-tool`,
77
+ };
78
+ }
79
+ export function buildLifecycleCommands(phrenPath) {
80
+ const entry = resolveCliEntryScript();
81
+ const isWindows = process.platform === "win32";
82
+ const escapedPhren = phrenPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
83
+ if (entry) {
84
+ const escapedEntry = entry.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
85
+ if (isWindows) {
86
+ return {
87
+ sessionStart: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-session-start`,
88
+ userPromptSubmit: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-prompt`,
89
+ stop: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-stop`,
90
+ hookTool: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-tool`,
91
+ };
92
+ }
93
+ return {
94
+ sessionStart: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-session-start`,
95
+ userPromptSubmit: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-prompt`,
96
+ stop: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-stop`,
97
+ hookTool: `PHREN_PATH="${escapedPhren}" node "${escapedEntry}" hook-tool`,
98
+ };
99
+ }
100
+ const packageSpec = phrenPackageSpec();
101
+ if (isWindows) {
102
+ return {
103
+ sessionStart: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-session-start`,
104
+ userPromptSubmit: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-prompt`,
105
+ stop: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-stop`,
106
+ hookTool: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-tool`,
107
+ };
108
+ }
109
+ return {
110
+ sessionStart: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-session-start`,
111
+ userPromptSubmit: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-prompt`,
112
+ stop: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-stop`,
113
+ hookTool: `PHREN_PATH="${escapedPhren}" npx -y ${packageSpec} hook-tool`,
114
+ };
115
+ }
116
+ export function buildSharedLifecycleCommands() {
117
+ return buildPackageLifecycleCommands();
118
+ }
119
+ function withHookToolEnv(command, tool) {
120
+ if (process.platform === "win32") {
121
+ return `set "PHREN_HOOK_TOOL=${tool}" && ${command}`;
122
+ }
123
+ return `PHREN_HOOK_TOOL="${tool}" ${command}`;
124
+ }
125
+ function withHookToolLifecycleCommands(lifecycle, tool) {
126
+ return {
127
+ sessionStart: withHookToolEnv(lifecycle.sessionStart, tool),
128
+ userPromptSubmit: withHookToolEnv(lifecycle.userPromptSubmit, tool),
129
+ stop: withHookToolEnv(lifecycle.stop, tool),
130
+ hookTool: withHookToolEnv(lifecycle.hookTool, tool),
131
+ };
132
+ }
133
+ function installSessionWrapper(tool, phrenPath) {
134
+ const realBinary = resolveToolBinary(tool);
135
+ if (!realBinary)
136
+ return false;
137
+ const entry = resolveCliEntryScript();
138
+ const localBinDir = homePath(".local", "bin");
139
+ const wrapperPath = path.join(localBinDir, tool);
140
+ const escapedBinary = realBinary.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
141
+ const escapedPhren = phrenPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
142
+ const escapedEntry = entry ? entry.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : "";
143
+ const packageSpec = phrenPackageSpec();
144
+ const sessionStartCmd = entry
145
+ ? `env PHREN_PATH="$PHREN_PATH" node "$ENTRY_SCRIPT" hook-session-start`
146
+ : `env PHREN_PATH="$PHREN_PATH" npx -y ${packageSpec} hook-session-start`;
147
+ const stopCmd = entry
148
+ ? `env PHREN_PATH="$PHREN_PATH" node "$ENTRY_SCRIPT" hook-stop`
149
+ : `env PHREN_PATH="$PHREN_PATH" npx -y ${packageSpec} hook-stop`;
150
+ const content = `#!/bin/sh
151
+ set -u
152
+
153
+ REAL_BIN="${escapedBinary}"
154
+ PHREN_PATH="\${PHREN_PATH:-${escapedPhren}}"
155
+ ENTRY_SCRIPT="${escapedEntry}"
156
+ export PHREN_HOOK_TOOL="${tool}"
157
+
158
+ if [ ! -x "$REAL_BIN" ]; then
159
+ echo "phren wrapper error: real ${tool} binary not executable: $REAL_BIN" >&2
160
+ exit 127
161
+ fi
162
+
163
+ case "\${1:-}" in
164
+ -h|--help|help|-V|--version|version|completion)
165
+ exec "$REAL_BIN" "$@"
166
+ ;;
167
+ esac
168
+
169
+ run_with_timeout() {
170
+ _timeout_val="$1"
171
+ shift
172
+ if command -v timeout >/dev/null 2>&1; then
173
+ timeout "$_timeout_val" "$@" || true
174
+ else
175
+ "$@" || true
176
+ fi
177
+ }
178
+
179
+ HOOK_TIMEOUT="\${PHREN_HOOK_TIMEOUT_S:-${Math.ceil(HOOK_TIMEOUT_MS / 1000)}}s"
180
+
181
+ run_with_timeout "$HOOK_TIMEOUT" ${sessionStartCmd} >/dev/null 2>&1
182
+
183
+ "$REAL_BIN" "$@"
184
+ status=$?
185
+
186
+ run_with_timeout "$HOOK_TIMEOUT" ${stopCmd} >/dev/null 2>&1
187
+
188
+ exit $status
189
+ `;
190
+ try {
191
+ fs.mkdirSync(localBinDir, { recursive: true });
192
+ atomicWriteText(wrapperPath, content);
193
+ fs.chmodSync(wrapperPath, 0o755);
194
+ return true;
195
+ }
196
+ catch (err) {
197
+ debugLog(`installSessionWrapper: failed for ${tool}: ${errorMessage(err)}`);
198
+ return false;
199
+ }
200
+ }
201
+ function validateCopilotConfig(config) {
202
+ return (typeof config.version === "number" &&
203
+ Array.isArray(config.hooks?.sessionStart) &&
204
+ Array.isArray(config.hooks?.userPromptSubmitted) &&
205
+ Array.isArray(config.hooks?.sessionEnd));
206
+ }
207
+ function validateCursorConfig(config) {
208
+ return (typeof config.version === "number" &&
209
+ typeof config.sessionStart?.command === "string" &&
210
+ typeof config.beforeSubmitPrompt?.command === "string" &&
211
+ typeof config.stop?.command === "string");
212
+ }
213
+ function validateCodexConfig(config) {
214
+ return (Array.isArray(config.hooks?.SessionStart) &&
215
+ Array.isArray(config.hooks?.UserPromptSubmit) &&
216
+ Array.isArray(config.hooks?.Stop));
217
+ }
218
+ function readHookPreferences(phrenPath) {
219
+ try {
220
+ const prefsPath = installPreferencesFile(phrenPath);
221
+ const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
222
+ const enabled = prefs.hooksEnabled !== false;
223
+ const toolPrefs = prefs.hookTools && typeof prefs.hookTools === "object"
224
+ ? prefs.hookTools
225
+ : {};
226
+ return { enabled, toolPrefs };
227
+ }
228
+ catch (err) {
229
+ debugLog(`readHookPreferences: ${errorMessage(err)}`);
230
+ return { enabled: true, toolPrefs: {} };
231
+ }
232
+ }
233
+ export function isToolHookEnabled(phrenPath, tool) {
234
+ const { enabled, toolPrefs } = readHookPreferences(phrenPath);
235
+ if (!enabled)
236
+ return false;
237
+ const key = tool;
238
+ if (key in toolPrefs)
239
+ return toolPrefs[key] !== false;
240
+ return true;
241
+ }
242
+ export const HOOK_EVENT_VALUES = [
243
+ "pre-save", "post-save", "post-search",
244
+ "pre-finding", "post-finding",
245
+ "pre-index", "post-index",
246
+ "post-session-end", "post-consolidate",
247
+ ];
248
+ const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES);
249
+ /** Return the target (URL or shell command) for display or matching. */
250
+ export function getHookTarget(h) {
251
+ return "webhook" in h ? h.webhook : h.command;
252
+ }
253
+ const DEFAULT_CUSTOM_HOOK_TIMEOUT = 5000;
254
+ const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 10);
255
+ const HOOK_ERROR_LOG_MAX_LINES = 1000;
256
+ export function readCustomHooks(phrenPath) {
257
+ try {
258
+ const prefsPath = installPreferencesFile(phrenPath);
259
+ const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
260
+ if (!Array.isArray(prefs.customHooks))
261
+ 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)));
267
+ }
268
+ catch (err) {
269
+ debugLog(`readCustomHooks: ${errorMessage(err)}`);
270
+ return [];
271
+ }
272
+ }
273
+ function appendHookErrorLog(phrenPath, event, message) {
274
+ const logPath = runtimeFile(phrenPath, "hook-errors.log");
275
+ const line = `[${new Date().toISOString()}] [${event}] ${message}\n`;
276
+ fs.appendFileSync(logPath, line);
277
+ try {
278
+ const stat = fs.statSync(logPath);
279
+ if (stat.size > 200_000) {
280
+ const content = fs.readFileSync(logPath, "utf-8");
281
+ const lines = content.split("\n").filter(Boolean);
282
+ atomicWriteText(logPath, lines.slice(-HOOK_ERROR_LOG_MAX_LINES).join("\n") + "\n");
283
+ }
284
+ }
285
+ catch (err) {
286
+ if (process.env.PHREN_DEBUG)
287
+ process.stderr.write(`[phren] appendHookErrorLog rotate: ${errorMessage(err)}\n`);
288
+ }
289
+ }
290
+ export function runCustomHooks(phrenPath, event, env = {}) {
291
+ const hooks = readCustomHooks(phrenPath);
292
+ const matching = hooks.filter((h) => h.event === event);
293
+ const errors = [];
294
+ const isWindows = process.platform === "win32";
295
+ const shellCmd = isWindows ? "cmd" : "sh";
296
+ for (const hook of matching) {
297
+ if ("webhook" in hook) {
298
+ // Webhook hook: fire-and-forget HTTP POST (async, does not block runCustomHooks)
299
+ const payload = JSON.stringify({ event, env, timestamp: new Date().toISOString() });
300
+ const headers = { "Content-Type": "application/json" };
301
+ if (hook.secret) {
302
+ headers["X-Phren-Signature"] = `sha256=${createHmac("sha256", hook.secret).update(payload).digest("hex")}`;
303
+ }
304
+ fetch(hook.webhook, {
305
+ method: "POST",
306
+ headers,
307
+ body: payload,
308
+ redirect: "manual",
309
+ signal: AbortSignal.timeout(hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT),
310
+ })
311
+ .catch((err) => {
312
+ const message = `${event}: ${hook.webhook}: ${errorMessage(err)}`;
313
+ debugLog(`runCustomHooks webhook: ${message}`);
314
+ try {
315
+ appendHookErrorLog(phrenPath, event, message);
316
+ }
317
+ catch (logErr) {
318
+ if (process.env.PHREN_DEBUG)
319
+ process.stderr.write(`[phren] runCustomHooks webhookErrorLog: ${errorMessage(logErr)}\n`);
320
+ }
321
+ });
322
+ continue;
323
+ }
324
+ const shellArgs = isWindows ? ["/c", hook.command] : ["-c", hook.command];
325
+ try {
326
+ execFileSync(shellCmd, shellArgs, {
327
+ cwd: phrenPath,
328
+ encoding: "utf8",
329
+ timeout: hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT,
330
+ env: { ...process.env, PHREN_PATH: phrenPath, PHREN_HOOK_EVENT: event, ...env },
331
+ stdio: ["ignore", "ignore", "pipe"],
332
+ });
333
+ }
334
+ catch (err) {
335
+ const message = `${event}: ${hook.command}: ${errorMessage(err)}`;
336
+ debugLog(`runCustomHooks: ${message}`);
337
+ errors.push({ code: PhrenError.VALIDATION_ERROR, message });
338
+ try {
339
+ appendHookErrorLog(phrenPath, event, errorMessage(err));
340
+ }
341
+ catch (logErr) {
342
+ if (process.env.PHREN_DEBUG)
343
+ process.stderr.write(`[phren] runCustomHooks hookErrorLog: ${errorMessage(logErr)}\n`);
344
+ }
345
+ }
346
+ }
347
+ return { ran: matching.length, errors };
348
+ }
349
+ export function configureAllHooks(phrenPath, options = {}) {
350
+ const configured = [];
351
+ const detected = options.tools
352
+ ? options.tools
353
+ : options.allTools
354
+ ? new Set(["copilot", "cursor", "codex"])
355
+ : detectInstalledTools();
356
+ const lifecycle = buildLifecycleCommands(phrenPath);
357
+ // ── GitHub Copilot CLI (user-level: ~/.github/hooks/phren.json) ──────────
358
+ if (detected.has("copilot")) {
359
+ const copilotLifecycle = withHookToolLifecycleCommands(lifecycle, "copilot");
360
+ const copilotFile = hookConfigPath("copilot", phrenPath);
361
+ const copilotHooksDir = path.dirname(copilotFile);
362
+ try {
363
+ fs.mkdirSync(copilotHooksDir, { recursive: true });
364
+ const config = {
365
+ version: 1,
366
+ hooks: {
367
+ sessionStart: [{ type: "command", bash: copilotLifecycle.sessionStart }],
368
+ userPromptSubmitted: [{ type: "command", bash: copilotLifecycle.userPromptSubmit }],
369
+ sessionEnd: [{ type: "command", bash: copilotLifecycle.stop }],
370
+ },
371
+ };
372
+ if (!validateCopilotConfig(config))
373
+ throw new Error("invalid copilot hook config shape");
374
+ atomicWriteText(copilotFile, JSON.stringify(config, null, 2));
375
+ configured.push("Copilot CLI");
376
+ }
377
+ catch (err) {
378
+ debugLog(`configureAllHooks: copilot failed: ${errorMessage(err)}`);
379
+ }
380
+ if (isToolHookEnabled(phrenPath, "copilot"))
381
+ installSessionWrapper("copilot", phrenPath);
382
+ }
383
+ // ── Cursor (user-level: ~/.cursor/hooks.json) ────────────────────────────
384
+ if (detected.has("cursor")) {
385
+ const cursorLifecycle = withHookToolLifecycleCommands(lifecycle, "cursor");
386
+ const cursorFile = hookConfigPath("cursor", phrenPath);
387
+ try {
388
+ fs.mkdirSync(path.dirname(cursorFile), { recursive: true });
389
+ let existing = {};
390
+ try {
391
+ existing = JSON.parse(fs.readFileSync(cursorFile, "utf8"));
392
+ }
393
+ catch (err) {
394
+ if (process.env.PHREN_DEBUG)
395
+ process.stderr.write(`[phren] configureAllHooks cursorRead: ${errorMessage(err)}\n`);
396
+ }
397
+ const config = {
398
+ ...existing,
399
+ version: 1,
400
+ // Cursor parity: sessionStart is best-effort where supported; wrapper also enforces lifecycle.
401
+ sessionStart: { command: cursorLifecycle.sessionStart },
402
+ beforeSubmitPrompt: { command: cursorLifecycle.userPromptSubmit },
403
+ stop: { command: cursorLifecycle.stop },
404
+ };
405
+ if (!validateCursorConfig(config))
406
+ throw new Error("invalid cursor hook config shape");
407
+ atomicWriteText(cursorFile, JSON.stringify(config, null, 2));
408
+ configured.push("Cursor");
409
+ }
410
+ catch (err) {
411
+ debugLog(`configureAllHooks: cursor failed: ${errorMessage(err)}`);
412
+ }
413
+ if (isToolHookEnabled(phrenPath, "cursor"))
414
+ installSessionWrapper("cursor", phrenPath);
415
+ }
416
+ // ── Codex (codex.json in phren path) ─────────────────────────────────────
417
+ if (detected.has("codex")) {
418
+ const codexFile = hookConfigPath("codex", phrenPath);
419
+ try {
420
+ const codexLifecycle = withHookToolLifecycleCommands(buildSharedLifecycleCommands(), "codex");
421
+ let existing = {};
422
+ try {
423
+ existing = JSON.parse(fs.readFileSync(codexFile, "utf8"));
424
+ }
425
+ catch (err) {
426
+ if (process.env.PHREN_DEBUG)
427
+ process.stderr.write(`[phren] configureAllHooks codexRead: ${errorMessage(err)}\n`);
428
+ }
429
+ const config = {
430
+ ...existing,
431
+ hooks: {
432
+ SessionStart: [{ type: "command", command: codexLifecycle.sessionStart }],
433
+ UserPromptSubmit: [{ type: "command", command: codexLifecycle.userPromptSubmit }],
434
+ Stop: [{ type: "command", command: codexLifecycle.stop }],
435
+ },
436
+ };
437
+ if (!validateCodexConfig(config))
438
+ throw new Error("invalid codex hook config shape");
439
+ atomicWriteText(codexFile, JSON.stringify(config, null, 2));
440
+ configured.push("Codex");
441
+ }
442
+ catch (err) {
443
+ debugLog(`configureAllHooks: codex failed: ${errorMessage(err)}`);
444
+ }
445
+ if (isToolHookEnabled(phrenPath, "codex"))
446
+ installSessionWrapper("codex", phrenPath);
447
+ }
448
+ return configured;
449
+ }
@@ -0,0 +1,22 @@
1
+ // Backward-compatible wrapper around finding-impact.
2
+ import { findingIdFromLine, extractFindingIdsFromSnippet, logImpact, getHighImpactFindings, markImpactEntriesCompletedForSession, } from "./finding-impact.js";
3
+ export function impactEntryKey(project, findingId) {
4
+ return `${project}\u0000${findingId}`;
5
+ }
6
+ export { findingIdFromLine, extractFindingIdsFromSnippet, markImpactEntriesCompletedForSession, };
7
+ export function appendImpactEntries(phrenPath, entries) {
8
+ const pending = entries.filter((entry) => !entry.taskCompleted);
9
+ if (pending.length === 0)
10
+ return;
11
+ logImpact(phrenPath, pending.map((entry) => ({
12
+ findingId: entry.findingId,
13
+ project: entry.project,
14
+ sessionId: entry.sessionId,
15
+ })));
16
+ }
17
+ export function getHighImpactFindingKeys(phrenPath, minSuccessCount = 3) {
18
+ const findingIds = getHighImpactFindings(phrenPath, minSuccessCount);
19
+ // Legacy API encoded project+findingId; new API tracks finding ID globally.
20
+ // Return IDs as-is to preserve compatibility where only membership checks are used.
21
+ return findingIds;
22
+ }
@@ -0,0 +1,168 @@
1
+ import * as path from "path";
2
+ import { debugLog } from "./shared.js";
3
+ function describeSqlValue(value) {
4
+ if (value === null)
5
+ return "null";
6
+ if (value === undefined)
7
+ return "undefined";
8
+ if (value instanceof Uint8Array)
9
+ return "Uint8Array";
10
+ return typeof value;
11
+ }
12
+ function expectRowWidth(row, minColumns, context) {
13
+ if (!Array.isArray(row) || row.length < minColumns) {
14
+ throw new Error(`${context}: expected at least ${minColumns} columns, got ${Array.isArray(row) ? row.length : typeof row}`);
15
+ }
16
+ }
17
+ function normalizeDocSegment(value) {
18
+ return value.replace(/\\/g, "/").replace(/^\/+/, "");
19
+ }
20
+ function getProjectRoot(phrenPath, project) {
21
+ return path.join(path.resolve(phrenPath), project);
22
+ }
23
+ export function buildSourceDocKey(project, docPath, phrenPath, fallbackFilename) {
24
+ const normalizedProject = normalizeDocSegment(project);
25
+ const normalizedDocPath = path.resolve(docPath);
26
+ const projectRoot = getProjectRoot(phrenPath, project);
27
+ if (normalizedDocPath.startsWith(projectRoot + path.sep) || normalizedDocPath === projectRoot) {
28
+ const relPath = normalizeDocSegment(path.relative(projectRoot, normalizedDocPath));
29
+ if (relPath)
30
+ return `${normalizedProject}/${relPath}`;
31
+ }
32
+ const fallback = fallbackFilename ?? path.basename(docPath);
33
+ return `${normalizedProject}/${normalizeDocSegment(fallback)}`;
34
+ }
35
+ export function decodeStringRow(row, width, context) {
36
+ expectRowWidth(row, width, context);
37
+ return row.slice(0, width).map((value, index) => {
38
+ if (typeof value !== "string") {
39
+ throw new Error(`${context}: expected column ${index} to be string, got ${describeSqlValue(value)}`);
40
+ }
41
+ return value;
42
+ });
43
+ }
44
+ export function decodeFiniteNumber(value, context) {
45
+ if (typeof value !== "number" || !Number.isFinite(value)) {
46
+ throw new Error(`${context}: expected finite number, got ${describeSqlValue(value)}`);
47
+ }
48
+ return value;
49
+ }
50
+ export function getDocSourceKey(doc, phrenPath) {
51
+ return buildSourceDocKey(doc.project, doc.path, phrenPath, doc.filename);
52
+ }
53
+ /** Normalize a memory ID to canonical format: `mem:project/path/to/file.md`. */
54
+ export function normalizeMemoryId(rawId) {
55
+ let id = decodeURIComponent(rawId).replace(/\\/g, "/");
56
+ if (!id.startsWith("mem:"))
57
+ id = `mem:${id}`;
58
+ return id;
59
+ }
60
+ export function rowToDoc(row) {
61
+ const [project, filename, type, content, filePath] = decodeStringRow(row, 5, "rowToDoc");
62
+ return { project, filename, type, content, path: filePath };
63
+ }
64
+ export function rowToDocWithRowid(row) {
65
+ expectRowWidth(row, 6, "rowToDocWithRowid");
66
+ const rowid = decodeFiniteNumber(row[0], "rowToDocWithRowid");
67
+ const [project, filename, type, content, filePath] = decodeStringRow(row.slice(1), 5, "rowToDocWithRowid.doc");
68
+ return {
69
+ rowid,
70
+ doc: { project, filename, type, content, path: filePath },
71
+ };
72
+ }
73
+ export function queryRows(db, sql, params) {
74
+ try {
75
+ const results = db.exec(sql, params);
76
+ if (!Array.isArray(results) || !results.length || !results[0]?.values?.length)
77
+ return null;
78
+ return results[0].values;
79
+ }
80
+ catch (err) {
81
+ debugLog(`queryRows failed: ${err instanceof Error ? err.message : "unknown error"}`);
82
+ return null;
83
+ }
84
+ }
85
+ export function queryDocRows(db, sql, params) {
86
+ const raw = queryRows(db, sql, params);
87
+ if (!raw)
88
+ return null;
89
+ return raw.map(rowToDoc);
90
+ }
91
+ export function queryDocBySourceKey(db, phrenPath, sourceKey) {
92
+ const match = sourceKey.match(/^([^/]+)\/(.+)$/);
93
+ if (!match)
94
+ return null;
95
+ const [, project, rest] = match;
96
+ const filename = rest.includes("/") ? path.basename(rest) : rest;
97
+ const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ? AND filename = ?", [project, filename]);
98
+ if (!rows)
99
+ return null;
100
+ return rows.find((row) => getDocSourceKey(row, phrenPath) === sourceKey) ?? null;
101
+ }
102
+ export function extractSnippet(content, query, lines = 5) {
103
+ const terms = query.replace(/\b(AND|OR|NOT|NEAR)\b/gi, "")
104
+ .replace(/['"]/g, "")
105
+ .split(/\s+/)
106
+ .filter((term) => term.length > 1)
107
+ .map((term) => term.toLowerCase());
108
+ if (terms.length === 0) {
109
+ return content.split("\n").slice(0, lines).join("\n");
110
+ }
111
+ const contentLines = content.split("\n");
112
+ const headingIndices = [];
113
+ for (let i = 0; i < contentLines.length; i++) {
114
+ if (contentLines[i].trimStart().startsWith("#"))
115
+ headingIndices.push(i);
116
+ }
117
+ function nearestHeadingDist(idx) {
118
+ let min = Infinity;
119
+ for (const headingIndex of headingIndices) {
120
+ const distance = Math.abs(idx - headingIndex);
121
+ if (distance < min)
122
+ min = distance;
123
+ }
124
+ return min;
125
+ }
126
+ function sectionMiddle(idx) {
127
+ let sectionStart = 0;
128
+ let sectionEnd = contentLines.length;
129
+ for (const headingIndex of headingIndices) {
130
+ if (headingIndex <= idx)
131
+ sectionStart = headingIndex;
132
+ else {
133
+ sectionEnd = headingIndex;
134
+ break;
135
+ }
136
+ }
137
+ return (sectionStart + sectionEnd) / 2;
138
+ }
139
+ let bestIdx = 0;
140
+ let bestScore = 0;
141
+ let bestHeadingDist = Infinity;
142
+ let bestMidDist = Infinity;
143
+ for (let i = 0; i < contentLines.length; i++) {
144
+ const lineLower = contentLines[i].toLowerCase();
145
+ let score = 0;
146
+ for (const term of terms) {
147
+ if (lineLower.includes(term))
148
+ score++;
149
+ }
150
+ if (score === 0)
151
+ continue;
152
+ const headingDist = nearestHeadingDist(i);
153
+ const nearHeading = headingDist <= 3;
154
+ const midDist = Math.abs(i - sectionMiddle(i));
155
+ const better = score > bestScore ||
156
+ (score === bestScore && nearHeading && bestHeadingDist > 3) ||
157
+ (score === bestScore && nearHeading === (bestHeadingDist <= 3) && midDist < bestMidDist);
158
+ if (better) {
159
+ bestScore = score;
160
+ bestIdx = i;
161
+ bestHeadingDist = headingDist;
162
+ bestMidDist = midDist;
163
+ }
164
+ }
165
+ const start = Math.max(0, bestIdx - 1);
166
+ const end = Math.min(contentLines.length, bestIdx + lines - 1);
167
+ return contentLines.slice(start, end).join("\n");
168
+ }