@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.
- package/mcp/dist/cli/actions.js +3 -0
- package/mcp/dist/cli/config.js +3 -3
- package/mcp/dist/cli/govern.js +18 -8
- package/mcp/dist/cli/hooks-context.js +1 -1
- package/mcp/dist/cli/hooks-session.js +18 -62
- package/mcp/dist/cli/namespaces.js +1 -1
- package/mcp/dist/cli/search.js +5 -5
- package/mcp/dist/cli-hooks-prompt.js +7 -3
- package/mcp/dist/cli-hooks-session-handlers.js +3 -15
- package/mcp/dist/cli-hooks-stop.js +10 -48
- package/mcp/dist/content/archive.js +8 -20
- package/mcp/dist/content/learning.js +29 -8
- package/mcp/dist/data/access.js +13 -4
- package/mcp/dist/finding/lifecycle.js +9 -3
- package/mcp/dist/governance/audit.js +13 -5
- package/mcp/dist/governance/policy.js +13 -0
- package/mcp/dist/governance/rbac.js +1 -1
- package/mcp/dist/governance/scores.js +2 -1
- package/mcp/dist/hooks.js +52 -6
- package/mcp/dist/index.js +1 -1
- package/mcp/dist/init/init.js +66 -45
- package/mcp/dist/init/shared.js +1 -1
- package/mcp/dist/init-bootstrap.js +0 -47
- package/mcp/dist/init-fresh.js +13 -18
- package/mcp/dist/init-uninstall.js +22 -0
- package/mcp/dist/init-walkthrough.js +19 -24
- package/mcp/dist/link/doctor.js +9 -0
- package/mcp/dist/package-metadata.js +1 -1
- package/mcp/dist/phren-art.js +4 -120
- package/mcp/dist/proactivity.js +1 -1
- package/mcp/dist/project-topics.js +16 -46
- package/mcp/dist/provider-adapters.js +1 -1
- package/mcp/dist/runtime-profile.js +1 -1
- package/mcp/dist/shared/data-utils.js +25 -0
- package/mcp/dist/shared/fragment-graph.js +4 -18
- package/mcp/dist/shared/index.js +14 -10
- package/mcp/dist/shared/ollama.js +23 -5
- package/mcp/dist/shared/process.js +24 -0
- package/mcp/dist/shared/retrieval.js +7 -4
- package/mcp/dist/shared/search-fallback.js +1 -0
- package/mcp/dist/shared.js +2 -1
- package/mcp/dist/shell/render.js +1 -1
- package/mcp/dist/skill/registry.js +1 -1
- package/mcp/dist/skill/state.js +0 -3
- package/mcp/dist/task/github.js +1 -0
- package/mcp/dist/task/lifecycle.js +1 -6
- package/mcp/dist/tools/config.js +415 -400
- package/mcp/dist/tools/finding.js +390 -373
- package/mcp/dist/tools/ops.js +372 -365
- package/mcp/dist/tools/search.js +495 -487
- package/mcp/dist/tools/session.js +3 -2
- package/mcp/dist/tools/skills.js +9 -0
- package/mcp/dist/ui/page.js +1 -1
- package/mcp/dist/ui/server.js +645 -1040
- package/mcp/dist/utils.js +12 -8
- package/package.json +1 -1
- package/mcp/dist/init-dryrun.js +0 -55
- package/mcp/dist/init-migrate.js +0 -51
- package/mcp/dist/init-walkthrough-merge.js +0 -90
package/mcp/dist/tools/ops.js
CHANGED
|
@@ -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
|
-
|
|
17
|
+
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
18
|
+
async function handleAddProject(ctx, { path: targetPath, profile: requestedProfile, ownership }) {
|
|
18
19
|
const { phrenPath, profile, withWriteQueue } = ctx;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
26
|
+
error: added.error,
|
|
51
27
|
});
|
|
52
28
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
37
|
+
return mcpResponse({
|
|
38
|
+
ok: false,
|
|
39
|
+
error: errorMessage(err),
|
|
40
|
+
});
|
|
89
41
|
}
|
|
90
|
-
|
|
91
|
-
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function handleHealthCheck(ctx, { include_consolidation }) {
|
|
45
|
+
const { phrenPath, profile } = ctx;
|
|
46
|
+
const activeProfile = (() => {
|
|
92
47
|
try {
|
|
93
|
-
|
|
94
|
-
hooksEnabled = getHooksEnabledPreference(phrenPath);
|
|
48
|
+
return resolveRuntimeProfile(phrenPath);
|
|
95
49
|
}
|
|
96
|
-
catch
|
|
97
|
-
|
|
50
|
+
catch {
|
|
51
|
+
return profile || "";
|
|
98
52
|
}
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
mcpEnabled = getMcpEnabledPreference(phrenPath);
|
|
92
|
+
return getMachineName();
|
|
103
93
|
}
|
|
104
94
|
catch (err) {
|
|
105
|
-
logger.debug("healthCheck
|
|
95
|
+
logger.debug("healthCheck machineName", errorMessage(err));
|
|
106
96
|
}
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
155
|
+
}
|
|
156
|
+
let consolidation = null;
|
|
157
|
+
if (include_consolidation !== false) {
|
|
130
158
|
try {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
170
|
+
logger.debug("healthCheck consolidation", errorMessage(err));
|
|
171
|
+
consolidation = null;
|
|
138
172
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
if (
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
189
|
-
consolidation = null;
|
|
205
|
+
logger.debug("healthCheck liveUnsyncedCount", errorMessage(err));
|
|
190
206
|
}
|
|
191
207
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
//
|
|
202
|
-
const
|
|
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
|
|
218
|
-
if (
|
|
219
|
-
warnings.push(`
|
|
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
|
|
223
|
-
logger.debug("healthCheck liveUnsyncedCount", errorMessage(err));
|
|
224
|
-
}
|
|
225
|
+
catch { /* skip malformed lines */ }
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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("
|
|
334
|
+
logger.debug("readErrorLines", errorMessage(err));
|
|
335
|
+
return [];
|
|
267
336
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
}
|