@phren/cli 0.0.32 → 0.0.34

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 (59) hide show
  1. package/mcp/dist/cli/actions.js +3 -0
  2. package/mcp/dist/cli/config.js +3 -3
  3. package/mcp/dist/cli/govern.js +18 -8
  4. package/mcp/dist/cli/hooks-context.js +1 -1
  5. package/mcp/dist/cli/hooks-session.js +18 -62
  6. package/mcp/dist/cli/namespaces.js +1 -1
  7. package/mcp/dist/cli/search.js +5 -5
  8. package/mcp/dist/cli-hooks-prompt.js +7 -3
  9. package/mcp/dist/cli-hooks-session-handlers.js +3 -15
  10. package/mcp/dist/cli-hooks-stop.js +10 -48
  11. package/mcp/dist/content/archive.js +8 -20
  12. package/mcp/dist/content/learning.js +29 -8
  13. package/mcp/dist/data/access.js +13 -4
  14. package/mcp/dist/finding/lifecycle.js +9 -3
  15. package/mcp/dist/governance/audit.js +13 -5
  16. package/mcp/dist/governance/policy.js +13 -0
  17. package/mcp/dist/governance/rbac.js +1 -1
  18. package/mcp/dist/governance/scores.js +2 -1
  19. package/mcp/dist/hooks.js +52 -6
  20. package/mcp/dist/index.js +1 -1
  21. package/mcp/dist/init/init.js +66 -45
  22. package/mcp/dist/init/shared.js +1 -1
  23. package/mcp/dist/init-bootstrap.js +0 -47
  24. package/mcp/dist/init-fresh.js +13 -18
  25. package/mcp/dist/init-uninstall.js +22 -0
  26. package/mcp/dist/init-walkthrough.js +19 -24
  27. package/mcp/dist/link/doctor.js +9 -0
  28. package/mcp/dist/package-metadata.js +1 -1
  29. package/mcp/dist/phren-art.js +4 -120
  30. package/mcp/dist/proactivity.js +1 -1
  31. package/mcp/dist/project-topics.js +16 -46
  32. package/mcp/dist/provider-adapters.js +1 -1
  33. package/mcp/dist/runtime-profile.js +1 -1
  34. package/mcp/dist/shared/data-utils.js +25 -0
  35. package/mcp/dist/shared/fragment-graph.js +4 -18
  36. package/mcp/dist/shared/index.js +14 -10
  37. package/mcp/dist/shared/ollama.js +23 -5
  38. package/mcp/dist/shared/process.js +24 -0
  39. package/mcp/dist/shared/retrieval.js +7 -4
  40. package/mcp/dist/shared/search-fallback.js +1 -0
  41. package/mcp/dist/shared.js +2 -1
  42. package/mcp/dist/shell/render.js +1 -1
  43. package/mcp/dist/skill/registry.js +1 -1
  44. package/mcp/dist/skill/state.js +0 -3
  45. package/mcp/dist/task/github.js +1 -0
  46. package/mcp/dist/task/lifecycle.js +1 -6
  47. package/mcp/dist/tools/config.js +415 -400
  48. package/mcp/dist/tools/finding.js +390 -373
  49. package/mcp/dist/tools/ops.js +372 -365
  50. package/mcp/dist/tools/search.js +495 -487
  51. package/mcp/dist/tools/session.js +3 -2
  52. package/mcp/dist/tools/skills.js +9 -0
  53. package/mcp/dist/ui/page.js +1 -1
  54. package/mcp/dist/ui/server.js +645 -1040
  55. package/mcp/dist/utils.js +12 -8
  56. package/package.json +1 -1
  57. package/mcp/dist/init-dryrun.js +0 -55
  58. package/mcp/dist/init-migrate.js +0 -51
  59. package/mcp/dist/init-walkthrough-merge.js +0 -90
@@ -14,297 +14,421 @@ import { getProjectConsolidationStatus, CONSOLIDATION_ENTRY_THRESHOLD } from "..
14
14
  import { logger } from "../logger.js";
15
15
  import { getRuntimeHealth } from "../governance/policy.js";
16
16
  import { countUnsyncedCommits } from "../cli-hooks-git.js";
