@phren/cli 0.0.28 → 0.0.33

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 (153) hide show
  1. package/mcp/dist/capabilities/cli.js +2 -5
  2. package/mcp/dist/capabilities/mcp.js +5 -8
  3. package/mcp/dist/capabilities/types.js +2 -5
  4. package/mcp/dist/capabilities/vscode.js +2 -5
  5. package/mcp/dist/capabilities/web-ui.js +2 -5
  6. package/mcp/dist/{cli-actions.js → cli/actions.js} +25 -21
  7. package/mcp/dist/{cli.js → cli/cli.js} +13 -13
  8. package/mcp/dist/{cli-config.js → cli/config.js} +12 -12
  9. package/mcp/dist/{cli-extract.js → cli/extract.js} +8 -8
  10. package/mcp/dist/{cli-govern.js → cli/govern.js} +28 -17
  11. package/mcp/dist/{cli-graph.js → cli/graph.js} +10 -9
  12. package/mcp/dist/{cli-hooks-citations.js → cli/hooks-citations.js} +2 -2
  13. package/mcp/dist/{cli-hooks-context.js → cli/hooks-context.js} +23 -23
  14. package/mcp/dist/{cli-hooks-globs.js → cli/hooks-globs.js} +4 -4
  15. package/mcp/dist/{cli-hooks-output.js → cli/hooks-output.js} +9 -10
  16. package/mcp/dist/{cli-hooks-session.js → cli/hooks-session.js} +58 -117
  17. package/mcp/dist/{cli-hooks.js → cli/hooks.js} +27 -26
  18. package/mcp/dist/{cli-namespaces.js → cli/namespaces.js} +25 -24
  19. package/mcp/dist/{cli-ops.js → cli/ops.js} +9 -9
  20. package/mcp/dist/{cli-search.js → cli/search.js} +12 -11
  21. package/mcp/dist/cli-hooks-git.js +243 -0
  22. package/mcp/dist/cli-hooks-prompt.js +323 -0
  23. package/mcp/dist/cli-hooks-session-handlers.js +337 -0
  24. package/mcp/dist/cli-hooks-stop.js +519 -0
  25. package/mcp/dist/{content-archive.js → content/archive.js} +16 -29
  26. package/mcp/dist/{content-citation.js → content/citation.js} +5 -5
  27. package/mcp/dist/{content-dedup.js → content/dedup.js} +9 -12
  28. package/mcp/dist/{content-learning.js → content/learning.js} +41 -20
  29. package/mcp/dist/{content-validate.js → content/validate.js} +5 -5
  30. package/mcp/dist/{core-finding.js → core/finding.js} +4 -4
  31. package/mcp/dist/{core-project.js → core/project.js} +4 -4
  32. package/mcp/dist/{core-search.js → core/search.js} +2 -2
  33. package/mcp/dist/{data-access.js → data/access.js} +142 -15
  34. package/mcp/dist/{data-tasks.js → data/tasks.js} +7 -5
  35. package/mcp/dist/embedding.js +9 -14
  36. package/mcp/dist/entrypoint.js +11 -11
  37. package/mcp/dist/{finding-context.js → finding/context.js} +2 -2
  38. package/mcp/dist/{finding-impact.js → finding/impact.js} +3 -3
  39. package/mcp/dist/{finding-journal.js → finding/journal.js} +4 -4
  40. package/mcp/dist/{finding-lifecycle.js → finding/lifecycle.js} +13 -7
  41. package/mcp/dist/governance/audit.js +30 -0
  42. package/mcp/dist/{governance-locks.js → governance/locks.js} +14 -9
  43. package/mcp/dist/{governance-policy.js → governance/policy.js} +23 -12
  44. package/mcp/dist/{governance-rbac.js → governance/rbac.js} +4 -4
  45. package/mcp/dist/{governance-scores.js → governance/scores.js} +10 -11
  46. package/mcp/dist/hooks.js +53 -37
  47. package/mcp/dist/index-query.js +4 -1
  48. package/mcp/dist/index.js +54 -30
  49. package/mcp/dist/{init-config.js → init/config.js} +6 -6
  50. package/mcp/dist/{init.js → init/init.js} +80 -69
  51. package/mcp/dist/{init-preferences.js → init/preferences.js} +3 -3
  52. package/mcp/dist/{init-setup.js → init/setup.js} +17 -19
  53. package/mcp/dist/{init-shared.js → init/shared.js} +4 -4
  54. package/mcp/dist/init-bootstrap.js +21 -0
  55. package/mcp/dist/init-detect.js +38 -0
  56. package/mcp/dist/init-env.js +114 -0
  57. package/mcp/dist/init-fresh.js +234 -0
  58. package/mcp/dist/init-hooks.js +26 -0
  59. package/mcp/dist/init-mcp.js +65 -0
  60. package/mcp/dist/init-modes.js +135 -0
  61. package/mcp/dist/init-npm.js +37 -0
  62. package/mcp/dist/init-project-local.js +99 -0
  63. package/mcp/dist/init-semantic.js +48 -0
  64. package/mcp/dist/init-types.js +1 -0
  65. package/mcp/dist/init-uninstall.js +504 -0
  66. package/mcp/dist/init-update.js +96 -0
  67. package/mcp/dist/init-walkthrough.js +524 -0
  68. package/mcp/dist/{link-checksums.js → link/checksums.js} +5 -5
  69. package/mcp/dist/{link-context.js → link/context.js} +4 -4
  70. package/mcp/dist/{link-doctor.js → link/doctor.js} +20 -22
  71. package/mcp/dist/{link.js → link/link.js} +26 -31
  72. package/mcp/dist/{link-skills.js → link/skills.js} +10 -10
  73. package/mcp/dist/logger.js +11 -3
  74. package/mcp/dist/package-metadata.js +1 -1
  75. package/mcp/dist/phren-art.js +4 -126
  76. package/mcp/dist/phren-paths.js +30 -12
  77. package/mcp/dist/proactivity.js +3 -3
  78. package/mcp/dist/profile-store.js +5 -6
  79. package/mcp/dist/project-config.js +2 -2
  80. package/mcp/dist/project-topics.js +17 -47
  81. package/mcp/dist/provider-adapters.js +1 -1
  82. package/mcp/dist/query-correlation.js +1 -1
  83. package/mcp/dist/runtime-profile.js +1 -1
  84. package/mcp/dist/{session-checkpoints.js → session/checkpoints.js} +3 -3
  85. package/mcp/dist/{session-utils.js → session/utils.js} +1 -1
  86. package/mcp/dist/{shared-content.js → shared/content.js} +7 -7
  87. package/mcp/dist/{shared-data-utils.js → shared/data-utils.js} +28 -3
  88. package/mcp/dist/{shared-embedding-cache.js → shared/embedding-cache.js} +3 -3
  89. package/mcp/dist/{shared-fragment-graph.js → shared/fragment-graph.js} +19 -42
  90. package/mcp/dist/shared/governance.js +4 -0
  91. package/mcp/dist/{shared-index.js → shared/index.js} +105 -132
  92. package/mcp/dist/{shared-ollama.js → shared/ollama.js} +25 -7
  93. package/mcp/dist/shared/process.js +24 -0
  94. package/mcp/dist/{shared-retrieval.js → shared/retrieval.js} +22 -24
  95. package/mcp/dist/{shared-search-fallback.js → shared/search-fallback.js} +18 -20
  96. package/mcp/dist/{shared-sqljs.js → shared/sqljs.js} +3 -3
  97. package/mcp/dist/{shared-vector-index.js → shared/vector-index.js} +3 -3
  98. package/mcp/dist/shared.js +6 -60
  99. package/mcp/dist/{shell-entry.js → shell/entry.js} +6 -6
  100. package/mcp/dist/{shell-input.js → shell/input.js} +13 -13
  101. package/mcp/dist/{shell-palette.js → shell/palette.js} +3 -3
  102. package/mcp/dist/{shell-render.js → shell/render.js} +2 -2
  103. package/mcp/dist/{shell.js → shell/shell.js} +11 -11
  104. package/mcp/dist/{shell-state-store.js → shell/state-store.js} +5 -5
  105. package/mcp/dist/{shell-view-list.js → shell/view-list.js} +1 -1
  106. package/mcp/dist/{shell-view.js → shell/view.js} +13 -13
  107. package/mcp/dist/{skill-files.js → skill/files.js} +9 -9
  108. package/mcp/dist/{skill-registry.js → skill/registry.js} +5 -5
  109. package/mcp/dist/{skill-state.js → skill/state.js} +1 -4
  110. package/mcp/dist/startup-embedding.js +2 -2
  111. package/mcp/dist/status.js +15 -14
  112. package/mcp/dist/{tasks-github.js → task/github.js} +3 -2
  113. package/mcp/dist/{task-hygiene.js → task/hygiene.js} +4 -4
  114. package/mcp/dist/{task-lifecycle.js → task/lifecycle.js} +8 -13
  115. package/mcp/dist/telemetry.js +3 -4
  116. package/mcp/dist/tool-registry.js +29 -17
  117. package/mcp/dist/tools/config.js +530 -0
  118. package/mcp/dist/{mcp-data.js → tools/data.js} +8 -10
  119. package/mcp/dist/{mcp-extract-facts.js → tools/extract-facts.js} +6 -6
  120. package/mcp/dist/{mcp-extract.js → tools/extract.js} +6 -6
  121. package/mcp/dist/tools/finding.js +584 -0
  122. package/mcp/dist/{mcp-graph.js → tools/graph.js} +11 -14
  123. package/mcp/dist/{mcp-hooks.js → tools/hooks.js} +6 -6
  124. package/mcp/dist/{mcp-memory.js → tools/memory.js} +5 -5
  125. package/mcp/dist/tools/ops.js +468 -0
  126. package/mcp/dist/tools/search.js +672 -0
  127. package/mcp/dist/{mcp-session.js → tools/session.js} +51 -25
  128. package/mcp/dist/{mcp-skills.js → tools/skills.js} +42 -35
  129. package/mcp/dist/{mcp-tasks.js → tools/tasks.js} +155 -282
  130. package/mcp/dist/{memory-ui-data.js → ui/data.js} +31 -17
  131. package/mcp/dist/{memory-ui.js → ui/memory-ui.js} +3 -3
  132. package/mcp/dist/{memory-ui-page.js → ui/page.js} +5 -7
  133. package/mcp/dist/ui/server.js +1024 -0
  134. package/mcp/dist/update.js +2 -2
  135. package/mcp/dist/utils.js +63 -19
  136. package/package.json +2 -2
  137. package/scripts/preuninstall.mjs +31 -0
  138. package/starter/global/CLAUDE.md +3 -2
  139. package/mcp/dist/governance-audit.js +0 -22
  140. package/mcp/dist/mcp-config.js +0 -551
  141. package/mcp/dist/mcp-finding.js +0 -594
  142. package/mcp/dist/mcp-ops.js +0 -363
  143. package/mcp/dist/mcp-search.js +0 -668
  144. package/mcp/dist/memory-ui-server.js +0 -1411
  145. package/mcp/dist/shared-governance.js +0 -4
  146. /package/mcp/dist/{content-metadata.js → content/metadata.js} +0 -0
  147. /package/mcp/dist/{shared-stemmer.js → shared/stemmer.js} +0 -0
  148. /package/mcp/dist/{shell-types.js → shell/types.js} +0 -0
  149. /package/mcp/dist/{mcp-types.js → tools/types.js} +0 -0
  150. /package/mcp/dist/{memory-ui-assets.js → ui/assets.js} +0 -0
  151. /package/mcp/dist/{memory-ui-graph.js → ui/graph.js} +0 -0
  152. /package/mcp/dist/{memory-ui-scripts.js → ui/scripts.js} +0 -0
  153. /package/mcp/dist/{memory-ui-styles.js → ui/styles.js} +0 -0
