@phren/cli 0.0.1

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,557 @@
1
+ import { mcpResponse } from "./mcp-types.js";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { isValidProjectName, safeProjectPath, errorMessage } from "./utils.js";
6
+ import { removeFinding as removeFindingCore, removeFindings as removeFindingsCore, } from "./core-finding.js";
7
+ import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, } from "./shared.js";
8
+ import { addFindingToFile, addFindingsToFile, checkSemanticConflicts, autoMergeConflicts, } from "./shared-content.js";
9
+ import { jaccardTokenize, jaccardSimilarity, stripMetadata } from "./content-dedup.js";
10
+ import { runCustomHooks } from "./hooks.js";
11
+ import { incrementSessionFindings } from "./mcp-session.js";
12
+ import { extractFragmentNames } from "./shared-fragment-graph.js";
13
+ import { extractFactFromFinding } from "./mcp-extract-facts.js";
14
+ import { appendChildFinding, readFindings } from "./data-access.js";
15
+ import { getActiveTaskForSession } from "./task-lifecycle.js";
16
+ import { FINDING_PROVENANCE_SOURCES } from "./content-citation.js";
17
+ import { isInactiveFindingLine, supersedeFinding, retractFinding as retractFindingLifecycle, resolveFindingContradiction, } from "./finding-lifecycle.js";
18
+ const JACCARD_MAYBE_LOW = 0.30;
19
+ const JACCARD_MAYBE_HIGH = 0.55; // above this isDuplicateFinding already catches it
20
+ const RESERVED_PROJECT_DIRS = new Set(["global", ".runtime", ".sessions", ".governance"]);
21
+ function findJaccardCandidates(phrenPath, project, finding) {
22
+ try {
23
+ const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
24
+ if (!fs.existsSync(findingsPath))
25
+ return [];
26
+ const content = fs.readFileSync(findingsPath, "utf8");
27
+ const newClean = stripMetadata(finding).trim();
28
+ const newTokens = jaccardTokenize(newClean);
29
+ if (newTokens.size < 3)
30
+ return [];
31
+ const candidates = [];
32
+ for (const line of content.split("\n")) {
33
+ if (!line.startsWith("- ") || isInactiveFindingLine(line))
34
+ continue;
35
+ const existingClean = stripMetadata(line).replace(/^-\s+/, "").trim();
36
+ const existingTokens = jaccardTokenize(existingClean);
37
+ if (existingTokens.size < 3)
38
+ continue;
39
+ const sim = jaccardSimilarity(newTokens, existingTokens);
40
+ if (sim >= JACCARD_MAYBE_LOW && sim < JACCARD_MAYBE_HIGH) {
41
+ candidates.push({ existing: existingClean, similarity: Math.round(sim * 100) / 100 });
42
+ }
43
+ }
44
+ return candidates;
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ }
50
+ function extractConflictsWith(annotations) {
51
+ return annotations
52
+ .map((annotation) => annotation.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1])
53
+ .filter((value) => Boolean(value));
54
+ }
55
+ function matchesFindingTextSelector(finding, selector) {
56
+ const query = selector.trim().toLowerCase();
57
+ if (!query)
58
+ return true;
59
+ const id = finding.id.toLowerCase();
60
+ const stableId = finding.stableId?.toLowerCase();
61
+ const text = finding.text.toLowerCase();
62
+ if (query.startsWith("fid:")) {
63
+ const normalizedFid = query.slice(4).trim();
64
+ if (!normalizedFid)
65
+ return false;
66
+ return stableId === normalizedFid || id === query || id === normalizedFid;
67
+ }
68
+ return id === query || stableId === query || text === query || text.includes(query);
69
+ }
70
+ /** Shared boilerplate for lifecycle mutation tools: validate project → call core fn → update index → map response. */
71
+ function withLifecycleMutation(phrenPath, project, writeQueue, updateIndex, handler, mapResponse) {
72
+ if (!isValidProjectName(project))
73
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
74
+ return writeQueue(async () => {
75
+ const result = handler();
76
+ if (!result.ok)
77
+ return mcpResponse({ ok: false, error: result.error });
78
+ const resolvedFindingsDir = safeProjectPath(phrenPath, project);
79
+ if (resolvedFindingsDir)
80
+ updateIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
81
+ const mapped = mapResponse(result.data);
82
+ return mcpResponse({ ok: true, message: mapped.message, data: mapped.data });
83
+ });
84
+ }
85
+ export function register(server, ctx) {
86
+ const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
87
+ server.registerTool("add_finding", {
88
+ title: "◆ phren · save finding",
89
+ description: "Tell phren a single insight for a project's FINDINGS.md. Call this the moment you discover " +
90
+ "a non-obvious pattern, hit a subtle bug, find a workaround, or learn something that would " +
91
+ "save time in a future session. Do not wait until the end of the session." +
92
+ " Optionally classify with findingType: decision, pitfall, pattern, tradeoff, architecture, or bug.",
93
+ inputSchema: z.object({
94
+ project: z.string().describe("Project name (must match a directory in your phren store)."),
95
+ finding: z.string().describe("The insight, written as a single bullet point. Be specific enough that someone could act on it without extra context."),
96
+ citation: z.object({
97
+ file: z.string().optional().describe("Source file path that supports this finding."),
98
+ line: z.number().int().positive().optional().describe("1-based line number in file."),
99
+ repo: z.string().optional().describe("Git repository root path for citation validation."),
100
+ commit: z.string().optional().describe("Git commit SHA that supports this finding."),
101
+ supersedes: z.string().optional().describe("First 60 chars of the old finding this one replaces. The old entry will be marked as superseded."),
102
+ 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."),
103
+ }).optional().describe("Optional source citation for traceability."),
104
+ sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this if you want session metrics to include this write."),
105
+ source: z.enum(FINDING_PROVENANCE_SOURCES)
106
+ .optional()
107
+ .describe("Optional finding provenance source: human, agent, hook, extract, consolidation, or unknown."),
108
+ findingType: z.enum(FINDING_TYPES)
109
+ .optional()
110
+ .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)."),
111
+ scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
112
+ }),
113
+ }, async ({ project, finding, citation, sessionId, source, findingType, scope }) => {
114
+ if (!isValidProjectName(project))
115
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
116
+ if (finding.length > 5000)
117
+ return mcpResponse({ ok: false, error: "Finding text exceeds 5000 character limit." });
118
+ const normalizedScope = normalizeMemoryScope(scope ?? "shared");
119
+ if (!normalizedScope)
120
+ return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
121
+ return withWriteQueue(async () => {
122
+ try {
123
+ const taggedFinding = findingType ? `[${findingType}] ${finding}` : finding;
124
+ // Jaccard "maybe zone" scan — free, no LLM call. Return candidates so the agent decides.
125
+ const potentialDuplicates = findJaccardCandidates(phrenPath, project, taggedFinding);
126
+ const semanticConflicts = await checkSemanticConflicts(phrenPath, project, taggedFinding);
127
+ runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project });
128
+ const result = addFindingToFile(phrenPath, project, taggedFinding, citation, {
129
+ sessionId,
130
+ source,
131
+ scope: normalizedScope,
132
+ extraAnnotations: semanticConflicts.checked ? semanticConflicts.annotations : undefined,
133
+ });
134
+ if (!result.ok) {
135
+ return mcpResponse({ ok: false, error: result.error });
136
+ }
137
+ // Determine status from the returned message string
138
+ const isSkipped = result.data.startsWith("Skipped duplicate");
139
+ const isAdded = !isSkipped;
140
+ if (isSkipped) {
141
+ return mcpResponse({ ok: true, message: result.data, data: { project, finding: taggedFinding, status: "skipped" } });
142
+ }
143
+ updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
144
+ if (isAdded) {
145
+ runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
146
+ incrementSessionFindings(phrenPath, 1, sessionId, project);
147
+ extractFactFromFinding(phrenPath, project, taggedFinding);
148
+ // Bidirectional link: if there's an active task in this session, append this finding to it.
149
+ if (sessionId) {
150
+ const activeTask = getActiveTaskForSession(phrenPath, sessionId, project);
151
+ if (activeTask) {
152
+ const taskMatch = activeTask.stableId ? `bid:${activeTask.stableId}` : activeTask.line;
153
+ // Extract fid from the last written line in FINDINGS.md
154
+ try {
155
+ const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
156
+ const findingsContent = fs.readFileSync(findingsPath, "utf8");
157
+ const lines = findingsContent.split("\n");
158
+ const taggedText = taggedFinding.replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase();
159
+ for (let li = lines.length - 1; li >= 0; li--) {
160
+ const l = lines[li];
161
+ if (!l.startsWith("- "))
162
+ continue;
163
+ const lineText = l.replace(/<!--.*?-->/g, "").replace(/^-\s+/, "").trim().slice(0, 60).toLowerCase();
164
+ if (lineText === taggedText || l.toLowerCase().includes(taggedText.slice(0, 30))) {
165
+ const fidMatch = l.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/);
166
+ if (fidMatch) {
167
+ appendChildFinding(phrenPath, project, taskMatch, `fid:${fidMatch[1]}`);
168
+ }
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ catch {
174
+ // Non-fatal: task-finding linkage is best-effort
175
+ }
176
+ }
177
+ }
178
+ }
179
+ const conflictsWithList = semanticConflicts.checked
180
+ ? extractConflictsWith(semanticConflicts.annotations)
181
+ : (result.data.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)?.[1] ? [result.data.match(/<!--\s*conflicts_with:\s*"([^"]+)"/)[1]] : []);
182
+ const conflictsWith = conflictsWithList[0];
183
+ // Extract fragment hints synchronously from the finding text (regex only, no DB).
184
+ // Full DB fragment linking happens on the next index rebuild via updateFileInIndex →
185
+ // extractAndLinkEntities. We surface hints here so callers can see what was detected.
186
+ const detectedFragments = extractFragmentNames(taggedFinding);
187
+ return mcpResponse({
188
+ ok: true,
189
+ message: result.data,
190
+ data: {
191
+ project,
192
+ finding: taggedFinding,
193
+ status: "added",
194
+ ...(conflictsWith ? { conflictsWith } : {}),
195
+ ...(conflictsWithList.length > 0 ? { conflicts: conflictsWithList } : {}),
196
+ ...(detectedFragments.length > 0 ? { detectedFragments } : {}),
197
+ ...(potentialDuplicates.length > 0 ? { potentialDuplicates } : {}),
198
+ scope: normalizedScope,
199
+ }
200
+ });
201
+ }
202
+ catch (err) {
203
+ if (err instanceof Error && err.message.includes("Rejected:")) {
204
+ return mcpResponse({ ok: false, error: errorMessage(err), errorCode: "VALIDATION_ERROR" });
205
+ }
206
+ throw err;
207
+ }
208
+ });
209
+ });
210
+ server.registerTool("add_findings", {
211
+ title: "◆ phren · save findings (bulk)",
212
+ description: "Tell phren multiple insights for a project's FINDINGS.md in one call.",
213
+ inputSchema: z.object({
214
+ project: z.string().describe("Project name (must match a directory in your phren store)."),
215
+ findings: z.array(z.string()).describe("List of insights to record."),
216
+ sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this if you want session metrics to include this write."),
217
+ }),
218
+ }, async ({ project, findings, sessionId }) => {
219
+ if (!isValidProjectName(project))
220
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
221
+ if (findings.length > 100)
222
+ return mcpResponse({ ok: false, error: "Bulk add limited to 100 findings per call." });
223
+ if (findings.some((f) => f.length > 5000))
224
+ return mcpResponse({ ok: false, error: "One or more findings exceed 5000 character limit." });
225
+ return withWriteQueue(async () => {
226
+ runCustomHooks(phrenPath, "pre-finding", { PHREN_PROJECT: project });
227
+ // Jaccard "maybe zone" scan per finding — free, no LLM. Agent sees candidates and decides.
228
+ const allPotentialDuplicates = [];
229
+ const extraAnnotationsByFinding = [];
230
+ for (const f of findings) {
231
+ const candidates = findJaccardCandidates(phrenPath, project, f);
232
+ if (candidates.length > 0)
233
+ allPotentialDuplicates.push({ finding: f, candidates });
234
+ try {
235
+ const conflicts = await checkSemanticConflicts(phrenPath, project, f);
236
+ extraAnnotationsByFinding.push(conflicts.checked && conflicts.annotations.length > 0 ? conflicts.annotations : []);
237
+ }
238
+ catch (err) {
239
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
240
+ process.stderr.write(`[phren] add_findings semanticConflict: ${errorMessage(err)}\n`);
241
+ extraAnnotationsByFinding.push([]);
242
+ }
243
+ }
244
+ const result = addFindingsToFile(phrenPath, project, findings, {
245
+ extraAnnotationsByFinding,
246
+ sessionId,
247
+ });
248
+ if (!result.ok)
249
+ return mcpResponse({ ok: false, error: result.error });
250
+ const { added, skipped, rejected } = result.data;
251
+ if (added.length > 0) {
252
+ runCustomHooks(phrenPath, "post-finding", { PHREN_PROJECT: project });
253
+ incrementSessionFindings(phrenPath, added.length, sessionId, project);
254
+ updateFileInIndex(path.join(phrenPath, project, "FINDINGS.md"));
255
+ }
256
+ const rejectedMsg = rejected.length > 0 ? `, ${rejected.length} rejected` : "";
257
+ // ok:true whenever the operation completed without error — use counts to distinguish outcomes.
258
+ return mcpResponse({
259
+ ok: true,
260
+ message: `Added ${added.length}/${findings.length} findings (${skipped.length} duplicates skipped${rejectedMsg})`,
261
+ data: {
262
+ project,
263
+ added,
264
+ skipped,
265
+ rejected,
266
+ ...(allPotentialDuplicates.length > 0 ? { potentialDuplicates: allPotentialDuplicates } : {}),
267
+ },
268
+ });
269
+ });
270
+ });
271
+ server.registerTool("supersede_finding", {
272
+ title: "◆ phren · supersede finding",
273
+ description: "Mark an existing finding as superseded and link it to the newer finding text.",
274
+ inputSchema: z.object({
275
+ project: z.string().describe("Project name."),
276
+ finding_text: z.string().describe("Finding to supersede (supports fid, exact text, or partial match)."),
277
+ superseded_by: z.string().describe("Text of the new finding that supersedes this one."),
278
+ }),
279
+ }, async ({ project, finding_text, superseded_by }) => {
280
+ return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => supersedeFinding(phrenPath, project, finding_text, superseded_by), (data) => ({
281
+ message: `Marked finding as superseded in ${project}.`,
282
+ data: { project, finding: data.finding, status: data.status, superseded_by: data.superseded_by },
283
+ }));
284
+ });
285
+ server.registerTool("retract_finding", {
286
+ title: "◆ phren · retract finding",
287
+ description: "Mark an existing finding as retracted and store the reason in lifecycle metadata.",
288
+ inputSchema: z.object({
289
+ project: z.string().describe("Project name."),
290
+ finding_text: z.string().describe("Finding to retract (supports fid, exact text, or partial match)."),
291
+ reason: z.string().describe("Reason for retraction."),
292
+ }),
293
+ }, async ({ project, finding_text, reason }) => {
294
+ return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => retractFindingLifecycle(phrenPath, project, finding_text, reason), (data) => ({
295
+ message: `Retracted finding in ${project}.`,
296
+ data: { project, finding: data.finding, status: data.status, reason: data.reason },
297
+ }));
298
+ });
299
+ server.registerTool("resolve_contradiction", {
300
+ title: "◆ phren · resolve contradiction",
301
+ description: "Resolve a contradiction between two findings and update lifecycle status based on the chosen resolution.",
302
+ inputSchema: z.object({
303
+ project: z.string().describe("Project name."),
304
+ finding_text: z.string().optional().describe("First finding (supports fid, exact text, or partial match)."),
305
+ finding_text_other: z.string().optional().describe("Second finding (supports fid, exact text, or partial match)."),
306
+ finding_a: z.string().optional().describe("Deprecated alias for finding_text."),
307
+ finding_b: z.string().optional().describe("Deprecated alias for finding_text_other."),
308
+ resolution: z.enum(["keep_a", "keep_b", "keep_both", "retract_both"]).describe("Resolution strategy."),
309
+ }).superRefine((value, zodCtx) => {
310
+ if (!(value.finding_text ?? value.finding_a)) {
311
+ zodCtx.addIssue({
312
+ code: z.ZodIssueCode.custom,
313
+ path: ["finding_text"],
314
+ message: "finding_text is required.",
315
+ });
316
+ }
317
+ if (!(value.finding_text_other ?? value.finding_b)) {
318
+ zodCtx.addIssue({
319
+ code: z.ZodIssueCode.custom,
320
+ path: ["finding_text_other"],
321
+ message: "finding_text_other is required.",
322
+ });
323
+ }
324
+ }),
325
+ }, async ({ project, finding_text, finding_text_other, finding_a, finding_b, resolution }) => {
326
+ const findingText = (finding_text ?? finding_a)?.trim();
327
+ const findingTextOther = (finding_text_other ?? finding_b)?.trim();
328
+ if (!findingText || !findingTextOther) {
329
+ return mcpResponse({
330
+ ok: false,
331
+ error: "Both finding_text and finding_text_other are required.",
332
+ });
333
+ }
334
+ return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => resolveFindingContradiction(phrenPath, project, findingText, findingTextOther, resolution), (data) => ({
335
+ message: `Resolved contradiction in ${project} with "${resolution}".`,
336
+ data: {
337
+ project,
338
+ resolution: data.resolution,
339
+ finding_text: data.finding_a,
340
+ finding_text_other: data.finding_b,
341
+ finding_a: data.finding_a,
342
+ finding_b: data.finding_b,
343
+ },
344
+ }));
345
+ });
346
+ server.registerTool("get_contradictions", {
347
+ title: "◆ phren · contradictions",
348
+ description: "List unresolved contradictions (findings currently marked with status contradicted).",
349
+ inputSchema: z.object({
350
+ project: z.string().optional().describe("Optional project filter. When omitted, scans all projects."),
351
+ finding_text: z.string().optional().describe("Optional finding selector (supports fid, exact text, or partial match)."),
352
+ }),
353
+ }, async ({ project, finding_text }) => {
354
+ if (project && !isValidProjectName(project))
355
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
356
+ const projects = project
357
+ ? [project]
358
+ : fs.readdirSync(phrenPath, { withFileTypes: true })
359
+ .filter((entry) => entry.isDirectory() && !RESERVED_PROJECT_DIRS.has(entry.name) && isValidProjectName(entry.name))
360
+ .map((entry) => entry.name);
361
+ const contradictions = [];
362
+ for (const p of projects) {
363
+ const result = readFindings(phrenPath, p);
364
+ if (!result.ok)
365
+ continue;
366
+ for (const finding of result.data) {
367
+ if (finding.status !== "contradicted")
368
+ continue;
369
+ if (finding_text && !matchesFindingTextSelector(finding, finding_text))
370
+ continue;
371
+ contradictions.push({
372
+ project: p,
373
+ id: finding.id,
374
+ stableId: finding.stableId,
375
+ text: finding.text,
376
+ date: finding.date,
377
+ status_updated: finding.status_updated,
378
+ status_reason: finding.status_reason,
379
+ status_ref: finding.status_ref,
380
+ });
381
+ }
382
+ }
383
+ return mcpResponse({
384
+ ok: true,
385
+ message: contradictions.length
386
+ ? `Found ${contradictions.length} unresolved contradiction${contradictions.length === 1 ? "" : "s"}.`
387
+ : "No unresolved contradictions found.",
388
+ data: {
389
+ project: project ?? null,
390
+ finding_text: finding_text ?? null,
391
+ contradictions,
392
+ },
393
+ });
394
+ });
395
+ server.registerTool("remove_finding", {
396
+ title: "◆ phren · remove finding",
397
+ description: "Remove a finding from a project's FINDINGS.md by matching text. Use this when a " +
398
+ "previously captured insight turns out to be wrong, outdated, or no longer relevant.",
399
+ inputSchema: z.object({
400
+ project: z.string().describe("Project name."),
401
+ finding: z.string().describe("Partial text to match against existing findings."),
402
+ }),
403
+ }, async ({ project, finding }) => {
404
+ if (!isValidProjectName(project))
405
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
406
+ return withWriteQueue(async () => {
407
+ const result = removeFindingCore(phrenPath, project, finding);
408
+ if (result.ok) {
409
+ const resolvedFindingsDir = safeProjectPath(phrenPath, project);
410
+ if (resolvedFindingsDir)
411
+ updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
412
+ }
413
+ if (!result.ok)
414
+ return mcpResponse({ ok: false, error: result.message });
415
+ return mcpResponse({ ok: true, message: result.message, data: result.data });
416
+ });
417
+ });
418
+ server.registerTool("remove_findings", {
419
+ title: "◆ phren · remove findings (bulk)",
420
+ description: "Remove multiple findings from a project's FINDINGS.md in one call.",
421
+ inputSchema: z.object({
422
+ project: z.string().describe("Project name."),
423
+ findings: z.array(z.string()).describe("List of partial texts to match and remove."),
424
+ }),
425
+ }, async ({ project, findings }) => {
426
+ if (!isValidProjectName(project))
427
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
428
+ return withWriteQueue(async () => {
429
+ const result = removeFindingsCore(phrenPath, project, findings);
430
+ if (result.ok) {
431
+ const resolvedFindingsDir = safeProjectPath(phrenPath, project);
432
+ if (resolvedFindingsDir)
433
+ updateFileInIndex(path.join(resolvedFindingsDir, "FINDINGS.md"));
434
+ }
435
+ if (!result.ok)
436
+ return mcpResponse({ ok: false, error: result.message });
437
+ return mcpResponse({ ok: result.ok, message: result.message, data: result.data });
438
+ });
439
+ });
440
+ server.registerTool("push_changes", {
441
+ title: "◆ phren · push",
442
+ description: "Commit and push any changes in the phren store. Call this at the end of a session " +
443
+ "or after adding multiple findings/tasks items. Commits all modified files in the " +
444
+ "phren directory and pushes if a remote is configured.",
445
+ inputSchema: z.object({
446
+ message: z.string().optional().describe("Commit message. Defaults to 'update phren'."),
447
+ }),
448
+ }, async ({ message }) => {
449
+ return withWriteQueue(async () => {
450
+ const { execFileSync } = await import("child_process");
451
+ const runGit = (args, opts = {}) => execFileSync("git", args, {
452
+ cwd: phrenPath,
453
+ encoding: "utf8",
454
+ timeout: opts.timeout ?? EXEC_TIMEOUT_MS,
455
+ env: opts.env,
456
+ stdio: ["ignore", "pipe", "pipe"],
457
+ }).trim();
458
+ try {
459
+ const status = runGit(["status", "--porcelain"]);
460
+ if (!status)
461
+ return mcpResponse({ ok: true, message: "Nothing to save. Phren is up to date.", data: { files: 0, pushed: false } });
462
+ const files = status.split("\n").filter(Boolean);
463
+ const projectNames = Array.from(new Set(files
464
+ .map((line) => line.slice(3).trim().split("/")[0])
465
+ .filter((name) => name && !name.startsWith(".") && name !== "profiles")));
466
+ const commitMsg = message || `phren: save ${files.length} file(s) across ${projectNames.length} project(s)`;
467
+ runCustomHooks(phrenPath, "pre-save");
468
+ // Restrict to known phren file types to avoid staging .env or credential files
469
+ runGit(["add", "--", "*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]);
470
+ runGit(["commit", "-m", commitMsg]);
471
+ let hasRemote = false;
472
+ try {
473
+ const remotes = runGit(["remote"]);
474
+ hasRemote = remotes.length > 0;
475
+ }
476
+ catch (err) {
477
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
478
+ process.stderr.write(`[phren] push_changes remoteCheck: ${errorMessage(err)}\n`);
479
+ }
480
+ if (!hasRemote) {
481
+ const changedFiles = status.split("\n").filter(Boolean).length;
482
+ return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). No remote configured, skipping push.`, data: { files: changedFiles, pushed: false } });
483
+ }
484
+ let pushed = false;
485
+ let lastPushError = "";
486
+ const delays = [2000, 4000, 8000];
487
+ for (let attempt = 0; attempt <= 3; attempt++) {
488
+ try {
489
+ runGit(["push"], { timeout: 15000 });
490
+ pushed = true;
491
+ break;
492
+ }
493
+ catch (pushErr) {
494
+ lastPushError = pushErr instanceof Error ? pushErr.message : String(pushErr);
495
+ debugLog(`Push attempt ${attempt + 1} failed: ${lastPushError}`);
496
+ if (attempt < 3) {
497
+ try {
498
+ runGit(["pull", "--rebase", "--quiet"], { timeout: 15000 });
499
+ }
500
+ catch (pullErr) {
501
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
502
+ process.stderr.write(`[phren] push_changes pullRebase: ${pullErr instanceof Error ? pullErr.message : String(pullErr)}\n`);
503
+ const resolved = autoMergeConflicts(phrenPath);
504
+ if (resolved) {
505
+ try {
506
+ runGit(["rebase", "--continue"], {
507
+ timeout: 10000,
508
+ env: { ...process.env, GIT_EDITOR: "true" },
509
+ });
510
+ }
511
+ catch (continueErr) {
512
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
513
+ process.stderr.write(`[phren] push_changes rebaseContinue: ${continueErr instanceof Error ? continueErr.message : String(continueErr)}\n`);
514
+ try {
515
+ runGit(["rebase", "--abort"]);
516
+ }
517
+ catch (abortErr) {
518
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
519
+ process.stderr.write(`[phren] push_changes rebaseAbort: ${abortErr instanceof Error ? abortErr.message : String(abortErr)}\n`);
520
+ }
521
+ break;
522
+ }
523
+ }
524
+ else {
525
+ try {
526
+ runGit(["rebase", "--abort"]);
527
+ }
528
+ catch (abortErr) {
529
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
530
+ process.stderr.write(`[phren] push_changes rebaseAbort2: ${abortErr instanceof Error ? abortErr.message : String(abortErr)}\n`);
531
+ }
532
+ break;
533
+ }
534
+ }
535
+ await new Promise(r => setTimeout(r, delays[attempt]));
536
+ }
537
+ }
538
+ }
539
+ const changedFiles = status.split("\n").filter(Boolean).length;
540
+ runCustomHooks(phrenPath, "post-save", { PHREN_FILES_CHANGED: String(changedFiles), PHREN_PUSHED: String(pushed) });
541
+ if (pushed) {
542
+ return mcpResponse({ ok: true, message: `Saved ${changedFiles} changed file(s). Pushed to remote.`, data: { files: changedFiles, pushed: true } });
543
+ }
544
+ else {
545
+ return mcpResponse({
546
+ ok: true,
547
+ message: `Changes were committed but push failed.\n\nGit error: ${lastPushError}\n\nRun 'git push' manually from your phren directory.`,
548
+ data: { files: changedFiles, pushed: false, pushError: lastPushError },
549
+ });
550
+ }
551
+ }
552
+ catch (err) {
553
+ return mcpResponse({ ok: false, error: `Save failed: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
554
+ }
555
+ });
556
+ });
557
+ }