milens 0.6.3 → 0.6.4

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 (137) hide show
  1. package/.agents/skills/adapters/SKILL.md +31 -0
  2. package/.agents/skills/analyzer/SKILL.md +55 -0
  3. package/.agents/skills/apps/SKILL.md +42 -0
  4. package/.agents/skills/docs/SKILL.md +46 -0
  5. package/.agents/skills/milens/SKILL.md +168 -0
  6. package/.agents/skills/milens-code-review/SKILL.md +186 -0
  7. package/.agents/skills/milens-eval/SKILL.md +221 -0
  8. package/.agents/skills/milens-plan/SKILL.md +227 -0
  9. package/.agents/skills/milens-refactor-clean/SKILL.md +209 -0
  10. package/.agents/skills/milens-security-review/SKILL.md +224 -0
  11. package/.agents/skills/milens-tdd/SKILL.md +156 -0
  12. package/.agents/skills/parser/SKILL.md +60 -0
  13. package/.agents/skills/root/SKILL.md +64 -0
  14. package/.agents/skills/scripts/SKILL.md +27 -0
  15. package/.agents/skills/security/SKILL.md +44 -0
  16. package/.agents/skills/server/SKILL.md +46 -0
  17. package/.agents/skills/store/SKILL.md +53 -0
  18. package/.agents/skills/test/SKILL.md +73 -0
  19. package/LICENSE +75 -75
  20. package/README.md +508 -432
  21. package/adapters/README.md +107 -0
  22. package/adapters/claude-code/.claude/mcp.json +9 -0
  23. package/adapters/claude-code/CLAUDE.md +58 -0
  24. package/adapters/codex/.codex/codex.md +52 -0
  25. package/adapters/copilot/.github/copilot-instructions.md +62 -0
  26. package/adapters/cursor/.cursorrules +9 -0
  27. package/adapters/gemini/.gemini/context.md +58 -0
  28. package/adapters/opencode/.opencode/config.json +9 -0
  29. package/adapters/opencode/AGENTS.md +58 -0
  30. package/adapters/zed/.zed/settings.json +8 -0
  31. package/dist/agents-md.d.ts +3 -0
  32. package/dist/agents-md.d.ts.map +1 -0
  33. package/dist/agents-md.js +112 -0
  34. package/dist/agents-md.js.map +1 -0
  35. package/dist/analyzer/engine.js +1 -1
  36. package/dist/analyzer/engine.js.map +1 -1
  37. package/dist/cli.js +1190 -401
  38. package/dist/cli.js.map +1 -1
  39. package/dist/metrics.d.ts +51 -0
  40. package/dist/metrics.d.ts.map +1 -0
  41. package/dist/metrics.js +64 -0
  42. package/dist/metrics.js.map +1 -0
  43. package/dist/parser/lang-go.js +47 -47
  44. package/dist/parser/lang-java.js +29 -29
  45. package/dist/parser/lang-js.js +105 -105
  46. package/dist/parser/lang-php.js +38 -38
  47. package/dist/parser/lang-py.js +34 -34
  48. package/dist/parser/lang-ruby.js +14 -14
  49. package/dist/parser/lang-rust.js +30 -30
  50. package/dist/parser/lang-ts.js +191 -191
  51. package/dist/security/deps.d.ts +38 -0
  52. package/dist/security/deps.d.ts.map +1 -0
  53. package/dist/security/deps.js +685 -0
  54. package/dist/security/deps.js.map +1 -0
  55. package/dist/security/rules.d.ts +42 -0
  56. package/dist/security/rules.d.ts.map +1 -0
  57. package/dist/security/rules.js +940 -0
  58. package/dist/security/rules.js.map +1 -0
  59. package/dist/server/hooks.d.ts +26 -0
  60. package/dist/server/hooks.d.ts.map +1 -0
  61. package/dist/server/hooks.js +253 -0
  62. package/dist/server/hooks.js.map +1 -0
  63. package/dist/server/mcp-prompts.d.ts +277 -0
  64. package/dist/server/mcp-prompts.d.ts.map +1 -0
  65. package/dist/server/mcp-prompts.js +627 -0
  66. package/dist/server/mcp-prompts.js.map +1 -0
  67. package/dist/server/mcp.d.ts.map +1 -1
  68. package/dist/server/mcp.js +618 -643
  69. package/dist/server/mcp.js.map +1 -1
  70. package/dist/server/test-plan.d.ts +20 -0
  71. package/dist/server/test-plan.d.ts.map +1 -0
  72. package/dist/server/test-plan.js +100 -0
  73. package/dist/server/test-plan.js.map +1 -0
  74. package/dist/skills.js +152 -152
  75. package/dist/store/annotations.d.ts +41 -0
  76. package/dist/store/annotations.d.ts.map +1 -0
  77. package/dist/store/annotations.js +192 -0
  78. package/dist/store/annotations.js.map +1 -0
  79. package/dist/store/confidence.d.ts +18 -0
  80. package/dist/store/confidence.d.ts.map +1 -0
  81. package/dist/store/confidence.js +82 -0
  82. package/dist/store/confidence.js.map +1 -0
  83. package/dist/store/db.d.ts +37 -14
  84. package/dist/store/db.d.ts.map +1 -1
  85. package/dist/store/db.js +332 -239
  86. package/dist/store/db.js.map +1 -1
  87. package/dist/store/schema.sql +128 -116
  88. package/dist/store/vectors.js +2 -2
  89. package/dist/types.d.ts +101 -0
  90. package/dist/types.d.ts.map +1 -1
  91. package/docs/README.md +24 -0
  92. package/package.json +80 -66
  93. package/dist/gateway/analyzer.d.ts +0 -6
  94. package/dist/gateway/analyzer.d.ts.map +0 -1
  95. package/dist/gateway/analyzer.js +0 -218
  96. package/dist/gateway/analyzer.js.map +0 -1
  97. package/dist/gateway/cache.d.ts +0 -35
  98. package/dist/gateway/cache.d.ts.map +0 -1
  99. package/dist/gateway/cache.js +0 -175
  100. package/dist/gateway/cache.js.map +0 -1
  101. package/dist/gateway/config.d.ts +0 -10
  102. package/dist/gateway/config.d.ts.map +0 -1
  103. package/dist/gateway/config.js +0 -167
  104. package/dist/gateway/config.js.map +0 -1
  105. package/dist/gateway/context-memory.d.ts +0 -68
  106. package/dist/gateway/context-memory.d.ts.map +0 -1
  107. package/dist/gateway/context-memory.js +0 -157
  108. package/dist/gateway/context-memory.js.map +0 -1
  109. package/dist/gateway/observability.d.ts +0 -83
  110. package/dist/gateway/observability.d.ts.map +0 -1
  111. package/dist/gateway/observability.js +0 -152
  112. package/dist/gateway/observability.js.map +0 -1
  113. package/dist/gateway/privacy.d.ts +0 -27
  114. package/dist/gateway/privacy.d.ts.map +0 -1
  115. package/dist/gateway/privacy.js +0 -139
  116. package/dist/gateway/privacy.js.map +0 -1
  117. package/dist/gateway/providers.d.ts +0 -66
  118. package/dist/gateway/providers.d.ts.map +0 -1
  119. package/dist/gateway/providers.js +0 -377
  120. package/dist/gateway/providers.js.map +0 -1
  121. package/dist/gateway/router.d.ts +0 -18
  122. package/dist/gateway/router.d.ts.map +0 -1
  123. package/dist/gateway/router.js +0 -102
  124. package/dist/gateway/router.js.map +0 -1
  125. package/dist/gateway/server.d.ts +0 -20
  126. package/dist/gateway/server.d.ts.map +0 -1
  127. package/dist/gateway/server.js +0 -387
  128. package/dist/gateway/server.js.map +0 -1
  129. package/dist/gateway/translator.d.ts +0 -19
  130. package/dist/gateway/translator.d.ts.map +0 -1
  131. package/dist/gateway/translator.js +0 -340
  132. package/dist/gateway/translator.js.map +0 -1
  133. package/dist/gateway/types.d.ts +0 -215
  134. package/dist/gateway/types.d.ts.map +0 -1
  135. package/dist/gateway/types.js +0 -3
  136. package/dist/gateway/types.js.map +0 -1
  137. package/dist/store/gateway-schema.sql +0 -53
@@ -6,19 +6,18 @@ import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { resolve, relative, join, dirname } from 'node:path';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { readFileSync, mkdirSync } from 'node:fs';
10
- import { readdir, readFile, stat } from 'node:fs/promises';
9
+ import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
11
10
  import { homedir } from 'node:os';
12
11
  import ignore from 'ignore';
13
12
  import { Database } from '../store/db.js';
14
13
  import { RepoRegistry } from '../store/registry.js';
15
- import { isTestFile } from '../utils.js';
16
- import { reviewPr, reviewSymbol } from '../analyzer/review.js';
17
- import { generateTestPlan, findCoverageGaps, analyzeTestImpact } from '../analyzer/testplan.js';
18
- import { TfIdfProvider, EmbeddingStore, buildEmbeddingText } from '../store/vectors.js';
19
14
  import { getParser, loadLanguage } from '../parser/loader.js';
20
15
  import { ALL_LANGS } from '../parser/languages.js';
21
16
  import { fileURLToPath } from 'node:url';
17
+ import { generateTestPlan } from './test-plan.js';
18
+ import { AnnotationStore } from '../store/annotations.js';
19
+ import { registerAllPrompts } from './mcp-prompts.js';
20
+ import { loadRules } from '../security/rules.js';
22
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
22
  const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
24
23
  // ── Lazy DB connection with idle eviction ──
