milens 0.6.4 → 0.6.6

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 (130) hide show
  1. package/.agents/skills/adapters/SKILL.md +20 -0
  2. package/.agents/skills/analyzer/SKILL.md +38 -16
  3. package/.agents/skills/apps/SKILL.md +25 -1
  4. package/.agents/skills/docs/SKILL.md +33 -5
  5. package/.agents/skills/milens/SKILL.md +36 -6
  6. package/.agents/skills/milens-architect/SKILL.md +128 -0
  7. package/.agents/skills/milens-debugger/SKILL.md +141 -0
  8. package/.agents/skills/orchestrator/SKILL.md +59 -0
  9. package/.agents/skills/parser/SKILL.md +35 -14
  10. package/.agents/skills/root/SKILL.md +39 -17
  11. package/.agents/skills/scripts/SKILL.md +21 -3
  12. package/.agents/skills/security/SKILL.md +32 -11
  13. package/.agents/skills/server/SKILL.md +47 -20
  14. package/.agents/skills/store/SKILL.md +40 -18
  15. package/.agents/skills/test/SKILL.md +57 -9
  16. package/LICENSE +21 -75
  17. package/README.md +294 -467
  18. package/adapters/claude-code/CLAUDE.md +36 -15
  19. package/adapters/codex/.codex/codex.md +38 -23
  20. package/adapters/copilot/.github/copilot-instructions.md +29 -22
  21. package/adapters/gemini/.gemini/context.md +33 -10
  22. package/adapters/opencode/AGENTS.md +36 -15
  23. package/dist/agents-md.d.ts.map +1 -1
  24. package/dist/agents-md.js +56 -5
  25. package/dist/agents-md.js.map +1 -1
  26. package/dist/analyzer/engine.d.ts +4 -0
  27. package/dist/analyzer/engine.d.ts.map +1 -1
  28. package/dist/analyzer/engine.js +378 -14
  29. package/dist/analyzer/engine.js.map +1 -1
  30. package/dist/analyzer/resolver.d.ts +2 -0
  31. package/dist/analyzer/resolver.d.ts.map +1 -1
  32. package/dist/analyzer/resolver.js +187 -9
  33. package/dist/analyzer/resolver.js.map +1 -1
  34. package/dist/analyzer/review.d.ts.map +1 -1
  35. package/dist/analyzer/review.js +254 -32
  36. package/dist/analyzer/review.js.map +1 -1
  37. package/dist/analyzer/scope-resolver.d.ts +42 -0
  38. package/dist/analyzer/scope-resolver.d.ts.map +1 -0
  39. package/dist/analyzer/scope-resolver.js +687 -0
  40. package/dist/analyzer/scope-resolver.js.map +1 -0
  41. package/dist/cli.js +590 -20
  42. package/dist/cli.js.map +1 -1
  43. package/dist/orchestrator/orchestrator.d.ts +65 -0
  44. package/dist/orchestrator/orchestrator.d.ts.map +1 -0
  45. package/dist/orchestrator/orchestrator.js +178 -0
  46. package/dist/orchestrator/orchestrator.js.map +1 -0
  47. package/dist/orchestrator/reporter.d.ts +15 -0
  48. package/dist/orchestrator/reporter.d.ts.map +1 -0
  49. package/dist/orchestrator/reporter.js +38 -0
  50. package/dist/orchestrator/reporter.js.map +1 -0
  51. package/dist/parser/extract.d.ts +6 -1
  52. package/dist/parser/extract.d.ts.map +1 -1
  53. package/dist/parser/extract.js +14 -2
  54. package/dist/parser/extract.js.map +1 -1
  55. package/dist/parser/lang-css.d.ts.map +1 -1
  56. package/dist/parser/lang-css.js +7 -1
  57. package/dist/parser/lang-css.js.map +1 -1
  58. package/dist/parser/lang-go.d.ts.map +1 -1
  59. package/dist/parser/lang-go.js +16 -0
  60. package/dist/parser/lang-go.js.map +1 -1
  61. package/dist/parser/lang-html.d.ts +4 -0
  62. package/dist/parser/lang-html.d.ts.map +1 -1
  63. package/dist/parser/lang-html.js +40 -1
  64. package/dist/parser/lang-html.js.map +1 -1
  65. package/dist/parser/lang-java.d.ts.map +1 -1
  66. package/dist/parser/lang-java.js +12 -0
  67. package/dist/parser/lang-java.js.map +1 -1
  68. package/dist/parser/lang-js.d.ts.map +1 -1
  69. package/dist/parser/lang-js.js +3 -0
  70. package/dist/parser/lang-js.js.map +1 -1
  71. package/dist/parser/lang-php.d.ts.map +1 -1
  72. package/dist/parser/lang-php.js +11 -0
  73. package/dist/parser/lang-php.js.map +1 -1
  74. package/dist/parser/lang-py.d.ts.map +1 -1
  75. package/dist/parser/lang-py.js +14 -0
  76. package/dist/parser/lang-py.js.map +1 -1
  77. package/dist/parser/lang-ruby.d.ts.map +1 -1
  78. package/dist/parser/lang-ruby.js +20 -0
  79. package/dist/parser/lang-ruby.js.map +1 -1
  80. package/dist/parser/lang-rust.d.ts.map +1 -1
  81. package/dist/parser/lang-rust.js +27 -4
  82. package/dist/parser/lang-rust.js.map +1 -1
  83. package/dist/parser/lang-ts.d.ts.map +1 -1
  84. package/dist/parser/lang-ts.js +3 -0
  85. package/dist/parser/lang-ts.js.map +1 -1
  86. package/dist/parser/lang-vue.d.ts +17 -1
  87. package/dist/parser/lang-vue.d.ts.map +1 -1
  88. package/dist/parser/lang-vue.js +177 -0
  89. package/dist/parser/lang-vue.js.map +1 -1
  90. package/dist/parser/language-provider.d.ts +27 -0
  91. package/dist/parser/language-provider.d.ts.map +1 -0
  92. package/dist/parser/language-provider.js +2 -0
  93. package/dist/parser/language-provider.js.map +1 -0
  94. package/dist/security/rules.d.ts.map +1 -1
  95. package/dist/security/rules.js +4 -1
  96. package/dist/security/rules.js.map +1 -1
  97. package/dist/server/hooks.d.ts +3 -0
  98. package/dist/server/hooks.d.ts.map +1 -1
  99. package/dist/server/hooks.js +79 -0
  100. package/dist/server/hooks.js.map +1 -1
  101. package/dist/server/mcp-prompts.d.ts.map +1 -1
  102. package/dist/server/mcp-prompts.js +1 -1
  103. package/dist/server/mcp-prompts.js.map +1 -1
  104. package/dist/server/mcp.d.ts.map +1 -1
  105. package/dist/server/mcp.js +638 -61
  106. package/dist/server/mcp.js.map +1 -1
  107. package/dist/server/watcher.d.ts +47 -0
  108. package/dist/server/watcher.d.ts.map +1 -0
  109. package/dist/server/watcher.js +136 -0
  110. package/dist/server/watcher.js.map +1 -0
  111. package/dist/skills.js +201 -36
  112. package/dist/skills.js.map +1 -1
  113. package/dist/store/annotations.d.ts.map +1 -1
  114. package/dist/store/annotations.js +18 -15
  115. package/dist/store/annotations.js.map +1 -1
  116. package/dist/store/confidence.d.ts +10 -0
  117. package/dist/store/confidence.d.ts.map +1 -1
  118. package/dist/store/confidence.js +28 -1
  119. package/dist/store/confidence.js.map +1 -1
  120. package/dist/store/db.d.ts +16 -0
  121. package/dist/store/db.d.ts.map +1 -1
  122. package/dist/store/db.js +121 -7
  123. package/dist/store/db.js.map +1 -1
  124. package/dist/store/schema.sql +25 -10
  125. package/dist/uninstall.d.ts +54 -0
  126. package/dist/uninstall.d.ts.map +1 -0
  127. package/dist/uninstall.js +795 -0
  128. package/dist/uninstall.js.map +1 -0
  129. package/docs/README.md +7 -6
  130. package/package.json +4 -3