17
- export function register(server, ctx) {
17
+ // ── Handlers ─────────────────────────────────────────────────────────────────
18
+ async function handleAddProject(ctx, { path: targetPath, profile: requestedProfile, ownership }) {
18
19
  const { phrenPath, profile, withWriteQueue } = ctx;
19
- // ── add_project ────────────────────────────────────────────────────────────
20
- server.registerTool("add_project", {
21
- title: "◆ phren · add project",
22
- description: "Bootstrap a project into phren from a repo or working directory. " +
23
- "Copies or creates CLAUDE.md/summary/tasks/findings under ~/.phren/<project> and adds the project to the active profile.",
24
- inputSchema: z.object({
25
- path: z.string().describe("Project path to import. Pass the current repo path explicitly."),
26
- profile: z.string().optional().describe("Profile to update. Defaults to the active profile."),
27
- ownership: z.enum(PROJECT_OWNERSHIP_MODES).optional()
28
- .describe("How Phren should treat repo-facing instruction files: phren-managed, detached, or repo-managed."),
29
- }),
30
- }, async ({ path: targetPath, profile: requestedProfile, ownership }) => {
31
- return withWriteQueue(async () => {
32
- try {
33
- const added = addProjectFromPath(phrenPath, targetPath, requestedProfile || profile || undefined, parseProjectOwnershipMode(ownership) ?? undefined);
34
- if (!added.ok) {
35
- return mcpResponse({
36
- ok: false,
37
- error: added.error,
38
- });
39
- }
40
- await ctx.rebuildIndex();
41
- return mcpResponse({
42
- ok: true,
43
- message: `Added project "${added.data.project}" (${added.data.ownership}) from ${added.data.path}.`,
44
- data: added.data,
45
- });
46
- }
47
- catch (err) {
20
+ return withWriteQueue(async () => {
21
+ try {
22
+ const added = addProjectFromPath(phrenPath, targetPath, requestedProfile || profile || undefined, parseProjectOwnershipMode(ownership) ?? undefined);
23
+ if (!added.ok) {
48
24
  return mcpResponse({
49
25
  ok: false,
50
- error: errorMessage(err),
26
+ error: added.error,
51
27
  });
52
28
  }
53
- });
54
- });
55
- // ── health_check ───────────────────────────────────────────────────────────
56
- server.registerTool("health_check", {
57
- title: "◆ phren · health",
58
- description: "Return phren health status: version, FTS index status, hook registration, profile/machine info, and consolidation status for all projects.",
59
- inputSchema: z.object({
60
- include_consolidation: z.boolean().optional()
61
- .describe("Include consolidation status for all projects (default true)."),
62
- }),
63
- }, async ({ include_consolidation }) => {
64
- const activeProfile = (() => {
65
- try {
66
- return resolveRuntimeProfile(phrenPath);
67
- }
68
- catch {
69
- return profile || "";
70
- }
71
- })();
72
- // Version
73
- let version = "unknown";
74
- try {
75
- const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "..", "package.json");
76
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
77
- version = pkg.version || "unknown";
78
- }
79
- catch (err) {
80
- logger.debug("healthCheck version", errorMessage(err));
81
- }
82
- // FTS index (lives in /tmpphren-fts-*/, not .runtime/)
83
- let indexStatus = { exists: false };
84
- try {
85
- indexStatus = findFtsCacheForPath(phrenPath, activeProfile);
29
+ await ctx.rebuildIndex();
30
+ return mcpResponse({
31
+ ok: true,
32
+ message: `Added project "${added.data.project}" (${added.data.ownership}) from ${added.data.path}.`,
33
+ data: added.data,
34
+ });
86
35
  }
87
36
  catch (err) {
88
- logger.debug("healthCheck ftsCacheCheck", errorMessage(err));
37
+ return mcpResponse({
38
+ ok: false,
39
+ error: errorMessage(err),
40
+ });
89
41
  }
90
- // Hook registration
91
- let hooksEnabled = false;
42
+ });
43
+ }
44
+ async function handleHealthCheck(ctx, { include_consolidation }) {
45
+ const { phrenPath, profile } = ctx;
46
+ const activeProfile = (() => {
92
47
  try {
93
- const { getHooksEnabledPreference } = await import("../init/preferences.js");
94
- hooksEnabled = getHooksEnabledPreference(phrenPath);
48
+ return resolveRuntimeProfile(phrenPath);
95
49
  }
96
- catch (err) {
97
- logger.debug("healthCheck hooksEnabled", errorMessage(err));
50
+ catch {
51
+ return profile || "";
98
52
  }
99
- let mcpEnabled = false;
53
+ })();
54
+ // Version
55
+ let version = "unknown";
56
+ try {
57
+ const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "..", "package.json");
58
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
59
+ version = pkg.version || "unknown";
60
+ }
61
+ catch (err) {
62
+ logger.debug("healthCheck version", errorMessage(err));
63
+ }
64
+ // FTS index (lives in /tmpphren-fts-*/, not .runtime/)
65
+ let indexStatus = { exists: false };
66
+ try {
67
+ indexStatus = findFtsCacheForPath(phrenPath, activeProfile);
68
+ }
69
+ catch (err) {
70
+ logger.debug("healthCheck ftsCacheCheck", errorMessage(err));
71
+ }
72
+ // Hook registration
73
+ let hooksEnabled = false;
74
+ try {
75
+ const { getHooksEnabledPreference } = await import("../init/preferences.js");
76
+ hooksEnabled = getHooksEnabledPreference(phrenPath);
77
+ }
78
+ catch (err) {
79
+ logger.debug("healthCheck hooksEnabled", errorMessage(err));
80
+ }
81
+ let mcpEnabled = false;
82
+ try {
83
+ const { getMcpEnabledPreference } = await import("../init/preferences.js");
84
+ mcpEnabled = getMcpEnabledPreference(phrenPath);
85
+ }
86
+ catch (err) {
87
+ logger.debug("healthCheck mcpEnabled", errorMessage(err));
88
+ }
89
+ // Profile/machine info
90
+ const machineName = (() => {
100
91
  try {
101
- const { getMcpEnabledPreference } = await import("../init/preferences.js");
102
- mcpEnabled = getMcpEnabledPreference(phrenPath);
92
+ return getMachineName();
103
93
  }
104
94
  catch (err) {
105
- logger.debug("healthCheck mcpEnabled", errorMessage(err));
95
+ logger.debug("healthCheck machineName", errorMessage(err));
106
96
  }
107
- // Profile/machine info
108
- const machineName = (() => {
97
+ return undefined;
98
+ })();
99
+ const projectCount = getProjectDirs(phrenPath, activeProfile).length;
100
+ // Proactivity and taskMode
101
+ let proactivity = "high";
102
+ let taskMode = "auto";
103
+ try {
104
+ const { getWorkflowPolicy } = await import("../governance/policy.js");
105
+ const workflowPolicy = getWorkflowPolicy(phrenPath);
106
+ taskMode = workflowPolicy.taskMode;
107
+ }
108
+ catch (err) {
109
+ logger.debug("healthCheck taskMode", errorMessage(err));
110
+ }
111
+ let syncIntent;
112
+ try {
113
+ const { readInstallPreferences } = await import("../init/preferences.js");
114
+ const prefs = readInstallPreferences(phrenPath);
115
+ proactivity = prefs.proactivity || "high";
116
+ syncIntent = prefs.syncIntent;
117
+ }
118
+ catch (err) {
119
+ logger.debug("healthCheck proactivity", errorMessage(err));
120
+ }
121
+ // Determine sync status from intent + git remote state
122
+ let syncStatus = "local-only";
123
+ let syncDetail = "no git remote configured";
124
+ try {
125
+ const { execFileSync } = await import("child_process");
126
+ const remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
127
+ encoding: "utf8",
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ timeout: 5_000,
130
+ }).trim();
131
+ if (remote) {
109
132
  try {
110
- return getMachineName();
133
+ execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "origin"], {
134
+ stdio: ["ignore", "ignore", "ignore"],
135
+ timeout: 10_000,
136
+ });
137
+ syncStatus = "synced";
138
+ syncDetail = `origin=${remote}`;
111
139
  }
112
- catch (err) {
113
- logger.debug("healthCheck machineName", errorMessage(err));
140
+ catch {
141
+ syncStatus = syncIntent === "sync" ? "broken" : "local-only";
142
+ syncDetail = `origin=${remote} (unreachable)`;
114
143
  }
115
- return undefined;
116
- })();
117
- const projectCount = getProjectDirs(phrenPath, activeProfile).length;
118
- // Proactivity and taskMode
119
- let proactivity = "high";
120
- let taskMode = "auto";
121
- try {
122
- const { getWorkflowPolicy } = await import("../governance/policy.js");
123
- const workflowPolicy = getWorkflowPolicy(phrenPath);
124
- taskMode = workflowPolicy.taskMode;
125
144
  }
126
- catch (err) {
127
- logger.debug("healthCheck taskMode", errorMessage(err));
145
+ else if (syncIntent === "sync") {
146
+ syncStatus = "broken";
147
+ syncDetail = "sync was configured but no remote found";
148
+ }
149
+ }
150
+ catch {
151
+ if (syncIntent === "sync") {
152
+ syncStatus = "broken";
153
+ syncDetail = "sync was configured but no remote found";
128
154
  }
129
- let syncIntent;
155
+ }
156
+ let consolidation = null;
157
+ if (include_consolidation !== false) {
130
158
  try {
131
- const { readInstallPreferences } = await import("../init/preferences.js");
132
- const prefs = readInstallPreferences(phrenPath);
133
- proactivity = prefs.proactivity || "high";
134
- syncIntent = prefs.syncIntent;
159
+ const projectDirsForConsol = getProjectDirs(phrenPath, activeProfile);
160
+ const consolResults = [];
161
+ for (const dir of projectDirsForConsol) {
162
+ const status = getProjectConsolidationStatus(dir);
163
+ if (!status)
164
+ continue;
165
+ consolResults.push({ ...status, threshold: CONSOLIDATION_ENTRY_THRESHOLD });
166
+ }
167
+ consolidation = consolResults;
135
168
  }
136
169
  catch (err) {
137
- logger.debug("healthCheck proactivity", errorMessage(err));
170
+ logger.debug("healthCheck consolidation", errorMessage(err));
171
+ consolidation = null;
138
172
  }
139
- // Determine sync status from intent + git remote state
140
- let syncStatus = "local-only";
141
- let syncDetail = "no git remote configured";
142
- try {
143
- const { execFileSync } = await import("child_process");
144
- const remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
145
- encoding: "utf8",
146
- stdio: ["ignore", "pipe", "ignore"],
147
- timeout: 5_000,
148
- }).trim();
149
- if (remote) {
150
- try {
151
- execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "origin"], {
152
- stdio: ["ignore", "ignore", "ignore"],
153
- timeout: 10_000,
154
- });
155
- syncStatus = "synced";
156
- syncDetail = `origin=${remote}`;
157
- }
158
- catch {
159
- syncStatus = syncIntent === "sync" ? "broken" : "local-only";
160
- syncDetail = `origin=${remote} (unreachable)`;
161
- }
162
- }
163
- else if (syncIntent === "sync") {
164
- syncStatus = "broken";
165
- syncDetail = "sync was configured but no remote found";
166
- }
173
+ }
174
+ const consolSummary = consolidation && consolidation.length > 0
175
+ ? consolidation.filter(r => r.recommended).length > 0
176
+ ? `Consolidation: ${consolidation.filter(r => r.recommended).length} project(s) need consolidation`
177
+ : `Consolidation: all projects OK`
178
+ : null;
179
+ // ── Surface RuntimeHealth warnings ────────────────────────────────────
180
+ const warnings = [];
181
+ try {
182
+ const health = getRuntimeHealth(phrenPath);
183
+ // Unsynced commits
184
+ const unsynced = health.lastSync?.unsyncedCommits;
185
+ if (typeof unsynced === "number" && unsynced > 0) {
186
+ warnings.push(`Unsynced commits: ${unsynced} (last push: ${health.lastSync?.lastPushStatus ?? "unknown"})`);
167
187
  }
168
- catch {
169
- if (syncIntent === "sync") {
170
- syncStatus = "broken";
171
- syncDetail = "sync was configured but no remote found";
172
- }
188
+ // Last auto-save error
189
+ if (health.lastAutoSave?.status === "error") {
190
+ warnings.push(`Last auto-save failed: ${health.lastAutoSave.detail ?? "unknown error"}`);
173
191
  }
174
- let consolidation = null;
175
- if (include_consolidation !== false) {
192
+ // Last push error
193
+ if (health.lastSync?.lastPushStatus === "error") {
194
+ warnings.push(`Last push failed: ${health.lastSync.lastPushDetail ?? "unknown error"}`);
195
+ }
196
+ // Check live unsynced commit count (may differ from cached value)
197
+ if (syncStatus === "synced" && (!unsynced || unsynced === 0)) {
176
198
  try {
177
- const projectDirsForConsol = getProjectDirs(phrenPath, activeProfile);
178
- const consolResults = [];
179
- for (const dir of projectDirsForConsol) {
180
- const status = getProjectConsolidationStatus(dir);
181
- if (!status)
182
- continue;
183
- consolResults.push({ ...status, threshold: CONSOLIDATION_ENTRY_THRESHOLD });
199
+ const liveUnsynced = await countUnsyncedCommits(phrenPath);
200
+ if (liveUnsynced > 0) {
201
+ warnings.push(`Unsynced commits: ${liveUnsynced} (not yet pushed to remote)`);
184
202
  }
185
- consolidation = consolResults;
186
203
  }
187
204
  catch (err) {
188
- logger.debug("healthCheck consolidation", errorMessage(err));
189
- consolidation = null;
205
+ logger.debug("healthCheck liveUnsyncedCount", errorMessage(err));
190
206
  }
191
207
  }
192
- const consolSummary = consolidation && consolidation.length > 0
193
- ? consolidation.filter(r => r.recommended).length > 0
194
- ? `Consolidation: ${consolidation.filter(r => r.recommended).length} project(s) need consolidation`
195
- : `Consolidation: all projects OK`
196
- : null;
197
- // ── Surface RuntimeHealth warnings ────────────────────────────────────
198
- const warnings = [];
199
- try {
200
- const health = getRuntimeHealth(phrenPath);
201
- // Unsynced commits
202
- const unsynced = health.lastSync?.unsyncedCommits;
203
- if (typeof unsynced === "number" && unsynced > 0) {
204
- warnings.push(`Unsynced commits: ${unsynced} (last push: ${health.lastSync?.lastPushStatus ?? "unknown"})`);
205
- }
206
- // Last auto-save error
207
- if (health.lastAutoSave?.status === "error") {
208
- warnings.push(`Last auto-save failed: ${health.lastAutoSave.detail ?? "unknown error"}`);
209
- }
210
- // Last push error
211
- if (health.lastSync?.lastPushStatus === "error") {
212
- warnings.push(`Last push failed: ${health.lastSync.lastPushDetail ?? "unknown error"}`);
213
- }
214
- // Check live unsynced commit count (may differ from cached value)
215
- if (syncStatus === "synced" && (!unsynced || unsynced === 0)) {
208
+ }
209
+ catch (err) {
210
+ logger.debug("healthCheck runtimeHealth", errorMessage(err));
211
+ }
212
+ // Check recent sync warnings from background sync
213
+ try {
214
+ const syncWarningsPath = runtimeFile(phrenPath, "sync-warnings.jsonl");
215
+ if (fs.existsSync(syncWarningsPath)) {
216
+ const lines = fs.readFileSync(syncWarningsPath, "utf8").trim().split("\n").filter(Boolean);
217
+ const recent = lines.slice(-3); // last 3 warnings
218
+ for (const line of recent) {
216
219
  try {
217
- const liveUnsynced = await countUnsyncedCommits(phrenPath);
218
- if (liveUnsynced > 0) {
219
- warnings.push(`Unsynced commits: ${liveUnsynced} (not yet pushed to remote)`);
220
+ const entry = JSON.parse(line);
221
+ if (entry.error) {
222
+ warnings.push(`Background sync failed (${entry.at?.slice(0, 16) ?? "unknown"}): ${entry.error}`);
220
223
  }
221
224
  }
222
- catch (err) {
223
- logger.debug("healthCheck liveUnsyncedCount", errorMessage(err));
224
- }
225
+ catch { /* skip malformed lines */ }
225
226
  }
226
227
  }
227
- catch (err) {
228
- logger.debug("healthCheck runtimeHealth", errorMessage(err));
228
+ }
229
+ catch (err) {
230
+ logger.debug("healthCheck syncWarnings", errorMessage(err));
231
+ }
232
+ // Check embedding/LLM availability
233
+ try {
234
+ const { getOllamaUrl } = await import("../shared/ollama.js");
235
+ const ollamaUrl = getOllamaUrl();
236
+ const hasEmbeddingApi = !!process.env.PHREN_EMBEDDING_API_URL;
237
+ if (!ollamaUrl && !hasEmbeddingApi) {
238
+ warnings.push("Embeddings: unavailable (no Ollama or API endpoint configured)");
229
239
  }
230
- // Check recent sync warnings from background sync
231
- try {
232
- const syncWarningsPath = runtimeFile(phrenPath, "sync-warnings.jsonl");
233
- if (fs.existsSync(syncWarningsPath)) {
234
- const lines = fs.readFileSync(syncWarningsPath, "utf8").trim().split("\n").filter(Boolean);
235
- const recent = lines.slice(-3); // last 3 warnings
236
- for (const line of recent) {
237
- try {
238
- const entry = JSON.parse(line);
239
- if (entry.error) {
240
- warnings.push(`Background sync failed (${entry.at?.slice(0, 16) ?? "unknown"}): ${entry.error}`);
241
- }
242
- }
243
- catch { /* skip malformed lines */ }
244
- }
245
- }
240
+ const hasLlmEndpoint = !!process.env.PHREN_LLM_ENDPOINT;
241
+ const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
242
+ const hasOpenAiKey = !!process.env.OPENAI_API_KEY;
243
+ if (!hasLlmEndpoint && !hasAnthropicKey && !hasOpenAiKey) {
244
+ warnings.push("LLM features: unavailable (no API key configured for semantic dedup/conflict detection)");
246
245
  }
247
- catch (err) {
248
- logger.debug("healthCheck syncWarnings", errorMessage(err));
249
- }
250
- // Check embedding/LLM availability
246
+ }
247
+ catch (err) {
248
+ logger.debug("healthCheck serviceAvailability", errorMessage(err));
249
+ }
250
+ const warningsSummary = warnings.length > 0
251
+ ? `Warnings: ${warnings.length}\n ${warnings.join("\n ")}`
252
+ : null;
253
+ const lines = [
254
+ `Phren v${version}`,
255
+ `Profile: ${activeProfile || "(default)"}`,
256
+ machineName ? `Machine: ${machineName}` : null,
257
+ `Projects: ${projectCount}`,
258
+ `FTS index: ${indexStatus.exists ? `ok (${Math.round((indexStatus.sizeBytes ?? 0) / 1024)} KB)` : "missing"}`,
259
+ `MCP: ${mcpEnabled ? "enabled" : "disabled"}`,
260
+ `Hooks: ${hooksEnabled ? "enabled" : "disabled"}`,
261
+ `Proactivity: ${proactivity}`,
262
+ `Task mode: ${taskMode}`,
263
+ `Sync: ${syncStatus}${syncStatus !== "synced" ? ` (${syncDetail})` : ""}`,
264
+ consolSummary,
265
+ warningsSummary,
266
+ `Path: ${phrenPath}`,
267
+ ].filter(Boolean);
268
+ return mcpResponse({
269
+ ok: true,
270
+ message: lines.join("\n"),
271
+ data: {
272
+ version,
273
+ profile: activeProfile || "(default)",
274
+ machine: machineName ?? null,
275
+ projectCount,
276
+ index: indexStatus,
277
+ mcpEnabled,
278
+ hooksEnabled,
279
+ proactivity,
280
+ taskMode,
281
+ syncStatus,
282
+ syncDetail,
283
+ consolidation,
284
+ warnings,
285
+ phrenPath,
286
+ },
287
+ });
288
+ }
289
+ async function handleDoctorFix(_ctx, { check_data }) {
290
+ const { phrenPath } = _ctx;
291
+ const { runDoctor } = await import("../link/doctor.js");
292
+ const result = await runDoctor(phrenPath, true, check_data ?? false);
293
+ const lines = result.checks.map((c) => `${c.ok ? "ok" : "FAIL"} ${c.name}: ${c.detail}`);
294
+ const failCount = result.checks.filter((c) => !c.ok).length;
295
+ return mcpResponse({
296
+ ok: result.ok,
297
+ ...(result.ok ? {} : { error: `${failCount} check(s) could not be auto-fixed: ${lines.filter((l) => l.startsWith("FAIL")).join("; ")}` }),
298
+ message: result.ok
299
+ ? `Doctor fix complete: all ${result.checks.length} checks passed`
300
+ : `Doctor fix complete: ${failCount} issue(s) remain`,
301
+ data: {
302
+ machine: result.machine,
303
+ profile: result.profile,
304
+ checks: result.checks,
305
+ summary: lines.join("\n"),
306
+ },
307
+ });
308
+ }
309
+ async function handleListHookErrors(ctx, { limit }) {
310
+ const { phrenPath } = ctx;
311
+ const maxEntries = limit ?? 20;
312
+ const ERROR_PATTERNS = [
313
+ /\berror\b/i,
314
+ /\bfail(ed|ure|s)?\b/i,
315
+ /\bcrash(ed)?\b/i,
316
+ /\btimeout\b/i,
317
+ /\bEXCEPTION\b/i,
318
+ /\bEACCES\b/,
319
+ /\bENOENT\b/,
320
+ /\bEPERM\b/,
321
+ /\bENOSPC\b/,
322
+ ];
323
+ function readErrorLines(filePath, filterPatterns) {
251
324
  try {
252
- const { getOllamaUrl } = await import("../shared/ollama.js");
253
- const ollamaUrl = getOllamaUrl();
254
- const hasEmbeddingApi = !!process.env.PHREN_EMBEDDING_API_URL;
255
- if (!ollamaUrl && !hasEmbeddingApi) {
256
- warnings.push("Embeddings: unavailable (no Ollama or API endpoint configured)");
257
- }
258
- const hasLlmEndpoint = !!process.env.PHREN_LLM_ENDPOINT;
259
- const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
260
- const hasOpenAiKey = !!process.env.OPENAI_API_KEY;
261
- if (!hasLlmEndpoint && !hasAnthropicKey && !hasOpenAiKey) {
262
- warnings.push("LLM features: unavailable (no API key configured for semantic dedup/conflict detection)");
263
- }
325
+ if (!fs.existsSync(filePath))
326
+ return [];
327
+ const content = fs.readFileSync(filePath, "utf8");
328
+ const lines = content.split("\n").filter(l => l.trim());
329
+ if (!filterPatterns)
330
+ return lines; // hook-errors.log: every line is an error
331
+ return lines.filter(line => ERROR_PATTERNS.some(p => p.test(line)));
264
332
  }
265
333
  catch (err) {
266
- logger.debug("healthCheck serviceAvailability", errorMessage(err));
334
+ logger.debug("readErrorLines", errorMessage(err));
335
+ return [];
267
336
  }
268
- const warningsSummary = warnings.length > 0
269
- ? `Warnings: ${warnings.length}\n ${warnings.join("\n ")}`
270
- : null;
271
- const lines = [
272
- `Phren v${version}`,
273
- `Profile: ${activeProfile || "(default)"}`,
274
- machineName ? `Machine: ${machineName}` : null,
275
- `Projects: ${projectCount}`,
276
- `FTS index: ${indexStatus.exists ? `ok (${Math.round((indexStatus.sizeBytes ?? 0) / 1024)} KB)` : "missing"}`,
277
- `MCP: ${mcpEnabled ? "enabled" : "disabled"}`,
278
- `Hooks: ${hooksEnabled ? "enabled" : "disabled"}`,
279
- `Proactivity: ${proactivity}`,
280
- `Task mode: ${taskMode}`,
281
- `Sync: ${syncStatus}${syncStatus !== "synced" ? ` (${syncDetail})` : ""}`,
282
- consolSummary,
283
- warningsSummary,
284
- `Path: ${phrenPath}`,
285
- ].filter(Boolean);
337
+ }
338
+ // hook-errors.log contains only hook failure lines (no filtering needed)
339
+ const hookErrors = readErrorLines(runtimeFile(phrenPath, "hook-errors.log"), false);
340
+ // debug.log may contain non-error lines, so filter
341
+ const debugErrors = readErrorLines(runtimeFile(phrenPath, "debug.log"), true);
342
+ const allErrors = [...hookErrors, ...debugErrors];
343
+ if (allErrors.length === 0) {
286
344
  return mcpResponse({
287
345
  ok: true,
288
- message: lines.join("\n"),
289
- data: {
290
- version,
291
- profile: activeProfile || "(default)",
292
- machine: machineName ?? null,
293
- projectCount,
294
- index: indexStatus,
295
- mcpEnabled,
296
- hooksEnabled,
297
- proactivity,
298
- taskMode,
299
- syncStatus,
300
- syncDetail,
301
- consolidation,
302
- warnings,
303
- phrenPath,
304
- },
346
+ message: "No error entries found. Hook errors go to hook-errors.log; general errors require PHREN_DEBUG=1.",
347
+ data: { errors: [], total: 0 },
305
348
  });
349
+ }
350
+ const recent = allErrors.slice(-maxEntries);
351
+ return mcpResponse({
352
+ ok: true,
353
+ message: `Found ${allErrors.length} error(s), showing last ${recent.length}:\n\n${recent.join("\n")}`,
354
+ data: { errors: recent, total: allErrors.length, sources: { hookErrors: hookErrors.length, debugErrors: debugErrors.length } },
355
+ });
356
+ }
357
+ async function handleGetReviewQueue(ctx, { project }) {
358
+ const { phrenPath, profile } = ctx;
359
+ if (project && !isValidProjectName(project)) {
360
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}".` });
361
+ }
362
+ if (project) {
363
+ const result = readReviewQueue(phrenPath, project);
364
+ if (!result.ok) {
365
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
366
+ }
367
+ const items = result.data.map((item) => ({ ...item, project }));
368
+ return mcpResponse({
369
+ ok: true,
370
+ message: `${items.length} queue item(s) for "${project}".`,
371
+ data: { items },
372
+ });
373
+ }
374
+ const result = readReviewQueueAcrossProjects(phrenPath, profile);
375
+ if (!result.ok) {
376
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
377
+ }
378
+ return mcpResponse({
379
+ ok: true,
380
+ message: `${result.data.length} queue item(s) across all projects.`,
381
+ data: { items: result.data },
382
+ });
383
+ }
384
+ async function handleManageReviewItem(ctx, { project, line, action, new_text }) {
385
+ const { phrenPath, withWriteQueue } = ctx;
386
+ if (!isValidProjectName(project)) {
387
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}".` });
388
+ }
389
+ if (action === "edit" && !new_text) {
390
+ return mcpResponse({ ok: false, error: "new_text is required when action is 'edit'." });
391
+ }
392
+ return withWriteQueue(async () => {
393
+ let result;
394
+ switch (action) {
395
+ case "approve":
396
+ result = approveQueueItem(phrenPath, project, line);
397
+ break;
398
+ case "reject":
399
+ result = rejectQueueItem(phrenPath, project, line);
400
+ break;
401
+ case "edit":
402
+ result = editQueueItem(phrenPath, project, line, new_text);
403
+ break;
404
+ }
405
+ if (!result.ok) {
406
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
407
+ }
408
+ return mcpResponse({ ok: true, message: result.data });
306
409
  });
307
- // ── doctor_fix ─────────────────────────────────────────────────────────────
410
+ }
411
+ // ── Registration ─────────────────────────────────────────────────────────────
412
+ export function register(server, ctx) {
413
+ server.registerTool("add_project", {
414
+ title: "◆ phren · add project",
415
+ description: "Bootstrap a project into phren from a repo or working directory. " +
416
+ "Copies or creates CLAUDE.md/summary/tasks/findings under ~/.phren/<project> and adds the project to the active profile.",
417
+ inputSchema: z.object({
418
+ path: z.string().describe("Project path to import. Pass the current repo path explicitly."),
419
+ profile: z.string().optional().describe("Profile to update. Defaults to the active profile."),
420
+ ownership: z.enum(PROJECT_OWNERSHIP_MODES).optional()
421
+ .describe("How Phren should treat repo-facing instruction files: phren-managed, detached, or repo-managed."),
422
+ }),
423
+ }, (params) => handleAddProject(ctx, params));
424
+ server.registerTool("health_check", {
425
+ title: "◆ phren · health",
426
+ description: "Return phren health status: version, FTS index status, hook registration, profile/machine info, and consolidation status for all projects.",
427
+ inputSchema: z.object({
428
+ include_consolidation: z.boolean().optional()
429
+ .describe("Include consolidation status for all projects (default true)."),
430
+ }),
431
+ }, (params) => handleHealthCheck(ctx, params));
308
432
  server.registerTool("doctor_fix", {
309
433
  title: "◆ phren · doctor fix",
310
434
  description: "Run phren doctor with --fix: re-links hooks, symlinks, context, and memory pointers. " +
@@ -313,26 +437,7 @@ export function register(server, ctx) {
313
437
  check_data: z.boolean().optional()
314
438
  .describe("Also validate data files (tasks, findings, governance). Default false."),
315
439
  }),
316
- }, async ({ check_data }) => {
317
- const { runDoctor } = await import("../link/doctor.js");
318
- const result = await runDoctor(phrenPath, true, check_data ?? false);
319
- const lines = result.checks.map((c) => `${c.ok ? "ok" : "FAIL"} ${c.name}: ${c.detail}`);
320
- const failCount = result.checks.filter((c) => !c.ok).length;
321
- return mcpResponse({
322
- ok: result.ok,
323
- ...(result.ok ? {} : { error: `${failCount} check(s) could not be auto-fixed: ${lines.filter((l) => l.startsWith("FAIL")).join("; ")}` }),
324
- message: result.ok
325
- ? `Doctor fix complete: all ${result.checks.length} checks passed`
326
- : `Doctor fix complete: ${failCount} issue(s) remain`,
327
- data: {
328
- machine: result.machine,
329
- profile: result.profile,
330
- checks: result.checks,
331
- summary: lines.join("\n"),
332
- },
333
- });
334
- });
335
- // ── list_hook_errors ───────────────────────────────────────────────────────
440
+ }, (params) => handleDoctorFix(ctx, params));
336
441
  server.registerTool("list_hook_errors", {
337
442
  title: "◆ phren · hook errors",
338
443
  description: "List recent error entries from phren hook-errors.log and debug.log. " +
@@ -341,54 +446,7 @@ export function register(server, ctx) {
341
446
  limit: z.number().int().min(1).max(200).optional()
342
447
  .describe("Max error entries to return (default 20)."),
343
448
  }),
344
- }, async ({ limit }) => {
345
- const maxEntries = limit ?? 20;
346
- const ERROR_PATTERNS = [
347
- /\berror\b/i,
348
- /\bfail(ed|ure|s)?\b/i,
349
- /\bcrash(ed)?\b/i,
350
- /\btimeout\b/i,
351
- /\bEXCEPTION\b/i,
352
- /\bEACCES\b/,
353
- /\bENOENT\b/,
354
- /\bEPERM\b/,
355
- /\bENOSPC\b/,
356
- ];
357
- function readErrorLines(filePath, filterPatterns) {
358
- try {
359
- if (!fs.existsSync(filePath))
360
- return [];
361
- const content = fs.readFileSync(filePath, "utf8");
362
- const lines = content.split("\n").filter(l => l.trim());
363
- if (!filterPatterns)
364
- return lines; // hook-errors.log: every line is an error
365
- return lines.filter(line => ERROR_PATTERNS.some(p => p.test(line)));
366
- }
367
- catch (err) {
368
- logger.debug("readErrorLines", errorMessage(err));
369
- return [];
370
- }
371
- }
372
- // hook-errors.log contains only hook failure lines (no filtering needed)
373
- const hookErrors = readErrorLines(runtimeFile(phrenPath, "hook-errors.log"), false);
374
- // debug.log may contain non-error lines, so filter
375
- const debugErrors = readErrorLines(runtimeFile(phrenPath, "debug.log"), true);
376
- const allErrors = [...hookErrors, ...debugErrors];
377
- if (allErrors.length === 0) {
378
- return mcpResponse({
379
- ok: true,
380
- message: "No error entries found. Hook errors go to hook-errors.log; general errors require PHREN_DEBUG=1.",
381
- data: { errors: [], total: 0 },
382
- });
383
- }
384
- const recent = allErrors.slice(-maxEntries);
385
- return mcpResponse({
386
- ok: true,
387
- message: `Found ${allErrors.length} error(s), showing last ${recent.length}:\n\n${recent.join("\n")}`,
388
- data: { errors: recent, total: allErrors.length, sources: { hookErrors: hookErrors.length, debugErrors: debugErrors.length } },
389
- });
390
- });
391
- // ── get_review_queue ─────────────────────────────────────────────────────
449
+ }, (params) => handleListHookErrors(ctx, params));
392
450
  server.registerTool("get_review_queue", {
393
451
  title: "◆ phren · get review queue",
394
452
  description: "List all items in a project's review queue (review.md), or across all projects when omitted. " +
@@ -396,33 +454,7 @@ export function register(server, ctx) {
396
454
  inputSchema: z.object({
397
455
  project: z.string().optional().describe("Project name. Omit to read the review queue across all projects in the active profile."),
398
456
  }),
399
- }, async ({ project }) => {
400
- if (project && !isValidProjectName(project)) {
401
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}".` });
402
- }
403
- if (project) {
404
- const result = readReviewQueue(phrenPath, project);
405
- if (!result.ok) {
406
- return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
407
- }
408
- const items = result.data.map((item) => ({ ...item, project }));
409
- return mcpResponse({
410
- ok: true,
411
- message: `${items.length} queue item(s) for "${project}".`,
412
- data: { items },
413
- });
414
- }
415
- const result = readReviewQueueAcrossProjects(phrenPath, profile);
416
- if (!result.ok) {
417
- return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
418
- }
419
- return mcpResponse({
420
- ok: true,
421
- message: `${result.data.length} queue item(s) across all projects.`,
422
- data: { items: result.data },
423
- });
424
- });
425
- // ── manage_review_item ──────────────────────────────────────────────────
457
+ }, (params) => handleGetReviewQueue(ctx, params));
426
458
  server.registerTool("manage_review_item", {
427
459
  title: "◆ phren · manage review item",
428
460
  description: "Manage a review queue item: approve (removes from queue, finding stays), reject (removes from queue AND FINDINGS.md), or edit (updates text in both).",
@@ -432,30 +464,5 @@ export function register(server, ctx) {
432
464
  action: z.enum(["approve", "reject", "edit"]).describe("Action to perform on the queue item."),
433
465
  new_text: z.string().max(10000).optional().describe("Required when action is 'edit'."),
434
466
  }),
435
- }, async ({ project, line, action, new_text }) => {
436
- if (!isValidProjectName(project)) {
437
- return mcpResponse({ ok: false, error: `Invalid project name: "${project}".` });
438
- }
439
- if (action === "edit" && !new_text) {
440
- return mcpResponse({ ok: false, error: "new_text is required when action is 'edit'." });
441
- }
442
- return withWriteQueue(async () => {
443
- let result;
444
- switch (action) {
445
- case "approve":
446
- result = approveQueueItem(phrenPath, project, line);
447
- break;
448
- case "reject":
449
- result = rejectQueueItem(phrenPath, project, line);
450
- break;
451
- case "edit":
452
- result = editQueueItem(phrenPath, project, line, new_text);
453
- break;
454
- }
455
- if (!result.ok) {
456
- return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
457
- }
458
- return mcpResponse({ ok: true, message: result.data });
459
- });
460
- });
467
+ }, (params) => handleManageReviewItem(ctx, params));
461
468
  }