@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,339 @@
1
+ import { mcpResponse } from "./mcp-types.js";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as crypto from "crypto";
5
+ import { isValidProjectName } 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
+ export function register(server, ctx) {
10
+ // ── search_fragments ──────────────────────────────────────────────────
11
+ server.registerTool("search_fragments", {
12
+ title: "phren : search fragments",
13
+ description: "Search named fragments in the knowledge graph (libraries, tools, concepts mentioned in findings). " +
14
+ "Returns matching fragment names and how many findings reference each.",
15
+ inputSchema: z.object({
16
+ query: z.string().describe("Fragment name to search for (partial match)."),
17
+ project: z.string().optional().describe("Filter to a specific project."),
18
+ limit: z.number().int().min(1).max(50).optional().describe("Max results (default 10)."),
19
+ }),
20
+ }, async ({ query, project, limit }) => {
21
+ const db = ctx.db();
22
+ const max = limit ?? 10;
23
+ const pattern = `%${query.toLowerCase()}%`;
24
+ let sql;
25
+ let params;
26
+ if (project) {
27
+ sql = `
28
+ SELECT e.name, e.type, COUNT(el.source_id) as ref_count
29
+ FROM entities e
30
+ LEFT JOIN entity_links el ON el.target_id = e.id
31
+ WHERE e.name LIKE ? AND el.source_doc LIKE ?
32
+ GROUP BY e.id, e.name, e.type
33
+ ORDER BY ref_count DESC
34
+ LIMIT ?
35
+ `;
36
+ params = [pattern, `${project}/%`, max];
37
+ }
38
+ else {
39
+ sql = `
40
+ SELECT e.name, e.type, COUNT(el.source_id) as ref_count
41
+ FROM entities e
42
+ LEFT JOIN entity_links el ON el.target_id = e.id
43
+ WHERE e.name LIKE ?
44
+ GROUP BY e.id, e.name, e.type
45
+ ORDER BY ref_count DESC
46
+ LIMIT ?
47
+ `;
48
+ params = [pattern, max];
49
+ }
50
+ const rows = queryRows(db, sql, params);
51
+ if (!rows || rows.length === 0) {
52
+ logFragmentMiss(ctx.phrenPath, query, "search_fragments", project);
53
+ return mcpResponse({ ok: true, data: [], message: `No fragments matching "${query}".` });
54
+ }
55
+ const fragments = rows.map(r => ({
56
+ name: String(r[0]),
57
+ type: String(r[1]),
58
+ refCount: Number(r[2]),
59
+ }));
60
+ return mcpResponse({ ok: true, data: fragments });
61
+ });
62
+ // ── get_related_docs ──────────────────────────────────────────────────
63
+ server.registerTool("get_related_docs", {
64
+ title: "phren : related docs",
65
+ description: "Find all findings and docs that mention a specific fragment (library, tool, concept). " +
66
+ "Use this to see how a technology is used across projects.",
67
+ inputSchema: z.object({
68
+ entity: z.string().describe("Fragment name to look up."),
69
+ project: z.string().optional().describe("Filter to a specific project."),
70
+ limit: z.number().int().min(1).max(50).optional().describe("Max docs to return (default 10)."),
71
+ }),
72
+ }, async ({ entity, project, limit }) => {
73
+ const db = ctx.db();
74
+ const max = limit ?? 10;
75
+ const links = queryFragmentLinks(db, entity.toLowerCase());
76
+ let relatedDocs = links.related.filter(r => r.includes("/"));
77
+ if (project) {
78
+ relatedDocs = relatedDocs.filter(d => d.startsWith(`${project}/`));
79
+ }
80
+ relatedDocs = relatedDocs.slice(0, max);
81
+ if (relatedDocs.length === 0) {
82
+ logFragmentMiss(ctx.phrenPath, entity, "get_related_docs", project);
83
+ return mcpResponse({ ok: true, data: [], message: `No docs found referencing fragment "${entity}".` });
84
+ }
85
+ const results = [];
86
+ for (const doc of relatedDocs) {
87
+ const docRow = queryDocBySourceKey(db, ctx.phrenPath, doc);
88
+ const snippet = docRow?.content ? docRow.content.slice(0, 200) : "";
89
+ results.push({ sourceDoc: doc, snippet });
90
+ }
91
+ return mcpResponse({ ok: true, data: results });
92
+ });
93
+ // ── read_graph ────────────────────────────────────────────────────────
94
+ server.registerTool("read_graph", {
95
+ title: "phren : knowledge graph",
96
+ description: "Read the fragment relationship graph. Returns top fragments by reference count " +
97
+ "and their connected documents.",
98
+ inputSchema: z.object({
99
+ project: z.string().optional().describe("Filter to a specific project."),
100
+ limit: z.number().int().min(1).max(2000).optional().describe("Max fragments to return (default 500, max 2000)."),
101
+ offset: z.number().int().min(0).optional().describe("Number of fragments to skip for pagination (default 0)."),
102
+ }),
103
+ }, async ({ project, limit, offset }) => {
104
+ const db = ctx.db();
105
+ const max = limit ?? 500;
106
+ const skip = offset ?? 0;
107
+ // First get total count
108
+ let countSql;
109
+ let countParams;
110
+ if (project) {
111
+ countSql = `
112
+ SELECT COUNT(*) FROM (
113
+ SELECT e.id FROM entities e
114
+ JOIN entity_links el ON el.target_id = e.id
115
+ WHERE e.type != 'document' AND el.source_doc LIKE ?
116
+ GROUP BY e.id
117
+ )
118
+ `;
119
+ countParams = [`${project}/%`];
120
+ }
121
+ else {
122
+ countSql = `
123
+ SELECT COUNT(*) FROM (
124
+ SELECT e.id FROM entities e
125
+ JOIN entity_links el ON el.target_id = e.id
126
+ WHERE e.type != 'document'
127
+ GROUP BY e.id
128
+ )
129
+ `;
130
+ countParams = [];
131
+ }
132
+ const countRows = queryRows(db, countSql, countParams);
133
+ const total = countRows && countRows.length > 0 ? Number(countRows[0][0]) : 0;
134
+ let sql;
135
+ let params;
136
+ // Step 1: Get fragment list with counts (no GROUP_CONCAT to avoid comma-in-value bugs)
137
+ if (project) {
138
+ sql = `
139
+ SELECT e.id, e.name, e.type, COUNT(el.source_id) as ref_count
140
+ FROM entities e
141
+ JOIN entity_links el ON el.target_id = e.id
142
+ WHERE e.type != 'document' AND el.source_doc LIKE ?
143
+ GROUP BY e.id, e.name, e.type
144
+ ORDER BY ref_count DESC
145
+ LIMIT ? OFFSET ?
146
+ `;
147
+ params = [`${project}/%`, max, skip];
148
+ }
149
+ else {
150
+ sql = `
151
+ SELECT e.id, e.name, e.type, COUNT(el.source_id) as ref_count
152
+ FROM entities e
153
+ JOIN entity_links el ON el.target_id = e.id
154
+ WHERE e.type != 'document'
155
+ GROUP BY e.id, e.name, e.type
156
+ ORDER BY ref_count DESC
157
+ LIMIT ? OFFSET ?
158
+ `;
159
+ params = [max, skip];
160
+ }
161
+ const rows = queryRows(db, sql, params);
162
+ if (!rows || rows.length === 0) {
163
+ return mcpResponse({ ok: true, data: { fragments: [], total, hasMore: false }, message: "No fragments in the graph." });
164
+ }
165
+ // Step 2: For each fragment, fetch its docs as separate rows
166
+ const fragments = rows.map(r => {
167
+ const fragmentId = Number(r[0]);
168
+ const docSql = project
169
+ ? "SELECT DISTINCT el.source_doc FROM entity_links el WHERE el.target_id = ? AND el.source_doc LIKE ?"
170
+ : "SELECT DISTINCT el.source_doc FROM entity_links el WHERE el.target_id = ?";
171
+ const docParams = project ? [fragmentId, `${project}/%`] : [fragmentId];
172
+ const docRows = queryRows(db, docSql, docParams);
173
+ const docs = (docRows || []).map(dr => String(dr[0]));
174
+ const fragmentName = String(r[1]);
175
+ return {
176
+ id: `fragment:${fragmentName}`,
177
+ name: fragmentName,
178
+ type: String(r[2]),
179
+ refCount: Number(r[3]),
180
+ docs,
181
+ };
182
+ });
183
+ const hasMore = skip + fragments.length < total;
184
+ return mcpResponse({ ok: true, data: { fragments, total, hasMore, offset: skip, limit: max } });
185
+ });
186
+ // ── link_findings ─────────────────────────────────────────────────────
187
+ server.registerTool("link_findings", {
188
+ title: "phren : link findings",
189
+ description: "Manually link a finding to a fragment (technology/concept) that wasn't auto-detected. " +
190
+ "Use this to explicitly connect a finding to a library or tool.",
191
+ inputSchema: z.object({
192
+ project: z.string().describe("Project name."),
193
+ finding_text: z.string().describe("Partial text of the finding to link (used to locate the source doc)."),
194
+ entity: z.string().describe("Fragment name to link to (e.g. 'Redis', 'Docker')."),
195
+ relation: z.string().optional().describe("Relationship type (default: 'mentions')."),
196
+ entity_type: z.string().optional().describe("Fragment type (e.g. 'library', 'service', 'concept', 'architecture'). Defaults to 'fragment'."),
197
+ }),
198
+ }, async ({ project, finding_text, entity, relation, entity_type }) => {
199
+ if (!isValidProjectName(project)) {
200
+ return mcpResponse({ ok: false, error: `Invalid project: "${project}"` });
201
+ }
202
+ return ctx.withWriteQueue(async () => {
203
+ const db = ctx.db();
204
+ const relType = relation ?? "mentions";
205
+ const fragmentName = entity.toLowerCase();
206
+ const resolvedFragmentType = entity_type ?? "fragment";
207
+ // 1. Find or create fragment
208
+ try {
209
+ db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [fragmentName, resolvedFragmentType, new Date().toISOString().slice(0, 10)]);
210
+ }
211
+ catch (err) {
212
+ if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
213
+ process.stderr.write(`[phren] link_findings fragmentInsert: ${err instanceof Error ? err.message : String(err)}\n`);
214
+ }
215
+ const fragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [fragmentName, resolvedFragmentType]);
216
+ if (!fragmentResult?.length || !fragmentResult[0]?.values?.length) {
217
+ return mcpResponse({ ok: false, error: "Failed to create fragment." });
218
+ }
219
+ const targetId = Number(fragmentResult[0].values[0][0]);
220
+ // 2. Find source doc in the canonical findings document
221
+ const docCheck = queryRows(db, "SELECT content FROM docs WHERE project = ? AND filename = 'FINDINGS.md' LIMIT 1", [project]);
222
+ let sourceDoc = `${project}/FINDINGS.md`;
223
+ if (!docCheck || docCheck.length === 0) {
224
+ return mcpResponse({ ok: false, error: `No FINDINGS.md found for project "${project}".` });
225
+ }
226
+ const content = String(docCheck[0][0]);
227
+ if (!content.toLowerCase().includes(finding_text.toLowerCase())) {
228
+ return mcpResponse({ ok: false, error: `Finding text not found in ${sourceDoc}.` });
229
+ }
230
+ // 3. Find or create document fragment
231
+ try {
232
+ db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [sourceDoc, "document", new Date().toISOString().slice(0, 10)]);
233
+ }
234
+ catch (err) {
235
+ if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
236
+ process.stderr.write(`[phren] link_findings docFragmentInsert: ${err instanceof Error ? err.message : String(err)}\n`);
237
+ }
238
+ const docFragmentResult = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [sourceDoc, "document"]);
239
+ if (!docFragmentResult?.length || !docFragmentResult[0]?.values?.length) {
240
+ return mcpResponse({ ok: false, error: "Failed to create document fragment." });
241
+ }
242
+ const sourceId = Number(docFragmentResult[0].values[0][0]);
243
+ // 4. Insert fragment link
244
+ try {
245
+ db.run("INSERT OR IGNORE INTO entity_links (source_id, target_id, rel_type, source_doc) VALUES (?, ?, ?, ?)", [sourceId, targetId, relType, sourceDoc]);
246
+ }
247
+ catch (err) {
248
+ if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
249
+ process.stderr.write(`[phren] link_findings linkInsert: ${err instanceof Error ? err.message : String(err)}\n`);
250
+ return mcpResponse({ ok: false, error: "Failed to insert fragment link." });
251
+ }
252
+ // 4a. Also populate global_entities so manual links appear in cross_project_fragments
253
+ try {
254
+ ensureGlobalEntitiesTable(db);
255
+ db.run("INSERT OR IGNORE INTO global_entities (entity, project, doc_key) VALUES (?, ?, ?)", [fragmentName, project, sourceDoc]);
256
+ }
257
+ catch (err) {
258
+ if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
259
+ process.stderr.write(`[phren] link_findings globalFragments: ${err instanceof Error ? err.message : String(err)}\n`);
260
+ }
261
+ // 4b. Persist manual link so it survives index rebuilds (mandatory — failure aborts the operation)
262
+ const manualLinksPath = runtimeFile(ctx.phrenPath, "manual-links.json");
263
+ try {
264
+ withFileLock(manualLinksPath, () => {
265
+ let existing = [];
266
+ if (fs.existsSync(manualLinksPath)) {
267
+ try {
268
+ existing = JSON.parse(fs.readFileSync(manualLinksPath, "utf8"));
269
+ }
270
+ catch (err) {
271
+ if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
272
+ process.stderr.write(`[phren] link_findings manualLinksRead: ${err instanceof Error ? err.message : String(err)}\n`);
273
+ }
274
+ }
275
+ const newEntry = { entity: fragmentName, entityType: resolvedFragmentType, sourceDoc, relType };
276
+ const alreadyStored = existing.some((e) => e.entity === newEntry.entity && e.entityType === newEntry.entityType && e.sourceDoc === newEntry.sourceDoc && e.relType === newEntry.relType);
277
+ if (!alreadyStored) {
278
+ existing.push(newEntry);
279
+ const tmpPath = manualLinksPath + `.tmp-${crypto.randomUUID()}`;
280
+ fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2));
281
+ fs.renameSync(tmpPath, manualLinksPath);
282
+ }
283
+ });
284
+ }
285
+ catch (persistErr) {
286
+ return mcpResponse({
287
+ ok: false,
288
+ error: `Failed to persist manual link: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`,
289
+ errorCode: "INTERNAL_ERROR",
290
+ });
291
+ }
292
+ // 5. Rebuild index to refresh (only after successful persistence)
293
+ await ctx.rebuildIndex();
294
+ return mcpResponse({
295
+ ok: true,
296
+ message: `Linked "${entity}" to ${sourceDoc} with relation "${relType}".`,
297
+ });
298
+ });
299
+ });
300
+ // ── cross_project_fragments ───────────────────────────────────────────
301
+ server.registerTool("cross_project_fragments", {
302
+ title: "phren : cross-project fragments",
303
+ description: "Find fragments (libraries, tools, concepts) shared across multiple projects. " +
304
+ "Use this to discover how a technology or concept is used in other projects.",
305
+ inputSchema: z.object({
306
+ entity: z.string().describe("Fragment name to search for (partial match)."),
307
+ exclude_project: z.string().optional().describe("Exclude a specific project from results."),
308
+ limit: z.number().int().min(1).max(50).optional().describe("Max results (default 20)."),
309
+ }),
310
+ }, async ({ entity, exclude_project, limit }) => {
311
+ const db = ctx.db();
312
+ const max = limit ?? 20;
313
+ const results = queryCrossProjectFragments(db, entity, exclude_project);
314
+ const capped = results.slice(0, max);
315
+ if (capped.length === 0) {
316
+ logFragmentMiss(ctx.phrenPath, entity, "cross_project_fragments", exclude_project);
317
+ return mcpResponse({ ok: true, data: [], message: `No cross-project references found for "${entity}".` });
318
+ }
319
+ // Group by project for cleaner output
320
+ const byProject = new Map();
321
+ for (const r of capped) {
322
+ const arr = byProject.get(r.project) ?? [];
323
+ arr.push({ fragment: r.fragment, docKey: r.docKey });
324
+ byProject.set(r.project, arr);
325
+ }
326
+ const lines = [];
327
+ for (const [proj, refs] of byProject) {
328
+ lines.push(`### ${proj}`);
329
+ for (const ref of refs) {
330
+ lines.push(`- ${ref.fragment} (${ref.docKey})`);
331
+ }
332
+ }
333
+ return mcpResponse({
334
+ ok: true,
335
+ message: `Cross-project references for "${entity}" (${capped.length} results):\n\n${lines.join("\n")}`,
336
+ data: capped,
337
+ });
338
+ });
339
+ }
@@ -0,0 +1,256 @@
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 { readInstallPreferences, writeInstallPreferences } from "./init-preferences.js";
6
+ import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES } from "./hooks.js";
7
+ import { hookConfigPath } from "./shared.js";
8
+ import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig } from "./project-config.js";
9
+ import { isValidProjectName } from "./utils.js";
10
+ const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"];
11
+ const VALID_CUSTOM_EVENTS = HOOK_EVENT_VALUES;
12
+ /**
13
+ * Validate a custom hook command at registration time.
14
+ * Rejects obviously dangerous patterns to reduce confused-deputy risk
15
+ * if install-preferences.json is ever compromised.
16
+ * Returns an error string, or null if valid.
17
+ */
18
+ function validateHookCommand(command) {
19
+ const trimmed = command.trim();
20
+ if (!trimmed)
21
+ return "Command cannot be empty.";
22
+ if (trimmed.length > 1000)
23
+ return "Command too long (max 1000 characters).";
24
+ // Reject shell metacharacters that allow injection or arbitrary execution
25
+ // when the command is later run via `sh -c`.
26
+ if (/[`$(){}&|;<>]/.test(trimmed)) {
27
+ return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < >";
28
+ }
29
+ // eval and source can execute arbitrary code
30
+ if (/\b(eval|source)\b/.test(trimmed))
31
+ return "eval and source are not permitted in hook commands.";
32
+ // Command must start with a word character, path, or quoted string
33
+ if (!/^[\w./~"'"]/.test(trimmed))
34
+ return "Command must begin with an executable name or path.";
35
+ return null;
36
+ }
37
+ function normalizeHookTool(input) {
38
+ if (!input)
39
+ return null;
40
+ const lower = input.toLowerCase();
41
+ return HOOK_TOOLS.includes(lower) ? lower : null;
42
+ }
43
+ function normalizeProjectHookEvent(input) {
44
+ if (!input)
45
+ return null;
46
+ const normalized = input.trim().toLowerCase();
47
+ const aliasMap = {
48
+ userpromptsubmit: "UserPromptSubmit",
49
+ prompt: "UserPromptSubmit",
50
+ stop: "Stop",
51
+ sessionstart: "SessionStart",
52
+ start: "SessionStart",
53
+ posttooluse: "PostToolUse",
54
+ tool: "PostToolUse",
55
+ };
56
+ return aliasMap[normalized] ?? null;
57
+ }
58
+ export function register(server, ctx) {
59
+ const { phrenPath } = ctx;
60
+ // ── list_hooks ───────────────────────────────────────────────────────────
61
+ server.registerTool("list_hooks", {
62
+ title: "◆ phren · hooks",
63
+ description: "List hook status for all tools (claude, copilot, cursor, codex) with enable/disable state, " +
64
+ "config file paths, and custom integration hooks.",
65
+ inputSchema: z.object({
66
+ project: z.string().optional().describe("Optional project name to include project-level lifecycle hook overrides."),
67
+ }),
68
+ }, async ({ project }) => {
69
+ const prefs = readInstallPreferences(phrenPath);
70
+ const globalEnabled = prefs.hooksEnabled !== false;
71
+ const toolPrefs = prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {};
72
+ const paths = {
73
+ claude: hookConfigPath("claude", phrenPath),
74
+ copilot: hookConfigPath("copilot", phrenPath),
75
+ cursor: hookConfigPath("cursor", phrenPath),
76
+ codex: hookConfigPath("codex", phrenPath),
77
+ };
78
+ const customHooks = readCustomHooks(phrenPath);
79
+ let projectHooks = null;
80
+ if (project !== undefined) {
81
+ if (!isValidProjectName(project) || !fs.existsSync(path.join(phrenPath, project))) {
82
+ return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
83
+ }
84
+ const config = readProjectConfig(phrenPath, project);
85
+ projectHooks = {
86
+ project,
87
+ baseEnabled: typeof config.hooks?.enabled === "boolean" ? config.hooks.enabled : null,
88
+ configPath: path.join(phrenPath, project, "phren.project.yaml"),
89
+ events: PROJECT_HOOK_EVENTS.map((event) => ({
90
+ event,
91
+ configured: typeof config.hooks?.[event] === "boolean" ? config.hooks[event] : null,
92
+ enabled: isProjectHookEnabled(phrenPath, project, event, config),
93
+ })),
94
+ };
95
+ }
96
+ const tools = HOOK_TOOLS.map(tool => ({
97
+ tool,
98
+ enabled: globalEnabled && toolPrefs[tool] !== false,
99
+ configPath: paths[tool],
100
+ configExists: fs.existsSync(paths[tool]),
101
+ }));
102
+ const lines = [
103
+ `Hooks globally ${globalEnabled ? "enabled" : "disabled"}`,
104
+ "",
105
+ ...tools.map(t => `${t.tool}: ${t.enabled ? "enabled" : "disabled"} | config: ${t.configExists ? t.configPath : "(not found)"}`),
106
+ ];
107
+ if (projectHooks) {
108
+ lines.push("", `Project ${projectHooks.project}: base ${projectHooks.baseEnabled === null ? "inherit" : projectHooks.baseEnabled ? "enabled" : "disabled"} | config: ${projectHooks.configPath}`, ...projectHooks.events.map((event) => `${event.event}: ${event.enabled ? "enabled" : "disabled"}${event.configured === null ? " (inherit)" : ` (explicit ${event.configured ? "on" : "off"})`}`));
109
+ }
110
+ if (customHooks.length > 0) {
111
+ lines.push("", `${customHooks.length} custom hook(s):`);
112
+ for (const h of customHooks) {
113
+ const hookKind = "webhook" in h ? "[webhook] " : "";
114
+ lines.push(` ${h.event}: ${hookKind}${getHookTarget(h)}${h.timeout ? ` (${h.timeout}ms)` : ""}`);
115
+ }
116
+ }
117
+ return mcpResponse({ ok: true, message: lines.join("\n"), data: { globalEnabled, tools, customHooks, projectHooks } });
118
+ });
119
+ // ── toggle_hooks ─────────────────────────────────────────────────────────
120
+ server.registerTool("toggle_hooks", {
121
+ title: "◆ phren · toggle hooks",
122
+ description: "Enable or disable hooks globally, for a specific tool, or for a tracked project.",
123
+ inputSchema: z.object({
124
+ enabled: z.boolean().describe("true to enable, false to disable."),
125
+ tool: z.string().optional().describe("Specific tool. Omit to toggle globally."),
126
+ project: z.string().optional().describe("Tracked project name for project-level lifecycle hook overrides."),
127
+ event: z.string().optional().describe("Optional lifecycle event for project-level overrides: UserPromptSubmit, Stop, SessionStart, PostToolUse."),
128
+ }),
129
+ }, async ({ enabled, tool, project, event }) => {
130
+ if (tool && project) {
131
+ return mcpResponse({ ok: false, error: "Pass either tool or project, not both." });
132
+ }
133
+ if (event && !project) {
134
+ return mcpResponse({ ok: false, error: "event requires project." });
135
+ }
136
+ if (project) {
137
+ if (!isValidProjectName(project) || !fs.existsSync(path.join(phrenPath, project))) {
138
+ return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
139
+ }
140
+ const normalizedEvent = normalizeProjectHookEvent(event);
141
+ if (event && !normalizedEvent) {
142
+ return mcpResponse({ ok: false, error: `Invalid event "${event}". Use: ${PROJECT_HOOK_EVENTS.join(", ")}` });
143
+ }
144
+ if (normalizedEvent) {
145
+ writeProjectHookConfig(phrenPath, project, { [normalizedEvent]: enabled });
146
+ return mcpResponse({
147
+ ok: true,
148
+ message: `${enabled ? "Enabled" : "Disabled"} ${normalizedEvent} hook for ${project}.`,
149
+ data: { project, event: normalizedEvent, enabled },
150
+ });
151
+ }
152
+ writeProjectHookConfig(phrenPath, project, { enabled });
153
+ return mcpResponse({
154
+ ok: true,
155
+ message: `${enabled ? "Enabled" : "Disabled"} hooks for project ${project}.`,
156
+ data: { project, enabled },
157
+ });
158
+ }
159
+ if (tool) {
160
+ const normalized = normalizeHookTool(tool);
161
+ if (!normalized) {
162
+ return mcpResponse({ ok: false, error: `Invalid tool "${tool}". Use: ${HOOK_TOOLS.join(", ")}` });
163
+ }
164
+ const prefs = readInstallPreferences(phrenPath);
165
+ writeInstallPreferences(phrenPath, {
166
+ hookTools: {
167
+ ...(prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {}),
168
+ [normalized]: enabled,
169
+ },
170
+ });
171
+ return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks for ${normalized}.`, data: { tool: normalized, enabled } });
172
+ }
173
+ writeInstallPreferences(phrenPath, { hooksEnabled: enabled });
174
+ return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks globally.`, data: { global: true, enabled } });
175
+ });
176
+ // ── add_custom_hook ──────────────────────────────────────────────────────
177
+ server.registerTool("add_custom_hook", {
178
+ title: "◆ phren · add custom hook",
179
+ description: "Add a custom integration hook. Valid events: " +
180
+ VALID_CUSTOM_EVENTS.join(", ") + ". " +
181
+ "Provide either command (shell) or webhook (HTTP POST URL), not both.",
182
+ inputSchema: z.object({
183
+ event: z.enum(VALID_CUSTOM_EVENTS).describe("Hook event name."),
184
+ command: z.string().optional().describe("Shell command to execute."),
185
+ webhook: z.string().optional().describe("HTTP POST URL to call asynchronously (webhook hook)."),
186
+ secret: z.string().optional().describe("HMAC-SHA256 signing secret for webhook hooks. Sent as X-Phren-Signature header."),
187
+ timeout: z.number().int().min(1).optional().describe("Timeout in ms (default 5000)."),
188
+ }),
189
+ }, async ({ event, command, webhook, secret, timeout }) => {
190
+ if (!command && !webhook)
191
+ return mcpResponse({ ok: false, error: "Provide either command or webhook." });
192
+ if (command && webhook)
193
+ return mcpResponse({ ok: false, error: "Provide command or webhook, not both." });
194
+ let newHook;
195
+ if (webhook) {
196
+ const trimmed = webhook.trim();
197
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
198
+ return mcpResponse({ ok: false, error: "webhook must be an http:// or https:// URL." });
199
+ }
200
+ // Reject private/loopback hostnames to prevent SSRF
201
+ try {
202
+ const { hostname } = new URL(trimmed);
203
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
204
+ const ssrfBlocked = h === "localhost" ||
205
+ h === "::1" ||
206
+ /^127\./.test(h) ||
207
+ /^10\./.test(h) ||
208
+ /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
209
+ /^192\.168\./.test(h) ||
210
+ /^169\.254\./.test(h) ||
211
+ h.endsWith(".local") ||
212
+ h.endsWith(".internal");
213
+ if (ssrfBlocked) {
214
+ return mcpResponse({ ok: false, error: `webhook hostname "${hostname}" is a private or loopback address.` });
215
+ }
216
+ }
217
+ catch {
218
+ return mcpResponse({ ok: false, error: "webhook is not a valid URL." });
219
+ }
220
+ newHook = { event, webhook: trimmed, ...(secret ? { secret } : {}), ...(timeout !== undefined ? { timeout } : {}) };
221
+ }
222
+ else {
223
+ const cmdErr = validateHookCommand(command);
224
+ if (cmdErr)
225
+ return mcpResponse({ ok: false, error: cmdErr });
226
+ newHook = { event, command: command, ...(timeout !== undefined ? { timeout } : {}) };
227
+ }
228
+ return ctx.withWriteQueue(async () => {
229
+ const prefs = readInstallPreferences(phrenPath);
230
+ const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
231
+ writeInstallPreferences(phrenPath, { ...prefs, customHooks: [...existing, newHook] });
232
+ return mcpResponse({ ok: true, message: `Added custom hook for "${event}": ${"webhook" in newHook ? "[webhook] " : ""}${getHookTarget(newHook)}`, data: { hook: newHook, total: existing.length + 1 } });
233
+ });
234
+ });
235
+ // ── remove_custom_hook ───────────────────────────────────────────────────
236
+ server.registerTool("remove_custom_hook", {
237
+ title: "◆ phren · remove custom hook",
238
+ description: "Remove custom hook(s) by event and optional command text (partial match).",
239
+ inputSchema: z.object({
240
+ event: z.enum(VALID_CUSTOM_EVENTS).describe("Hook event name to match."),
241
+ command: z.string().optional().describe("Partial command text. Omit to remove all hooks for the event."),
242
+ }),
243
+ }, async ({ event, command }) => {
244
+ return ctx.withWriteQueue(async () => {
245
+ const prefs = readInstallPreferences(phrenPath);
246
+ const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
247
+ const remaining = existing.filter(h => h.event !== event || (command && !getHookTarget(h).includes(command)));
248
+ const removed = existing.length - remaining.length;
249
+ if (removed === 0) {
250
+ return mcpResponse({ ok: false, error: `No custom hooks matched event="${event}"${command ? ` command containing "${command}"` : ""}.` });
251
+ }
252
+ writeInstallPreferences(phrenPath, { ...prefs, customHooks: remaining });
253
+ return mcpResponse({ ok: true, message: `Removed ${removed} custom hook(s) for "${event}".`, data: { removed, remaining: remaining.length } });
254
+ });
255
+ });
256
+ }
@@ -0,0 +1,58 @@
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 { runtimeDir } from "./shared.js";
6
+ import { recordFeedback, flushEntryScores, } from "./shared-governance.js";
7
+ import { upsertCanonical } from "./shared-content.js";
8
+ import { isValidProjectName } from "./utils.js";
9
+ export function register(server, ctx) {
10
+ const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
11
+ server.registerTool("pin_memory", {
12
+ title: "◆ phren · pin memory",
13
+ description: "Promote an important memory into CANONICAL_MEMORIES.md so retrieval prioritizes it.",
14
+ inputSchema: z.object({
15
+ project: z.string().describe("Project name."),
16
+ memory: z.string().describe("Canonical memory text to pin."),
17
+ }),
18
+ }, async ({ project, memory }) => {
19
+ if (!isValidProjectName(project))
20
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
21
+ return withWriteQueue(async () => {
22
+ const result = upsertCanonical(phrenPath, project, memory);
23
+ if (!result.ok)
24
+ return mcpResponse({ ok: false, error: result.error });
25
+ // Update FTS index so newly pinned memory is immediately searchable
26
+ const canonicalPath = path.join(phrenPath, project, "CANONICAL_MEMORIES.md");
27
+ updateFileInIndex(canonicalPath);
28
+ return mcpResponse({ ok: true, message: result.data, data: { project, memory } });
29
+ });
30
+ });
31
+ server.registerTool("memory_feedback", {
32
+ title: "◆ phren · feedback",
33
+ description: "Record feedback on whether an injected memory was helpful or noisy/regressive.",
34
+ inputSchema: z.object({
35
+ key: z.string().describe("Memory key to score."),
36
+ feedback: z.enum(["helpful", "reprompt", "regression"]).describe("Feedback type."),
37
+ }),
38
+ }, async ({ key, feedback }) => {
39
+ return withWriteQueue(async () => {
40
+ recordFeedback(phrenPath, key, feedback);
41
+ flushEntryScores(phrenPath);
42
+ const feedbackWeights = {
43
+ helpful: 1.0,
44
+ not_helpful: -0.3,
45
+ reprompt: -0.5,
46
+ regression: -1.0,
47
+ };
48
+ const weight = feedbackWeights[feedback] ?? 0;
49
+ // Write feedback audit to a dedicated file — NOT to scores.jsonl, which uses a
50
+ // different schema ({key, delta, at}) and would crash readScoreJournal if polluted.
51
+ const auditFile = path.join(runtimeDir(phrenPath), "feedback-audit.jsonl");
52
+ fs.mkdirSync(path.dirname(auditFile), { recursive: true });
53
+ const entry = { key, feedback, weight, timestamp: new Date().toISOString() };
54
+ fs.appendFileSync(auditFile, JSON.stringify(entry) + "\n");
55
+ return mcpResponse({ ok: true, message: `Recorded feedback ${feedback} for ${key} (weight: ${weight})`, data: { key, feedback, weight } });
56
+ });
57
+ });
58
+ }