@@ -4,9 +4,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
4
4
  import { z } from 'zod';
5
5
  import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { resolve, relative, join, dirname } from 'node:path';
7
+ import { resolve, relative, join, dirname, basename } from 'node:path';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
9
+ import { readFileSync, readdirSync, statSync, mkdirSync, existsSync, writeFileSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
11
  import ignore from 'ignore';
12
12
  import { Database } from '../store/db.js';
@@ -18,6 +18,10 @@ import { generateTestPlan } from './test-plan.js';
18
18
  import { AnnotationStore } from '../store/annotations.js';
19
19
  import { registerAllPrompts } from './mcp-prompts.js';
20
20
  import { loadRules } from '../security/rules.js';
21
+ import { HookManager, defaultOnSessionStart, defaultOnSessionEnd, defaultOnPreCommit, defaultOnFileChange, defaultOnPreCompact, defaultOnPostCompact } from './hooks.js';
22
+ import { Orchestrator } from '../orchestrator/orchestrator.js';
23
+ import { FileWatcher } from './watcher.js';
24
+ import { reviewPr } from '../analyzer/review.js';
21
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
26
  const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
23
27
  // ── Lazy DB connection with idle eviction ──
@@ -35,7 +39,7 @@ class LazyDb {
35
39
  }
36
40
  get() {
37
41
  this.resetTimer();
38
- if (!this.instance) {
42
+ if (!this.instance || !this.instance.isOpen()) {
39
43
  this.instance = new Database(this.dbPath);
40
44
  this.statsCache = null;
41
45
  this.domainCache = null;
@@ -86,6 +90,30 @@ class LazyDb {
86
90
  this.instance = null;
87
91
  }
88
92
  }
93
+ // ── Session-level edit safety guard ──
94
+ class SessionGuard {
95
+ /** sessionId → Set of symbol names that had safety checks performed */
96
+ checks = new Map();
97
+ /** sessionId → total edit operations attempted (reported by agent) */
98
+ editOps = new Map();
99
+ recordCheck(sessionId, symbolName) {
100
+ if (!this.checks.has(sessionId))
101
+ this.checks.set(sessionId, new Set());
102
+ this.checks.get(sessionId).add(symbolName);
103
+ }
104
+ recordEditOp(sessionId) {
105
+ this.editOps.set(sessionId, (this.editOps.get(sessionId) ?? 0) + 1);
106
+ }
107
+ getAudit(sessionId) {
108
+ const checked = [...(this.checks.get(sessionId) ?? [])];
109
+ const ops = this.editOps.get(sessionId) ?? 0;
110
+ return { checked, editOps: ops };
111
+ }
112
+ clear(sessionId) {
113
+ this.checks.delete(sessionId);
114
+ this.editOps.delete(sessionId);
115
+ }
116
+ }
89
117
  // ── Tool usage tracking ──
90
118
  // Estimated tokens an agent would spend WITHOUT milens (manual exploration cost per tool)
91
119
  const TOKEN_SAVINGS_MULTIPLIER = {
@@ -314,7 +342,24 @@ function loadGrepIgnoreRules(rootPath) {
314
342
  return ig;
315
343
  }
316
344
  // ── Server instructions (sent to client via MCP protocol on initialize) ──
317
- const MILENS_INSTRUCTIONS = `milens code intelligence engine. Indexes codebases into symbol graphs.
345
+ const MILENS_INSTRUCTIONS = `⚠️ CRITICAL: This project is indexed by milens (knowledge graph). Use milens MCP tools BEFORE reading files directly.
346
+
347
+ WHY: The knowledge graph already knows every symbol, dependency, and reference. Reading files manually = slower, more expensive, and misses hidden connections.
348
+
349
+ RULE: Before opening ANY file to understand code, call the appropriate milens tool:
350
+ - overview({name: "X"}) — understand a symbol (context + impact + grep). Replaces 3-5 file reads.
351
+ - impact({target: "X", mode: "strict"}) — check blast radius BEFORE editing. strict mode BLOCKS if >5 deps.
352
+ - guard_edit_check({name: "X", session_id}) — HARD safety gate. Call BEFORE every edit. Returns BLOCKED if high risk.
353
+ - grep({pattern: "X"}) — find ALL text references (code, templates, docs, configs, styles).
354
+ - codebase_summary() — 500-token project overview. Use INSTEAD of reading README or exploring directories.
355
+ - detect_changes() — verify changes before committing. Shows changed symbols + risk scores.
356
+ - query({query: "X"}) — find symbol definitions by name. FTS5 instant search.
357
+
358
+ AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
359
+
360
+ TOKEN SAVINGS: Using milens first = 70% fewer tokens, zero missed dependencies.
361
+
362
+ milens — code intelligence engine. Indexes codebases into symbol graphs.
318
363
 
319
364
  ## Tool selection
320
365
  - \`query\` — find symbol definitions (code identifiers only)
@@ -322,6 +367,7 @@ const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codeba
322
367
  - \`context\` — 360° view: incoming + outgoing for a symbol
323
368
  - \`impact\` — blast radius: what breaks if symbol changes
324
369
  - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
370
+ - \`guard_edit_check\` — HARD pre-edit gate: blocks if dependents > 5, tracks checks for session audit
325
371
  - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
326
372
  - \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
327
373
  - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
@@ -335,7 +381,9 @@ const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codeba
335
381
  - \`get_type_hierarchy\` — inheritance tree
336
382
 
337
383
  ## Rules
338
- - Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
384
+ - Before editing a symbol: run \`guard_edit_check\` or \`edit_check\` or \`smart_context\` with intent=edit
385
+ - \`guard_edit_check({name, session_id})\` — HARD gate: records check for audit, blocks if dependents > 5
386
+ - \`impact({mode: "strict"})\` — strict mode returns BLOCKED when depth-1 deps > 5
339
387
  - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
340
388
  - For writing tests: run \`smart_context\` with intent=test — shows deps to mock + callers to cover
341
389
  - \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
@@ -356,6 +404,7 @@ export function createMcpServer(rootPath) {
356
404
  const registry = new RepoRegistry();
357
405
  const pools = new Map();
358
406
  const trackDb = getTrackingDb();
407
+ const guard = new SessionGuard();
359
408
  function normalizePath(p) {
360
409
  const abs = resolve(p);
361
410
  if (process.platform === 'win32') {
@@ -394,11 +443,13 @@ export function createMcpServer(rootPath) {
394
443
  if (!pools.has(root))
395
444
  pools.set(root, new LazyDb(dbPath));
396
445
  const lazy = pools.get(root);
397
- return { db: lazy.get(), root, lazy };
446
+ return { db: lazy.get(), root, dbPath, lazy };
398
447
  }
399
448
  const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
400
- // Auto-wrap every tool handler with usage tracking
449
+ // Auto-wrap every tool handler with usage tracking + background decay tick
401
450
  const origTool = server.tool.bind(server);
451
+ let lastDecayTick = 0;
452
+ const DECAY_INTERVAL = 5 * 60_000; // 5 minutes between decay ticks
402
453
  server.tool = ((...args) => {
403
454
  const toolName = args[0];
404
455
  const handler = args[args.length - 1];
@@ -409,6 +460,21 @@ export function createMcpServer(rootPath) {
409
460
  const responseText = result?.content?.map((c) => c.text).join('\n') ?? '';
410
461
  const repo = handlerArgs[0]?.repo;
411
462
  trackToolCall(trackDb, toolName, start, responseText, repo);
463
+ // Background confidence decay tick (real-time, every 5 min)
464
+ if (Date.now() - lastDecayTick > DECAY_INTERVAL) {
465
+ lastDecayTick = Date.now();
466
+ try {
467
+ for (const [, pool] of pools) {
468
+ const db = pool.get();
469
+ const store = new AnnotationStore(db.connection);
470
+ if (store.getAnnotationCount() >= 100) {
471
+ const { runDecayPass } = await import('../store/confidence.js');
472
+ runDecayPass(store);
473
+ }
474
+ }
475
+ }
476
+ catch { /* decay is best-effort */ }
477
+ }
412
478
  return result;
413
479
  };
414
480
  }
@@ -438,7 +504,7 @@ export function createMcpServer(rootPath) {
438
504
  });
439
505
  }
440
506
  // ── Tool: query ──
441
- server.tool('query', 'Search indexed symbol definitions by name/kind. For text in templates/configs/docs, use `grep`.', {
507
+ server.tool('query', 'Find symbol definitions by name (FTS5 instant search). Use instead of reading files to find where a function/class is defined. For text in templates/configs/docs, use `grep`.', {
442
508
  query: z.string().describe('Symbol name, kind, or keyword to search'),
443
509
  repo: z.string().optional().describe('Repository root path (optional if only one indexed)'),
444
510
  limit: z.number().optional().default(15).describe('Max results'),
@@ -452,10 +518,10 @@ export function createMcpServer(rootPath) {
452
518
  return { content: [{ type: 'text', text }] };
453
519
  });
454
520
  // ── Tool: grep ──
455
- server.tool('grep', 'Text search ALL project files (templates, styles, configs, docs). Finds every text occurrence, not just symbols.', {
456
- pattern: z.string().describe('Text or regex pattern to search for'),
521
+ server.tool('grep', 'Find EVERY text occurrence across ALL project files. Searches code, templates, styles, configs, docs — not just symbol definitions. Use INSTEAD of built-in search tools which may miss non-code files. ⚠️ IMPORTANT: Default is LITERAL mode (isRegex: false). Characters like | . * + ? are treated as literal text, NOT regex. To use regex alternation or wildcards, set isRegex: true.', {
522
+ pattern: z.string().describe('Text OR regex pattern to search for. ⚠️ Default is LITERAL: characters | . * + ? ( ) are escaped as plain text. To use regex, set isRegex: true.'),
457
523
  repo: z.string().optional().describe('Repository root path (optional)'),
458
- isRegex: z.boolean().optional().default(false).describe('Treat pattern as regex'),
524
+ isRegex: z.boolean().optional().default(false).describe('Set to true for regex patterns. Default false: special chars like | . * are treated as literal text.'),
459
525
  caseSensitive: z.boolean().optional().default(false),
460
526
  include: z.string().optional().describe('Glob filter for file paths (e.g. "**/*.vue", "*.scss")'),
461
527
  scope: z.enum(['all', 'code', 'imports', 'definitions']).optional().default('all')
@@ -473,8 +539,14 @@ export function createMcpServer(rootPath) {
473
539
  const filtered = scope === 'all' || scope === 'code'
474
540
  ? matches
475
541
  : matches.filter(m => matchesScope(m.text, scope));
542
+ // Detect regex-like patterns used in literal mode (hint BEFORE result output)
543
+ const regexChars = /[|.*+?(){}\[\]]/;
544
+ const regexHint = (!isRegex && regexChars.test(pattern))
545
+ ? `⚠️ HINT: Pattern "${pattern}" contains special regex characters (${pattern.match(regexChars).join(' ')}). Default is LITERAL mode — these are escaped as plain text. To use as regex, add isRegex: true.\n\n`
546
+ : '';
476
547
  if (filtered.length === 0) {
477
- return { content: [{ type: 'text', text: `No matches for "${pattern}"${scope !== 'all' ? ` (scope: ${scope})` : ''}` }] };
548
+ const hintBlock = regexHint + `No matches for "${pattern}"${scope !== 'all' ? ` (scope: ${scope})` : ''}`;
549
+ return { content: [{ type: 'text', text: hintBlock }] };
478
550
  }
479
551
  // Group by file for compact output
480
552
  const grouped = new Map();
@@ -483,7 +555,10 @@ export function createMcpServer(rootPath) {
483
555
  arr.push({ line: m.line, text: m.text });
484
556
  grouped.set(m.file, arr);
485
557
  }
486
- const lines = [`${filtered.length} matches in ${grouped.size} files${scope !== 'all' ? ` (scope: ${scope})` : ''}:\n`];
558
+ const lines = [];
559
+ if (regexHint)
560
+ lines.push(regexHint);
561
+ lines.push(`${filtered.length} matches in ${grouped.size} files${scope !== 'all' ? ` (scope: ${scope})` : ''}:\n`);
487
562
  for (const [file, hits] of grouped) {
488
563
  lines.push(file);
489
564
  for (const h of hits) {
@@ -496,7 +571,7 @@ export function createMcpServer(rootPath) {
496
571
  return { content: [{ type: 'text', text: lines.join('\n') }] };
497
572
  });
498
573
  // ── Tool: context ──
499
- server.tool('context', 'Symbol 360°: incoming refs + outgoing deps. Use `overview` for combined context+impact+grep.', {
574
+ server.tool('context', '360° view of a symbol: who calls it + what it depends on. Instant dependency graph. Use INSTEAD of reading multiple files to trace call chains manually — catches cross-file imports you would miss.', {
500
575
  name: z.string().describe('Symbol name to inspect'),
501
576
  repo: z.string().optional(),
502
577
  detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
@@ -534,13 +609,14 @@ export function createMcpServer(rootPath) {
534
609
  return { content: [{ type: 'text', text: lines.join('\n') }] };
535
610
  });
536
611
  // ── Tool: impact ──
537
- server.tool('impact', 'Blast radius: what symbols break if target changes. Code deps only — pair with `grep` for templates/configs.', {
612
+ server.tool('impact', 'Exact blast radius BEFORE you edit. Shows which symbols WILL BREAK if you change a target. Use instead of guessing or manually tracing dependencies. Code deps only — pair with `grep` for templates/configs.', {
538
613
  target: z.string().describe('Symbol name to analyze'),
539
614
  direction: z.enum(['upstream', 'downstream']).default('upstream'),
540
615
  depth: z.number().optional().default(3),
541
616
  repo: z.string().optional(),
542
617
  detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
543
- }, async ({ target, direction, depth, repo, detail }) => {
618
+ mode: z.enum(['normal', 'strict']).optional().default('normal').describe('strict=BLOCKED if depth-1 dependents > 5, normal=report only'),
619
+ }, async ({ target, direction, depth, repo, detail, mode }) => {
544
620
  const { db } = getDb(repo);
545
621
  const symbols = db.findSymbolByName(target);
546
622
  if (symbols.length === 0) {
@@ -556,8 +632,18 @@ export function createMcpServer(rootPath) {
556
632
  lines.push(`No ${direction} deps found.`);
557
633
  }
558
634
  else {
559
- lines.push(`${direction} (${refs.length} symbols):`);
635
+ const depth1Count = refs.filter(r => r.depth === 1).length;
636
+ lines.push(`${direction} (${refs.length} symbols, depth-1: ${depth1Count}):`);
560
637
  lines.push(fmtImpact(refs, detail));
638
+ // Strict mode: hard stop if depth-1 dependents > 5
639
+ if (mode === 'strict' && direction === 'upstream' && depth1Count > 5) {
640
+ lines.push('');
641
+ lines.push('---');
642
+ lines.push(`⚠️ BLOCKED: ${depth1Count} direct dependents (>5 threshold).`);
643
+ lines.push('Editing this symbol may cause cascading breakage.');
644
+ lines.push('To proceed: re-run with mode="normal" or explicitly acknowledge the risk.');
645
+ lines.push('---');
646
+ }
561
647
  }
562
648
  lines.push('');
563
649
  }
@@ -628,7 +714,7 @@ export function createMcpServer(rootPath) {
628
714
  return { content: [{ type: 'text', text: lines.join('\n') }] };
629
715
  });
630
716
  // ── Tool: overview ──
631
- server.tool('overview', 'Combined context + impact + grep in ONE call. Preferred before editing/deleting/renaming a symbol. Saves 2-3 round trips.', {
717
+ server.tool('overview', 'ONE call replaces 3-5 file reads. Combined context + impact + grep. Use BEFORE reading any source file. Saves 70% tokens vs reading files individually. Preferred before editing/deleting/renaming a symbol.', {
632
718
  name: z.string().describe('Symbol name'),
633
719
  repo: z.string().optional(),
634
720
  depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
@@ -742,12 +828,11 @@ export function createMcpServer(rootPath) {
742
828
  return { content: [{ type: 'text', text: lines.join('\n') }] };
743
829
  });
744
830
  // ── Tool: detect_changes ──
745
- server.tool('detect_changes', 'Git diff affected symbols + direct dependents.', {
831
+ server.tool('detect_changes', 'Pre-commit safety check. Uses git diff to show which symbols changed + their direct dependents + risk. Use INSTEAD of manually running `git diff` before every commit.', {
746
832
  ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
747
833
  repo: z.string().optional(),
748
834
  }, async ({ ref, repo }) => {
749
835
  const { db, root } = getDb(repo);
750
- // Validate ref: only allow safe git ref characters (alphanumeric, /, ., -, _, ~, ^)
751
836
  if (!/^[a-zA-Z0-9\/._~^\-]+$/.test(ref)) {
752
837
  return { content: [{ type: 'text', text: 'Invalid git ref.' }] };
753
838
  }
@@ -763,24 +848,70 @@ export function createMcpServer(rootPath) {
763
848
  if (changedFiles.length === 0) {
764
849
  return { content: [{ type: 'text', text: 'No changed files detected.' }] };
765
850
  }
851
+ // Get changed line ranges per file (git diff -U0 gives hunk headers with @@ -old,new +old,new @@)
852
+ const changedLinesByFile = new Map();
853
+ for (const file of changedFiles) {
854
+ try {
855
+ const diffOut = execFileSync('git', ['diff', '-U0', ref, '--', file], { cwd: root, encoding: 'utf-8' });
856
+ const changedLines = new Set();
857
+ for (const line of diffOut.split('\n')) {
858
+ const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
859
+ if (m) {
860
+ const start = parseInt(m[1], 10);
861
+ const count = m[2] ? parseInt(m[2], 10) : 1;
862
+ for (let i = start; i < start + count; i++) {
863
+ changedLines.add(i);
864
+ }
865
+ }
866
+ }
867
+ if (changedLines.size > 0)
868
+ changedLinesByFile.set(file, changedLines);
869
+ }
870
+ catch { /* file may not exist at ref */ }
871
+ }
766
872
  const lines = [`${changedFiles.length} changed files:\n`];
767
873
  let totalAffected = 0;
874
+ let totalChanged = 0;
768
875
  for (const file of changedFiles) {
769
876
  const syms = db.getSymbolsByFile(file);
770
877
  if (syms.length === 0) {
771
878
  lines.push(`${file}: (not indexed)`);
772
879
  continue;
773
880
  }
774
- lines.push(`${file}: ${syms.length} symbols`);
775
- for (const sym of syms) {
881
+ const changedLines = changedLinesByFile.get(file);
882
+ // Filter to only symbols whose line range overlaps with changed lines
883
+ const changedSyms = changedLines
884
+ ? syms.filter(s => {
885
+ for (let line = s.startLine; line <= s.endLine; line++) {
886
+ if (changedLines.has(line))
887
+ return true;
888
+ }
889
+ return false;
890
+ })
891
+ : syms; // fallback: no line-level diff available, report all
892
+ if (changedSyms.length === 0 && changedLines) {
893
+ // File changed but no symbols affected (e.g. whitespace, imports only)
894
+ lines.push(`${file}: (symbols unchanged)`);
895
+ continue;
896
+ }
897
+ const displaySyms = changedSyms.length > 0 ? changedSyms : syms;
898
+ const unchangedCount = changedSyms.length > 0 ? syms.length - changedSyms.length : 0;
899
+ const unchangedNote = unchangedCount > 0 ? ` (${unchangedCount} unchanged not shown)` : '';
900
+ lines.push(`${file}: ${displaySyms.length} changed symbols${unchangedNote}`);
901
+ for (const sym of displaySyms) {
776
902
  const upstream = db.findUpstream(sym.id, 1);
903
+ totalChanged++;
777
904
  if (upstream.length > 0) {
778
- lines.push(` ${sym.name} [${sym.kind}] → ${upstream.length} direct dependents`);
905
+ lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${upstream.length} direct dependents`);
779
906
  totalAffected += upstream.length;
780
907
  }
908
+ else {
909
+ lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine}`);
910
+ }
781
911
  }
782
912
  }
783
- lines.push(`\nTotal direct dependents affected: ${totalAffected}`);
913
+ lines.push(`\nTotal changed symbols: ${totalChanged}`);
914
+ lines.push(`Total direct dependents affected: ${totalAffected}`);
784
915
  return { content: [{ type: 'text', text: lines.join('\n') }] };
785
916
  });
786
917
  // ── Tool: explain_relationship ──
@@ -854,6 +985,20 @@ export function createMcpServer(rootPath) {
854
985
  lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
855
986
  }
856
987
  }
988
+ // File-path auto-suggest: domain hint
989
+ const parts = file.replace(/\\/g, '/').split('/');
990
+ let areaName = 'root';
991
+ if (parts.length > 1) {
992
+ if (parts[0] === 'src') {
993
+ areaName = parts.length > 2 ? parts[1] : 'root';
994
+ }
995
+ else {
996
+ areaName = parts[0];
997
+ }
998
+ }
999
+ if (areaName !== 'root') {
1000
+ lines.push(`\n💡 This file is in the '${areaName}' domain. Load skill 'milens-${areaName}' for key symbols and dependencies.`);
1001
+ }
857
1002
  return { content: [{ type: 'text', text: lines.join('\n') }] };
858
1003
  });
859
1004
  // ── Tool: get_type_hierarchy ──
@@ -890,7 +1035,7 @@ export function createMcpServer(rootPath) {
890
1035
  return { content: [{ type: 'text', text: lines.join('\n') }] };
891
1036
  });
892
1037
  // ── Tool: edit_check ──
893
- server.tool('edit_check', 'Pre-edit safety check: callers, export status, re-export chains, ⚠ warnings. Focused for editing intentno downstream deps, no outgoing calls. Use BEFORE modifying a symbol.', {
1038
+ server.tool('edit_check', 'Fast pre-edit safety. Shows callers, export status, re-export chains, test coverage, and ⚠ warnings. Use BEFORE modifying any function/class/method catches hidden risks you would miss.', {
894
1039
  name: z.string().describe('Symbol name to check before editing'),
895
1040
  repo: z.string().optional(),
896
1041
  }, async ({ name, repo }) => {
@@ -959,6 +1104,95 @@ export function createMcpServer(rootPath) {
959
1104
  }
960
1105
  return { content: [{ type: 'text', text: sections.join('\n') }] };
961
1106
  });
1107
+ // ── Tool: guard_edit_check ──
1108
+ server.tool('guard_edit_check', 'HARD pre-edit safety gate. Call BEFORE every edit operation — tracks checks for session audit. If dependents > 5, returns BLOCKED status requiring explicit confirmation. Combines edit_check with enforcement tracking.', {
1109
+ name: z.string().describe('Symbol name to check before editing'),
1110
+ repo: z.string().optional(),
1111
+ session_id: z.string().optional().describe('Session ID for audit tracking'),
1112
+ confirm: z.string().optional().describe('Type "I understand the risk" to bypass a BLOCKED result'),
1113
+ }, async ({ name, repo, session_id, confirm }) => {
1114
+ const { db, root } = getDb(repo);
1115
+ const symbols = db.findSymbolByName(name);
1116
+ const sections = [];
1117
+ if (symbols.length === 0) {
1118
+ return { content: [{ type: 'text', text: `"${name}" not found in index. Try \`grep\` to find it in templates/docs first.` }] };
1119
+ }
1120
+ for (const sym of symbols) {
1121
+ sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
1122
+ const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1123
+ const depsCount = incoming.filter(l => {
1124
+ const from = db.findSymbolById(l.fromId);
1125
+ return from && !isTestFilePath(from.filePath);
1126
+ }).length;
1127
+ if (session_id)
1128
+ guard.recordCheck(session_id, name);
1129
+ // Hard stop: if > 5 non-test dependents and no confirmation
1130
+ if (depsCount > 5 && confirm !== 'I understand the risk') {
1131
+ sections.push(`⚠️ BLOCKED: ${name} has ${depsCount} non-test dependents (>5 threshold).`);
1132
+ sections.push(`Direct callers (${incoming.length} total):`);
1133
+ for (const l of incoming) {
1134
+ const from = db.findSymbolById(l.fromId);
1135
+ sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
1136
+ }
1137
+ sections.push('');
1138
+ sections.push(`To proceed, re-call with confirm: "I understand the risk"`);
1139
+ sections.push(`Or: run \`impact({target: "${name}", depth: 2})\` to see full blast radius.`);
1140
+ }
1141
+ else {
1142
+ // Safe to edit or explicitly confirmed
1143
+ const status = depsCount > 5 ? '⚠️ CONFIRMED (high risk)' : '✅ Safe to edit';
1144
+ sections.push(`${status} — ${depsCount} non-test dependents`);
1145
+ if (incoming.length > 0) {
1146
+ sections.push(`callers (${incoming.length}):`);
1147
+ for (const l of incoming) {
1148
+ const from = db.findSymbolById(l.fromId);
1149
+ sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
1150
+ }
1151
+ }
1152
+ else {
1153
+ sections.push(`callers: none`);
1154
+ }
1155
+ // Export chain
1156
+ const grepMatches = grepFiles(root, name, { maxResults: 5, includePattern: '**/index.{ts,js,mjs}' });
1157
+ const reExportMatches = grepMatches.filter(m => /export\s*\{[^}]*/.test(m.text) && m.text.includes('from'));
1158
+ if (reExportMatches.length > 0) {
1159
+ sections.push(`re-exported via:`);
1160
+ for (const m of reExportMatches) {
1161
+ sections.push(` ${m.file}:${m.line}`);
1162
+ }
1163
+ }
1164
+ // Heritage
1165
+ const descendants = db.getTypeHierarchy(sym.id).descendants;
1166
+ if (descendants.length > 0) {
1167
+ sections.push(`⚠ inherited by ${descendants.length} types:`);
1168
+ for (const { symbol: d } of descendants) {
1169
+ sections.push(` ${fmtSymbol(d)}`);
1170
+ }
1171
+ }
1172
+ // Test coverage
1173
+ const testRefs = incoming.filter(l => {
1174
+ const from = db.findSymbolById(l.fromId);
1175
+ return from && isTestFilePath(from.filePath);
1176
+ });
1177
+ if (testRefs.length > 0) {
1178
+ const testFiles = [...new Set(testRefs.map(l => {
1179
+ const from = db.findSymbolById(l.fromId);
1180
+ return from?.filePath;
1181
+ }).filter(Boolean))];
1182
+ sections.push(`✓ tested from: ${testFiles.join(', ')}`);
1183
+ }
1184
+ else if (sym.exported) {
1185
+ sections.push(`⚠ no test coverage for this exported symbol`);
1186
+ }
1187
+ }
1188
+ }
1189
+ // Unresolved warning
1190
+ const unresolved = db.getUnresolvedStats();
1191
+ if (unresolved.imports > 0 || unresolved.calls > 0) {
1192
+ sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
1193
+ }
1194
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
1195
+ });
962
1196
  // ── Tool: trace ──