@@ -0,0 +1,584 @@
1
+ import { mcpResponse } from "./types.js";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { logger } from "../logger.js";
6
+ import { isValidProjectName, safeProjectPath, errorMessage } from "../utils.js";
7
+ import { removeFinding as removeFindingCore, removeFindings as removeFindingsCore, } from "../core/finding.js";
8
+ import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, RESERVED_PROJECT_DIR_NAMES, } from "../shared.js";
9
+ import { addFindingToFile, addFindingsToFile, checkSemanticConflicts, autoMergeConflicts, } from "../shared/content.js";
10
+ import { jaccardTokenize, jaccardSimilarity, stripMetadata } from "../content/dedup.js";
11
+ import { runCustomHooks } from "../hooks.js";
12
+ import { incrementSessionFindings } from "./session.js";
13
+ import { extractFragmentNames } from "../shared/fragment-graph.js";
14
+ import { extractFactFromFinding } from "./extract-facts.js";
15
+ import { appendChildFinding, editFinding as editFindingCore, readFindings } from "../data/access.js";
16
+ import { getActiveTaskForSession } from "../task/lifecycle.js";
17
+ import { FINDING_PROVENANCE_SOURCES } from "../content/citation.js";
18
+ import { isInactiveFindingLine, supersedeFinding, retractFinding as retractFindingLifecycle, resolveFindingContradiction, } from "../finding/lifecycle.js";
19
+ import { permissionDeniedError } from "../governance/rbac.js";
20
+ const JACCARD_MAYBE_LOW = 0.30;
21
+ const JACCARD_MAYBE_HIGH = 0.55; // above this isDuplicateFinding already catches it
22
+ function findJaccardCandidates(phrenPath, project, finding) {
23
+ try {
24
+ const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
25
+ if (!fs.existsSync(findingsPath))
26
+ return [];
27
+ const content = fs.readFileSync(findingsPath, "utf8");
28
+ const newClean = stripMetadata(finding).trim();
29
+ const newTokens = jaccardTokenize(newClean);
30
+ if (newTokens.size < 3)
31
+ return [];
32
+ const candidates = [];
33
+ for (const line of content.split("\n")) {
34
+ if (!line.startsWith("- ") || isInactiveFindingLine(line))
35
+ continue;
36
+ const existingClean = stripMetadata(line).replace(/^-\s+/, "").trim();
37
+ const existingTokens = jaccardTokenize(existingClean);
38
+ if (existingTokens.size < 3)
39
+ continue;
40
+ const sim = jaccardSimilarity(newTokens, existingTokens);
41
+ if (sim >= JACCARD_MAYBE_LOW && sim < JACCARD_MAYBE_HIGH) {
42
+ candidates.push({ existing: existingClean, similarity: Math.round(sim * 100) / 100 });
43
+ }
44
+ }
45
+ return candidates;
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ function extractConflictsWith(annotations) {
52
+ return annotations
53
+ .map((annotation) => annotation.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1])
54
+ .filter((value) => Boolean(value));
55
+ }
56
+ function matchesFindingTextSelector(finding, selector) {
57
+ const query = selector.trim().toLowerCase();
58
+ if (!query)
59
+ return true;
60
+ const id = finding.id.toLowerCase();
61
+ const stableId = finding.stableId?.toLowerCase();
62
+ const text = finding.text.toLowerCase();
63
+ if (query.startsWith("fid:")) {
64
+ const normalizedFid = query.slice(4).trim();
65
+ if (!normalizedFid)
66
+ return false;
67
+ return stableId === normalizedFid || id === query || id === normalizedFid;
68
+ }
69
+ return id === query || stableId === query || text === query || text.includes(query);
70
+ }
71
+ /** Shared boilerplate for lifecycle mutation tools: validate project → call core fn → update index → map response. */
72
+ function withLifecycleMutation(phrenPath, project, writeQueue, updateIndex, handler, mapResponse) {
73
+ if (!isValidProjectName(project))
74
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
75
+ return writeQueue(async () => {
76
+ const result = handler();
77
+ if (!result.ok)
78
+ return mcpResponse({ ok: false, error: result.error });
79
+ const resolvedFindingsDir = safeProjectPath(phrenPath, project);
80
+ if (resolvedFindingsDir)
81
+ updateIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
82
+ const mapped = mapResponse(result.data);
83
+ return mcpResponse({ ok: true, message: mapped.message, data: mapped.data });
84
+ });
85
+ }
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 }) {
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) {
469
+ server.registerTool("add_finding", {
470
+ title: "◆ phren · save finding",
471
+ description: "Tell phren one or more insights for a project's FINDINGS.md. Call this the moment you discover " +
472
+ "a non-obvious pattern, hit a subtle bug, find a workaround, or learn something that would " +
473
+ "save time in a future session. Do not wait until the end of the session." +
474
+ " Pass a single string or an array of strings." +
475
+ " Optionally classify with findingType: decision, pitfall, pattern, tradeoff, architecture, or bug.",
476
+ inputSchema: z.object({
477
+ project: z.string().describe("Project name (must match a directory in your phren store)."),
478
+ finding: z.union([
479
+ z.string().describe("A single insight, written as a bullet point."),
480
+ z.array(z.string()).describe("Multiple insights to record in one call."),
481
+ ]).describe("The insight(s) to save. Pass a string for one finding, or an array for bulk."),
482
+ citation: z.object({
483
+ file: z.string().optional().describe("Source file path that supports this finding."),
484
+ line: z.number().int().positive().optional().describe("1-based line number in file."),
485
+ repo: z.string().optional().describe("Git repository root path for citation validation."),
486
+ commit: z.string().optional().describe("Git commit SHA that supports this finding."),
487
+ supersedes: z.string().optional().describe("First 60 chars of the old finding this one replaces. The old entry will be marked as superseded."),
488
+ task_item: z.string().optional().describe("Task item stable ID like bid:abcd1234, positional ID like A1, or item text to link this finding to."),
489
+ }).optional().describe("Optional source citation for traceability (only used when finding is a single string)."),
490
+ sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this if you want session metrics to include this write."),
491
+ source: z.enum(FINDING_PROVENANCE_SOURCES)
492
+ .optional()
493
+ .describe("Optional finding provenance source: human, agent, hook, extract, consolidation, or unknown."),
494
+ findingType: z.enum(FINDING_TYPES)
495
+ .optional()
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)."),
497
+ scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
498
+ }),
499
+ }, (params) => handleAddFinding(ctx, params));
500
+ server.registerTool("supersede_finding", {
501
+ title: "◆ phren · supersede finding",
502
+ description: "Mark an existing finding as superseded and link it to the newer finding text.",
503
+ inputSchema: z.object({
504
+ project: z.string().describe("Project name."),
505
+ finding_text: z.string().describe("Finding to supersede (supports fid, exact text, or partial match)."),
506
+ superseded_by: z.string().describe("Text of the new finding that supersedes this one."),
507
+ }),
508
+ }, (params) => handleSupersedeFinding(ctx, params));
509
+ server.registerTool("retract_finding", {
510
+ title: "◆ phren · retract finding",
511
+ description: "Mark an existing finding as retracted and store the reason in lifecycle metadata.",
512
+ inputSchema: z.object({
513
+ project: z.string().describe("Project name."),
514
+ finding_text: z.string().describe("Finding to retract (supports fid, exact text, or partial match)."),
515
+ reason: z.string().describe("Reason for retraction."),
516
+ }),
517
+ }, (params) => handleRetractFinding(ctx, params));
518
+ server.registerTool("resolve_contradiction", {
519
+ title: "◆ phren · resolve contradiction",
520
+ description: "Resolve a contradiction between two findings and update lifecycle status based on the chosen resolution.",
521
+ inputSchema: z.object({
522
+ project: z.string().describe("Project name."),
523
+ finding_text: z.string().optional().describe("First finding (supports fid, exact text, or partial match)."),
524
+ finding_text_other: z.string().optional().describe("Second finding (supports fid, exact text, or partial match)."),
525
+ finding_a: z.string().optional().describe("Deprecated alias for finding_text."),
526
+ finding_b: z.string().optional().describe("Deprecated alias for finding_text_other."),
527
+ resolution: z.enum(["keep_a", "keep_b", "keep_both", "retract_both"]).describe("Resolution strategy."),
528
+ }).superRefine((value, zodCtx) => {
529
+ if (!(value.finding_text ?? value.finding_a)) {
530
+ zodCtx.addIssue({
531
+ code: z.ZodIssueCode.custom,
532
+ path: ["finding_text"],
533
+ message: "finding_text is required.",
534
+ });
535
+ }
536
+ if (!(value.finding_text_other ?? value.finding_b)) {
537
+ zodCtx.addIssue({
538
+ code: z.ZodIssueCode.custom,
539
+ path: ["finding_text_other"],
540
+ message: "finding_text_other is required.",
541
+ });
542
+ }
543
+ }),
544
+ }, (params) => handleResolveContradiction(ctx, params));
545
+ server.registerTool("get_contradictions", {
546
+ title: "◆ phren · contradictions",
547
+ description: "List unresolved contradictions (findings currently marked with status contradicted).",
548
+ inputSchema: z.object({
549
+ project: z.string().optional().describe("Optional project filter. When omitted, scans all projects."),
550
+ finding_text: z.string().optional().describe("Optional finding selector (supports fid, exact text, or partial match)."),
551
+ }),
552
+ }, (params) => handleGetContradictions(ctx, params));
553
+ server.registerTool("edit_finding", {
554
+ title: "◆ phren · edit finding",
555
+ description: "Edit a finding in place while preserving its metadata and history.",
556
+ inputSchema: z.object({
557
+ project: z.string().describe("Project name."),
558
+ old_text: z.string().describe("Existing finding text to match."),
559
+ new_text: z.string().describe("Replacement finding text."),
560
+ }),
561
+ }, (params) => handleEditFinding(ctx, params));
562
+ server.registerTool("remove_finding", {
563
+ title: "◆ phren · remove finding",
564
+ description: "Remove one or more findings from a project's FINDINGS.md by matching text. Use this when a " +
565
+ "previously captured insight turns out to be wrong, outdated, or no longer relevant." +
566
+ " Pass a single string or an array of strings.",
567
+ inputSchema: z.object({
568
+ project: z.string().describe("Project name."),
569
+ finding: z.union([
570
+ z.string().describe("Partial text to match against existing findings."),
571
+ z.array(z.string()).describe("List of partial texts to match and remove."),
572
+ ]).describe("Text(s) to match and remove. Pass a string for one, or an array for bulk."),
573
+ }),
574
+ }, (params) => handleRemoveFinding(ctx, params));
575
+ server.registerTool("push_changes", {
576
+ title: "◆ phren · push",
577
+ description: "Commit and push any changes in the phren store. Call this at the end of a session " +
578
+ "or after adding multiple findings/tasks items. Commits all modified files in the " +
579
+ "phren directory and pushes if a remote is configured.",
580
+ inputSchema: z.object({
581
+ message: z.string().optional().describe("Commit message. Defaults to 'update phren'."),
582
+ }),
583
+ }, (params) => handlePushChanges(ctx, params));
584
+ }
@@ -1,11 +1,12 @@
1
- import { mcpResponse } from "./mcp-types.js";
1
+ import { mcpResponse } from "./types.js";
2
2
  import { z } from "zod";