@@ -31,8 +30,6 @@ class LazyDb {
31
30
  statsCache = null;
32
31
  domainCache = null;
33
32
  static CACHE_TTL = 30_000; // 30s TTL
34
- // Cached TF-IDF provider (trained once per DB session, reused across semantic_search/find_similar calls)
35
- tfidfCache = null;
36
33
  constructor(dbPath) {
37
34
  this.dbPath = dbPath;
38
35
  }
@@ -69,19 +66,6 @@ class LazyDb {
69
66
  invalidateCache() {
70
67
  this.statsCache = null;
71
68
  this.domainCache = null;
72
- this.tfidfCache = null;
73
- }
74
- /** Get or build a TF-IDF provider + embedding store, trained on the corpus. */
75
- getTfidf() {
76
- if (this.tfidfCache)
77
- return this.tfidfCache;
78
- const db = this.get();
79
- const provider = new TfIdfProvider();
80
- const allSyms = db.getAllSymbols();
81
- provider.trainIdf(allSyms.map(s => buildEmbeddingText({ name: s.name, kind: s.kind, filePath: s.filePath, signature: s.signature })));
82
- const store = new EmbeddingStore(db.getRawDb(), provider.dimensions);
83
- this.tfidfCache = { provider, store };
84
- return this.tfidfCache;
85
69
  }
86
70
  resetTimer() {
87
71
  if (this.timer)
@@ -94,7 +78,6 @@ class LazyDb {
94
78
  this.timer = null;
95
79
  this.statsCache = null;
96
80
  this.domainCache = null;
97
- this.tfidfCache = null;
98
81
  }
99
82
  shutdown() {
100
83
  if (this.timer)
@@ -118,21 +101,6 @@ const TOKEN_SAVINGS_MULTIPLIER = {
118
101
  domains: 3, // vs exploring file structure
119
102
  status: 2, // vs checking multiple stats
120
103
  detect_changes: 4, // vs git diff + manual symbol mapping
121
- review_pr: 10, // vs detect_changes + impact per symbol + coverage check
122
- review_symbol: 5, // vs edit_check + impact + coverage
123
- test_plan: 7, // vs context + outgoing links + mock analysis
124
- test_coverage_gaps: 5, // vs scanning all symbols + checking test refs
125
- test_impact: 6, // vs detect_changes + upstream traversal + test file mapping
126
- annotate: 2, // simple write
127
- recall: 3, // vs manual grep for notes
128
- session_start: 1, // simple write
129
- session_context: 2, // session lookup
130
- handoff: 3, // session transfer
131
- codebase_summary: 8, // vs domains + status + query for top symbols
132
- semantic_search: 5, // vs multiple query + grep calls to find related code
133
- find_similar: 4, // vs manual comparison of symbol signatures
134
- ast_explore: 2, // vs reading raw AST manually
135
- test_query: 2, // vs writing SQL + checking schema
136
104
  explain_relationship: 4, // vs manual path finding
137
105
  find_dead_code: 3, // vs manual export usage search
138
106
  get_file_symbols: 2, // vs reading entire file
@@ -194,6 +162,14 @@ function fmtImpact(items, detail = 'L1') {
194
162
  }
195
163
  return lines.join('\n');
196
164
  }
165
+ /** Check if a file path looks like a test/spec file */
166
+ function isTestFilePath(filePath) {
167
+ return /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
168
+ /^tests?[/\\]/.test(filePath) ||
169
+ /__tests__[/\\]/.test(filePath) ||
170
+ /_test\.(go|py|rb|rs|java|php)$/.test(filePath) ||
171
+ /^test_.*\.py$/.test(filePath.split('/').pop() ?? '');
172
+ }
197
173
  // ── Text grep across project files ──
198
174
  const GREP_SKIP_DIRS = new Set([
199
175
  'node_modules', '.git', 'dist', 'build', 'out',
@@ -212,7 +188,7 @@ const BINARY_EXTENSIONS = new Set([
212
188
  '.wasm', '.node', '.so', '.dll', '.dylib',
213
189
  '.lock',
214
190
  ]);
215
- async function grepFiles(rootPath, pattern, options) {
191
+ function grepFiles(rootPath, pattern, options) {
216
192
  const { isRegex = false, caseSensitive = false, maxResults = 50, includePattern } = options;
217
193
  const flags = caseSensitive ? '' : 'i';
218
194
  let regex;
@@ -225,12 +201,12 @@ async function grepFiles(rootPath, pattern, options) {
225
201
  const ig = loadGrepIgnoreRules(rootPath);
226
202
  const includeRe = includePattern ? globToRegex(includePattern) : null;
227
203
  const results = [];
228
- async function walk(dir) {
204
+ function walk(dir) {
229
205
  if (results.length >= maxResults)
230
206
  return;
231
207
  let entries;
232
208
  try {
233
- entries = await readdir(dir);
209
+ entries = readdirSync(dir);
234
210
  }
235
211
  catch {
236
212
  return;
@@ -246,26 +222,26 @@ async function grepFiles(rootPath, pattern, options) {
246
222
  continue;
247
223
  if (ig.ignores(rel))
248
224
  continue;
249
- let st;
225
+ let stat;
250
226
  try {
251
- st = await stat(abs);
227
+ stat = statSync(abs);
252
228
  }
253
229
  catch {
254
230
  continue;
255
231
  }
256
- if (st.isDirectory()) {
257
- await walk(abs);
232
+ if (stat.isDirectory()) {
233
+ walk(abs);
258
234
  }
259
- else if (st.isFile()) {
235
+ else if (stat.isFile()) {
260
236
  const ext = '.' + entry.split('.').pop()?.toLowerCase();
261
237
  if (BINARY_EXTENSIONS.has(ext))
262
238
  continue;
263
- if (st.size > 512 * 1024)
239
+ if (stat.size > 512 * 1024)
264
240
  continue; // skip files > 512KB
265
241
  if (includeRe && !includeRe.test(rel))
266
242
  continue;
267
243
  try {
268
- const content = await readFile(abs, 'utf-8');
244
+ const content = readFileSync(abs, 'utf-8');
269
245
  const lines = content.split('\n');
270
246
  for (let i = 0; i < lines.length && results.length < maxResults; i++) {
271
247
  if (regex.test(lines[i])) {
@@ -277,7 +253,7 @@ async function grepFiles(rootPath, pattern, options) {
277
253
  }
278
254
  }
279
255
  }
280
- await walk(rootPath);
256
+ walk(rootPath);
281
257
  return results;
282
258
  }
283
259
  function escapeRegExp(s) {
@@ -287,9 +263,7 @@ function escapeRegExp(s) {
287
263
  function matchesScope(lineText, scope) {
288
264
  const trimmed = lineText.trimStart();
289
265
  if (scope === 'imports') {
290
- return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed)
291
- || /^"[^"]*"\s*$/.test(trimmed) // Go import block line: "fmt"
292
- || /^\w+\s+"[^"]*"\s*$/.test(trimmed); // Go aliased import: alias "pkg"
266
+ return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed);
293
267
  }
294
268
  // definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
295
269
  return /^(export\s+)?(async\s+)?(function|class|interface|type|enum|struct|trait|const|let|var|def|fn|pub\s+fn|pub\s+struct|pub\s+enum|module)\s/.test(trimmed);
@@ -298,12 +272,8 @@ function matchesScope(lineText, scope) {
298
272
  function safeRegex(pattern, flags) {
299
273
  if (pattern.length > 200)
300
274
  throw new Error('Pattern too long');
301
- // Reject nested quantifiers: quantifier applied to a group that contains a quantifier
302
- // e.g. (a+)+, (a*)+, (a{2,})+, (?:a+)*, (.+)+
303
- if (/([+*}])\s*\)\s*[+*?{]/.test(pattern))
304
- throw new Error('Unsafe regex pattern');
305
- // Reject quantifier directly after quantifier: a++, a*+, a+{2}, but allow lazy quantifiers: a+?, a*?, a??
306
- if (/[+*}]\s*[+*{]/.test(pattern))
275
+ // Reject nested quantifiers like (a+)+, (a*)*, (a{1,})+
276
+ if (/([+*}])\)?[+*{]/.test(pattern))
307
277
  throw new Error('Unsafe regex pattern');
308
278
  // Reject overlapping alternation inside quantified groups: (a|a)*, (ab|a)+
309
279
  if (/\((?:[^)]*\|[^)]*)\)[+*{]/.test(pattern))
@@ -326,26 +296,12 @@ function safeRegex(pattern, flags) {
326
296
  return new RegExp(pattern, flags);
327
297
  }
328
298
  function globToRegex(glob) {
329
- // Extract brace patterns {a,b,c} as placeholders BEFORE escaping
330
- const braceGroups = [];
331
- let expanded = glob.replace(/\{([^}]+)\}/g, (_, inner) => {
332
- const alts = inner.split(',').map(s => s.trim());
333
- const idx = braceGroups.length;
334
- braceGroups.push(alts.map(a => a.replace(/[.+^$|[\]\\]/g, '\\$&')).join('|'));
335
- return `§BRACE${idx}§`;
336
- });
337
- const escaped = expanded
338
- .replace(/[.+^$|[\]\\]/g, '\\$&')
299
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
339
300
  .replace(/\*\*/g, '§STARSTAR§')
340
301
  .replace(/\*/g, '[^/]*')
341
302
  .replace(/§STARSTAR§/g, '.*')
342
303
  .replace(/\?/g, '.');
343
- // Restore brace groups after escaping
344
- let result = escaped;
345
- for (let i = 0; i < braceGroups.length; i++) {
346
- result = result.replace(`§BRACE${i}§`, `(${braceGroups[i]})`);
347
- }
348
- return new RegExp(`^${result}$`, 'i');
304
+ return new RegExp(`^${escaped}$`, 'i');
349
305
  }
350
306
  function loadGrepIgnoreRules(rootPath) {
351
307
  const ig = ignore();
@@ -358,57 +314,42 @@ function loadGrepIgnoreRules(rootPath) {
358
314
  return ig;
359
315
  }
360
316
  // ── Server instructions (sent to client via MCP protocol on initialize) ──
361
- const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codebases into symbol graphs.
362
-
363
- ## Tool selection
364
- - \`query\` — find symbol definitions (code identifiers only)
365
- - \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
366
- - \`context\` — 360° view: incoming + outgoing for a symbol
367
- - \`impact\` — blast radius: what breaks if symbol changes
368
- - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
369
- - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
370
- - \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
371
- - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Django, Go, Gin, PHP, Rails, Sinatra, Spring)
372
- - \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
373
- - \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
374
- - \`repos\` — list all indexed repositories with summary stats (multi-repo support)
375
- - \`detect_changes\` — git diff → affected symbols
376
- - \`review_pr\` — PR risk assessment: blast radius + test coverage per changed symbol → LOW/MEDIUM/HIGH/CRITICAL
377
- - \`review_symbol\` — quick single-symbol risk: role, heat, dependents, test coverage, risk level
378
- - \`test_plan\` — dependency-aware test plan: deps to mock, mock strategies (stub/spy/fake), suggested tests
379
- - \`test_coverage_gaps\` — untested exported symbols sorted by risk (hub functions first)
380
- - \`test_impact\` — which tests to run for current changes (maps changed symbols → test files)
381
- - \`annotate\` — store observations/notes about a symbol (persists across sessions)
382
- - \`recall\` retrieve annotations (filter by symbol, key, agent, session)
383
- - \`session_start\` register agent session for multi-agent coordination
384
- - \`session_context\` — get session metadata + annotations
385
- - \`handoff\` — transfer context between agent sessions
386
- - \`codebase_summary\` high-level bootstrapping context: domains, key symbols, coverage, annotations
387
- - \`semantic_search\` — hybrid search: FTS5 + vector cosine similarity (requires --embeddings during analyze)
388
- - \`find_similar\` find symbols similar to a given symbol by vector embedding proximity
389
- - \`explain_relationship\`shortest path between two symbols
390
- - \`find_dead_code\`unused exports
391
- - \`get_file_symbols\` — all symbols in a file
392
- - \`get_type_hierarchy\` inheritance tree
393
-
394
- ## Rules
395
- - Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
396
- - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
397
- - For writing tests: run \`test_plan\` for structured test plan, or \`smart_context\` with intent=test
398
- - For PR review: run \`review_pr\` for overall risk, \`review_symbol\` for single symbol assessment
399
- - For agent bootstrapping: run \`codebase_summary\` as the first tool call in a new session
400
- - \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
401
- - Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
402
- - impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
403
- - ⚠ markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
404
- - ✓ test coverage shown on edit_check — symbols with no test coverage get a warning
405
- - ⏳ staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
406
-
407
- ## Resources (MCP Resources protocol)
408
- - \`milens://overview\` — index overview (stats, domains, coverage, staleness)
409
- - \`milens://symbol/{name}\` — symbol context by name
410
- - \`milens://file/{path}\` — all symbols in a file
411
- - \`milens://domain/{name}\` — domain cluster details
317
+ const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codebases into symbol graphs.
318
+
319
+ ## Tool selection
320
+ - \`query\` — find symbol definitions (code identifiers only)
321
+ - \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
322
+ - \`context\` — 360° view: incoming + outgoing for a symbol
323
+ - \`impact\` — blast radius: what breaks if symbol changes
324
+ - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
325
+ - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
326
+ - \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
327
+ - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
328
+ - \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
329
+ - \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
330
+ - \`repos\` — list all indexed repositories with summary stats (multi-repo support)
331
+ - \`detect_changes\` — git diff → affected symbols
332
+ - \`explain_relationship\` — shortest path between two symbols
333
+ - \`find_dead_code\` — unused exports
334
+ - \`get_file_symbols\` — all symbols in a file
335
+ - \`get_type_hierarchy\` — inheritance tree
336
+
337
+ ## Rules
338
+ - Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
339
+ - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
340
+ - For writing tests: run \`smart_context\` with intent=test shows deps to mock + callers to cover
341
+ - \`impact\` only tracks code deps always pair with \`grep\` for templates/configs
342
+ - Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
343
+ - impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
344
+ - markers indicate unresolved INTERNAL references external package imports/calls are tracked separately
345
+ - test coverage shown on edit_check symbols with no test coverage get a warning
346
+ - staleness: files not re-analyzed in 24h are flagged consider re-running \`milens analyze\`
347
+
348
+ ## Resources (MCP Resources protocol)
349
+ - \`milens://overview\` — index overview (stats, domains, coverage, staleness)
350
+ - \`milens://symbol/{name}\` — symbol context by name
351
+ - \`milens://file/{path}\` all symbols in a file
352
+ - \`milens://domain/{name}\` domain cluster details
412
353
  `;
413
354
  // ── Server setup ──
414
355
  export function createMcpServer(rootPath) {
@@ -473,6 +414,29 @@ export function createMcpServer(rootPath) {
473
414
  }
474
415
  return origTool(...args);
475
416
  });
417
+ // ── Selective tool profiles (W4) ──
418
+ const profile = process.env.MILENS_PROFILE || undefined;
419
+ if (profile && profile !== 'full') {
420
+ const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
421
+ const standard = new Set([...minimal, 'domains', 'repos', 'explain_relationship', 'find_dead_code', 'get_type_hierarchy', 'trace', 'routes', 'smart_context', 'review_pr', 'review_symbol', 'test_coverage_gaps', 'test_plan', 'test_impact', 'session_start', 'recall']);
422
+ const allowed = profile === 'minimal' ? minimal : standard;
423
+ // Wrap server.tool again to gate by profile
424
+ const profileWrappedTool = server.tool.bind(server);
425
+ server.tool = ((...args) => {
426
+ const toolName = args[0];
427
+ if (!allowed.has(toolName)) {
428
+ // Return a no-op tool that explains it's disabled
429
+ const origLength = args.length;
430
+ const handler = args[origLength - 1];
431
+ if (typeof handler === 'function') {
432
+ args[origLength - 1] = async () => ({
433
+ content: [{ type: 'text', text: `Tool "${toolName}" disabled by profile "${profile}". Use --profile full to enable.` }],
434
+ });
435
+ }
436
+ }
437
+ return profileWrappedTool(...args);
438
+ });
439
+ }
476
440
  // ── Tool: query ──
477
441
  server.tool('query', 'Search indexed symbol definitions by name/kind. For text in templates/configs/docs, use `grep`.', {
478
442
  query: z.string().describe('Symbol name, kind, or keyword to search'),
@@ -502,7 +466,7 @@ export function createMcpServer(rootPath) {
502
466
  const effectiveInclude = scope === 'code' && !include
503
467
  ? '**/*.{ts,tsx,js,jsx,mjs,cjs,vue,py,go,rs,java,php,rb}'
504
468
  : include;
505
- const matches = await grepFiles(root, pattern, {
469
+ const matches = grepFiles(root, pattern, {
506
470
  isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
507
471
  });
508
472
  // Apply scope-specific line filtering
@@ -717,7 +681,7 @@ export function createMcpServer(rootPath) {
717
681
  }
718
682
  }
719
683
  // Section 4: Grep (text references across all files)
720
- const grepMatches = await grepFiles(root, name, { maxResults: 20 });
684
+ const grepMatches = grepFiles(root, name, { maxResults: 20 });
721
685
  if (grepMatches.length > 0) {
722
686
  const grouped = new Map();
723
687
  for (const m of grepMatches) {
@@ -755,24 +719,19 @@ export function createMcpServer(rootPath) {
755
719
  try {
756
720
  const dbPath = registry.findDbPath(entry.rootPath);
757
721
  if (dbPath) {
758
- const fromPool = pools.has(entry.rootPath);
759
- const tempDb = fromPool
722
+ const tempDb = pools.has(entry.rootPath)
760
723
  ? pools.get(entry.rootPath).get()
761
724
  : new Database(dbPath);
762
- try {
763
- const summary = tempDb.getRepoSummary();
764
- lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
765
- if (summary.domains.length > 0) {
766
- lines.push(` domains: ${summary.domains.join(', ')}`);
767
- }
768
- if (summary.staleCount > 0) {
769
- lines.push(` ⏳ ${summary.staleCount} stale files`);
770
- }
725
+ const summary = tempDb.getRepoSummary();
726
+ lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
727
+ if (summary.domains.length > 0) {
728
+ lines.push(` domains: ${summary.domains.join(', ')}`);
771
729
  }
772
- finally {
773
- if (!fromPool)
774
- tempDb.close();
730
+ if (summary.staleCount > 0) {
731
+ lines.push(` ⏳ ${summary.staleCount} stale files`);
775
732
  }
733
+ if (!pools.has(entry.rootPath))
734
+ tempDb.close();
776
735
  }
777
736
  }
778
737
  catch {
@@ -794,9 +753,8 @@ export function createMcpServer(rootPath) {
794
753
  }
795
754
  let changedFiles;
796
755
  try {
797
- // Show both staged and unstaged changes against the ref
798
756
  const output = execFileSync('git', ['diff', '--name-only', ref], { cwd: root, encoding: 'utf-8' });
799
- const staged = execFileSync('git', ['diff', '--cached', '--name-only', ref], { cwd: root, encoding: 'utf-8' });
757
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: root, encoding: 'utf-8' });
800
758
  changedFiles = [...new Set([...output.trim().split('\n'), ...staged.trim().split('\n')])].filter(Boolean);
801
759
  }
802
760
  catch {
@@ -825,343 +783,6 @@ export function createMcpServer(rootPath) {
825
783
  lines.push(`\nTotal direct dependents affected: ${totalAffected}`);
826
784
  return { content: [{ type: 'text', text: lines.join('\n') }] };
827
785
  });
828
- // ── Tool: review_pr ──
829
- server.tool('review_pr', 'PR risk assessment: analyzes changed files, scores each symbol by blast radius, test coverage, and role. Returns overall risk level (LOW/MEDIUM/HIGH/CRITICAL) with hotspots and recommendations.', {
830
- ref: z.string().optional().default('HEAD').describe('Git ref to diff (default: HEAD)'),
831
- base: z.string().optional().describe('Base ref to compare against (e.g. main, develop)'),
832
- repo: z.string().optional(),
833
- }, async ({ ref, repo, base }) => {
834
- const { db, root } = getDb(repo);
835
- const result = reviewPr(db, root, ref, base);
836
- if (result.symbols.length === 0) {
837
- return { content: [{ type: 'text', text: result.summary }] };
838
- }
839
- const lines = [
840
- `## PR Risk: ${result.risk} (score: ${result.score})`,
841
- '',
842
- result.summary,
843
- '',
844
- ];
845
- if (result.hotspots.length > 0) {
846
- lines.push(`### Hotspots (${result.hotspots.length})\n`);
847
- for (const h of result.hotspots) {
848
- const tested = h.tested ? '✓ tested' : '⚠ untested';
849
- lines.push(`${fmtSymbol(h.symbol)} → ${h.dependents} dependents, ${tested} [${h.riskLevel}]`);
850
- if (h.reasons.length > 0)
851
- lines.push(` reasons: ${h.reasons.join(', ')}`);
852
- }
853
- lines.push('');
854
- }
855
- // Show remaining non-hotspot symbols (compact)
856
- const nonHotspots = result.symbols.filter(s => s.riskLevel !== 'HIGH' && s.riskLevel !== 'CRITICAL');
857
- if (nonHotspots.length > 0) {
858
- lines.push(`### Other changed symbols (${nonHotspots.length})\n`);
859
- for (const s of nonHotspots.slice(0, 20)) {
860
- const tested = s.tested ? '✓' : '⚠';
861
- lines.push(`${tested} ${s.symbol.name} [${s.symbol.kind}] ${s.symbol.filePath}:${s.symbol.startLine} → ${s.dependents} deps [${s.riskLevel}]`);
862
- }
863
- if (nonHotspots.length > 20)
864
- lines.push(` ... and ${nonHotspots.length - 20} more`);
865
- lines.push('');
866
- }
867
- if (result.untestedChanges > 0) {
868
- lines.push(`⚠ ${result.untestedChanges} exported symbols changed without test coverage`);
869
- }
870
- return { content: [{ type: 'text', text: lines.join('\n') }] };
871
- });
872
- // ── Tool: review_symbol ──
873
- server.tool('review_symbol', 'Quick risk assessment for a single symbol: role, heat, dependents count, test coverage, risk level.', {
874
- name: z.string().describe('Symbol name to assess'),
875
- repo: z.string().optional(),
876
- }, async ({ name, repo }) => {
877
- const { db } = getDb(repo);
878
- const result = reviewSymbol(db, name);
879
- if (!result) {
880
- return { content: [{ type: 'text', text: `"${name}" not found in index. Try \`grep\`.` }] };
881
- }
882
- const tested = result.tested ? '✓ tested' : '⚠ untested';
883
- const lines = [
884
- `${fmtSymbol(result.symbol)}${result.symbol.exported ? ' (exported)' : ''}`,
885
- `risk: ${result.riskLevel} (score: ${result.riskScore})`,
886
- `role: ${result.symbol.role ?? 'unknown'}, heat: ${result.symbol.heat ?? 0}`,
887
- `dependents: ${result.dependents}, ${tested}`,
888
- ];
889
- if (result.reasons.length > 0) {
890
- lines.push(`reasons: ${result.reasons.join(', ')}`);
891
- }
892
- return { content: [{ type: 'text', text: lines.join('\n') }] };
893
- });
894
- // ── Tool: test_plan ──
895
- server.tool('test_plan', 'Generate a structured test plan for a symbol: dependencies to mock, mock strategies (stub/spy/fake), suggested unit/integration/edge-case tests.', {
896
- name: z.string().describe('Symbol name to generate test plan for'),
897
- repo: z.string().optional(),
898
- }, async ({ name, repo }) => {
899
- const { db } = getDb(repo);
900
- const plan = generateTestPlan(db, name);
901
- if (!plan) {
902
- return { content: [{ type: 'text', text: `"${name}" not found in index. Try \`grep\`.` }] };
903
- }
904
- const lines = [
905
- `## Test Plan: ${plan.target.name} [${plan.target.kind}]`,
906
- `file: ${plan.target.filePath}${plan.target.role ? `, role: ${plan.target.role}` : ''}`,
907
- '',
908
- ];
909
- if (plan.existingTests.length > 0) {
910
- lines.push(`### Existing tests`);
911
- for (const t of plan.existingTests)
912
- lines.push(` ✓ ${t}`);
913
- lines.push('');
914
- }
915
- else {
916
- lines.push(`### Existing tests: none\n`);
917
- }
918
- if (plan.dependencies.length > 0) {
919
- lines.push(`### Dependencies (${plan.dependencies.length})`);
920
- for (const dep of plan.dependencies) {
921
- lines.push(` ${dep.name} [${dep.kind}] ${dep.filePath} (${dep.linkType})`);
922
- }
923
- lines.push('');
924
- }
925
- if (plan.mockSuggestions.length > 0) {
926
- lines.push(`### Mock Suggestions`);
927
- for (const m of plan.mockSuggestions) {
928
- lines.push(` ${m.dependency}: ${m.strategy} — ${m.reason}`);
929
- }
930
- lines.push('');
931
- }
932
- if (plan.suggestedTests.length > 0) {
933
- lines.push(`### Suggested Tests`);
934
- for (const t of plan.suggestedTests) {
935
- lines.push(` [${t.type}] ${t.description}`);
936
- if (t.mocksNeeded.length > 0)
937
- lines.push(` mocks: ${t.mocksNeeded.join(', ')}`);
938
- }
939
- lines.push('');
940
- }
941
- if (plan.callers.length > 0) {
942
- lines.push(`### Callers (for integration context)`);
943
- for (const c of plan.callers.slice(0, 10)) {
944
- lines.push(` ${c.name} [${c.kind}] ${c.filePath}`);
945
- }
946
- if (plan.callers.length > 10)
947
- lines.push(` ... and ${plan.callers.length - 10} more`);
948
- }
949
- return { content: [{ type: 'text', text: lines.join('\n') }] };
950
- });
951
- // ── Tool: test_coverage_gaps ──
952
- server.tool('test_coverage_gaps', 'Find exported symbols without test coverage, sorted by risk (hub functions first). Shows what most needs tests.', {
953
- file: z.string().optional().describe('Scope to a specific file (relative to repo root)'),
954
- limit: z.number().optional().default(30),
955
- repo: z.string().optional(),
956
- }, async ({ file, limit, repo }) => {
957
- const { db } = getDb(repo);
958
- const gaps = findCoverageGaps(db, file, limit);
959
- if (gaps.length === 0) {
960
- return { content: [{ type: 'text', text: file ? `No coverage gaps in "${file}".` : 'No untested exported symbols found.' }] };
961
- }
962
- const lines = [`${gaps.length} untested exported symbols:\n`];
963
- for (const g of gaps) {
964
- lines.push(`[${g.riskIfUntested}] ${fmtSymbol(g.symbol)} → ${g.dependents} dependents`);
965
- }
966
- return { content: [{ type: 'text', text: lines.join('\n') }] };
967
- });
968
- // ── Tool: test_impact ──
969
- server.tool('test_impact', 'Which tests to run for current changes? Maps changed symbols → test files that reference them (directly or via callers).', {
970
- ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
971
- repo: z.string().optional(),
972
- }, async ({ ref, repo }) => {
973
- const { db, root } = getDb(repo);
974
- const result = analyzeTestImpact(db, root, ref);
975
- if (result.changedSymbols.length === 0 && result.mustRun.length === 0) {
976
- return { content: [{ type: 'text', text: 'No changed symbols detected.' }] };
977
- }
978
- const lines = [];
979
- if (result.mustRun.length > 0) {
980
- lines.push(`### Must Run (${result.mustRun.length})`);
981
- for (const f of result.mustRun)
982
- lines.push(` ✓ ${f}`);
983
- lines.push('');
984
- }
985
- if (result.shouldRun.length > 0) {
986
- lines.push(`### Should Run (${result.shouldRun.length}) — indirect coverage`);
987
- for (const f of result.shouldRun)
988
- lines.push(` ~ ${f}`);
989
- lines.push('');
990
- }
991
- if (result.coverageGaps.length > 0) {
992
- lines.push(`### Coverage Gaps (${result.coverageGaps.length})`);
993
- for (const g of result.coverageGaps) {
994
- lines.push(` ⚠ ${g.name} (${g.filePath}) — ${g.dependents} dependents, no test`);
995
- }
996
- lines.push('');
997
- }
998
- lines.push(`${result.changedSymbols.length} symbols changed, ${result.mustRun.length} tests must run, ${result.shouldRun.length} tests should run`);
999
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1000
- });
1001
- // ── Tool: annotate ──
1002
- server.tool('annotate', 'Store an observation/note about a symbol. Annotations persist across sessions and are visible via context() and recall().', {
1003
- symbol: z.string().describe('Symbol name to annotate'),
1004
- key: z.string().describe('Annotation key (e.g. "perf", "todo", "note", "risk")'),
1005
- value: z.string().describe('Annotation value/note'),
1006
- agent: z.string().optional().describe('Agent name that created this annotation'),
1007
- session_id: z.string().optional().describe('Session ID for grouping'),
1008
- ttl_hours: z.number().optional().describe('Time-to-live in hours (default: permanent)'),
1009
- repo: z.string().optional(),
1010
- }, async ({ symbol, key, value, agent, session_id, ttl_hours, repo }) => {
1011
- const { db } = getDb(repo);
1012
- const syms = db.findSymbolByName(symbol);
1013
- if (syms.length === 0) {
1014
- return { content: [{ type: 'text', text: `"${symbol}" not found in index.` }] };
1015
- }
1016
- const sym = syms[0];
1017
- const id = db.addAnnotation(sym.id, key, value, agent, session_id, ttl_hours);
1018
- return { content: [{ type: 'text', text: `Annotation #${id} stored: ${sym.name}.${key} = "${value}"` }] };
1019
- });
1020
- // ── Tool: recall ──
1021
- server.tool('recall', 'Retrieve annotations/notes about symbols. Filter by symbol, key, agent, or session.', {
1022
- symbol: z.string().optional().describe('Symbol name to filter by'),
1023
- key: z.string().optional().describe('Annotation key to filter by'),
1024
- agent: z.string().optional().describe('Agent name to filter by'),
1025
- session_id: z.string().optional().describe('Session ID to filter by'),
1026
- limit: z.number().optional().default(30),
1027
- repo: z.string().optional(),
1028
- }, async ({ symbol, key, agent, session_id, limit, repo }) => {
1029
- const { db } = getDb(repo);
1030
- let symbolId;
1031
- if (symbol) {
1032
- const syms = db.findSymbolByName(symbol);
1033
- if (syms.length > 0)
1034
- symbolId = syms[0].id;
1035
- }
1036
- const annotations = db.getAnnotations({ symbolId, key, agent, sessionId: session_id, limit });
1037
- if (annotations.length === 0) {
1038
- return { content: [{ type: 'text', text: 'No annotations found.' }] };
1039
- }
1040
- const lines = [`${annotations.length} annotations:\n`];
1041
- for (const a of annotations) {
1042
- const agentTag = a.agent ? ` [${a.agent}]` : '';
1043
- lines.push(`#${a.id} ${a.symbolId.split('#')[0]}::${a.key} = "${a.value}"${agentTag} (${a.createdAt})`);
1044
- }
1045
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1046
- });
1047
- // ── Tool: session_start ──
1048
- server.tool('session_start', 'Register a new agent session for multi-agent coordination. Returns session ID.', {
1049
- agent: z.string().describe('Agent name/identifier'),
1050
- context: z.string().optional().describe('Initial context JSON for the session'),
1051
- repo: z.string().optional(),
1052
- }, async ({ agent, context, repo }) => {
1053
- const { db } = getDb(repo);
1054
- const id = randomUUID();
1055
- db.startSession(id, agent, context);
1056
- return { content: [{ type: 'text', text: `Session started: ${id} (agent: ${agent})` }] };
1057
- });
1058
- // ── Tool: session_context ──
1059
- server.tool('session_context', 'Get full context for an agent session: metadata + all annotations created during it.', {
1060
- session_id: z.string().describe('Session ID'),
1061
- repo: z.string().optional(),
1062
- }, async ({ session_id, repo }) => {
1063
- const { db } = getDb(repo);
1064
- const session = db.getSession(session_id);
1065
- if (!session) {
1066
- return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] };
1067
- }
1068
- const annotations = db.getAnnotations({ sessionId: session_id, limit: 100 });
1069
- const lines = [
1070
- `## Session: ${session.id}`,
1071
- `agent: ${session.agent}, status: ${session.status}`,
1072
- `started: ${session.startedAt}${session.endedAt ? `, ended: ${session.endedAt}` : ''}`,
1073
- ];
1074
- if (session.context)
1075
- lines.push(`context: ${session.context}`);
1076
- if (annotations.length > 0) {
1077
- lines.push('', `### Annotations (${annotations.length})`);
1078
- for (const a of annotations) {
1079
- lines.push(` ${a.symbolId.split('#')[0]}::${a.key} = "${a.value}"`);
1080
- }
1081
- }
1082
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1083
- });
1084
- // ── Tool: handoff ──
1085
- server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one with carried-over context.', {
1086
- from_session: z.string().describe('Source session ID to transfer from'),
1087
- to_agent: z.string().describe('Target agent name'),
1088
- context: z.string().optional().describe('Additional context to pass'),
1089
- repo: z.string().optional(),
1090
- }, async ({ from_session, to_agent, context, repo }) => {
1091
- const { db } = getDb(repo);
1092
- const fromSession = db.getSession(from_session);
1093
- if (!fromSession) {
1094
- return { content: [{ type: 'text', text: `Source session "${from_session}" not found.` }] };
1095
- }
1096
- // End source session
1097
- db.endSession(from_session, 'completed');
1098
- // Gather annotations from source
1099
- const annotations = db.getAnnotations({ sessionId: from_session, limit: 100 });
1100
- // Build handoff context
1101
- const handoffContext = JSON.stringify({
1102
- from: { session: from_session, agent: fromSession.agent },
1103
- original_context: fromSession.context ? (() => { try {
1104
- return JSON.parse(fromSession.context);
1105
- }
1106
- catch {
1107
- return fromSession.context;
1108
- } })() : null,
1109
- additional_context: context ?? null,
1110
- annotations_count: annotations.length,
1111
- });
1112
- // Create new session
1113
- const newId = randomUUID();
1114
- db.startSession(newId, to_agent, handoffContext);
1115
- return { content: [{ type: 'text', text: `Handoff complete: ${fromSession.agent} → ${to_agent}\nNew session: ${newId}\nCarried: ${annotations.length} annotations` }] };
1116
- });
1117
- // ── Tool: codebase_summary ──
1118
- server.tool('codebase_summary', 'High-level codebase context for agent bootstrapping: domains, key symbols, recent activity, active annotations. Designed as the first tool call for new agent sessions.', {
1119
- repo: z.string().optional(),
1120
- }, async ({ repo }) => {
1121
- const { db, root, lazy } = getDb(repo);
1122
- const stats = lazy.getCachedStats();
1123
- const domains = lazy.getCachedDomainStats();
1124
- const coverage = db.getTestCoverage();
1125
- const lines = [
1126
- `## Codebase Summary`,
1127
- `${stats.symbols} symbols, ${stats.links} links, ${stats.files} files`,
1128
- '',
1129
- ];
1130
- // Domains
1131
- if (domains.length > 0) {
1132
- lines.push(`### Domains`);
1133
- for (const d of domains) {
1134
- lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols`);
1135
- }
1136
- lines.push('');
1137
- }
1138
- // Top symbols by heat
1139
- const allSyms = db.getTopSymbolsByHeat(10);
1140
- if (allSyms.length > 0) {
1141
- lines.push(`### Key Symbols (top 10 by heat)`);
1142
- for (const s of allSyms) {
1143
- lines.push(` ${fmtSymbol(s, 'L2')}`);
1144
- }
1145
- lines.push('');
1146
- }
1147
- // Test coverage
1148
- if (coverage.exportedProductionSymbols > 0) {
1149
- const pct = Math.round((coverage.testedSymbols / coverage.exportedProductionSymbols) * 100);
1150
- lines.push(`### Test Coverage: ${pct}% (${coverage.testedSymbols}/${coverage.exportedProductionSymbols} exported symbols tested)`);
1151
- lines.push('');
1152
- }
1153
- // Active annotations
1154
- const annotations = db.getAnnotations({ limit: 10 });
1155
- if (annotations.length > 0) {
1156
- lines.push(`### Recent Annotations (${annotations.length})`);
1157
- for (const a of annotations) {
1158
- const agentTag = a.agent ? ` [${a.agent}]` : '';
1159
- lines.push(` ${a.symbolId.split('#')[0]}::${a.key} = "${a.value}"${agentTag}`);
1160
- }
1161
- lines.push('');
1162
- }
1163
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1164
- });
1165
786
  // ── Tool: explain_relationship ──
1166
787
  server.tool('explain_relationship', 'Shortest dependency path between two symbols.', {
1167
788
  from: z.string().describe('Source symbol name'),
@@ -1212,13 +833,10 @@ export function createMcpServer(rootPath) {
1212
833
  const sorted = detail === 'L2'
1213
834
  ? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
1214
835
  : symbols;
1215
- // Batch-fetch link counts to avoid N+1 queries
1216
- const linkCounts = detail === 'L0'
1217
- ? new Map()
1218
- : db.getLinkCountsForSymbols(sorted.map(s => s.id));
1219
836
  const lines = [`${file}: ${symbols.length} symbols\n`];
1220
837
  for (const sym of sorted) {
1221
- const counts = linkCounts.get(sym.id) ?? { incoming: 0, outgoing: 0 };
838
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
839
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1222
840
  const exp = sym.exported ? ' (exported)' : '';
1223
841
  if (detail === 'L0') {
1224
842
  lines.push(`${sym.name} [${sym.kind}]${exp}`);
@@ -1230,10 +848,10 @@ export function createMcpServer(rootPath) {
1230
848
  if (sym.heat != null && sym.heat > 0)
1231
849
  meta.push(`heat:${sym.heat}`);
1232
850
  const metaStr = meta.length > 0 ? ` {${meta.join(',')}}` : '';
1233
- lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${counts.incoming} refs, → ${counts.outgoing} deps`);
851
+ lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1234
852
  }
1235
853
  else {
1236
- lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${counts.incoming} refs, → ${counts.outgoing} deps`);
854
+ lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1237
855
  }
1238
856
  }
1239
857
  return { content: [{ type: 'text', text: lines.join('\n') }] };
@@ -1299,10 +917,8 @@ export function createMcpServer(rootPath) {
1299
917
  sections.push(`callers: none`);
1300
918
  }
1301
919
  // 3. Export chain — is this re-exported from barrel files?
1302
- const grepMatches = await grepFiles(root, name, { maxResults: 10, includePattern: '{**/index.{ts,js,mjs},**/__init__.py}' });
1303
- const reExportMatches = grepMatches.filter(m => (/export\s*\{[^}]*/.test(m.text) && m.text.includes('from')) ||
1304
- /from\s+\./.test(m.text) // Python re-export: from .module import X
1305
- );
920
+ const grepMatches = grepFiles(root, name, { maxResults: 10, includePattern: '**/index.{ts,js,mjs}' });
921
+ const reExportMatches = grepMatches.filter(m => /export\s*\{[^}]*/.test(m.text) && m.text.includes('from'));
1306
922
  if (reExportMatches.length > 0) {
1307
923
  sections.push(`re-exported via:`);
1308
924
  for (const m of reExportMatches) {
@@ -1317,27 +933,30 @@ export function createMcpServer(rootPath) {
1317
933
  sections.push(` ${fmtSymbol(d)}`);
1318
934
  }
1319
935
  }
1320
- // 5. Test coverage (reuse incoming from step 2)
1321
- const allIncoming = db.getIncomingLinks(sym.id);
1322
- const testFiles = new Set();
1323
- for (const l of allIncoming) {
936
+ }
937
+ // 5. Unresolved warning (only for internal)
938
+ const unresolved = db.getUnresolvedStats();
939
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
940
+ sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
941
+ }
942
+ // 6. Test coverage for this symbol
943
+ for (const sym of symbols) {
944
+ const incoming = db.getIncomingLinks(sym.id);
945
+ const testRefs = incoming.filter(l => {
1324
946
  const from = db.findSymbolById(l.fromId);
1325
- if (from && isTestFile(from.filePath)) {
1326
- testFiles.add(from.filePath);
1327
- }
1328
- }
1329
- if (testFiles.size > 0) {
1330
- sections.push(`✓ tested from: ${[...testFiles].join(', ')}`);
947
+ return from && isTestFilePath(from.filePath);
948
+ });
949
+ if (testRefs.length > 0) {
950
+ const testFiles = [...new Set(testRefs.map(l => {
951
+ const from = db.findSymbolById(l.fromId);
952
+ return from?.filePath;
953
+ }).filter(Boolean))];
954
+ sections.push(`✓ tested from: ${testFiles.join(', ')}`);
1331
955
  }
1332
956
  else if (sym.exported) {
1333
957
  sections.push(`⚠ no test coverage for this exported symbol`);
1334
958
  }
1335
959
  }
1336
- // 6. Unresolved warning (only for internal)
1337
- const unresolved = db.getUnresolvedStats();
1338
- if (unresolved.imports > 0 || unresolved.calls > 0) {
1339
- sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
1340
- }
1341
960
  return { content: [{ type: 'text', text: sections.join('\n') }] };
1342
961
  });
1343
962
  // ── Tool: trace ──
@@ -1399,9 +1018,9 @@ export function createMcpServer(rootPath) {
1399
1018
  return { content: [{ type: 'text', text: sections.join('\n') }] };
1400
1019
  });
1401
1020
  // ── Tool: routes ──
1402
- server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Django, Go HTTP, Gin, PHP, Rails, Sinatra, Spring patterns.', {
1021
+ server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
1403
1022
  repo: z.string().optional(),
1404
- framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, django, go, gin, php, rails, sinatra, spring). Default: auto-detect all.'),
1023
+ framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
1405
1024
  limit: z.number().optional().default(50),
1406
1025
  }, async ({ repo, framework, limit }) => {
1407
1026
  const root = resolveRoot(repo);
@@ -1411,24 +1030,20 @@ export function createMcpServer(rootPath) {
1411
1030
  { name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
1412
1031
  { name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1413
1032
  { name: 'flask', pattern: /@(?:app|bp|blueprint)\.(route|get|post|put|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1414
- { name: 'django', pattern: /\b(path|re_path|url)\s*\(\s*r?['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1415
1033
  { name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
1416
1034
  { name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
1417
- { name: 'gin', pattern: /\b\w+\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|Any|Handle)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
1418
1035
  { name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
1419
1036
  { name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
1420
- { name: 'sinatra', pattern: /\b(get|post|put|patch|delete)\s+['"]([^'"]+)['"]\s+do/, fileGlob: '**/*.rb' },
1421
- { name: 'spring', pattern: /@(RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?['"]([^'"]+)['"]/, fileGlob: '**/*.java' },
1422
1037
  ];
1423
1038
  const activePatterns = framework
1424
1039
  ? routePatterns.filter(p => p.name === framework.toLowerCase())
1425
1040
  : routePatterns;
1426
1041
  if (activePatterns.length === 0) {
1427
- return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, django, go, gin, php, rails, sinatra, spring` }] };
1042
+ return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
1428
1043
  }
1429
1044
  const routes = [];
1430
1045
  for (const rp of activePatterns) {
1431
- const matches = await grepFiles(root, rp.pattern.source, {
1046
+ const matches = grepFiles(root, rp.pattern.source, {
1432
1047
  isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
1433
1048
  });
1434
1049
  for (const m of matches) {
@@ -1541,15 +1156,15 @@ export function createMcpServer(rootPath) {
1541
1156
  }
1542
1157
  }
1543
1158
  // Re-export detection
1544
- const reExportMatches = (await grepFiles(root, name, { maxResults: 5, includePattern: '{**/index.{ts,js,mjs},**/__init__.py}' }))
1545
- .filter(m => (/export\s*\{/.test(m.text) && m.text.includes('from')) || /from\s+\./.test(m.text));
1159
+ const reExportMatches = grepFiles(root, name, { maxResults: 5, includePattern: '**/index.{ts,js,mjs}' })
1160
+ .filter(m => /export\s*\{/.test(m.text) && m.text.includes('from'));
1546
1161
  if (reExportMatches.length > 0) {
1547
1162
  sections.push(`re-exported via: ${reExportMatches.map(m => `${m.file}:${m.line}`).join(', ')}`);
1548
1163
  }
1549
1164
  // Test coverage
1550
1165
  const testRefs = incoming.filter(l => {
1551
1166
  const from = db.findSymbolById(l.fromId);
1552
- return from && isTestFile(from.filePath);
1167
+ return from && isTestFilePath(from.filePath);
1553
1168
  });
1554
1169
  if (testRefs.length > 0) {
1555
1170
  sections.push(`✓ has test coverage`);
@@ -1571,18 +1186,17 @@ export function createMcpServer(rootPath) {
1571
1186
  else {
1572
1187
  sections.push(`no call chains found (may be entrypoint or unreachable)`);
1573
1188
  }
1574
- // Fetch outgoing once, split by type
1575
- const allOutgoing = db.getOutgoingLinks(sym.id);
1576
- const callLinks = allOutgoing.filter(l => l.type === 'calls');
1577
- if (callLinks.length > 0) {
1578
- sections.push(`calls (${callLinks.length}):`);
1579
- for (const l of callLinks) {
1189
+ // What does this call? (downstream immediate)
1190
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type === 'calls');
1191
+ if (outgoing.length > 0) {
1192
+ sections.push(`calls (${outgoing.length}):`);
1193
+ for (const l of outgoing) {
1580
1194
  const to = db.findSymbolById(l.toId);
1581
1195
  sections.push(` ${to ? fmtSymbol(to) : l.toId}`);
1582
1196
  }
1583
1197
  }
1584
- // Data types used (reuse allOutgoing)
1585
- const dataTypes = allOutgoing
1198
+ // Data types used
1199
+ const dataTypes = db.getOutgoingLinks(sym.id)
1586
1200
  .filter(l => l.type === 'imports')
1587
1201
  .map(l => db.findSymbolById(l.toId))
1588
1202
  .filter(s => s && (s.kind === 'interface' || s.kind === 'type' || s.kind === 'class'))
@@ -1592,33 +1206,47 @@ export function createMcpServer(rootPath) {
1592
1206
  }
1593
1207
  }
1594
1208
  else if (intent === 'test') {
1595
- // Test coverage + what to mock — pre-resolve all link symbols
1209
+ // Test coverage + what to mock
1596
1210
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1597
- const incomingResolved = incoming.map(l => ({ link: l, sym: db.findSymbolById(l.fromId) }));
1598
- const testRefs = incomingResolved.filter(r => r.sym && isTestFile(r.sym.filePath));
1211
+ const testRefs = incoming.filter(l => {
1212
+ const from = db.findSymbolById(l.fromId);
1213
+ return from && isTestFilePath(from.filePath);
1214
+ });
1599
1215
  if (testRefs.length > 0) {
1600
- const testFiles = [...new Set(testRefs.map(r => r.sym.filePath))];
1216
+ const testFiles = [...new Set(testRefs.map(l => {
1217
+ const from = db.findSymbolById(l.fromId);
1218
+ return from?.filePath;
1219
+ }).filter(Boolean))];
1601
1220
  sections.push(`✓ tested from: ${testFiles.join(', ')}`);
1602
1221
  }
1603
1222
  else {
1604
1223
  sections.push(`⚠ no existing tests`);
1605
1224
  }
1606
- // Dependencies to mock — pre-resolve outgoing symbols
1225
+ // Dependencies to mock
1607
1226
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1608
- const outgoingResolved = outgoing.map(l => ({ link: l, sym: db.findSymbolById(l.toId) }));
1609
- const externalDeps = outgoingResolved.filter(r => r.sym && r.sym.filePath !== sym.filePath);
1227
+ const externalDeps = outgoing.filter(l => {
1228
+ const to = db.findSymbolById(l.toId);
1229
+ return to && to.filePath !== sym.filePath;
1230
+ });
1610
1231
  if (externalDeps.length > 0) {
1611
1232
  sections.push(`dependencies to mock (${externalDeps.length}):`);
1612
- for (const r of externalDeps) {
1613
- sections.push(` ${r.link.type}: ${fmtSymbol(r.sym)}`);
1233
+ for (const l of externalDeps) {
1234
+ const to = db.findSymbolById(l.toId);
1235
+ if (to)
1236
+ sections.push(` ${l.type}: ${fmtSymbol(to)}`);
1614
1237
  }
1615
1238
  }
1616
1239
  // Inputs — what calls this? (test should cover these call patterns)
1617
- const nonTestCallers = incomingResolved.filter(r => r.sym && !isTestFile(r.sym.filePath));
1240
+ const nonTestCallers = incoming.filter(l => {
1241
+ const from = db.findSymbolById(l.fromId);
1242
+ return from && !isTestFilePath(from.filePath);
1243
+ });
1618
1244
  if (nonTestCallers.length > 0) {
1619
1245
  sections.push(`callers to cover (${nonTestCallers.length}):`);
1620
- for (const r of nonTestCallers.slice(0, 5)) {
1621
- sections.push(` ${fmtSymbol(r.sym)}`);
1246
+ for (const l of nonTestCallers.slice(0, 5)) {
1247
+ const from = db.findSymbolById(l.fromId);
1248
+ if (from)
1249
+ sections.push(` ${fmtSymbol(from)}`);
1622
1250
  }
1623
1251
  }
1624
1252
  }
@@ -1707,115 +1335,283 @@ export function createMcpServer(rootPath) {
1707
1335
  return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
1708
1336
  }
1709
1337
  });
1710
- // ── Tool: semantic_search ──
1711
- server.tool('semantic_search', 'Hybrid search combining FTS5 text matching and vector cosine similarity (Reciprocal Rank Fusion). Requires --embeddings flag during analyze. Falls back to FTS5-only if no embeddings exist.', {
1712
- query: z.string().describe('Natural language or keyword query'),
1713
- limit: z.number().optional().describe('Max results (default 15)'),
1714
- repo: z.string().optional(),
1715
- }, async ({ query, limit, repo }) => {
1716
- const startMs = Date.now();
1717
- const maxResults = limit ?? 15;
1718
- const { db, lazy } = getDb(repo);
1719
- // FTS5 results
1720
- const ftsResults = db.searchSymbols(query, maxResults * 2);
1721
- // Check for embeddings
1722
- let hasEmbeddings = false;
1338
+ // ═══ codebase_summary ═══
1339
+ server.tool('codebase_summary', 'Compact ~500 token codebase overview: domains, top hubs, test coverage, annotations count. Use at the start of every session.', { repo: z.string().optional() }, async ({ repo }) => {
1340
+ const { db } = getDb(repo);
1341
+ const summary = db.getCodebaseSummary();
1342
+ const lines = [
1343
+ 'Milens Codebase Summary:',
1344
+ ` Symbols: ${summary.symbols} | Links: ${summary.links} | Files: ${summary.files}`,
1345
+ ];
1346
+ if (summary.exportedSymbols > 0) {
1347
+ lines.push(` Test coverage: ${summary.coveragePct}% (${summary.testedSymbols}/${summary.exportedSymbols} exported symbols tested)`);
1348
+ }
1349
+ if (summary.domains.length > 0) {
1350
+ const domainStr = summary.domains.map(d => `${d.domain}(${d.symbols}s)`).join(', ');
1351
+ lines.push(` Domains: ${domainStr}`);
1352
+ }
1353
+ if (summary.topHubs.length > 0) {
1354
+ const hubStr = summary.topHubs.map(h => `${h.name}(${h.kind},heat:${h.heat})`).join(', ');
1355
+ lines.push(` Top hubs: ${hubStr}`);
1356
+ }
1723
1357
  try {
1724
- const row = db.getRawDb().prepare('SELECT COUNT(*) as c FROM symbol_embeddings').get();
1725
- hasEmbeddings = row && row.c > 0;
1726
- }
1727
- catch { /* table may not exist in old DBs */ }
1728
- if (!hasEmbeddings) {
1729
- // FTS-only fallback
1730
- const lines = ftsResults.slice(0, maxResults).map(s => fmtSymbol(s, 'L2'));
1731
- const text = lines.length > 0
1732
- ? `${lines.length} results (FTS only, run \`milens analyze --embeddings\` for hybrid):\n${lines.join('\n')}`
1733
- : 'No results.';
1734
- trackToolCall(trackDb, 'semantic_search', startMs, text, repo);
1735
- return { content: [{ type: 'text', text }] };
1736
- }
1737
- // Vector results use cached TF-IDF provider (trained once per DB session)
1738
- const { provider, store } = lazy.getTfidf();
1739
- const queryVec = await provider.embed(query);
1740
- const vectorResults = store.searchSimilar(queryVec, maxResults * 2);
1741
- // Reciprocal Rank Fusion (k=60)
1742
- const k = 60;
1743
- const scores = new Map();
1744
- ftsResults.forEach((sym, rank) => {
1745
- const rrf = 1 / (k + rank + 1);
1746
- const entry = scores.get(sym.id) ?? { score: 0, symbol: sym };
1747
- entry.score += rrf;
1748
- scores.set(sym.id, entry);
1749
- });
1750
- vectorResults.forEach((vr, rank) => {
1751
- const rrf = 1 / (k + rank + 1);
1752
- const entry = scores.get(vr.symbolId);
1753
- if (entry) {
1754
- entry.score += rrf;
1358
+ const annCount = db.getAnnotationCount();
1359
+ lines.push(` Total annotations: ${annCount}`);
1360
+ }
1361
+ catch {
1362
+ lines.push(' Total annotations: 0');
1363
+ }
1364
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1365
+ });
1366
+ // ═══ review_pr ═══
1367
+ server.tool('review_pr', 'PR risk assessment: git diff -> affected symbols with risk scores (LOW/MEDIUM/HIGH/CRITICAL).', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
1368
+ const { db, root } = getDb(repo);
1369
+ let changedFiles = [];
1370
+ try {
1371
+ const { execSync } = await import('node:child_process');
1372
+ const diff = execSync(`git diff --name-only ${ref}`, { cwd: root, encoding: 'utf-8' }).trim();
1373
+ changedFiles = diff ? diff.split('\n').filter(Boolean) : [];
1374
+ }
1375
+ catch { }
1376
+ if (changedFiles.length === 0) {
1377
+ return { content: [{ type: 'text', text: 'No changed files detected.' }] };
1378
+ }
1379
+ const allAffected = [];
1380
+ for (const file of changedFiles) {
1381
+ const syms = db.getSymbolsByFile(file);
1382
+ if (syms.length === 0)
1383
+ continue;
1384
+ for (const sym of syms) {
1385
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1386
+ const depsCount = incoming.length;
1387
+ const heat = sym.heat ?? 0;
1388
+ const hasTest = db.getSymbolTestCoverage(sym.id);
1389
+ const score = Math.round((heat / 100) * 40 + Math.min(depsCount / 10, 1) * 35 + (hasTest ? 0 : 25));
1390
+ let level = 'LOW';
1391
+ if (score > 75)
1392
+ level = 'CRITICAL';
1393
+ else if (score > 50)
1394
+ level = 'HIGH';
1395
+ else if (score > 25)
1396
+ level = 'MEDIUM';
1397
+ allAffected.push({ symbol: sym.name, kind: sym.kind, file: sym.filePath, heat, dependents: depsCount, hasTest, riskScore: score, riskLevel: level });
1398
+ }
1399
+ }
1400
+ allAffected.sort((a, b) => b.riskScore - a.riskScore);
1401
+ const summary = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
1402
+ for (const a of allAffected)
1403
+ summary[a.riskLevel]++;
1404
+ const lines = [`PR Risk Assessment (vs ${ref}):\n`];
1405
+ lines.push(`${changedFiles.length} changed files, ${allAffected.length} affected symbols\n`);
1406
+ for (const a of allAffected.slice(0, 30)) {
1407
+ lines.push(` ${a.symbol} [${a.kind}] ${a.file} — heat:${a.heat} deps:${a.dependents} test:${a.hasTest ? 'yes' : 'no'} → ${a.riskLevel}(${a.riskScore})`);
1408
+ }
1409
+ lines.push(`\nSummary: CRITICAL=${summary.CRITICAL} HIGH=${summary.HIGH} MEDIUM=${summary.MEDIUM} LOW=${summary.LOW}`);
1410
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1411
+ });
1412
+ // ═══ review_symbol ═══
1413
+ server.tool('review_symbol', 'Deep-dive single symbol risk: role, heat, dependents, test status, risk level.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
1414
+ const { db, root } = getDb(repo);
1415
+ const syms = db.findSymbolByName(name);
1416
+ if (syms.length === 0)
1417
+ return { content: [{ type: 'text', text: `"${name}" not found.` }] };
1418
+ const lines = [];
1419
+ for (const sym of syms) {
1420
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1421
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1422
+ const depsCount = incoming.length;
1423
+ const depsTop = incoming.slice(0, 5).map(l => { const s = db.findSymbolById(l.fromId); return s?.name ?? l.fromId; });
1424
+ const outCount = outgoing.length;
1425
+ const outTop = outgoing.slice(0, 5).map(l => { const s = db.findSymbolById(l.toId); return s?.name ?? l.toId; });
1426
+ const hasTest = sym.exported ? db.getSymbolTestCoverage(sym.id) : false;
1427
+ const heat = sym.heat ?? 0;
1428
+ const score = Math.round((heat / 100) * 40 + Math.min(depsCount / 10, 1) * 35 + (hasTest ? 0 : 25));
1429
+ let risk = 'LOW';
1430
+ if (score > 75)
1431
+ risk = 'CRITICAL';
1432
+ else if (score > 50)
1433
+ risk = 'HIGH';
1434
+ else if (score > 25)
1435
+ risk = 'MEDIUM';
1436
+ lines.push(`${sym.name} [${sym.kind}] ${sym.filePath}:${sym.startLine}`);
1437
+ lines.push(` role: ${sym.role ?? 'unknown'} | heat: ${heat} | exported: ${sym.exported}`);
1438
+ lines.push(` dependents: ${depsCount} ${depsTop.length ? '(' + depsTop.join(', ') + ')' : ''}`);
1439
+ lines.push(` dependencies: ${outCount} ${outTop.length ? '(' + outTop.join(', ') + ')' : ''}`);
1440
+ lines.push(` test coverage: ${hasTest ? 'yes' : 'no'}`);
1441
+ lines.push(` risk: ${risk} (score: ${score})`);
1442
+ if (risk === 'CRITICAL')
1443
+ lines.push(` recommendation: High risk — has ${depsCount} dependents with no test coverage. Write tests before modifying.`);
1444
+ else if (risk === 'HIGH')
1445
+ lines.push(` recommendation: Review dependents carefully before modifying.`);
1446
+ lines.push('');
1447
+ }
1448
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1449
+ });
1450
+ // ═══ test_coverage_gaps ═══
1451
+ server.tool('test_coverage_gaps', 'Untested exported symbols sorted by risk. Prioritize writing tests for these.', { limit: z.number().optional().default(20), repo: z.string().optional() }, async ({ limit, repo }) => {
1452
+ const { db } = getDb(repo);
1453
+ const coverage = db.getTestCoverage();
1454
+ const gaps = db.getTestCoverageGaps(limit);
1455
+ const lines = [`Test Coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${coverage.exportedProductionSymbols > 0 ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0}%) from ${coverage.testFiles} test files\n`];
1456
+ if (gaps.length === 0) {
1457
+ lines.push('All exported symbols have test coverage!');
1458
+ }
1459
+ else {
1460
+ lines.push(`Top ${gaps.length} untested symbols:\n`);
1461
+ for (const g of gaps) {
1462
+ const incoming = db.getIncomingLinks(g.id).filter(l => l.type !== 'contains');
1463
+ const risk = (g.heat ?? 0) > 80 ? 'CRITICAL' : (g.heat ?? 0) > 50 ? 'HIGH' : (g.heat ?? 0) > 30 ? 'MEDIUM' : 'LOW';
1464
+ lines.push(` ${g.name} [${g.kind}] ${g.filePath}:${g.startLine} — heat:${g.heat ?? 0} deps:${incoming.length} risk:${risk}`);
1755
1465
  }
1756
- else {
1757
- const sym = db.findSymbolById(vr.symbolId);
1758
- if (sym)
1759
- scores.set(vr.symbolId, { score: rrf, symbol: sym });
1466
+ }
1467
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1468
+ });
1469
+ // ═══ test_impact ═══
1470
+ server.tool('test_impact', 'Map changed code -> which test files to run. Use after making changes.', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
1471
+ const { db, root } = getDb(repo);
1472
+ let changedFiles = [];
1473
+ try {
1474
+ const { execSync } = await import('node:child_process');
1475
+ const diff = execSync(`git diff --name-only ${ref}`, { cwd: root, encoding: 'utf-8' }).trim();
1476
+ changedFiles = diff ? diff.split('\n').filter(Boolean) : [];
1477
+ }
1478
+ catch { }
1479
+ if (changedFiles.length === 0)
1480
+ return { content: [{ type: 'text', text: 'No changed files.' }] };
1481
+ const changedIds = [];
1482
+ const changedNames = [];
1483
+ for (const file of changedFiles) {
1484
+ for (const sym of db.getSymbolsByFile(file)) {
1485
+ changedIds.push(sym.id);
1486
+ changedNames.push(sym.name);
1760
1487
  }
1761
- });
1762
- const ranked = [...scores.entries()]
1763
- .sort((a, b) => b[1].score - a[1].score)
1764
- .slice(0, maxResults);
1765
- const lines = ranked.map(([, { score, symbol }]) => `${fmtSymbol(symbol, 'L2')} (score: ${score.toFixed(4)})`);
1766
- const text = lines.length > 0
1767
- ? `${lines.length} results (hybrid FTS+vector):\n${lines.join('\n')}`
1768
- : 'No results.';
1769
- trackToolCall(trackDb, 'semantic_search', startMs, text, repo);
1770
- return { content: [{ type: 'text', text }] };
1488
+ }
1489
+ if (changedIds.length === 0)
1490
+ return { content: [{ type: 'text', text: 'No symbols in changed files.' }] };
1491
+ const impact = db.getTestImpact(changedIds);
1492
+ const lines = [`Changed symbols (${changedNames.length}): ${changedNames.join(', ')}`];
1493
+ lines.push(`\nAffected test files (${impact.testFiles.length}):`);
1494
+ for (const f of impact.testFiles)
1495
+ lines.push(` ${f}`);
1496
+ if (impact.testFiles.length > 0) {
1497
+ lines.push(`\nSuggested command: npx vitest run ${impact.testFiles.join(' ')}`);
1498
+ }
1499
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1771
1500
  });
1772
- // ── Tool: find_similar ──
1773
- server.tool('find_similar', 'Find symbols similar to a given symbol by vector embedding proximity. Requires --embeddings flag during analyze.', {
1774
- name: z.string().describe('Symbol name to find similar symbols for'),
1775
- limit: z.number().optional().describe('Max results (default 10)'),
1776
- repo: z.string().optional(),
1777
- }, async ({ name, limit, repo }) => {
1778
- const startMs = Date.now();
1779
- const maxResults = limit ?? 10;
1780
- const { db, lazy } = getDb(repo);
1501
+ // ═══ test_plan ═══
1502
+ server.tool('test_plan', 'Generate a test strategy for a symbol: mock plan + >=3 test scenarios.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
1503
+ const { db } = getDb(repo);
1504
+ const plan = generateTestPlan(db, name);
1505
+ if (!plan)
1506
+ return { content: [{ type: 'text', text: `"${name}" not found.` }] };
1507
+ return { content: [{ type: 'text', text: plan.planText }] };
1508
+ });
1509
+ // ═══ annotate ═══
1510
+ server.tool('annotate', 'Record a note about a symbol for future sessions. Use after discovering bugs, patterns, or important caveats.', {
1511
+ symbol: z.string(),
1512
+ key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']),
1513
+ value: z.string(),
1514
+ agent: z.string().optional(),
1515
+ session_id: z.string().optional(),
1516
+ confidence: z.number().optional().default(0.5),
1517
+ }, async ({ symbol, key, value, agent, session_id, confidence }) => {
1518
+ const { db } = getDb();
1519
+ const store = new AnnotationStore(db.connection);
1520
+ const ann = store.annotate(symbol, key, value, { agent, sessionId: session_id });
1521
+ return { content: [{ type: 'text', text: `Annotation saved: ${ann.id}\n symbol: ${ann.symbol}\n key: ${ann.key}\n confidence: ${ann.confidence}` }] };
1522
+ });
1523
+ // ═══ recall ═══
1524
+ server.tool('recall', 'Retrieve annotations saved in previous sessions. Filter by symbol, key, or agent.', {
1525
+ symbol: z.string().optional(), key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']).optional(),
1526
+ agent: z.string().optional(), limit: z.number().optional().default(50),
1527
+ }, async ({ symbol, key, agent, limit }) => {
1528
+ const { db } = getDb();
1529
+ const store = new AnnotationStore(db.connection);
1530
+ const results = store.recall({ symbol, key, agent, limit });
1531
+ if (results.length === 0)
1532
+ return { content: [{ type: 'text', text: 'No annotations found.' }] };
1533
+ const lines = [`${results.length} annotation(s):\n`];
1534
+ for (const a of results) {
1535
+ lines.push(`[${a.key}] ${a.symbol} — ${a.value.slice(0, 120)}`);
1536
+ lines.push(` confidence: ${a.confidence.toFixed(1)} | agent: ${a.agent ?? '?'} | ${a.updatedAt}\n`);
1537
+ }
1538
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1539
+ });
1540
+ // ═══ session_start ═══
1541
+ server.tool('session_start', 'Start a new session. Returns a session ID to use with annotate, session_end, and handoff.', { agent: z.string().describe('Agent name (e.g. vibe-coder, reviewer)') }, async ({ agent }) => {
1542
+ const { db } = getDb();
1543
+ const store = new AnnotationStore(db.connection);
1544
+ const sessionId = store.sessionStart(agent);
1545
+ return { content: [{ type: 'text', text: `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().` }] };
1546
+ });
1547
+ // ═══ session_context ═══
1548
+ server.tool('session_context', 'Get metadata about a session: annotations, tool calls, duration.', { session_id: z.string() }, async ({ session_id }) => {
1549
+ const { db } = getDb();
1550
+ const store = new AnnotationStore(db.connection);
1551
+ const ctx = store.sessionContext(session_id);
1552
+ if (!ctx.session)
1553
+ return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] };
1554
+ const s = ctx.session;
1555
+ const lines = [
1556
+ `Session: ${s.id}`,
1557
+ `Agent: ${s.agent} | Status: ${s.status}`,
1558
+ `Started: ${s.startedAt} | Ended: ${s.endedAt ?? 'in progress'}`,
1559
+ `Tool calls: ${s.toolCallsCount} | Annotations: ${s.annotationsCount}`,
1560
+ ];
1561
+ if (s.context)
1562
+ lines.push(`Context: ${s.context}`);
1563
+ if (ctx.annotations.length > 0) {
1564
+ lines.push(`\nAnnotations (${ctx.annotations.length}):`);
1565
+ for (const a of ctx.annotations) {
1566
+ lines.push(` [${a.key}] ${a.symbol}: ${a.value.slice(0, 80)}`);
1567
+ }
1568
+ }
1569
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1570
+ });
1571
+ // ═══ session_end ═══
1572
+ server.tool('session_end', 'End a session and record its stats. Use at the end of every session.', { session_id: z.string(), status: z.enum(['completed', 'failed']).optional().default('completed') }, async ({ session_id, status }) => {
1573
+ const { db } = getDb();
1574
+ const store = new AnnotationStore(db.connection);
1575
+ const summary = store.sessionEnd(session_id, status);
1576
+ return { content: [{ type: 'text', text: `Session ended: ${session_id}\nStatus: ${status}\nAnnotations: ${summary.annotationCount}` }] };
1577
+ });
1578
+ // ═══ handoff ═══
1579
+ server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one for the target agent.', {
1580
+ from_session: z.string(), to_agent: z.string(),
1581
+ context: z.string().describe('Summary of what was done, key decisions, and caveats for the next agent'),
1582
+ }, async ({ from_session, to_agent, context }) => {
1583
+ const { db } = getDb();
1584
+ const store = new AnnotationStore(db.connection);
1585
+ const result = store.handoff(from_session, to_agent, context);
1586
+ return { content: [{ type: 'text', text: `Handoff complete.\nNew session: ${result.newSessionId}\nAgent: ${to_agent}\nAnnotations copied: ${result.annotationsCopied}` }] };
1587
+ });
1588
+ // ═══ semantic_search ═══
1589
+ server.tool('semantic_search', 'Search symbols by semantic meaning (falls back to FTS5 keyword search when embeddings unavailable).', { query: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ query, limit, repo }) => {
1590
+ const { db } = getDb(repo);
1591
+ if (db.searchSymbols(query, limit).length > 0) {
1592
+ const results = db.searchSymbols(query, limit);
1593
+ const lines = [`Semantic search (FTS5 fallback — embeddings not available):\n`];
1594
+ for (const s of results) {
1595
+ lines.push(`${s.name} [${s.kind}] ${s.filePath}:${s.startLine}${s.exported ? ' (exported)' : ''}`);
1596
+ }
1597
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1598
+ }
1599
+ return { content: [{ type: 'text', text: `No results for "${query}". Embeddings not available. Run \`milens analyze --embeddings\` for semantic search.` }] };
1600
+ });
1601
+ // ═══ find_similar ═══
1602
+ server.tool('find_similar', 'Find symbols topologically similar to a given symbol (shared callers/callees). Useful for finding patterns to copy or refactor together.', { name: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ name, limit, repo }) => {
1603
+ const { db } = getDb(repo);
1781
1604
  const syms = db.findSymbolByName(name);
1782
- if (syms.length === 0) {
1783
- const text = `Symbol "${name}" not found.`;
1784
- trackToolCall(trackDb, 'find_similar', startMs, text, repo);
1785
- return { content: [{ type: 'text', text }] };
1786
- }
1787
- const targetSym = syms[0];
1788
- // Check for embeddings
1789
- let hasEmbeddings = false;
1790
- try {
1791
- const row = db.getRawDb().prepare('SELECT COUNT(*) as c FROM symbol_embeddings').get();
1792
- hasEmbeddings = row && row.c > 0;
1793
- }
1794
- catch { /* table may not exist */ }
1795
- if (!hasEmbeddings) {
1796
- const text = 'No embeddings found. Run `milens analyze --embeddings` first.';
1797
- trackToolCall(trackDb, 'find_similar', startMs, text, repo);
1798
- return { content: [{ type: 'text', text }] };
1799
- }
1800
- // Use cached TF-IDF provider (trained once per DB session)
1801
- const { provider, store } = lazy.getTfidf();
1802
- // Get or compute target embedding
1803
- let targetVec = store.get(targetSym.id);
1804
- if (!targetVec) {
1805
- targetVec = await provider.embed(buildEmbeddingText({
1806
- name: targetSym.name, kind: targetSym.kind, filePath: targetSym.filePath, signature: targetSym.signature,
1807
- }));
1808
- }
1809
- const similar = store.searchSimilar(targetVec, maxResults, targetSym.id);
1810
- const lines = [`Similar to ${fmtSymbol(targetSym, 'L1')}:\n`];
1811
- for (const { symbolId, score } of similar) {
1812
- const sym = db.findSymbolById(symbolId);
1813
- if (sym)
1814
- lines.push(` ${(score * 100).toFixed(1)}% ${fmtSymbol(sym, 'L2')}`);
1815
- }
1816
- const text = lines.join('\n');
1817
- trackToolCall(trackDb, 'find_similar', startMs, text, repo);
1818
- return { content: [{ type: 'text', text }] };
1605
+ if (syms.length === 0)
1606
+ return { content: [{ type: 'text', text: `"${name}" not found.` }] };
1607
+ const results = db.findTopologicallySimilar(syms[0].id, limit);
1608
+ if (results.length === 0)
1609
+ return { content: [{ type: 'text', text: `No similar symbols found for "${name}".` }] };
1610
+ const lines = [`Symbols similar to "${name}":\n`];
1611
+ for (const r of results) {
1612
+ lines.push(` ${r.symbol.name} [${r.symbol.kind}] ${r.symbol.filePath}:${r.symbol.startLine} — similarity: ${r.similarity.toFixed(2)}`);
1613
+ }
1614
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1819
1615
  });
1820
1616
  // ══════════════════════════════════════════════
1821
1617
  // ── MCP Resources ──
@@ -1859,11 +1655,11 @@ export function createMcpServer(rootPath) {
1859
1655
  return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
1860
1656
  }
1861
1657
  const lines = [`${filePath}: ${symbols.length} symbols\n`];
1862
- const linkCounts = db.getLinkCountsForSymbols(symbols.map(s => s.id));
1863
1658
  for (const sym of symbols) {
1864
- const counts = linkCounts.get(sym.id) ?? { incoming: 0, outgoing: 0 };
1659
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1660
+ const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1865
1661
  const exp = sym.exported ? ' (exported)' : '';
1866
- lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${counts.incoming} refs, → ${counts.outgoing} deps`);
1662
+ lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1867
1663
  }
1868
1664
  return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1869
1665
  });
@@ -1971,6 +1767,185 @@ export function createMcpServer(rootPath) {
1971
1767
  },
1972
1768
  }],
1973
1769
  }));
1770
+ // ── Prompt: vibe-code-planner ──
1771
+ server.prompt('vibe-code-planner', 'ECC-style Planner Agent workflow: analyze codebase, create implementation plan with blast radius awareness', { feature: z.string().describe('Feature or task name to plan') }, ({ feature }) => ({
1772
+ messages: [{
1773
+ role: 'user',
1774
+ content: {
1775
+ type: 'text',
1776
+ text: `I am the Planner Agent. I need to create an implementation plan for "${feature}".\n\n` +
1777
+ `Follow this ECC Planner workflow:\n\n` +
1778
+ `PHASE 1 — CODEBASE INTELLIGENCE:\n` +
1779
+ `1. Run \`codebase_summary()\` to understand the project structure\n` +
1780
+ `2. Run \`domains()\` to see module clusters\n` +
1781
+ `3. Run \`routes()\` to find relevant API endpoints\n\n` +
1782
+ `PHASE 2 — TARGET ANALYSIS:\n` +
1783
+ `4. Run \`smart_context({name: "keySymbol", intent: "edit"})\` for each affected symbol\n` +
1784
+ `5. Run \`edit_check({name: "keySymbol"})\` for safety\n` +
1785
+ `6. Run \`trace({to: "keySymbol"})\` to understand execution flow\n\n` +
1786
+ `PHASE 3 — IMPACT PREDICTION:\n` +
1787
+ `7. Run \`impact({target: "keySymbol", depth: 3})\` to see blast radius\n` +
1788
+ `8. Run \`explain_relationship({from: "A", to: "B"})\` for distant dependencies\n\n` +
1789
+ `PHASE 4 — TEST STRATEGY:\n` +
1790
+ `9. Run \`test_plan({name: "keySymbol"})\` for mock strategy\n` +
1791
+ `10. Run \`test_coverage_gaps()\` to check existing coverage\n\n` +
1792
+ `PHASE 5 — FINAL PLAN:\n` +
1793
+ `Output a plan.md with: Overview, Architecture Changes, Implementation Steps (file+action+why+deps+risk), Testing Strategy, Risks & Mitigations, Success Criteria.\n\n` +
1794
+ `Use the ECC plan format with specific file paths, dependencies, and risk levels (LOW/MEDIUM/HIGH).`,
1795
+ },
1796
+ }],
1797
+ }));
1798
+ // ── Prompt: vibe-code-reviewer ──
1799
+ server.prompt('vibe-code-reviewer', 'ECC-style Reviewer Agent workflow: PR risk assessment, dead code detection, security scan', { session_id: z.string().optional().describe('Optional session ID for annotation context') }, ({ session_id }) => ({
1800
+ messages: [{
1801
+ role: 'user',
1802
+ content: {
1803
+ type: 'text',
1804
+ text: `I am the Reviewer Agent. Review the current changes thoroughly.${session_id ? ` Session: ${session_id}` : ''}\n\n` +
1805
+ `Follow this ECC Reviewer workflow:\n\n` +
1806
+ `1. Run \`review_pr()\` to get risk scores for all changed symbols\n` +
1807
+ `2. For each CRITICAL/HIGH symbol:\n` +
1808
+ ` a. Run \`review_symbol({name})\` for deep dive\n` +
1809
+ ` b. Run \`context({name})\` to see relationships\n` +
1810
+ ` c. Run \`grep({pattern: "symbolName"})\` for text references\n` +
1811
+ `3. Run \`find_dead_code()\` to detect orphaned symbols\n` +
1812
+ `4. Run \`grep({pattern: "password|secret|api_key|token", scope: "code"})\` for secrets\n` +
1813
+ `5. Run \`grep({pattern: "TODO|FIXME|HACK|console\\\\.log", scope: "code"})\` for tech debt\n` +
1814
+ `6. Run \`detect_changes()\` to verify expected files only\n` +
1815
+ `7. Create a review report: symbols OK to merge vs symbols needing fixes\n` +
1816
+ `8. Run \`annotate({symbol, key: "bug"|"security", value})\` for any critical findings`,
1817
+ },
1818
+ }],
1819
+ }));
1820
+ // ── Prompt: closed-loop-session ──
1821
+ server.prompt('closed-loop-session', 'Complete 6-phase closed-loop session: Analyze → Plan → Code → Verify → Learn → Improve', { task: z.string().describe('Task description'), agent: z.string().optional().default('vibe-coder') }, ({ task, agent }) => ({
1822
+ messages: [{
1823
+ role: 'user',
1824
+ content: {
1825
+ type: 'text',
1826
+ text: `Run a complete closed-loop development session for: "${task}"\n\n` +
1827
+ `PHASE 1 — ANALYZE (bootstrap):\n` +
1828
+ ` session_start({agent: "${agent}"}) → codebase_summary() → domains() → recall()\n\n` +
1829
+ `PHASE 2 — PLAN:\n` +
1830
+ ` smart_context({intent: "edit"}) → edit_check() → impact({depth: 3}) → test_plan()\n\n` +
1831
+ `PHASE 3 — CODE:\n` +
1832
+ ` Implement changes with guard: edit_check() before each edit, impact() mid-edit, context() for reference\n\n` +
1833
+ `PHASE 4 — VERIFY:\n` +
1834
+ ` detect_changes() → test_impact() → review_pr() → test_coverage_gaps() → grep(secrets)\n\n` +
1835
+ `PHASE 5 — LEARN:\n` +
1836
+ ` annotate() key observations → session_context() → handoff() if needed\n\n` +
1837
+ `PHASE 6 — IMPROVE:\n` +
1838
+ ` milens evolve (if patterns ready) → milens metrics (check health)\n\n` +
1839
+ `At the end: session_end({session_id}) to record stats.`,
1840
+ },
1841
+ }],
1842
+ }));
1843
+ // ── Register MCP Prompts (W1) ──
1844
+ registerAllPrompts(server);
1845
+ // ── Tool: security_scan (S2) ──
1846
+ server.tool('security_scan', 'Scan codebase for security vulnerabilities using 50+ built-in rules. Replaces multiple manual grep() calls. Categories: secrets, injection, unicode, dangerous, config, data-leak, crypto, auth, file-access.', {
1847
+ scope: z.enum(['all', 'secrets', 'injection', 'unicode', 'dangerous', 'config', 'data-leak', 'crypto', 'auth', 'file-access']).optional().default('all').describe('Scan scope'),
1848
+ repo: z.string().optional().describe('Repository root path'),
1849
+ severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).optional().describe('Minimum severity filter'),
1850
+ limit: z.number().optional().default(50).describe('Max findings'),
1851
+ }, async ({ scope, repo, severity, limit }) => {
1852
+ const { db, root } = getDb(repo);
1853
+ const rules = loadRules();
1854
+ // Filter rules by scope and severity
1855
+ const filtered = rules.filter(r => {
1856
+ if (scope !== 'all' && r.category !== scope)
1857
+ return false;
1858
+ if (severity) {
1859
+ const sevOrder = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
1860
+ if ((sevOrder[r.severity] || 0) < (sevOrder[severity] || 0))
1861
+ return false;
1862
+ }
1863
+ return r.enabled;
1864
+ });
1865
+ // Get all source files from the DB
1866
+ const symbols = db.getAllSymbols();
1867
+ const fileSet = new Set();
1868
+ for (const s of symbols) {
1869
+ if (s.filePath && !s.filePath.includes('node_modules') && !s.filePath.includes('.git')) {
1870
+ fileSet.add(s.filePath);
1871
+ }
1872
+ }
1873
+ const files = [...fileSet].slice(0, 1000); // cap at 1000 files
1874
+ const { readFileSync: rfs, existsSync: es } = await import('node:fs');
1875
+ const { resolve: resolvePath } = await import('node:path');
1876
+ const findings = [];
1877
+ const byCategory = {};
1878
+ const bySeverity = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
1879
+ for (const file of files) {
1880
+ const fullPath = resolvePath(root, file);
1881
+ if (!es(fullPath))
1882
+ continue;
1883
+ // Skip files that don't match rule fileGlobs (simple check)
1884
+ const applicableRules = filtered.filter(r => {
1885
+ if (!r.fileGlob)
1886
+ return true;
1887
+ // Simple glob: just check extension
1888
+ const ext = r.fileGlob.replace('**/*.', '').replace('**/*', '');
1889
+ return file.endsWith(ext) || r.fileGlob === '**/*';
1890
+ });
1891
+ if (applicableRules.length === 0)
1892
+ continue;
1893
+ try {
1894
+ const content = rfs(fullPath, 'utf-8');
1895
+ const lines = content.split('\n');
1896
+ for (const rule of applicableRules) {
1897
+ for (const pattern of rule.patterns) {
1898
+ let match;
1899
+ // Reset regex lastIndex for global patterns
1900
+ pattern.lastIndex = 0;
1901
+ while ((match = pattern.exec(content)) !== null) {
1902
+ const lineNum = content.substring(0, match.index).split('\n').length;
1903
+ const ctxStart = Math.max(0, lineNum - 3);
1904
+ const ctxEnd = Math.min(lines.length, lineNum + 2);
1905
+ const context = lines.slice(ctxStart, ctxEnd).join('\n');
1906
+ findings.push({
1907
+ ruleId: rule.id,
1908
+ category: rule.category,
1909
+ severity: rule.severity,
1910
+ owasp: rule.owasp,
1911
+ file,
1912
+ line: lineNum,
1913
+ match: match[0].length > 100 ? match[0].slice(0, 97) + '...' : match[0],
1914
+ context,
1915
+ fix: rule.fix,
1916
+ });
1917
+ byCategory[rule.category] = (byCategory[rule.category] || 0) + 1;
1918
+ bySeverity[rule.severity] = (bySeverity[rule.severity] || 0) + 1;
1919
+ }
1920
+ }
1921
+ }
1922
+ }
1923
+ catch {
1924
+ // Skip unreadable files
1925
+ }
1926
+ }
1927
+ // Calculate security score (100 - deductions)
1928
+ const deduction = findings.filter((f) => f.severity === 'CRITICAL').length * 5 +
1929
+ findings.filter((f) => f.severity === 'HIGH').length * 2 +
1930
+ findings.filter((f) => f.severity === 'MEDIUM').length * 0.5;
1931
+ const score = Math.max(0, Math.round(100 - deduction));
1932
+ const limited = findings.slice(0, limit);
1933
+ return {
1934
+ content: [{
1935
+ type: 'text',
1936
+ text: JSON.stringify({
1937
+ summary: {
1938
+ totalScanned: files.length,
1939
+ findings: findings.length,
1940
+ byCategory,
1941
+ bySeverity,
1942
+ score,
1943
+ },
1944
+ findings: limited,
1945
+ }, null, 2),
1946
+ }],
1947
+ };
1948
+ });
1974
1949
  return server;
1975
1950
  }
1976
1951
  // ── Transport: stdio ──