963
1197
  server.tool('trace', 'Trace execution flow: find call chains from entrypoints to a target symbol, or from a symbol downstream. Shows HOW code gets executed.', {
964
1198
  name: z.string().describe('Symbol name to trace'),
@@ -1336,7 +1570,7 @@ export function createMcpServer(rootPath) {
1336
1570
  }
1337
1571
  });
1338
1572
  // ═══ 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 }) => {
1573
+ server.tool('codebase_summary', '500-token project overview. Use at session start INSTEAD of reading README, exploring directory structure, or reading multiple files to understand the codebase. Returns domains, key symbols, coverage %.', { repo: z.string().optional() }, async ({ repo }) => {
1340
1574
  const { db } = getDb(repo);
1341
1575
  const summary = db.getCodebaseSummary();
1342
1576
  const lines = [
@@ -1366,43 +1600,23 @@ export function createMcpServer(rootPath) {
1366
1600
  // ═══ review_pr ═══
1367
1601
  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
1602
  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) {
1603
+ const result = reviewPr(db, root, ref);
1604
+ if (result.changedFiles.length === 0) {
1377
1605
  return { content: [{ type: 'text', text: 'No changed files detected.' }] };
1378
1606
  }
1379
1607
  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);
1608
+ for (const s of result.symbols) {
1609
+ allAffected.push({
1610
+ symbol: s.symbol.name, kind: s.symbol.kind, file: s.symbol.filePath,
1611
+ heat: s.symbol.heat ?? 0, dependents: s.dependents,
1612
+ hasTest: s.tested, riskScore: s.riskScore, riskLevel: s.riskLevel,
1613
+ });
1614
+ }
1401
1615
  const summary = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
1402
1616
  for (const a of allAffected)
1403
1617
  summary[a.riskLevel]++;
1404
1618
  const lines = [`PR Risk Assessment (vs ${ref}):\n`];
1405
- lines.push(`${changedFiles.length} changed files, ${allAffected.length} affected symbols\n`);
1619
+ lines.push(`${result.changedFiles.length} changed files, ${allAffected.length} affected symbols\n`);
1406
1620
  for (const a of allAffected.slice(0, 30)) {
1407
1621
  lines.push(` ${a.symbol} [${a.kind}] ${a.file} — heat:${a.heat} deps:${a.dependents} test:${a.hasTest ? 'yes' : 'no'} → ${a.riskLevel}(${a.riskScore})`);
1408
1622
  }
@@ -1539,10 +1753,20 @@ export function createMcpServer(rootPath) {
1539
1753
  });
1540
1754
  // ═══ session_start ═══
1541
1755
  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();
1756
+ const { db, root, dbPath } = getDb();
1543
1757
  const store = new AnnotationStore(db.connection);
1544
1758
  const sessionId = store.sessionStart(agent);
1545
- return { content: [{ type: 'text', text: `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().` }] };
1759
+ let hookOutput = '';
1760
+ try {
1761
+ const manager = new HookManager();
1762
+ const config = manager.loadConfig(root);
1763
+ if (config.enabled && config.onSessionStart) {
1764
+ hookOutput = await defaultOnSessionStart({ agent, sessionId, rootPath: root }, dbPath);
1765
+ }
1766
+ }
1767
+ catch { /* hooks are best-effort */ }
1768
+ const text = `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().`;
1769
+ return { content: [{ type: 'text', text: hookOutput ? `${hookOutput}\n\n${text}` : text }] };
1546
1770
  });