3
3
  import * as fs from "fs";
4
4
  import * as crypto from "crypto";
5
- import { isValidProjectName, errorMessage } from "./utils.js";
6
- import { queryDocBySourceKey, queryRows, queryFragmentLinks, queryCrossProjectFragments, ensureGlobalEntitiesTable, logFragmentMiss } from "./shared-index.js";
7
- import { runtimeFile } from "./shared.js";
8
- import { withFileLock } from "./shared-governance.js";
5
+ import { isValidProjectName, errorMessage } from "../utils.js";
6
+ import { queryDocBySourceKey, queryRows, queryFragmentLinks, queryCrossProjectFragments, ensureGlobalEntitiesTable, logFragmentMiss } from "../shared/index.js";
7
+ import { runtimeFile } from "../shared.js";
8
+ import { withFileLock } from "../shared/governance.js";
9
+ import { logger } from "../logger.js";
9
10
  export function register(server, ctx) {
10
11
  // ── search_fragments ──────────────────────────────────────────────────
11
12
  server.registerTool("search_fragments", {
@@ -209,8 +210,7 @@ export function register(server, ctx) {
209
210
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [fragmentName, resolvedFragmentType, new Date().toISOString().slice(0, 10)]);
210
211
  }
211
212
  catch (err) {
212
- if (process.env.PHREN_DEBUG)
213
- process.stderr.write(`[phren] link_findings fragmentInsert: ${errorMessage(err)}\n`);
213
+ logger.debug("graph", `link_findings fragmentInsert: ${errorMessage(err)}`);
214
214
  }
215
215
  const fragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [fragmentName, resolvedFragmentType]);
216
216
  if (!fragmentResult?.length || !fragmentResult[0]?.values?.length) {
@@ -232,8 +232,7 @@ export function register(server, ctx) {
232
232
  db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [sourceDoc, "document", new Date().toISOString().slice(0, 10)]);
233
233
  }
234
234
  catch (err) {
235
- if (process.env.PHREN_DEBUG)
236
- process.stderr.write(`[phren] link_findings docFragmentInsert: ${errorMessage(err)}\n`);
235
+ logger.debug("graph", `link_findings docFragmentInsert: ${errorMessage(err)}`);
237
236
  }
238
237
  const docFragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [sourceDoc, "document"]);
