@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
@@ -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
- export function register(server, ctx) {
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
- }, async ({ project, finding, citation, sessionId, source, findingType, scope }) => {
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
- }, async ({ project, finding_text, superseded_by }) => {
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
- }, async ({ project, finding_text, reason }) => {
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
- }, async ({ project, finding_text, finding_text_other, finding_a, finding_b, resolution }) => {
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
- }, async ({ project, finding_text }) => {
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
- }, async ({ project, old_text, new_text }) => {
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
- }, async ({ project, finding }) => {
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
- }, async ({ message }) => {
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
  }