1547
1771
  // ═══ session_context ═══
1548
1772
  server.tool('session_context', 'Get metadata about a session: annotations, tool calls, duration.', { session_id: z.string() }, async ({ session_id }) => {
@@ -1569,11 +1793,35 @@ export function createMcpServer(rootPath) {
1569
1793
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1570
1794
  });
1571
1795
  // ═══ 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();
1796
+ server.tool('session_end', 'End a session and record its stats. Shows audit trail: which symbols were safety-checked vs total edit operations. Use at the end of every session.', { session_id: z.string(), status: z.enum(['completed', 'failed']).optional().default('completed') }, async ({ session_id, status }) => {
1797
+ const { db, root, dbPath } = getDb();
1574
1798
  const store = new AnnotationStore(db.connection);
1575
1799
  const summary = store.sessionEnd(session_id, status);
1576
- return { content: [{ type: 'text', text: `Session ended: ${session_id}\nStatus: ${status}\nAnnotations: ${summary.annotationCount}` }] };
1800
+ // Audit trail from SessionGuard
1801
+ const audit = guard.getAudit(session_id);
1802
+ guard.clear(session_id);
1803
+ let hookOutput = '';
1804
+ try {
1805
+ const ctx = store.sessionContext(session_id);
1806
+ const manager = new HookManager();
1807
+ const config = manager.loadConfig(root);
1808
+ if (config.enabled && config.onSessionEnd) {
1809
+ hookOutput = await defaultOnSessionEnd({ agent: ctx.session.agent, sessionId: session_id, rootPath: root }, dbPath);
1810
+ }
1811
+ }
1812
+ catch { /* hooks are best-effort */ }
1813
+ const lines = [
1814
+ `Session ended: ${session_id}`,
1815
+ `Status: ${status}`,
1816
+ `Annotations: ${summary.annotationCount}`,
1817
+ `───`,
1818
+ `Audit Trail:`,
1819
+ ` safety checks performed: ${audit.checked.length}`,
1820
+ audit.checked.length > 0 ? ` symbols checked: ${audit.checked.join(', ')}` : ' ⚠ no symbols were checked via guard_edit_check',
1821
+ audit.editOps > 0 ? ` edit operations reported: ${audit.editOps}` : null,
1822
+ ].filter(Boolean);
1823
+ const text = lines.join('\n');
1824
+ return { content: [{ type: 'text', text: hookOutput ? `${text}\n\n${hookOutput}` : text }] };
1577
1825
  });
1578
1826
  // ═══ handoff ═══
1579
1827
  server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one for the target agent.', {
@@ -1585,6 +1833,33 @@ export function createMcpServer(rootPath) {
1585
1833
  const result = store.handoff(from_session, to_agent, context);
1586
1834
  return { content: [{ type: 'text', text: `Handoff complete.\nNew session: ${result.newSessionId}\nAgent: ${to_agent}\nAnnotations copied: ${result.annotationsCopied}` }] };
1587
1835
  });