239
238
  if (!docFragmentResult?.length || !docFragmentResult[0]?.values?.length) {
@@ -245,8 +244,7 @@ export function register(server, ctx) {
245
244
  db.run("INSERT OR IGNORE INTO entity_links (source_id, target_id, rel_type, source_doc) VALUES (?, ?, ?, ?)", [sourceId, targetId, relType, sourceDoc]);
246
245
  }
247
246
  catch (err) {
248
- if (process.env.PHREN_DEBUG)
249
- process.stderr.write(`[phren] link_findings linkInsert: ${errorMessage(err)}\n`);
247
+ logger.debug("graph", `link_findings linkInsert: ${errorMessage(err)}`);
250
248
  return mcpResponse({ ok: false, error: "Failed to insert fragment link." });
251
249
  }
252
250
  // 4a. Also populate global_entities so manual links appear in cross_project_fragments
@@ -255,8 +253,7 @@ export function register(server, ctx) {
255
253
  db.run("INSERT OR IGNORE INTO global_entities (entity, project, doc_key) VALUES (?, ?, ?)", [fragmentName, project, sourceDoc]);
256
254
  }
257
255
  catch (err) {
258
- if (process.env.PHREN_DEBUG)
259
- process.stderr.write(`[phren] link_findings globalFragments: ${errorMessage(err)}\n`);
256
+ logger.debug("graph", `link_findings globalFragments: ${errorMessage(err)}`);
260
257
  }
261
258
  // 4b. Persist manual link so it survives index rebuilds (mandatory — failure aborts the operation)
262
259
  const manualLinksPath = runtimeFile(ctx.phrenPath, "manual-links.json");
@@ -268,7 +265,7 @@ export function register(server, ctx) {
268
265
  existing = JSON.parse(fs.readFileSync(manualLinksPath, "utf8"));
269
266
  }
270
267
  catch (err) {
271
- process.stderr.write(`[phren] link_findings manualLinksRead: manual-links.json is malformed — aborting to avoid data loss: ${errorMessage(err)}\n`);
268
+ logger.error("graph", `link_findings manualLinksRead: manual-links.json is malformed — aborting to avoid data loss: ${errorMessage(err)}`);
272
269
  throw err;
273
270
  }
274
271
  }