@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
|
@@ -83,8 +83,389 @@ function withLifecycleMutation(phrenPath, project, writeQueue, updateIndex, hand
|
|
|
83
83
|
return mcpResponse({ ok: true, message: mapped.message, data: mapped.data });
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
87
|
+
async function handleAddFinding(ctx, { project, finding, citation, sessionId, source, findingType, scope }) {
|
|
88
|
+
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
89
|
+
if (!isValidProjectName(project))
|
|
90
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
91
|
+
const addFindingDenied = permissionDeniedError(phrenPath, "add_finding", project);
|
|
92
|
+
if (addFindingDenied)
|
|
93
|
+
return mcpResponse({ ok: false, error: addFindingDenied });
|
|
94
|
+
if (Array.isArray(finding)) {
|
|
95
|
+
const findings = finding;
|
|
96
|
+
if (findings.length > 100)
|
|
97
|
+
return mcpResponse({ ok: false, error: "Bulk add limited to 100 findings per call." });
|
|
98
|
+
if (findings.some((f) => f.length > 5000))
|
|
99
|
+
return mcpResponse({ ok: false, error: "One or more findings exceed 5000 character limit." });
|
|
100
|
+
return withWriteQueue(async () => {
|
|
101
|
+
runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project });
|
|
102
|
+
const allPotentialDuplicates = [];
|
|
103
|
+
const extraAnnotationsByFinding = [];
|
|
104
|
+
for (const f of findings) {
|
|
105
|
+
const candidates = findJaccardCandidates(phrenPath, project, f);
|
|
106
|
+
if (candidates.length > 0)
|
|
107
|
+
allPotentialDuplicates.push({ finding: f, candidates });
|
|
108
|
+
try {
|
|
109
|
+
const conflicts = await checkSemanticConflicts(phrenPath, project, f);
|
|
110
|
+
extraAnnotationsByFinding.push(conflicts.checked && conflicts.annotations.length > 0 ? conflicts.annotations : []);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
logger.debug("add_finding", `bulk semanticConflict: ${errorMessage(err)}`);
|
|
114
|
+
extraAnnotationsByFinding.push([]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const result = addFindingsToFile(phrenPath, project, findings, {
|
|
118
|
+
extraAnnotationsByFinding,
|
|
119
|
+
sessionId,
|
|
120
|
+
});
|
|
121
|
+
if (!result.ok)
|
|
122
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
123
|
+
const { added, skipped, rejected } = result.data;
|
|
124
|
+
if (added.length > 0) {
|
|
125
|
+
runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
|
|
126
|
+
incrementSessionFindings(phrenPath, added.length, sessionId, project);
|
|
127
|
+
updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
|
|
128
|
+
}
|
|
129
|
+
const rejectedMsg = rejected.length > 0 ? `, ${rejected.length} rejected` : "";
|
|
130
|
+
return mcpResponse({
|
|
131
|
+
ok: true,
|
|
132
|
+
message: `Added ${added.length}/${findings.length} findings (${skipped.length} duplicates skipped${rejectedMsg})`,
|
|
133
|
+
data: {
|
|
134
|
+
project,
|
|
135
|
+
added,
|
|
136
|
+
skipped,
|
|
137
|
+
rejected,
|
|
138
|
+
...(allPotentialDuplicates.length > 0 ? { potentialDuplicates: allPotentialDuplicates } : {}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (finding.length > 5000)
|
|
144
|
+
return mcpResponse({ ok: false, error: "Finding text exceeds 5000 character limit." });
|
|
145
|
+
const normalizedScope = normalizeMemoryScope(scope ?? "shared");
|
|
146
|
+
if (!normalizedScope)
|
|
147
|
+
return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
|
|
148
|
+
return withWriteQueue(async () => {
|
|
149
|
+
try {
|
|
150
|
+
const taggedFinding = findingType ? `[${findingType}] ${finding}` : finding;
|
|
151
|
+
// Jaccard "maybe zone" scan — free, no LLM call. Return candidates so the agent decides.
|
|
152
|
+
const potentialDuplicates = findJaccardCandidates(phrenPath, project, taggedFinding);
|
|
153
|
+
const semanticConflicts = await checkSemanticConflicts(phrenPath, project, taggedFinding);
|
|
154
|
+
runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project });
|
|
155
|
+
const result = addFindingToFile(phrenPath, project, taggedFinding, citation, {
|
|
156
|
+
sessionId,
|
|
157
|
+
source,
|
|
158
|
+
scope: normalizedScope,
|
|
159
|
+
extraAnnotations: semanticConflicts.checked ? semanticConflicts.annotations : undefined,
|
|
160
|
+
});
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
163
|
+
}
|
|
164
|
+
if (result.data.status === "skipped") {
|
|
165
|
+
return mcpResponse({ ok: true, message: result.data.message, data: { project, finding: taggedFinding, status: "skipped" } });
|
|
166
|
+
}
|
|
167
|
+
updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
|
|
168
|
+
if (result.data.status === "added" || result.data.status === "created") {
|
|
169
|
+
runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
|
|
170
|
+
incrementSessionFindings(phrenPath, 1, sessionId, project);
|
|
171
|
+
extractFactFromFinding(phrenPath, project, taggedFinding);
|
|
172
|
+
// Bidirectional link: if there's an active task in this session, append this finding to it.
|
|
173
|
+
if (sessionId) {
|
|
174
|
+
const activeTask = getActiveTaskForSession(phrenPath, sessionId, project);
|
|
175
|
+
if (activeTask) {
|
|
176
|
+
const taskMatch = activeTask.stableId ? `bid:${activeTask.stableId}` : activeTask.line;
|
|
177
|
+
// Extract fid from the last written line in FINDINGS.md
|
|
178
|
+
try {
|
|
179
|
+
const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
|
|
180
|
+
const findingsContent = fs.readFileSync(findingsPath, "utf8");
|
|
181
|
+
const lines = findingsContent.split("\n");
|
|
182
|
+
const taggedText = taggedFinding.replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase();
|
|
183
|
+
for (let li = lines.length - 1; li >= 0; li--) {
|
|
184
|
+
const l = lines[li];
|
|
185
|
+
if (!l.startsWith("- "))
|
|
186
|
+
continue;
|
|
187
|
+
const lineText = l.replace(/<!--.*?-->/g, "").replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase();
|
|
188
|
+
if (lineText === taggedText || l.toLowerCase().includes(taggedText.slice(0, 30))) {
|
|
189
|
+
const fidMatch = l.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/);
|
|
190
|
+
if (fidMatch) {
|
|
191
|
+
appendChildFinding(phrenPath, project, taskMatch, `fid:${fidMatch[1]}`);
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Non-fatal: task-finding linkage is best-effort
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const conflictsWithList = semanticConflicts.checked
|
|
204
|
+
? extractConflictsWith(semanticConflicts.annotations)
|
|
205
|
+
: (result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1] ? [result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)[1]] : []);
|
|
206
|
+
const conflictsWith = conflictsWithList[0];
|
|
207
|
+
// Extract fragment hints synchronously from the finding text (regex only, no DB).
|
|
208
|
+
// Full DB fragment linking happens on the next index rebuild via updateFileInIndex →
|
|
209
|
+
// extractAndLinkFragments. We surface hints here so callers can see what was detected.
|
|
210
|
+
const detectedFragments = extractFragmentNames(taggedFinding);
|
|
211
|
+
return mcpResponse({
|
|
212
|
+
ok: true,
|
|
213
|
+
message: result.data.message,
|
|
214
|
+
data: {
|
|
215
|
+
project,
|
|
216
|
+
finding: taggedFinding,
|
|
217
|
+
status: result.data.status,
|
|
218
|
+
...(conflictsWith ? { conflictsWith } : {}),
|
|
219
|
+
...(conflictsWithList.length > 0 ? { conflicts: conflictsWithList } : {}),
|
|
220
|
+
...(detectedFragments.length > 0 ? { detectedFragments } : {}),
|
|
221
|
+
...(potentialDuplicates.length > 0 ? { potentialDuplicates } : {}),
|
|
222
|
+
scope: normalizedScope,
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (err instanceof Error && err.message.includes("Rejected:")) {
|
|
228
|
+
return mcpResponse({ ok: false, error: errorMessage(err), errorCode: "VALIDATION_ERROR" });
|
|
229
|
+
}
|
|
230
|
+
return mcpResponse({ ok: false, error: `Unexpected error saving finding: ${errorMessage(err)}` });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async function handleSupersedeFinding(ctx, { project, finding_text, superseded_by }) {
|
|
235
|
+
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
236
|
+
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => supersedeFinding(phrenPath, project, finding_text, superseded_by), (data) => ({
|
|
237
|
+
message: `Marked finding as superseded in ${project}.`,
|
|
238
|
+
data: { project, finding: data.finding, status: data.status, superseded_by: data.superseded_by },
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
async function handleRetractFinding(ctx, { project, finding_text, reason }) {
|
|
242
|
+
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
243
|
+
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => retractFindingLifecycle(phrenPath, project, finding_text, reason), (data) => ({
|
|
244
|
+
message: `Retracted finding in ${project}.`,
|
|
245
|
+
data: { project, finding: data.finding, status: data.status, reason: data.reason },
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
async function handleResolveContradiction(ctx, { project, finding_text, finding_text_other, finding_a, finding_b, resolution }) {
|
|
249
|
+
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
250
|
+
const findingText = (finding_text ?? finding_a)?.trim();
|
|
251
|
+
const findingTextOther = (finding_text_other ?? finding_b)?.trim();
|
|
252
|
+
if (!findingText || !findingTextOther) {
|
|
253
|
+
return mcpResponse({
|
|
254
|
+
ok: false,
|
|
255
|
+
error: "Both finding_text and finding_text_other are required.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => resolveFindingContradiction(phrenPath, project, findingText, findingTextOther, resolution), (data) => ({
|
|
259
|
+
message: `Resolved contradiction in ${project} with "${resolution}".`,
|
|
260
|
+
data: {
|
|
261
|
+
project,
|
|
262
|
+
resolution: data.resolution,
|
|
263
|
+
finding_text: data.finding_a,
|
|
264
|
+
finding_text_other: data.finding_b,
|
|
265
|
+
finding_a: data.finding_a,
|
|
266
|
+
finding_b: data.finding_b,
|
|
267
|
+
},
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
async function handleGetContradictions(ctx, { project, finding_text }) {
|
|
271
|
+
const { phrenPath } = ctx;
|
|
272
|
+
if (project && !isValidProjectName(project))
|
|
273
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
274
|
+
const projects = project
|
|
275
|
+
? [project]
|
|
276
|
+
: fs.readdirSync(phrenPath, { withFileTypes: true })
|
|
277
|
+
.filter((entry) => entry.isDirectory() && !RESERVED_PROJECT_DIR_NAMES.has(entry.name) && isValidProjectName(entry.name))
|
|
278
|
+
.map((entry) => entry.name);
|
|
279
|
+
const contradictions = [];
|
|
280
|
+
for (const p of projects) {
|
|
281
|
+
const result = readFindings(phrenPath, p);
|
|
282
|
+
if (!result.ok)
|
|
283
|
+
continue;
|
|
284
|
+
for (const finding of result.data) {
|
|
285
|
+
if (finding.status !== "contradicted")
|
|
286
|
+
continue;
|
|
287
|
+
if (finding_text && !matchesFindingTextSelector(finding, finding_text))
|
|
288
|
+
continue;
|
|
289
|
+
contradictions.push({
|
|
290
|
+
project: p,
|
|
291
|
+
id: finding.id,
|
|
292
|
+
stableId: finding.stableId,
|
|
293
|
+
text: finding.text,
|
|
294
|
+
date: finding.date,
|
|
295
|
+
status_updated: finding.status_updated,
|
|
296
|
+
status_reason: finding.status_reason,
|
|
297
|
+
status_ref: finding.status_ref,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return mcpResponse({
|
|
302
|
+
ok: true,
|
|
303
|
+
message: contradictions.length
|
|
304
|
+
? `Found ${contradictions.length} unresolved contradiction${contradictions.length === 1 ? "" : "s"}.`
|
|
305
|
+
: "No unresolved contradictions found.",
|
|
306
|
+
data: {
|
|
307
|
+
project: project ?? null,
|
|
308
|
+
finding_text: finding_text ?? null,
|
|
309
|
+
contradictions,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async function handleEditFinding(ctx, { project, old_text, new_text }) {
|
|
87
314
|
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
315
|
+
if (!isValidProjectName(project))
|
|
316
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
317
|
+
const editDenied = permissionDeniedError(phrenPath, "edit_finding", project);
|
|
318
|
+
if (editDenied)
|
|
319
|
+
return mcpResponse({ ok: false, error: editDenied });
|
|
320
|
+
return withWriteQueue(async () => {
|
|
321
|
+
const result = editFindingCore(phrenPath, project, old_text, new_text);
|
|
322
|
+
if (!result.ok)
|
|
323
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
324
|
+
const resolvedFindingsDir = safeProjectPath(phrenPath, project);
|
|
325
|
+
if (resolvedFindingsDir)
|
|
326
|
+
updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
|
|
327
|
+
return mcpResponse({
|
|
328
|
+
ok: true,
|
|
329
|
+
message: result.data,
|
|
330
|
+
data: { project, old_text, new_text },
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async function handleRemoveFinding(ctx, { project, finding }) {
|
|
335
|
+
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
336
|
+
if (!isValidProjectName(project))
|
|
337
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
338
|
+
const removeDenied = permissionDeniedError(phrenPath, "remove_finding", project);
|
|
339
|
+
if (removeDenied)
|
|
340
|
+
return mcpResponse({ ok: false, error: removeDenied });
|
|
341
|
+
if (Array.isArray(finding)) {
|
|
342
|
+
return withWriteQueue(async () => {
|
|
343
|
+
const result = removeFindingsCore(phrenPath, project, finding);
|
|
344
|
+
if (!result.ok)
|
|
345
|
+
return mcpResponse({ ok: false, error: result.message });
|
|
346
|
+
const resolvedFindingsDir = safeProjectPath(phrenPath, project);
|
|
347
|
+
if (resolvedFindingsDir)
|
|
348
|
+
updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
|
|
349
|
+
return mcpResponse({ ok: true, message: result.message, data: result.data });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return withWriteQueue(async () => {
|
|
353
|
+
const result = removeFindingCore(phrenPath, project, finding);
|
|
354
|
+
if (!result.ok)
|
|
355
|
+
return mcpResponse({ ok: false, error: result.message });
|
|
356
|
+
const resolvedFindingsDir = safeProjectPath(phrenPath, project);
|
|
357
|
+
if (resolvedFindingsDir)
|
|
358
|
+
updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
|
|
359
|
+
return mcpResponse({ ok: true, message: result.message, data: result.data });
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async function handlePushChanges(ctx, { message }) {
|
|
363
|
+
const { phrenPath, withWriteQueue } = ctx;
|
|
364
|
+
return withWriteQueue(async () => {
|
|
365
|
+
const { execFileSync } = await import("child_process");
|
|
366
|
+
const runGit = (args, opts = {}) => execFileSync("git", args, {
|
|
367
|
+
cwd: phrenPath,
|
|
368
|
+
encoding: "utf8",
|
|
369
|
+
timeout: opts.timeout ?? EXEC_TIMEOUT_MS,
|
|
370
|
+
env: opts.env,
|
|
371
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
372
|
+
}).trim();
|
|
373
|
+
try {
|
|
374
|
+
const status = runGit(["status", "--porcelain"]);
|
|
375
|
+
if (!status)
|
|
376
|
+
return mcpResponse({ ok: true, message: "Nothing to save. Phren is up to date.", data: { files: 0, pushed: false } });
|
|
377
|
+
const files = status.split("\n").filter(Boolean);
|
|
378
|
+
const projectNames = Array.from(new Set(files
|
|
379
|
+
.map((line) => line.slice(3).trim().split("/")[0])
|
|
380
|
+
.filter((name) => name && !name.startsWith(".") && name !== "profiles")));
|
|
381
|
+
const commitMsg = message || `phren: save ${files.length} file(s) across ${projectNames.length} project(s)`;
|
|
382
|
+
runCustomHooks(phrenPath, "pre-save");
|
|
383
|
+
// Stage all files including untracked (new project dirs, first FINDINGS.md, etc.)
|
|
384
|
+
runGit(["add", "-A"]);
|
|
385
|
+
runGit(["commit", "-m", commitMsg]);
|
|
386
|
+
let hasRemote = false;
|
|
387
|
+
try {
|
|
388
|
+
const remotes = runGit(["remote"]);
|
|
389
|
+
hasRemote = remotes.length > 0;
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
logger.warn("push_changes", `remoteCheck: ${errorMessage(err)}`);
|
|
393
|
+
}
|
|
394
|
+
if (!hasRemote) {
|
|
395
|
+
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
396
|
+
return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). No remote configured, skipping push.`, data: { files: changedFiles, pushed: false } });
|
|
397
|
+
}
|
|
398
|
+
let pushed = false;
|
|
399
|
+
let lastPushError = "";
|
|
400
|
+
const delays = [2000, 4000, 8000];
|
|
401
|
+
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
402
|
+
try {
|
|
403
|
+
runGit(["push"], { timeout: 15000 });
|
|
404
|
+
pushed = true;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
catch (pushErr) {
|
|
408
|
+
lastPushError = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
409
|
+
debugLog(`Push attempt ${attempt + 1} failed: ${lastPushError}`);
|
|
410
|
+
if (attempt < 3) {
|
|
411
|
+
try {
|
|
412
|
+
runGit(["pull", "--rebase", "--quiet"], { timeout: 15000 });
|
|
413
|
+
}
|
|
414
|
+
catch (pullErr) {
|
|
415
|
+
logger.warn("push_changes", `pullRebase: ${pullErr instanceof Error ? pullErr.message : String(pullErr)}`);
|
|
416
|
+
const resolved = autoMergeConflicts(phrenPath);
|
|
417
|
+
if (resolved) {
|
|
418
|
+
try {
|
|
419
|
+
runGit(["rebase", "--continue"], {
|
|
420
|
+
timeout: 10000,
|
|
421
|
+
env: { ...process.env, GIT_EDITOR: "true" },
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
catch (continueErr) {
|
|
425
|
+
logger.warn("push_changes", `rebaseContinue: ${continueErr instanceof Error ? continueErr.message : String(continueErr)}`);
|
|
426
|
+
try {
|
|
427
|
+
runGit(["rebase", "--abort"]);
|
|
428
|
+
}
|
|
429
|
+
catch (abortErr) {
|
|
430
|
+
logger.warn("push_changes", `rebaseAbort: ${abortErr instanceof Error ? abortErr.message : String(abortErr)}`);
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
try {
|
|
437
|
+
runGit(["rebase", "--abort"]);
|
|
438
|
+
}
|
|
439
|
+
catch (abortErr) {
|
|
440
|
+
logger.warn("push_changes", `rebaseAbort2: ${abortErr instanceof Error ? abortErr.message : String(abortErr)}`);
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
await new Promise(r => setTimeout(r, delays[attempt]));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
450
|
+
runCustomHooks(phrenPath, "post-save", { PHREN_FILES_CHANGED: String(changedFiles), PHREN_PUSHED: String(pushed) });
|
|
451
|
+
if (pushed) {
|
|
452
|
+
return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). Pushed to remote.`, data: { files: changedFiles, pushed: true } });
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
return mcpResponse({
|
|
456
|
+
ok: true,
|
|
457
|
+
message: `Changes were committed but push failed.\n\nGit error: ${lastPushError}\n\nRun 'git push' manually from your phren directory.`,
|
|
458
|
+
data: { files: changedFiles, pushed: false, pushError: lastPushError },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
return mcpResponse({ ok: false, error: `Save failed: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
468
|
+
export function register(server, ctx) {
|
|
88
469
|
server.registerTool("add_finding", {
|
|
89
470
|
title: "◆ phren · save finding",
|
|
90
471
|
description: "Tell phren one or more insights for a project's FINDINGS.md. Call this the moment you discover " +
|
|
@@ -115,152 +496,7 @@ export function register(server, ctx) {
|
|
|
115
496
|
.describe("Classify this finding: 'decision' (architectural choice with rationale), 'pitfall' (bug or failure mode to avoid), 'pattern' (reusable approach that works well), 'tradeoff' (deliberate compromise), 'architecture' (structural design note), 'bug' (confirmed defect or failure)."),
|
|
116
497
|
scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
|
|
117
498
|
}),
|
|
118
|
-
},
|
|
119
|
-
if (!isValidProjectName(project))
|
|
120
|
-
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
121
|
-
const addFindingDenied = permissionDeniedError(phrenPath, "add_finding", project);
|
|
122
|
-
if (addFindingDenied)
|
|
123
|
-
return mcpResponse({ ok: false, error: addFindingDenied });
|
|
124
|
-
if (Array.isArray(finding)) {
|
|
125
|
-
const findings = finding;
|
|
126
|
-
if (findings.length > 100)
|
|
127
|
-
return mcpResponse({ ok: false, error: "Bulk add limited to 100 findings per call." });
|
|
128
|
-
if (findings.some((f) => f.length > 5000))
|
|
129
|
-
return mcpResponse({ ok: false, error: "One or more findings exceed 5000 character limit." });
|
|
130
|
-
return withWriteQueue(async () => {
|
|
131
|
-
runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project });
|
|
132
|
-
const allPotentialDuplicates = [];
|
|
133
|
-
const extraAnnotationsByFinding = [];
|
|
134
|
-
for (const f of findings) {
|
|
135
|
-
const candidates = findJaccardCandidates(phrenPath, project, f);
|
|
136
|
-
if (candidates.length > 0)
|
|
137
|
-
allPotentialDuplicates.push({ finding: f, candidates });
|
|
138
|
-
try {
|
|
139
|
-
const conflicts = await checkSemanticConflicts(phrenPath, project, f);
|
|
140
|
-
extraAnnotationsByFinding.push(conflicts.checked && conflicts.annotations.length > 0 ? conflicts.annotations : []);
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
logger.debug("add_finding", `bulk semanticConflict: ${errorMessage(err)}`);
|
|
144
|
-
extraAnnotationsByFinding.push([]);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
const result = addFindingsToFile(phrenPath, project, findings, {
|
|
148
|
-
extraAnnotationsByFinding,
|
|
149
|
-
sessionId,
|
|
150
|
-
});
|
|
151
|
-
if (!result.ok)
|
|
152
|
-
return mcpResponse({ ok: false, error: result.error });
|
|
153
|
-
const { added, skipped, rejected } = result.data;
|
|
154
|
-
if (added.length > 0) {
|
|
155
|
-
runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
|
|
156
|
-
incrementSessionFindings(phrenPath, added.length, sessionId, project);
|
|
157
|
-
updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
|
|
158
|
-
}
|
|
159
|
-
const rejectedMsg = rejected.length > 0 ? `, ${rejected.length} rejected` : "";
|
|
160
|
-
return mcpResponse({
|
|
161
|
-
ok: true,
|
|
162
|
-
message: `Added ${added.length}/${findings.length} findings (${skipped.length} duplicates skipped${rejectedMsg})`,
|
|
163
|
-
data: {
|
|
164
|
-
project,
|
|
165
|
-
added,
|
|
166
|
-
skipped,
|
|
167
|
-
rejected,
|
|
168
|
-
...(allPotentialDuplicates.length > 0 ? { potentialDuplicates: allPotentialDuplicates } : {}),
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
if (finding.length > 5000)
|
|
174
|
-
return mcpResponse({ ok: false, error: "Finding text exceeds 5000 character limit." });
|
|
175
|
-
const normalizedScope = normalizeMemoryScope(scope ?? "shared");
|
|
176
|
-
if (!normalizedScope)
|
|
177
|
-
return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
|
|
178
|
-
return withWriteQueue(async () => {
|
|
179
|
-
try {
|
|
180
|
-
const taggedFinding = findingType ? `[${findingType}] ${finding}` : finding;
|
|
181
|
-
// Jaccard "maybe zone" scan — free, no LLM call. Return candidates so the agent decides.
|
|
182
|
-
const potentialDuplicates = findJaccardCandidates(phrenPath, project, taggedFinding);
|
|
183
|
-
const semanticConflicts = await checkSemanticConflicts(phrenPath, project, taggedFinding);
|
|
184
|
-
runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project });
|
|
185
|
-
const result = addFindingToFile(phrenPath, project, taggedFinding, citation, {
|
|
186
|
-
sessionId,
|
|
187
|
-
source,
|
|
188
|
-
scope: normalizedScope,
|
|
189
|
-
extraAnnotations: semanticConflicts.checked ? semanticConflicts.annotations : undefined,
|
|
190
|
-
});
|
|
191
|
-
if (!result.ok) {
|
|
192
|
-
return mcpResponse({ ok: false, error: result.error });
|
|
193
|
-
}
|
|
194
|
-
if (result.data.status === "skipped") {
|
|
195
|
-
return mcpResponse({ ok: true, message: result.data.message, data: { project, finding: taggedFinding, status: "skipped" } });
|
|
196
|
-
}
|
|
197
|
-
updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
|
|
198
|
-
if (result.data.status === "added" || result.data.status === "created") {
|
|
199
|
-
runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
|
|
200
|
-
incrementSessionFindings(phrenPath, 1, sessionId, project);
|
|
201
|
-
extractFactFromFinding(phrenPath, project, taggedFinding);
|
|
202
|
-
// Bidirectional link: if there's an active task in this session, append this finding to it.
|
|
203
|
-
if (sessionId) {
|
|
204
|
-
const activeTask = getActiveTaskForSession(phrenPath, sessionId, project);
|
|
205
|
-
if (activeTask) {
|
|
206
|
-
const taskMatch = activeTask.stableId ? `bid:${activeTask.stableId}` : activeTask.line;
|
|
207
|
-
// Extract fid from the last written line in FINDINGS.md
|
|
208
|
-
try {
|
|
209
|
-
const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
|
|
210
|
-
const findingsContent = fs.readFileSync(findingsPath, "utf8");
|
|
211
|
-
const lines = findingsContent.split("\n");
|
|
212
|
-
const taggedText = taggedFinding.replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase();
|
|
213
|
-
for (let li = lines.length - 1; li >= 0; li--) {
|
|
214
|
-
const l = lines[li];
|
|
215
|
-
if (!l.startsWith("- "))
|
|
216
|
-
continue;
|
|
217
|
-
const lineText = l.replace(/<!--.*?-->/g, "").replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase();
|
|
218
|
-
if (lineText === taggedText || l.toLowerCase().includes(taggedText.slice(0, 30))) {
|
|
219
|
-
const fidMatch = l.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/);
|
|
220
|
-
if (fidMatch) {
|
|
221
|
-
appendChildFinding(phrenPath, project, taskMatch, `fid:${fidMatch[1]}`);
|
|
222
|
-
}
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
// Non-fatal: task-finding linkage is best-effort
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
const conflictsWithList = semanticConflicts.checked
|
|
234
|
-
? extractConflictsWith(semanticConflicts.annotations)
|
|
235
|
-
: (result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1] ? [result.data.message.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)[1]] : []);
|
|
236
|
-
const conflictsWith = conflictsWithList[0];
|
|
237
|
-
// Extract fragment hints synchronously from the finding text (regex only, no DB).
|
|
238
|
-
// Full DB fragment linking happens on the next index rebuild via updateFileInIndex →
|
|
239
|
-
// extractAndLinkEntities. We surface hints here so callers can see what was detected.
|
|
240
|
-
const detectedFragments = extractFragmentNames(taggedFinding);
|
|
241
|
-
return mcpResponse({
|
|
242
|
-
ok: true,
|
|
243
|
-
message: result.data.message,
|
|
244
|
-
data: {
|
|
245
|
-
project,
|
|
246
|
-
finding: taggedFinding,
|
|
247
|
-
status: result.data.status,
|
|
248
|
-
...(conflictsWith ? { conflictsWith } : {}),
|
|
249
|
-
...(conflictsWithList.length > 0 ? { conflicts: conflictsWithList } : {}),
|
|
250
|
-
...(detectedFragments.length > 0 ? { detectedFragments } : {}),
|
|
251
|
-
...(potentialDuplicates.length > 0 ? { potentialDuplicates } : {}),
|
|
252
|
-
scope: normalizedScope,
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
catch (err) {
|
|
257
|
-
if (err instanceof Error && err.message.includes("Rejected:")) {
|
|
258
|
-
return mcpResponse({ ok: false, error: errorMessage(err), errorCode: "VALIDATION_ERROR" });
|
|
259
|
-
}
|
|
260
|
-
return mcpResponse({ ok: false, error: `Unexpected error saving finding: ${errorMessage(err)}` });
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
});
|
|
499
|
+
}, (params) => handleAddFinding(ctx, params));
|
|
264
500
|
server.registerTool("supersede_finding", {
|
|
265
501
|
title: "◆ phren · supersede finding",
|
|
266
502
|
description: "Mark an existing finding as superseded and link it to the newer finding text.",
|
|
@@ -269,12 +505,7 @@ export function register(server, ctx) {
|
|
|
269
505
|
finding_text: z.string().describe("Finding to supersede (supports fid, exact text, or partial match)."),
|
|
270
506
|
superseded_by: z.string().describe("Text of the new finding that supersedes this one."),
|
|
271
507
|
}),
|
|
272
|
-
},
|
|
273
|
-
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => supersedeFinding(phrenPath, project, finding_text, superseded_by), (data) => ({
|
|
274
|
-
message: `Marked finding as superseded in ${project}.`,
|
|
275
|
-
data: { project, finding: data.finding, status: data.status, superseded_by: data.superseded_by },
|
|
276
|
-
}));
|
|
277
|
-
});
|
|
508
|
+
}, (params) => handleSupersedeFinding(ctx, params));
|
|
278
509
|
server.registerTool("retract_finding", {
|
|
279
510
|
title: "◆ phren · retract finding",
|
|
280
511
|
description: "Mark an existing finding as retracted and store the reason in lifecycle metadata.",
|
|
@@ -283,12 +514,7 @@ export function register(server, ctx) {
|
|
|
283
514
|
finding_text: z.string().describe("Finding to retract (supports fid, exact text, or partial match)."),
|
|
284
515
|
reason: z.string().describe("Reason for retraction."),
|
|
285
516
|
}),
|
|
286
|
-
},
|
|
287
|
-
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => retractFindingLifecycle(phrenPath, project, finding_text, reason), (data) => ({
|
|
288
|
-
message: `Retracted finding in ${project}.`,
|
|
289
|
-
data: { project, finding: data.finding, status: data.status, reason: data.reason },
|
|
290
|
-
}));
|
|
291
|
-
});
|
|
517
|
+
}, (params) => handleRetractFinding(ctx, params));
|
|
292
518
|
server.registerTool("resolve_contradiction", {
|
|
293
519
|
title: "◆ phren · resolve contradiction",
|
|
294
520
|
description: "Resolve a contradiction between two findings and update lifecycle status based on the chosen resolution.",
|
|
@@ -315,27 +541,7 @@ export function register(server, ctx) {
|
|
|
315
541
|
});
|
|
316
542
|
}
|
|
317
543
|
}),
|
|
318
|
-
},
|
|
319
|
-
const findingText = (finding_text ?? finding_a)?.trim();
|
|
320
|
-
const findingTextOther = (finding_text_other ?? finding_b)?.trim();
|
|
321
|
-
if (!findingText || !findingTextOther) {
|
|
322
|
-
return mcpResponse({
|
|
323
|
-
ok: false,
|
|
324
|
-
error: "Both finding_text and finding_text_other are required.",
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => resolveFindingContradiction(phrenPath, project, findingText, findingTextOther, resolution), (data) => ({
|
|
328
|
-
message: `Resolved contradiction in ${project} with "${resolution}".`,
|
|
329
|
-
data: {
|
|
330
|
-
project,
|
|
331
|
-
resolution: data.resolution,
|
|
332
|
-
finding_text: data.finding_a,
|
|
333
|
-
finding_text_other: data.finding_b,
|
|
334
|
-
finding_a: data.finding_a,
|
|
335
|
-
finding_b: data.finding_b,
|
|
336
|
-
},
|
|
337
|
-
}));
|
|
338
|
-
});
|
|
544
|
+
}, (params) => handleResolveContradiction(ctx, params));
|
|
339
545
|
server.registerTool("get_contradictions", {
|
|
340
546
|
title: "◆ phren · contradictions",
|
|
341
547
|
description: "List unresolved contradictions (findings currently marked with status contradicted).",
|
|
@@ -343,48 +549,7 @@ export function register(server, ctx) {
|
|
|
343
549
|
project: z.string().optional().describe("Optional project filter. When omitted, scans all projects."),
|
|
344
550
|
finding_text: z.string().optional().describe("Optional finding selector (supports fid, exact text, or partial match)."),
|
|
345
551
|
}),
|
|
346
|
-
},
|
|
347
|
-
if (project && !isValidProjectName(project))
|
|
348
|
-
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
349
|
-
const projects = project
|
|
350
|
-
? [project]
|
|
351
|
-
: fs.readdirSync(phrenPath, { withFileTypes: true })
|
|
352
|
-
.filter((entry) => entry.isDirectory() && !RESERVED_PROJECT_DIR_NAMES.has(entry.name) && isValidProjectName(entry.name))
|
|
353
|
-
.map((entry) => entry.name);
|
|
354
|
-
const contradictions = [];
|
|
355
|
-
for (const p of projects) {
|
|
356
|
-
const result = readFindings(phrenPath, p);
|
|
357
|
-
if (!result.ok)
|
|
358
|
-
continue;
|
|
359
|
-
for (const finding of result.data) {
|
|
360
|
-
if (finding.status !== "contradicted")
|
|
361
|
-
continue;
|
|
362
|
-
if (finding_text && !matchesFindingTextSelector(finding, finding_text))
|
|
363
|
-
continue;
|
|
364
|
-
contradictions.push({
|
|
365
|
-
project: p,
|
|
366
|
-
id: finding.id,
|
|
367
|
-
stableId: finding.stableId,
|
|
368
|
-
text: finding.text,
|
|
369
|
-
date: finding.date,
|
|
370
|
-
status_updated: finding.status_updated,
|
|
371
|
-
status_reason: finding.status_reason,
|
|
372
|
-
status_ref: finding.status_ref,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
return mcpResponse({
|
|
377
|
-
ok: true,
|
|
378
|
-
message: contradictions.length
|
|
379
|
-
? `Found ${contradictions.length} unresolved contradiction${contradictions.length === 1 ? "" : "s"}.`
|
|
380
|
-
: "No unresolved contradictions found.",
|
|
381
|
-
data: {
|
|
382
|
-
project: project ?? null,
|
|
383
|
-
finding_text: finding_text ?? null,
|
|
384
|
-
contradictions,
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
});
|
|
552
|
+
}, (params) => handleGetContradictions(ctx, params));
|
|
388
553
|
server.registerTool("edit_finding", {
|
|
389
554
|
title: "◆ phren · edit finding",
|
|
390
555
|
description: "Edit a finding in place while preserving its metadata and history.",
|
|
@@ -393,26 +558,7 @@ export function register(server, ctx) {
|
|
|
393
558
|
old_text: z.string().describe("Existing finding text to match."),
|
|
394
559
|
new_text: z.string().describe("Replacement finding text."),
|
|
395
560
|
}),
|
|
396
|
-
},
|
|
397
|
-
if (!isValidProjectName(project))
|
|
398
|
-
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
399
|
-
const editDenied = permissionDeniedError(phrenPath, "edit_finding", project);
|
|
400
|
-
if (editDenied)
|
|
401
|
-
return mcpResponse({ ok: false, error: editDenied });
|
|
402
|
-
return withWriteQueue(async () => {
|
|
403
|
-
const result = editFindingCore(phrenPath, project, old_text, new_text);
|
|
404
|
-
if (!result.ok)
|
|
405
|
-
return mcpResponse({ ok: false, error: result.error });
|
|
406
|
-
const resolvedFindingsDir = safeProjectPath(phrenPath, project);
|
|
407
|
-
if (resolvedFindingsDir)
|
|
408
|
-
updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
|
|
409
|
-
return mcpResponse({
|
|
410
|
-
ok: true,
|
|
411
|
-
message: result.data,
|
|
412
|
-
data: { project, old_text, new_text },
|
|
413
|
-
});
|
|
414
|
-
});
|
|
415
|
-
});
|
|
561
|
+
}, (params) => handleEditFinding(ctx, params));
|
|
416
562
|
server.registerTool("remove_finding", {
|
|
417
563
|
title: "◆ phren · remove finding",
|
|
418
564
|
description: "Remove one or more findings from a project's FINDINGS.md by matching text. Use this when a " +
|
|
@@ -425,33 +571,7 @@ export function register(server, ctx) {
|
|
|
425
571
|
z.array(z.string()).describe("List of partial texts to match and remove."),
|
|
426
572
|
]).describe("Text(s) to match and remove. Pass a string for one, or an array for bulk."),
|
|
427
573
|
}),
|
|
428
|
-
},
|
|
429
|
-
if (!isValidProjectName(project))
|
|
430
|
-
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
431
|
-
const removeDenied = permissionDeniedError(phrenPath, "remove_finding", project);
|
|
432
|
-
if (removeDenied)
|
|
433
|
-
return mcpResponse({ ok: false, error: removeDenied });
|
|
434
|
-
if (Array.isArray(finding)) {
|
|
435
|
-
return withWriteQueue(async () => {
|
|
436
|
-
const result = removeFindingsCore(phrenPath, project, finding);
|
|
437
|
-
if (!result.ok)
|
|
438
|
-
return mcpResponse({ ok: false, error: result.message });
|
|
439
|
-
const resolvedFindingsDir = safeProjectPath(phrenPath, project);
|
|
440
|
-
if (resolvedFindingsDir)
|
|
441
|
-
updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
|
|
442
|
-
return mcpResponse({ ok: true, message: result.message, data: result.data });
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
return withWriteQueue(async () => {
|
|
446
|
-
const result = removeFindingCore(phrenPath, project, finding);
|
|
447
|
-
if (!result.ok)
|
|
448
|
-
return mcpResponse({ ok: false, error: result.message });
|
|
449
|
-
const resolvedFindingsDir = safeProjectPath(phrenPath, project);
|
|
450
|
-
if (resolvedFindingsDir)
|
|
451
|
-
updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
|
|
452
|
-
return mcpResponse({ ok: true, message: result.message, data: result.data });
|
|
453
|
-
});
|
|
454
|
-
});
|
|
574
|
+
}, (params) => handleRemoveFinding(ctx, params));
|
|
455
575
|
server.registerTool("push_changes", {
|
|
456
576
|
title: "◆ phren · push",
|
|
457
577
|
description: "Commit and push any changes in the phren store. Call this at the end of a session " +
|
|
@@ -460,108 +580,5 @@ export function register(server, ctx) {
|
|
|
460
580
|
inputSchema: z.object({
|
|
461
581
|
message: z.string().optional().describe("Commit message. Defaults to 'update phren'."),
|
|
462
582
|
}),
|
|
463
|
-
},
|
|
464
|
-
return withWriteQueue(async () => {
|
|
465
|
-
const { execFileSync } = await import("child_process");
|
|
466
|
-
const runGit = (args, opts = {}) => execFileSync("git", args, {
|
|
467
|
-
cwd: phrenPath,
|
|
468
|
-
encoding: "utf8",
|
|
469
|
-
timeout: opts.timeout ?? EXEC_TIMEOUT_MS,
|
|
470
|
-
env: opts.env,
|
|
471
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
472
|
-
}).trim();
|
|
473
|
-
try {
|
|
474
|
-
const status = runGit(["status", "--porcelain"]);
|
|
475
|
-
if (!status)
|
|
476
|
-
return mcpResponse({ ok: true, message: "Nothing to save. Phren is up to date.", data: { files: 0, pushed: false } });
|
|
477
|
-
const files = status.split("\n").filter(Boolean);
|
|
478
|
-
const projectNames = Array.from(new Set(files
|
|
479
|
-
.map((line) => line.slice(3).trim().split("/")[0])
|
|
480
|
-
.filter((name) => name && !name.startsWith(".") && name !== "profiles")));
|
|
481
|
-
const commitMsg = message || `phren: save ${files.length} file(s) across ${projectNames.length} project(s)`;
|
|
482
|
-
runCustomHooks(phrenPath, "pre-save");
|
|
483
|
-
// Stage all files including untracked (new project dirs, first FINDINGS.md, etc.)
|
|
484
|
-
runGit(["add", "-A"]);
|
|
485
|
-
runGit(["commit", "-m", commitMsg]);
|
|
486
|
-
let hasRemote = false;
|
|
487
|
-
try {
|
|
488
|
-
const remotes = runGit(["remote"]);
|
|
489
|
-
hasRemote = remotes.length > 0;
|
|
490
|
-
}
|
|
491
|
-
catch (err) {
|
|
492
|
-
logger.warn("push_changes", `remoteCheck: ${errorMessage(err)}`);
|
|
493
|
-
}
|
|
494
|
-
if (!hasRemote) {
|
|
495
|
-
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
496
|
-
return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). No remote configured, skipping push.`, data: { files: changedFiles, pushed: false } });
|
|
497
|
-
}
|
|
498
|
-
let pushed = false;
|
|
499
|
-
let lastPushError = "";
|
|
500
|
-
const delays = [2000, 4000, 8000];
|
|
501
|
-
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
502
|
-
try {
|
|
503
|
-
runGit(["push"], { timeout: 15000 });
|
|
504
|
-
pushed = true;
|
|
505
|
-
break;
|
|
506
|
-
}
|
|
507
|
-
catch (pushErr) {
|
|
508
|
-
lastPushError = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
509
|
-
debugLog(`Push attempt ${attempt + 1} failed: ${lastPushError}`);
|
|
510
|
-
if (attempt < 3) {
|
|
511
|
-
try {
|
|
512
|
-
runGit(["pull", "--rebase", "--quiet"], { timeout: 15000 });
|
|
513
|
-
}
|
|
514
|
-
catch (pullErr) {
|
|
515
|
-
logger.warn("push_changes", `pullRebase: ${pullErr instanceof Error ? pullErr.message : String(pullErr)}`);
|
|
516
|
-
const resolved = autoMergeConflicts(phrenPath);
|
|
517
|
-
if (resolved) {
|
|
518
|
-
try {
|
|
519
|
-
runGit(["rebase", "--continue"], {
|
|
520
|
-
timeout: 10000,
|
|
521
|
-
env: { ...process.env, GIT_EDITOR: "true" },
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
catch (continueErr) {
|
|
525
|
-
logger.warn("push_changes", `rebaseContinue: ${continueErr instanceof Error ? continueErr.message : String(continueErr)}`);
|
|
526
|
-
try {
|
|
527
|
-
runGit(["rebase", "--abort"]);
|
|
528
|
-
}
|
|
529
|
-
catch (abortErr) {
|
|
530
|
-
logger.warn("push_changes", `rebaseAbort: ${abortErr instanceof Error ? abortErr.message : String(abortErr)}`);
|
|
531
|
-
}
|
|
532
|
-
break;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
536
|
-
try {
|
|
537
|
-
runGit(["rebase", "--abort"]);
|
|
538
|
-
}
|
|
539
|
-
catch (abortErr) {
|
|
540
|
-
logger.warn("push_changes", `rebaseAbort2: ${abortErr instanceof Error ? abortErr.message : String(abortErr)}`);
|
|
541
|
-
}
|
|
542
|
-
break;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
await new Promise(r => setTimeout(r, delays[attempt]));
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
550
|
-
runCustomHooks(phrenPath, "post-save", { PHREN_FILES_CHANGED: String(changedFiles), PHREN_PUSHED: String(pushed) });
|
|
551
|
-
if (pushed) {
|
|
552
|
-
return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). Pushed to remote.`, data: { files: changedFiles, pushed: true } });
|
|
553
|
-
}
|
|
554
|
-
else {
|
|
555
|
-
return mcpResponse({
|
|
556
|
-
ok: true,
|
|
557
|
-
message: `Changes were committed but push failed.\n\nGit error: ${lastPushError}\n\nRun 'git push' manually from your phren directory.`,
|
|
558
|
-
data: { files: changedFiles, pushed: false, pushError: lastPushError },
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
catch (err) {
|
|
563
|
-
return mcpResponse({ ok: false, error: `Save failed: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
});
|
|
583
|
+
}, (params) => handlePushChanges(ctx, params));
|
|
567
584
|
}
|