1836
+ // ═══ pre_commit_check ═══
1837
+ server.tool('pre_commit_check', 'Run pre-commit risk analysis: detect_changes + review_pr + dead code + coverage gaps. Use before committing.', { repo: z.string().optional().describe('Repository root path') }, async ({ repo }) => {
1838
+ const { root } = getDb(repo);
1839
+ const report = await defaultOnPreCommit(root);
1840
+ return { content: [{ type: 'text', text: report }] };
1841
+ });
1842
+ // ═══ hook_onFileChange ═══
1843
+ server.tool('hook_onFileChange', 'Trigger the onFileChange hook. Call this when files are modified to get impact summary.', {
1844
+ files: z.array(z.string()).describe('List of changed file paths'),
1845
+ repo: z.string().optional(),
1846
+ }, async ({ files, repo }) => {
1847
+ const { root } = getDb(repo);
1848
+ const report = await defaultOnFileChange(files, root);
1849
+ return { content: [{ type: 'text', text: report }] };
1850
+ });
1851
+ // ═══ hook_preCompact ═══
1852
+ server.tool('hook_preCompact', 'Trigger pre-compaction hook. Saves a metrics snapshot before context window compaction.', { repo: z.string().optional() }, async ({ repo }) => {
1853
+ const { root, dbPath } = getDb(repo);
1854
+ const report = await defaultOnPreCompact(root, dbPath);
1855
+ return { content: [{ type: 'text', text: report }] };
1856
+ });
1857
+ // ═══ hook_postCompact ═══
1858
+ server.tool('hook_postCompact', 'Trigger post-compaction hook. Recalls annotations to restore context after compaction.', { repo: z.string().optional() }, async ({ repo }) => {
1859
+ const { root } = getDb(repo);
1860
+ const report = await defaultOnPostCompact(root);
1861
+ return { content: [{ type: 'text', text: report }] };
1862
+ });
1588
1863
  // ═══ semantic_search ═══
