@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,462 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as crypto from "crypto";
4
+ import { execFileSync } from "child_process";
5
+ import { debugLog, EXEC_TIMEOUT_MS, getProjectDirs } from "./shared.js";
6
+ import { errorMessage } from "./utils.js";
7
+ import { countActiveFindings } from "./content-archive.js";
8
+ import { isTaskFileName } from "./data-tasks.js";
9
+ import { METADATA_REGEX } from "./content-metadata.js";
10
+ /** Maximum allowed length for a single finding entry (token budget protection). */
11
+ export const MAX_FINDING_LENGTH = 2000;
12
+ function safeParseDate(s) {
13
+ const d = new Date(s);
14
+ return isNaN(d.getTime()) || d.getFullYear() < 2020 ? null : d;
15
+ }
16
+ /** Thresholds used for consolidation recommendations. */
17
+ export const CONSOLIDATION_ENTRY_THRESHOLD = 25;
18
+ const CONSOLIDATION_TIME_THRESHOLD_DAYS = 60;
19
+ const CONSOLIDATION_MIN_FOR_TIME_CHECK = 10;
20
+ /**
21
+ * Validate a single finding text before it is persisted.
22
+ * Returns null if valid, or an error message string if invalid.
23
+ */
24
+ export function validateFinding(text) {
25
+ if (!text || !text.trim())
26
+ return "Finding text cannot be empty.";
27
+ if (text.length > MAX_FINDING_LENGTH)
28
+ return `Finding exceeds maximum length of ${MAX_FINDING_LENGTH} characters (got ${text.length}). Shorten the text or split into multiple findings.`;
29
+ return null;
30
+ }
31
+ /**
32
+ * Compute consolidation status for a single project directory.
33
+ * Returns null if the project has no FINDINGS.md.
34
+ */
35
+ export function getProjectConsolidationStatus(dir) {
36
+ const learningsPath = path.join(dir, "FINDINGS.md");
37
+ if (!fs.existsSync(learningsPath))
38
+ return null;
39
+ const content = fs.readFileSync(learningsPath, "utf8");
40
+ const markerMatch = content.match(/<!--\s*consolidated:\s*(\d{4}-\d{2}-\d{2})/);
41
+ const lastConsolidated = markerMatch ? markerMatch[1] : null;
42
+ // Count entries since last consolidated marker, skipping both <details> and
43
+ // <!-- phren:archive:start/end --> blocks via countActiveFindings.
44
+ const contentSinceMarker = markerMatch
45
+ ? content.slice(content.indexOf(markerMatch[0]) + markerMatch[0].length)
46
+ : content;
47
+ const entriesSince = countActiveFindings(contentSinceMarker);
48
+ let daysSince = null;
49
+ if (lastConsolidated) {
50
+ const consolidated = safeParseDate(lastConsolidated);
51
+ daysSince = consolidated ? Math.floor((Date.now() - consolidated.getTime()) / 86400000) : null;
52
+ }
53
+ const recommended = entriesSince >= CONSOLIDATION_ENTRY_THRESHOLD ||
54
+ (daysSince !== null && daysSince >= CONSOLIDATION_TIME_THRESHOLD_DAYS && entriesSince >= CONSOLIDATION_MIN_FOR_TIME_CHECK) ||
55
+ (lastConsolidated === null && entriesSince >= CONSOLIDATION_ENTRY_THRESHOLD);
56
+ return {
57
+ project: path.basename(dir),
58
+ entriesSince,
59
+ daysSince,
60
+ lastConsolidated,
61
+ recommended,
62
+ };
63
+ }
64
+ /**
65
+ * Check which projects have enough new findings to warrant consolidation.
66
+ * Returns projects that exceed the entry or time thresholds.
67
+ */
68
+ export function checkConsolidationNeeded(phrenPath, profile) {
69
+ const projectDirs = getProjectDirs(phrenPath, profile);
70
+ const results = [];
71
+ for (const dir of projectDirs) {
72
+ const status = getProjectConsolidationStatus(dir);
73
+ if (status && status.recommended) {
74
+ results.push(status);
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+ /**
80
+ * Validate FINDINGS.md format and structure.
81
+ * Returns an array of issue description strings (empty array means valid).
82
+ */
83
+ export function validateFindingsFormat(content) {
84
+ const issues = [];
85
+ const lines = content.split("\n");
86
+ if (!lines[0]?.startsWith("# ")) {
87
+ issues.push("Missing title heading (expected: # Project Findings)");
88
+ }
89
+ for (const line of lines) {
90
+ if (line.startsWith("## ")) {
91
+ const heading = line.slice(3).trim();
92
+ // Only validate headings that look like they should be dates
93
+ if (/^\d/.test(heading) && !/^\d{4}-\d{2}-\d{2}$/.test(heading)) {
94
+ issues.push(`Date heading should be YYYY-MM-DD format: "${line}"`);
95
+ }
96
+ }
97
+ }
98
+ return issues;
99
+ }
100
+ /**
101
+ * Strip the ## Done section (and equivalents) from task content to reduce index bloat.
102
+ * Keeps the title, Active, and Queue sections which are the actionable parts.
103
+ * Handles: Done, Completed, Archived, Finished, Complete.
104
+ */
105
+ export function stripTaskDoneSection(content) {
106
+ const donePattern = /^## (Done|Completed|Archived|Finished|Complete)\b.*$/im;
107
+ const match = content.match(donePattern);
108
+ if (!match || match.index === undefined)
109
+ return content;
110
+ return content.slice(0, match.index).trimEnd() + "\n";
111
+ }
112
+ /**
113
+ * Validate tasks.md format and structure.
114
+ * Returns an array of issue description strings (empty array means valid).
115
+ */
116
+ export function validateTaskFormat(content) {
117
+ const issues = [];
118
+ const lines = content.split("\n");
119
+ if (!lines[0]?.startsWith("# ")) {
120
+ issues.push("Missing title heading");
121
+ }
122
+ const hasSections = content.includes("## Active") ||
123
+ content.includes("## Queue") ||
124
+ content.includes("## Done");
125
+ if (!hasSections) {
126
+ issues.push("Missing expected sections (Active, Queue, Done)");
127
+ }
128
+ return issues;
129
+ }
130
+ /**
131
+ * Extract ours/theirs versions from a file containing git conflict markers.
132
+ * Returns null if no conflict markers are found.
133
+ */
134
+ export function extractConflictVersions(content) {
135
+ if (!content.includes("<<<<<<<"))
136
+ return null;
137
+ const oursLines = [];
138
+ const theirsLines = [];
139
+ let state = "normal";
140
+ for (const line of content.split("\n")) {
141
+ if (line.startsWith("<<<<<<<")) {
142
+ state = "ours";
143
+ continue;
144
+ }
145
+ if (line === "=======" || line.startsWith("======= ")) {
146
+ state = "theirs";
147
+ continue;
148
+ }
149
+ if (line.startsWith(">>>>>>>")) {
150
+ state = "normal";
151
+ continue;
152
+ }
153
+ if (state === "normal") {
154
+ oursLines.push(line);
155
+ theirsLines.push(line);
156
+ }
157
+ else if (state === "ours") {
158
+ oursLines.push(line);
159
+ }
160
+ else {
161
+ theirsLines.push(line);
162
+ }
163
+ }
164
+ return { ours: oursLines.join("\n"), theirs: theirsLines.join("\n") };
165
+ }
166
+ // Parse FINDINGS.md into a map of date -> finding blocks.
167
+ // Each finding is a bullet line plus any immediately following HTML comment lines
168
+ // (e.g. <!-- phren:cite {...} -->). These are stored as multi-line strings and
169
+ // deduplicated by the bullet text only, preserving provenance comments.
170
+ function parseFindingsEntries(content) {
171
+ const entries = new Map();
172
+ let currentDate = "";
173
+ let currentBlock = [];
174
+ const lines = content.split("\n");
175
+ for (let i = 0; i < lines.length; i++) {
176
+ const line = lines[i];
177
+ if (line.startsWith("## ")) {
178
+ // Flush any pending block before switching date
179
+ if (currentBlock.length > 0 && currentDate) {
180
+ entries.get(currentDate).push(currentBlock.join("\n"));
181
+ currentBlock = [];
182
+ }
183
+ const heading = line.slice(3).trim();
184
+ if (/^\d{4}-\d{2}-\d{2}$/.test(heading)) {
185
+ currentDate = heading;
186
+ if (!entries.has(currentDate))
187
+ entries.set(currentDate, []);
188
+ }
189
+ }
190
+ else if (line.startsWith("- ") && currentDate) {
191
+ // Flush previous block
192
+ if (currentBlock.length > 0) {
193
+ entries.get(currentDate).push(currentBlock.join("\n"));
194
+ }
195
+ currentBlock = [line];
196
+ }
197
+ else if (currentBlock.length > 0 && /^\s*<!--/.test(line)) {
198
+ // HTML comment continuation of current finding block
199
+ currentBlock.push(line);
200
+ }
201
+ else {
202
+ // Non-comment, non-bullet line: flush any pending block
203
+ if (currentBlock.length > 0 && currentDate) {
204
+ entries.get(currentDate).push(currentBlock.join("\n"));
205
+ currentBlock = [];
206
+ }
207
+ }
208
+ }
209
+ // Flush final block
210
+ if (currentBlock.length > 0 && currentDate) {
211
+ entries.get(currentDate).push(currentBlock.join("\n"));
212
+ }
213
+ return entries;
214
+ }
215
+ // Extract the bullet text from a finding block (first line) for dedup purposes
216
+ function findingBulletText(block) {
217
+ // Strip stable finding ID so two entries with different fids but same text are considered duplicates during merge.
218
+ return block.split("\n")[0].replace(METADATA_REGEX.findingId, "").replace(/\s+/g, " ").trim();
219
+ }
220
+ /**
221
+ * Extract non-entry preamble content from a FINDINGS.md string.
222
+ * Returns lines that appear before the first date section heading (## YYYY-MM-DD)
223
+ * and are not the title line, so things like <!-- consolidated: ... --> markers
224
+ * and <details>phren:archive blocks are preserved during merge.
225
+ */
226
+ function extractFindingsPreamble(content) {
227
+ const lines = content.split("\n");
228
+ const preamble = [];
229
+ for (const line of lines) {
230
+ if (line.startsWith("## ") && /^\d{4}-\d{2}-\d{2}$/.test(line.slice(3).trim()))
231
+ break;
232
+ preamble.push(line);
233
+ }
234
+ // Drop the title line (index 0) since it's handled separately
235
+ return preamble.slice(1);
236
+ }
237
+ /**
238
+ * Extract postamble content from a FINDINGS.md string.
239
+ * Returns lines that appear after all date sections, such as <details> archive blocks.
240
+ */
241
+ function extractFindingsPostamble(content) {
242
+ const lines = content.split("\n");
243
+ // Find the last date-section heading
244
+ let lastDateIdx = -1;
245
+ for (let i = lines.length - 1; i >= 0; i--) {
246
+ if (lines[i].startsWith("## ") && /^\d{4}-\d{2}-\d{2}$/.test(lines[i].slice(3).trim())) {
247
+ lastDateIdx = i;
248
+ break;
249
+ }
250
+ }
251
+ if (lastDateIdx === -1)
252
+ return [];
253
+ // Skip forward past the date section's content to find postamble
254
+ for (let i = lastDateIdx + 1; i < lines.length; i++) {
255
+ if (lines[i].startsWith("## ") && !/^\d{4}-\d{2}-\d{2}$/.test(lines[i].slice(3).trim())) {
256
+ return lines.slice(i);
257
+ }
258
+ if (lines[i].startsWith("<details") || lines[i].startsWith("<!-- consolidated:")) {
259
+ return lines.slice(i);
260
+ }
261
+ }
262
+ return [];
263
+ }
264
+ /**
265
+ * Merge two FINDINGS.md versions: union entries per date, newest date first.
266
+ * Deduplicates by bullet text only, keeping comment lines from whichever
267
+ * version is kept (ours takes priority).
268
+ * Preserves preamble content (consolidated markers) and postamble (archive blocks).
269
+ */
270
+ export function mergeFindings(ours, theirs) {
271
+ const ourEntries = parseFindingsEntries(ours);
272
+ const theirEntries = parseFindingsEntries(theirs);
273
+ const allDates = [...new Set([...ourEntries.keys(), ...theirEntries.keys()])].sort().reverse();
274
+ const titleLine = ours.split("\n")[0] || "# Findings";
275
+ // Preserve preamble from ours (consolidated markers, etc.)
276
+ const preamble = extractFindingsPreamble(ours);
277
+ // Preserve postamble from ours (archive <details> blocks, etc.)
278
+ const postamble = extractFindingsPostamble(ours);
279
+ const lines = [titleLine];
280
+ if (preamble.length > 0) {
281
+ lines.push(...preamble);
282
+ }
283
+ else {
284
+ lines.push("");
285
+ }
286
+ for (const date of allDates) {
287
+ const ourItems = ourEntries.get(date) ?? [];
288
+ const theirItems = theirEntries.get(date) ?? [];
289
+ // Dedup by bullet text, ours wins on conflict
290
+ const seen = new Set();
291
+ const merged = [];
292
+ for (const block of ourItems) {
293
+ const key = findingBulletText(block);
294
+ if (!seen.has(key)) {
295
+ seen.add(key);
296
+ merged.push(block);
297
+ }
298
+ }
299
+ for (const block of theirItems) {
300
+ const key = findingBulletText(block);
301
+ if (!seen.has(key)) {
302
+ seen.add(key);
303
+ merged.push(block);
304
+ }
305
+ }
306
+ if (merged.length > 0) {
307
+ lines.push(`## ${date}`, "");
308
+ for (const block of merged) {
309
+ lines.push(block, "");
310
+ }
311
+ }
312
+ }
313
+ if (postamble.length > 0) {
314
+ lines.push(...postamble);
315
+ }
316
+ return lines.join("\n");
317
+ }
318
+ /** Pattern for stable bid comment embedded in task lines. */
319
+ const MERGE_BID_PATTERN = /<!--\s*bid:([a-z0-9]{8})\s*-->/;
320
+ /** Render a TaskRecord back to its original lines. */
321
+ function renderTaskRecord(record) {
322
+ return [record.bullet, ...record.continuations];
323
+ }
324
+ /** Merge key: stable ID if present, otherwise normalised bullet text. */
325
+ function taskRecordKey(record) {
326
+ if (record.stableId)
327
+ return `bid:${record.stableId}`;
328
+ return record.bullet.replace(MERGE_BID_PATTERN, "").trim().toLowerCase();
329
+ }
330
+ // Parse tasks.md into a map of section name -> multi-line TaskRecord entries.
331
+ function parseTaskSections(content) {
332
+ const sections = new Map();
333
+ let current = "";
334
+ let currentRecord = null;
335
+ const flush = () => {
336
+ if (currentRecord && current) {
337
+ sections.get(current).push(currentRecord);
338
+ currentRecord = null;
339
+ }
340
+ };
341
+ for (const line of content.split("\n")) {
342
+ if (line.startsWith("## ")) {
343
+ flush();
344
+ current = line.slice(3).trim();
345
+ if (!sections.has(current))
346
+ sections.set(current, []);
347
+ }
348
+ else if (line.startsWith("- ") && current) {
349
+ flush();
350
+ const bidMatch = line.match(MERGE_BID_PATTERN);
351
+ currentRecord = {
352
+ stableId: bidMatch ? bidMatch[1] : undefined,
353
+ bullet: line,
354
+ continuations: [],
355
+ };
356
+ }
357
+ else if (currentRecord && line.trim().startsWith("Context:")) {
358
+ currentRecord.continuations.push(line);
359
+ }
360
+ else {
361
+ flush();
362
+ }
363
+ }
364
+ flush();
365
+ return sections;
366
+ }
367
+ /**
368
+ * Merge two tasks.md versions: union items per section, deduplicated by stable ID when
369
+ * present or by normalised bullet text otherwise. Context/continuation lines are preserved.
370
+ * Ours wins on conflict. Section order follows Active > Queue > Done.
371
+ */
372
+ export function mergeTask(ours, theirs) {
373
+ const ourSections = parseTaskSections(ours);
374
+ const theirSections = parseTaskSections(theirs);
375
+ const sectionOrder = ["Active", "Queue", "Done"];
376
+ const allSections = [...new Set([...ourSections.keys(), ...theirSections.keys()])];
377
+ const ordered = [
378
+ ...sectionOrder.filter(s => allSections.includes(s)),
379
+ ...allSections.filter(s => !sectionOrder.includes(s)),
380
+ ];
381
+ const titleLine = ours.split("\n")[0] || "# task";
382
+ const lines = [titleLine, ""];
383
+ for (const section of ordered) {
384
+ const ourItems = ourSections.get(section) ?? [];
385
+ const theirItems = theirSections.get(section) ?? [];
386
+ // Merge: ours wins; include theirs only when key not already seen
387
+ const seen = new Map();
388
+ for (const record of ourItems)
389
+ seen.set(taskRecordKey(record), record);
390
+ for (const record of theirItems) {
391
+ const key = taskRecordKey(record);
392
+ if (!seen.has(key)) {
393
+ seen.set(key, record);
394
+ }
395
+ else if (record.stableId) {
396
+ // Merge fields from theirs into ours when using stable ID: preserve context lines
397
+ const oursRecord = seen.get(key);
398
+ if (oursRecord.continuations.length === 0 && record.continuations.length > 0) {
399
+ seen.set(key, { ...oursRecord, continuations: record.continuations });
400
+ }
401
+ }
402
+ }
403
+ lines.push(`## ${section}`, "");
404
+ for (const record of seen.values()) {
405
+ lines.push(...renderTaskRecord(record));
406
+ }
407
+ lines.push("");
408
+ }
409
+ return lines.join("\n");
410
+ }
411
+ /**
412
+ * Attempt to auto-resolve git conflicts in FINDINGS.md and tasks.md files.
413
+ * Returns true if all conflicts were resolved, false if any remain.
414
+ */
415
+ export function autoMergeConflicts(phrenPath) {
416
+ let conflictedFiles;
417
+ try {
418
+ const out = execFileSync("git", ["diff", "--name-only", "--diff-filter=U"], {
419
+ cwd: phrenPath,
420
+ encoding: "utf8",
421
+ stdio: ["ignore", "pipe", "ignore"],
422
+ timeout: EXEC_TIMEOUT_MS,
423
+ }).trim();
424
+ conflictedFiles = out ? out.split("\n") : [];
425
+ }
426
+ catch (err) {
427
+ debugLog(`autoMergeConflicts: failed to list conflicted files: ${errorMessage(err)}`);
428
+ return false;
429
+ }
430
+ if (conflictedFiles.length === 0)
431
+ return true;
432
+ let allResolved = true;
433
+ for (const relFile of conflictedFiles) {
434
+ const fullPath = path.join(phrenPath, relFile);
435
+ const filename = path.basename(relFile).toLowerCase();
436
+ const canAutoMerge = filename === "findings.md" || isTaskFileName(filename);
437
+ if (!canAutoMerge) {
438
+ debugLog(`Cannot auto-merge: ${relFile} (not a known mergeable file)`);
439
+ allResolved = false;
440
+ continue;
441
+ }
442
+ try {
443
+ const content = fs.readFileSync(fullPath, "utf8");
444
+ const versions = extractConflictVersions(content);
445
+ if (!versions)
446
+ continue;
447
+ const merged = filename === "findings.md"
448
+ ? mergeFindings(versions.ours, versions.theirs)
449
+ : mergeTask(versions.ours, versions.theirs);
450
+ const tmpMergePath = fullPath + `.tmp-${crypto.randomUUID()}`;
451
+ fs.writeFileSync(tmpMergePath, merged);
452
+ fs.renameSync(tmpMergePath, fullPath);
453
+ execFileSync("git", ["add", "--", relFile], { cwd: phrenPath, stdio: ["ignore", "ignore", "ignore"], timeout: EXEC_TIMEOUT_MS });
454
+ debugLog(`Auto-merged: ${relFile}`);
455
+ }
456
+ catch (err) {
457
+ debugLog(`Failed to auto-merge ${relFile}: ${errorMessage(err)}`);
458
+ allResolved = false;
459
+ }
460
+ }
461
+ return allResolved;
462
+ }
@@ -0,0 +1,54 @@
1
+ import { isValidProjectName } from "./utils.js";
2
+ import { addFindingToFile, } from "./shared-content.js";
3
+ import { removeFinding as removeFindingStore, } from "./data-access.js";
4
+ import { MAX_FINDING_LENGTH } from "./content-validate.js";
5
+ /**
6
+ * Validate and add a single finding. Shared validation logic used by
7
+ * both CLI `phren add-finding` and MCP `add_finding` tool.
8
+ */
9
+ export function addFinding(phrenPath, project, finding, citation, findingType) {
10
+ if (!isValidProjectName(project)) {
11
+ return { ok: false, message: `Invalid project name: "${project}"` };
12
+ }
13
+ if (finding.length > MAX_FINDING_LENGTH) {
14
+ return { ok: false, message: `Finding text exceeds ${MAX_FINDING_LENGTH} character limit.` };
15
+ }
16
+ const taggedFinding = findingType ? `[${findingType}] ${finding}` : finding;
17
+ const result = addFindingToFile(phrenPath, project, taggedFinding, citation);
18
+ if (!result.ok) {
19
+ return { ok: false, message: result.error };
20
+ }
21
+ return { ok: true, message: result.data, data: { project, finding: taggedFinding } };
22
+ }
23
+ /**
24
+ * Remove a finding by partial text match.
25
+ */
26
+ export function removeFinding(phrenPath, project, finding) {
27
+ if (!isValidProjectName(project)) {
28
+ return { ok: false, message: `Invalid project name: "${project}"` };
29
+ }
30
+ const result = removeFindingStore(phrenPath, project, finding);
31
+ if (!result.ok) {
32
+ return { ok: false, message: result.error };
33
+ }
34
+ return { ok: true, message: result.data, data: { project, finding } };
35
+ }
36
+ /**
37
+ * Remove multiple findings by partial text match.
38
+ */
39
+ export function removeFindings(phrenPath, project, findings) {
40
+ if (!isValidProjectName(project)) {
41
+ return { ok: false, message: `Invalid project name: "${project}"` };
42
+ }
43
+ const results = [];
44
+ for (const finding of findings) {
45
+ const result = removeFindingStore(phrenPath, project, finding);
46
+ results.push({ finding, ok: result.ok, message: result.ok ? result.data : result.error ?? "unknown error" });
47
+ }
48
+ const succeeded = results.filter(r => r.ok).length;
49
+ return {
50
+ ok: succeeded > 0,
51
+ message: `Removed ${succeeded}/${findings.length} findings`,
52
+ data: { project, results },
53
+ };
54
+ }
@@ -0,0 +1,36 @@
1
+ import * as path from "path";
2
+ import { phrenErr, phrenOk, readRootManifest } from "./shared.js";
3
+ import { bootstrapFromExisting } from "./init-setup.js";
4
+ import { resolveActiveProfile } from "./profile-store.js";
5
+ import { TASKS_FILENAME } from "./data-tasks.js";
6
+ export function addProjectFromPath(phrenPath, targetPath, requestedProfile, ownership) {
7
+ if (!targetPath) {
8
+ return phrenErr("Path is required. Pass the current project directory explicitly to avoid adding the wrong working directory.");
9
+ }
10
+ const activeProfile = resolveActiveProfile(phrenPath, requestedProfile);
11
+ if (!activeProfile.ok)
12
+ return activeProfile;
13
+ const manifest = readRootManifest(phrenPath);
14
+ const resolvedPath = path.resolve(targetPath);
15
+ if (manifest?.installMode === "project-local") {
16
+ const workspaceRoot = path.resolve(manifest.workspaceRoot || "");
17
+ const matchesWorkspace = resolvedPath === workspaceRoot || resolvedPath.startsWith(workspaceRoot + path.sep);
18
+ if (!matchesWorkspace) {
19
+ return phrenErr(`Project-local phren can only add the owning workspace: ${workspaceRoot}`);
20
+ }
21
+ }
22
+ const selectedProfile = activeProfile.data;
23
+ const added = bootstrapFromExisting(phrenPath, resolvedPath, { profile: selectedProfile, ownership });
24
+ return phrenOk({
25
+ project: added.project,
26
+ path: resolvedPath,
27
+ profile: selectedProfile ?? null,
28
+ ownership: added.ownership,
29
+ files: {
30
+ claude: added.claudePath,
31
+ summary: path.join(phrenPath, added.project, "summary.md"),
32
+ findings: path.join(phrenPath, added.project, "FINDINGS.md"),
33
+ task: path.join(phrenPath, added.project, TASKS_FILENAME),
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,50 @@
1
+ import { STOP_WORDS } from "./utils.js";
2
+ import { queryDocRows } from "./shared-index.js";
3
+ /**
4
+ * Keyword overlap fallback for when FTS5 returns no results.
5
+ * Scans all docs (optionally filtered by project/type), scores each by
6
+ * how many query terms appear in its content, and returns top matches.
7
+ *
8
+ * Shared between the MCP search tool and CLI `phren search`.
9
+ */
10
+ export function keywordFallbackSearch(db, query, opts) {
11
+ let fallbackSql = "SELECT project, filename, type, content, path FROM docs";
12
+ const fallbackParams = [];
13
+ const clauses = [];
14
+ if (opts.project) {
15
+ clauses.push("project = ?");
16
+ fallbackParams.push(opts.project);
17
+ }
18
+ if (opts.type) {
19
+ clauses.push("type = ?");
20
+ fallbackParams.push(opts.type);
21
+ }
22
+ if (clauses.length)
23
+ fallbackSql += " WHERE " + clauses.join(" AND ");
24
+ const allRows = queryDocRows(db, fallbackSql, fallbackParams);
25
+ if (!allRows)
26
+ return null;
27
+ const terms = query
28
+ .toLowerCase()
29
+ .replace(/[^\w\s-]/g, " ")
30
+ .split(/\s+/)
31
+ .filter(w => w.length > 1 && !STOP_WORDS.has(w));
32
+ if (terms.length === 0)
33
+ return null;
34
+ const scored = allRows
35
+ .map((row) => {
36
+ const content = row.content.toLowerCase();
37
+ let score = 0;
38
+ for (const term of terms) {
39
+ if (content.includes(term))
40
+ score++;
41
+ }
42
+ return { row, score };
43
+ })
44
+ .filter(r => r.score > 0)
45
+ .sort((a, b) => b.score - a.score)
46
+ .slice(0, opts.limit);
47
+ if (scored.length === 0)
48
+ return null;
49
+ return scored.map(s => s.row);
50
+ }