1589
1864
  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
1865
  const { db } = getDb(repo);
@@ -1946,18 +2221,320 @@ export function createMcpServer(rootPath) {
1946
2221
  }],
1947
2222
  };
1948
2223
  });
2224
+ // ═══ compare_impact ═══
2225
+ server.tool('compare_impact', 'Compare impact graph before/after an edit. Takes a snapshot first, then call again to see the diff. Returns new/removed dependents and heat changes.', {
2226
+ name: z.string().describe('Symbol name to compare'),
2227
+ action: z.enum(['snapshot', 'compare']).describe("'snapshot' to save current state, 'compare' to diff against last snapshot"),
2228
+ repo: z.string().optional(),
2229
+ }, async ({ name, action, repo }) => {
2230
+ const { db, root, dbPath } = getDb(repo);
2231
+ const orchestrator = new Orchestrator({ rootPath: root, dbPath });
2232
+ try {
2233
+ if (action === 'snapshot') {
2234
+ const snap = orchestrator.snapshot(name, db);
2235
+ return { content: [{ type: 'text', text: `Snapshot saved for "${name}":\n Heat: ${snap.heatScore}\n Dependents: ${snap.dependents.length}\n Timestamp: ${snap.timestamp}` }] };
2236
+ }
2237
+ const diff = orchestrator.compare(name, db);
2238
+ if (!diff.before) {
2239
+ return { content: [{ type: 'text', text: `No snapshot found for "${name}". Call compare_impact with action: 'snapshot' first.` }] };
2240
+ }
2241
+ const lines = [];
2242
+ lines.push(`Impact diff for "${name}"\n`);
2243
+ lines.push(`Before: ${diff.before.dependents.length} dependents, heat ${diff.heatBefore}`);
2244
+ lines.push(`After: ${diff.after.dependents.length} dependents, heat ${diff.heatAfter}`);
2245
+ if (diff.newDependents.length > 0) {
2246
+ lines.push(`\n+ ${diff.newDependents.length} new dependents:`);
2247
+ for (const d of diff.newDependents)
2248
+ lines.push(` + ${d.name} in ${d.filePath}`);
2249
+ }
2250
+ if (diff.removedDependents.length > 0) {
2251
+ lines.push(`\n- ${diff.removedDependents.length} removed dependents:`);
2252
+ for (const d of diff.removedDependents)
2253
+ lines.push(` - ${d.name} in ${d.filePath}`);
2254
+ }
2255
+ if (diff.heatChanged) {
2256
+ lines.push(`\nHeat changed: ${diff.heatBefore} → ${diff.heatAfter} (${diff.heatAfter > diff.heatBefore ? 'increased' : 'decreased'})`);
2257
+ }
2258
+ if (diff.newDependents.length === 0 && diff.removedDependents.length === 0 && !diff.heatChanged) {
2259
+ lines.push(`\nNo changes detected in impact graph.`);
2260
+ }
2261
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
2262
+ }
2263
+ finally {
2264
+ db.close();
2265
+ }
2266
+ });
2267
+ // ═══ orchestrate ═══
2268
+ server.tool('orchestrate', 'Run full orchestration cycle: detect_changes → review_pr → impact → coverage gaps → dead code. Returns structured action plan.', { repo: z.string().optional() }, async ({ repo }) => {
2269
+ const { root, dbPath } = getDb(repo);
2270
+ const orchestrator = new Orchestrator({ rootPath: root, dbPath, useEmoji: false });
2271
+ const report = await orchestrator.runAndFormat();
2272
+ return { content: [{ type: 'text', text: report }] };
2273
+ });
2274
+ // ═══ fix_apply ═══
2275
+ server.tool('fix_apply', 'Apply a security fix suggestion to a file. Creates a backup before modifying. CRITICAL rules require confirm: true.', {
2276
+ ruleId: z.string().describe('Security rule ID (e.g. "hardcoded_secret")'),
2277
+ file: z.string().describe('File path relative to repo root'),
2278
+ line: z.number().describe('Line number where the issue was found'),
2279
+ confirm: z.boolean().optional().default(false).describe('Confirmation required for CRITICAL rules'),
2280
+ repo: z.string().optional(),
2281
+ }, async ({ ruleId, file, line, confirm, repo }) => {
2282
+ const { root } = getDb(repo);
2283
+ const rules = loadRules();
2284
+ const rule = rules.find(r => r.id === ruleId);
2285
+ if (!rule)
2286
+ return { content: [{ type: 'text', text: `Rule not found: "${ruleId}"` }] };
2287
+ if (rule.severity === 'CRITICAL' && !confirm) {
2288
+ return { content: [{ type: 'text', text: `CRITICAL rule "${ruleId}" requires confirmation. Set confirm: true to proceed.` }] };
2289
+ }
2290
+ const fullPath = resolve(root, file);
2291
+ if (!existsSync(fullPath))
2292
+ return { content: [{ type: 'text', text: `File not found: ${file}` }] };
2293
+ const content = readFileSync(fullPath, 'utf-8');
2294
+ const lines = content.split('\n');
2295
+ if (line < 1 || line > lines.length)
2296
+ return { content: [{ type: 'text', text: `Line ${line} out of range (file has ${lines.length} lines).` }] };
2297
+ // Backup original
2298
+ const backupDir = join(root, '.milens', 'backups');
2299
+ mkdirSync(backupDir, { recursive: true });
2300
+ const backupPath = join(backupDir, `${file.replace(/[\\/]/g, '_')}_${Date.now()}.bak`);
2301
+ writeFileSync(backupPath, content, 'utf-8');
2302
+ // Apply fix: add comment above the affected line with the fix suggestion
2303
+ const targetLine = lines[line - 1];
2304
+ const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
2305
+ const fixComment = `${indent}// milens(fix): rule=${rule.id} — ${rule.fix ?? 'Review manually'}`;
2306
+ lines.splice(line - 1, 0, fixComment);
2307
+ const newContent = lines.join('\n');
2308
+ writeFileSync(fullPath, newContent, 'utf-8');
2309
+ return { content: [{ type: 'text', text: `Fix applied for rule "${ruleId}" at ${file}:${line}\nSeverity: ${rule.severity}\nBackup: ${relative(root, backupPath)}\nFix: ${rule.fix ?? 'Manual review needed'}\n\nAdded fix comment above line ${line}.` }] };
2310
+ });
2311
+ // ═══ test_generate ═══
2312
+ server.tool('test_generate', 'Generate a test file for a symbol using its test plan. Detects test framework and follows project conventions.', {
2313
+ symbol: z.string().describe('Symbol name to generate tests for'),
2314
+ repo: z.string().optional(),
2315
+ }, async ({ symbol, repo }) => {
2316
+ const { db, root } = getDb(repo);
2317
+ const plan = generateTestPlan(db, symbol);
2318
+ if (!plan)
2319
+ return { content: [{ type: 'text', text: `Symbol not found: "${symbol}"` }] };
2320
+ // Detect test framework
2321
+ const framework = detectTestFramework(root);
2322
+ const testExt = framework === 'pytest' ? '.py' : '.test.ts';
2323
+ // Determine test file path
2324
+ const srcFile = plan.file;
2325
+ const srcDir = dirname(srcFile);
2326
+ const srcName = basename(srcFile, srcFile.includes('.') ? '.' + srcFile.split('.').pop() : '');
2327
+ const testFileName = `${srcName}${testExt}`;
2328
+ const testDir = join(srcDir, '__tests__');
2329
+ const testPath = join(testDir, testFileName);
2330
+ // Don't overwrite existing test files
2331
+ if (existsSync(join(root, testPath))) {
2332
+ return { content: [{ type: 'text', text: `Test file already exists at ${testPath}. Skipping to avoid overwrite.` }] };
2333
+ }
2334
+ // Check if a sister test file exists alongside the source
2335
+ const altTestPath = join(srcDir, testFileName);
2336
+ const existingTestDir = existsSync(join(root, testDir));
2337
+ const altExists = existsSync(join(root, altTestPath));
2338
+ if (altExists) {
2339
+ return { content: [{ type: 'text', text: `Test file exists at ${altTestPath}. Skipping to avoid overwrite.` }] };
2340
+ }
2341
+ // Generate test code
2342
+ const testCode = generateTestCode(plan, framework, srcFile);
2343
+ // Write the test file
2344
+ const writePath = existingTestDir ? testPath : altTestPath;
2345
+ mkdirSync(dirname(join(root, writePath)), { recursive: true });
2346
+ writeFileSync(join(root, writePath), testCode, 'utf-8');
2347
+ return { content: [{ type: 'text', text: `Test file generated: ${writePath}\nFramework: ${framework}\nScenarios: ${plan.testScenarios.length}\nMock deps: ${plan.mockStrategy.length}` }] };
2348
+ });
2349
+ // ── Prompt: dead_code_remove ──
2350
+ server.prompt('dead_code_remove', 'Safe dead code removal workflow: detect → verify → remove → test.', { repo: z.string().optional().describe('Repository root path') }, ({ repo }) => ({
2351
+ messages: [{
2352
+ role: 'user',
2353
+ content: {
2354
+ type: 'text',
2355
+ text: `I need to safely remove dead code. Follow this workflow:\n\n` +
2356
+ `1. Run \`find_dead_code()\` to list symbols with zero incoming references\n` +
2357
+ `2. For each candidate symbol, verify it's truly unused:\n` +
2358
+ ` a. Run \`context({name: "symbolName"})\` to check for hidden callers\n` +
2359
+ ` b. Run \`grep({pattern: "symbolName"})\` to find all text references (templates, configs, docs, routes)\n` +
2360
+ ` c. Run \`impact({target: "symbolName", direction: "downstream"})\` to confirm no downstream impact\n` +
2361
+ `3. If neither context nor grep finds references → safe to remove\n` +
2362
+ `4. Before removal: \`edit_check({name: "symbolName"})\` for final safety check\n` +
2363
+ `5. Remove the symbol and its definition\n` +
2364
+ `6. Run test suite to verify no regressions\n` +
2365
+ `7. Report: which symbols removed, which skipped (and why)\n\n` +
2366
+ `IMPORTANT: Never auto-remove. Always ask for confirmation before deleting each symbol.` +
2367
+ (repo ? `\nRepo: ${repo}` : ''),
2368
+ },
2369
+ }],
2370
+ }));
1949
2371
  return server;
1950
2372
  }
2373
+ // ── Helpers ──
2374
+ function detectTestFramework(rootPath) {
2375
+ try {
2376
+ const pkgPath = resolve(rootPath, 'package.json');
2377
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
2378
+ const deps = { ...pkg.devDependencies, ...pkg.dependencies };
2379
+ if (deps.vitest)
2380
+ return 'vitest';
2381
+ if (deps.jest)
2382
+ return 'jest';
2383
+ if (deps.mocha)
2384
+ return 'mocha';
2385
+ }
2386
+ catch { }
2387
+ // Check for Python
2388
+ try {
2389
+ const cfg = readFileSync(resolve(rootPath, 'pytest.ini'), 'utf-8');
2390
+ return 'pytest';
2391
+ }
2392
+ catch { }
2393
+ try {
2394
+ const cfg = readFileSync(resolve(rootPath, 'setup.cfg'), 'utf-8');
2395
+ if (cfg.includes('[tool:pytest]'))
2396
+ return 'pytest';
2397
+ }
2398
+ catch { }
2399
+ return 'vitest'; // default (Vitest is most common for TS projects)
2400
+ }
2401
+ function generateTestCode(plan, framework, srcFile) {
2402
+ const lines = [];
2403
+ if (framework === 'pytest') {
2404
+ lines.push(`# Generated by milens — test plan for ${plan.symbol}`);
2405
+ lines.push(`import pytest`);
2406
+ lines.push(`from ${srcFile.replace(/[/\\]/g, '.').replace(/\.(ts|tsx|js|jsx|py)$/, '')} import ${plan.symbol}`);
2407
+ lines.push('');
2408
+ lines.push(`class Test${capitalize(plan.symbol)}:`);
2409
+ for (const s of plan.testScenarios) {
2410
+ lines.push(` def test_${s.name.toLowerCase().replace(/\s+/g, '_')}(self):`);
2411
+ lines.push(` """${s.description}"""`);
2412
+ lines.push(` pass # TODO: implement`);
2413
+ lines.push('');
2414
+ }
2415
+ }
2416
+ else {
2417
+ const hasTypescript = srcFile.endsWith('.ts') || srcFile.endsWith('.tsx');
2418
+ const ext = hasTypescript ? '.ts' : '.js';
2419
+ lines.push(`// Generated by milens — test plan for ${plan.symbol}`);
2420
+ if (framework === 'vitest') {
2421
+ lines.push(`import { describe, it, expect${plan.mockStrategy.length > 0 ? ', vi' : ''} } from 'vitest';`);
2422
+ lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2423
+ }
2424
+ else if (framework === 'mocha') {
2425
+ lines.push(`import { expect } from 'chai';`);
2426
+ lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2427
+ }
2428
+ else {
2429
+ lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2430
+ }
2431
+ // Mock imports
2432
+ for (const m of plan.mockStrategy) {
2433
+ if (framework === 'vitest') {
2434
+ lines.push(`vi.mock('${m.dependency}');`);
2435
+ }
2436
+ else if (framework === 'jest') {
2437
+ lines.push(`jest.mock('${m.dependency}');`);
2438
+ }
2439
+ }
2440
+ lines.push('');
2441
+ const describeFn = framework === 'mocha' ? `describe('${plan.symbol}'` : framework === 'vitest' ? `describe('${plan.symbol}', () =>` : `describe('${plan.symbol}', () =>`;
2442
+ const beforeEachHook = framework === 'mocha' ? ` beforeEach(() => {` : ` beforeEach(() => {`;
2443
+ const endBrace = framework === 'mocha' ? `});` : `});`;
2444
+ lines.push(`${describeFn} {`);
2445
+ lines.push(`${beforeEachHook}`);
2446
+ lines.push(` // Setup mocks`);
2447
+ lines.push(` });`);
2448
+ lines.push('');
2449
+ for (const s of plan.testScenarios) {
2450
+ const testFn = framework === 'mocha' ? ` it('${s.name}'` : ` it('${s.name}', () =>`;
2451
+ lines.push(` ${testFn} => {`);
2452
+ lines.push(` // ${s.description}`);
2453
+ lines.push(` const result = ${plan.symbol}();`);
2454
+ lines.push(` expect(result).toBeDefined();`);
2455
+ lines.push(` });`);
2456
+ lines.push('');
2457
+ }
2458
+ lines.push(`});`);
2459
+ }
2460
+ return lines.join('\n');
2461
+ }
2462
+ function capitalize(s) {
2463
+ return s.charAt(0).toUpperCase() + s.slice(1);
2464
+ }
2465
+ function relativeImport(srcFile, hasTypescript) {
2466
+ // Convert src/foo/bar.ts → ../foo/bar (relative import for __tests__/bar.test.ts)
2467
+ const withoutExt = srcFile.replace(/\.(ts|tsx|js|jsx)$/, '');
2468
+ return `.${hasTypescript ? '' : '.js'}/${withoutExt.split('/').pop()}`;
2469
+ }
1951
2470
  // ── Transport: stdio ──
1952
2471
  export async function startStdio(rootPath) {
1953
2472
  const server = createMcpServer(rootPath);
1954
2473
  const transport = new StdioServerTransport();
2474
+ // Start file watcher for auto re-index (respects hook config)
2475
+ let watcher = null;
2476
+ if (rootPath) {
2477
+ const { RepoRegistry } = await import('../store/registry.js');
2478
+ const { HookManager } = await import('./hooks.js');
2479
+ const reg = new RepoRegistry();
2480
+ const entry = reg.findByRoot(rootPath);
2481
+ if (entry) {
2482
+ const hookMgr = new HookManager();
2483
+ const hookConfig = hookMgr.loadConfig(rootPath);
2484
+ if (hookConfig.enabled && hookConfig.onFileChange) {
2485
+ watcher = new FileWatcher({
2486
+ rootPath,
2487
+ dbPath: entry.dbPath,
2488
+ logger: (_level, msg) => {
2489
+ server.server.sendLoggingMessage({ level: 'info', data: msg });
2490
+ },
2491
+ });
2492
+ watcher.start();
2493
+ }
2494
+ }
2495
+ }
2496
+ // Cleanup on exit
2497
+ const cleanup = () => {
2498
+ if (watcher)
2499
+ watcher.stop();
2500
+ };
2501
+ process.on('SIGINT', cleanup);
2502
+ process.on('SIGTERM', cleanup);
1955
2503
  await server.connect(transport);
1956
2504
  }
1957
2505
  // ── Transport: HTTP (Streamable) ──
1958
2506
  export async function startHttp(port, rootPath) {
1959
2507
  const server = createMcpServer(rootPath);
1960
2508
  const sessions = new Map();
2509
+ // Start file watcher for auto re-index (respects hook config)
2510
+ let watcher = null;
2511
+ if (rootPath) {
2512
+ const { RepoRegistry } = await import('../store/registry.js');
2513
+ const { HookManager } = await import('./hooks.js');
2514
+ const reg = new RepoRegistry();
2515
+ const entry = reg.findByRoot(rootPath);
2516
+ if (entry) {
2517
+ const hookMgr = new HookManager();
2518
+ const hookConfig = hookMgr.loadConfig(rootPath);
2519
+ if (hookConfig.enabled && hookConfig.onFileChange) {
2520
+ watcher = new FileWatcher({
2521
+ rootPath,
2522
+ dbPath: entry.dbPath,
2523
+ logger: (_level, msg) => {
2524
+ server.server.sendLoggingMessage({ level: 'info', data: msg });
2525
+ },
2526
+ });
2527
+ watcher.start();
2528
+ }
2529
+ }
2530
+ }
2531
+ // Cleanup on exit
2532
+ const cleanup = () => {
2533
+ if (watcher)
2534
+ watcher.stop();
2535
+ };
2536
+ process.on('SIGINT', cleanup);
2537
+ process.on('SIGTERM', cleanup);
1961
2538
  // Evict idle sessions every 5 minutes
1962
2539
  const SESSION_TTL = 30 * 60_000; // 30 minutes
1963
2540
  const evictTimer = setInterval(() => {