milens 0.6.4 → 0.6.5

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 (67) hide show
  1. package/.agents/skills/adapters/SKILL.md +20 -0
  2. package/.agents/skills/analyzer/SKILL.md +35 -13
  3. package/.agents/skills/apps/SKILL.md +25 -1
  4. package/.agents/skills/docs/SKILL.md +32 -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 +45 -19
  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 +260 -433
  18. package/dist/agents-md.d.ts.map +1 -1
  19. package/dist/agents-md.js +5 -3
  20. package/dist/agents-md.js.map +1 -1
  21. package/dist/analyzer/engine.d.ts +1 -0
  22. package/dist/analyzer/engine.d.ts.map +1 -1
  23. package/dist/analyzer/engine.js +36 -6
  24. package/dist/analyzer/engine.js.map +1 -1
  25. package/dist/cli.js +296 -19
  26. package/dist/cli.js.map +1 -1
  27. package/dist/orchestrator/orchestrator.d.ts +65 -0
  28. package/dist/orchestrator/orchestrator.d.ts.map +1 -0
  29. package/dist/orchestrator/orchestrator.js +178 -0
  30. package/dist/orchestrator/orchestrator.js.map +1 -0
  31. package/dist/orchestrator/reporter.d.ts +15 -0
  32. package/dist/orchestrator/reporter.d.ts.map +1 -0
  33. package/dist/orchestrator/reporter.js +38 -0
  34. package/dist/orchestrator/reporter.js.map +1 -0
  35. package/dist/security/rules.d.ts.map +1 -1
  36. package/dist/security/rules.js +4 -1
  37. package/dist/security/rules.js.map +1 -1
  38. package/dist/server/hooks.d.ts +3 -0
  39. package/dist/server/hooks.d.ts.map +1 -1
  40. package/dist/server/hooks.js +79 -0
  41. package/dist/server/hooks.js.map +1 -1
  42. package/dist/server/mcp-prompts.d.ts.map +1 -1
  43. package/dist/server/mcp-prompts.js +1 -1
  44. package/dist/server/mcp-prompts.js.map +1 -1
  45. package/dist/server/mcp.d.ts.map +1 -1
  46. package/dist/server/mcp.js +418 -15
  47. package/dist/server/mcp.js.map +1 -1
  48. package/dist/server/watcher.d.ts +39 -0
  49. package/dist/server/watcher.d.ts.map +1 -0
  50. package/dist/server/watcher.js +134 -0
  51. package/dist/server/watcher.js.map +1 -0
  52. package/dist/skills.js +51 -7
  53. package/dist/skills.js.map +1 -1
  54. package/dist/store/annotations.d.ts.map +1 -1
  55. package/dist/store/annotations.js +18 -15
  56. package/dist/store/annotations.js.map +1 -1
  57. package/dist/store/confidence.d.ts +10 -0
  58. package/dist/store/confidence.d.ts.map +1 -1
  59. package/dist/store/confidence.js +28 -1
  60. package/dist/store/confidence.js.map +1 -1
  61. package/dist/store/db.d.ts +16 -0
  62. package/dist/store/db.d.ts.map +1 -1
  63. package/dist/store/db.js +121 -7
  64. package/dist/store/db.js.map +1 -1
  65. package/dist/store/schema.sql +24 -9
  66. package/docs/README.md +3 -5
  67. 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,9 @@ 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';
21
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
25
  const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
23
26
  // ── Lazy DB connection with idle eviction ──
@@ -35,7 +38,7 @@ class LazyDb {
35
38
  }
36
39
  get() {
37
40
  this.resetTimer();
38
- if (!this.instance) {
41
+ if (!this.instance || !this.instance.isOpen()) {
39
42
  this.instance = new Database(this.dbPath);
40
43
  this.statsCache = null;
41
44
  this.domainCache = null;
@@ -394,11 +397,13 @@ export function createMcpServer(rootPath) {
394
397
  if (!pools.has(root))
395
398
  pools.set(root, new LazyDb(dbPath));
396
399
  const lazy = pools.get(root);
397
- return { db: lazy.get(), root, lazy };
400
+ return { db: lazy.get(), root, dbPath, lazy };
398
401
  }
399
402
  const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
400
- // Auto-wrap every tool handler with usage tracking
403
+ // Auto-wrap every tool handler with usage tracking + background decay tick
401
404
  const origTool = server.tool.bind(server);
405
+ let lastDecayTick = 0;
406
+ const DECAY_INTERVAL = 5 * 60_000; // 5 minutes between decay ticks
402
407
  server.tool = ((...args) => {
403
408
  const toolName = args[0];
404
409
  const handler = args[args.length - 1];
@@ -409,6 +414,21 @@ export function createMcpServer(rootPath) {
409
414
  const responseText = result?.content?.map((c) => c.text).join('\n') ?? '';
410
415
  const repo = handlerArgs[0]?.repo;
411
416
  trackToolCall(trackDb, toolName, start, responseText, repo);
417
+ // Background confidence decay tick (real-time, every 5 min)
418
+ if (Date.now() - lastDecayTick > DECAY_INTERVAL) {
419
+ lastDecayTick = Date.now();
420
+ try {
421
+ for (const [, pool] of pools) {
422
+ const db = pool.get();
423
+ const store = new AnnotationStore(db.connection);
424
+ if (store.getAnnotationCount() >= 100) {
425
+ const { runDecayPass } = await import('../store/confidence.js');
426
+ runDecayPass(store);
427
+ }
428
+ }
429
+ }
430
+ catch { /* decay is best-effort */ }
431
+ }
412
432
  return result;
413
433
  };
414
434
  }
@@ -742,12 +762,11 @@ export function createMcpServer(rootPath) {
742
762
  return { content: [{ type: 'text', text: lines.join('\n') }] };
743
763
  });
744
764
  // ── Tool: detect_changes ──
745
- server.tool('detect_changes', 'Git diff → affected symbols + direct dependents.', {
765
+ server.tool('detect_changes', 'Git diff → affected symbols + direct dependents. Uses line-level diff to report only actually-changed symbols.', {
746
766
  ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
747
767
  repo: z.string().optional(),
748
768
  }, async ({ ref, repo }) => {
749
769
  const { db, root } = getDb(repo);
750
- // Validate ref: only allow safe git ref characters (alphanumeric, /, ., -, _, ~, ^)
751
770
  if (!/^[a-zA-Z0-9\/._~^\-]+$/.test(ref)) {
752
771
  return { content: [{ type: 'text', text: 'Invalid git ref.' }] };
753
772
  }
@@ -763,24 +782,70 @@ export function createMcpServer(rootPath) {
763
782
  if (changedFiles.length === 0) {
764
783
  return { content: [{ type: 'text', text: 'No changed files detected.' }] };
765
784
  }
785
+ // Get changed line ranges per file (git diff -U0 gives hunk headers with @@ -old,new +old,new @@)
786
+ const changedLinesByFile = new Map();
787
+ for (const file of changedFiles) {
788
+ try {
789
+ const diffOut = execFileSync('git', ['diff', '-U0', ref, '--', file], { cwd: root, encoding: 'utf-8' });
790
+ const changedLines = new Set();
791
+ for (const line of diffOut.split('\n')) {
792
+ const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
793
+ if (m) {
794
+ const start = parseInt(m[1], 10);
795
+ const count = m[2] ? parseInt(m[2], 10) : 1;
796
+ for (let i = start; i < start + count; i++) {
797
+ changedLines.add(i);
798
+ }
799
+ }
800
+ }
801
+ if (changedLines.size > 0)
802
+ changedLinesByFile.set(file, changedLines);
803
+ }
804
+ catch { /* file may not exist at ref */ }
805
+ }
766
806
  const lines = [`${changedFiles.length} changed files:\n`];
767
807
  let totalAffected = 0;
808
+ let totalChanged = 0;
768
809
  for (const file of changedFiles) {
769
810
  const syms = db.getSymbolsByFile(file);
770
811
  if (syms.length === 0) {
771
812
  lines.push(`${file}: (not indexed)`);
772
813
  continue;
773
814
  }
774
- lines.push(`${file}: ${syms.length} symbols`);
775
- for (const sym of syms) {
815
+ const changedLines = changedLinesByFile.get(file);
816
+ // Filter to only symbols whose line range overlaps with changed lines
817
+ const changedSyms = changedLines
818
+ ? syms.filter(s => {
819
+ for (let line = s.startLine; line <= s.endLine; line++) {
820
+ if (changedLines.has(line))
821
+ return true;
822
+ }
823
+ return false;
824
+ })
825
+ : syms; // fallback: no line-level diff available, report all
826
+ if (changedSyms.length === 0 && changedLines) {
827
+ // File changed but no symbols affected (e.g. whitespace, imports only)
828
+ lines.push(`${file}: (symbols unchanged)`);
829
+ continue;
830
+ }
831
+ const displaySyms = changedSyms.length > 0 ? changedSyms : syms;
832
+ const unchangedCount = changedSyms.length > 0 ? syms.length - changedSyms.length : 0;
833
+ const unchangedNote = unchangedCount > 0 ? ` (${unchangedCount} unchanged not shown)` : '';
834
+ lines.push(`${file}: ${displaySyms.length} changed symbols${unchangedNote}`);
835
+ for (const sym of displaySyms) {
776
836
  const upstream = db.findUpstream(sym.id, 1);
837
+ totalChanged++;
777
838
  if (upstream.length > 0) {
778
- lines.push(` ${sym.name} [${sym.kind}] → ${upstream.length} direct dependents`);
839
+ lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${upstream.length} direct dependents`);
779
840
  totalAffected += upstream.length;
780
841
  }
842
+ else {
843
+ lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine}`);
844
+ }
781
845
  }
782
846
  }
783
- lines.push(`\nTotal direct dependents affected: ${totalAffected}`);
847
+ lines.push(`\nTotal changed symbols: ${totalChanged}`);
848
+ lines.push(`Total direct dependents affected: ${totalAffected}`);
784
849
  return { content: [{ type: 'text', text: lines.join('\n') }] };
785
850
  });
786
851
  // ── Tool: explain_relationship ──
@@ -1539,10 +1604,20 @@ export function createMcpServer(rootPath) {
1539
1604
  });
1540
1605
  // ═══ session_start ═══
1541
1606
  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();
1607
+ const { db, root, dbPath } = getDb();
1543
1608
  const store = new AnnotationStore(db.connection);
1544
1609
  const sessionId = store.sessionStart(agent);
1545
- return { content: [{ type: 'text', text: `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().` }] };
1610
+ let hookOutput = '';
1611
+ try {
1612
+ const manager = new HookManager();
1613
+ const config = manager.loadConfig(root);
1614
+ if (config.enabled && config.onSessionStart) {
1615
+ hookOutput = await defaultOnSessionStart({ agent, sessionId, rootPath: root }, dbPath);
1616
+ }
1617
+ }
1618
+ catch { /* hooks are best-effort */ }
1619
+ const text = `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().`;
1620
+ return { content: [{ type: 'text', text: hookOutput ? `${hookOutput}\n\n${text}` : text }] };
1546
1621
  });
1547
1622
  // ═══ session_context ═══
1548
1623
  server.tool('session_context', 'Get metadata about a session: annotations, tool calls, duration.', { session_id: z.string() }, async ({ session_id }) => {
@@ -1570,10 +1645,21 @@ export function createMcpServer(rootPath) {
1570
1645
  });
1571
1646
  // ═══ session_end ═══
1572
1647
  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();
1648
+ const { db, root, dbPath } = getDb();
1574
1649
  const store = new AnnotationStore(db.connection);
1575
1650
  const summary = store.sessionEnd(session_id, status);
1576
- return { content: [{ type: 'text', text: `Session ended: ${session_id}\nStatus: ${status}\nAnnotations: ${summary.annotationCount}` }] };
1651
+ let hookOutput = '';
1652
+ try {
1653
+ const ctx = store.sessionContext(session_id);
1654
+ const manager = new HookManager();
1655
+ const config = manager.loadConfig(root);
1656
+ if (config.enabled && config.onSessionEnd) {
1657
+ hookOutput = await defaultOnSessionEnd({ agent: ctx.session.agent, sessionId: session_id, rootPath: root }, dbPath);
1658
+ }
1659
+ }
1660
+ catch { /* hooks are best-effort */ }
1661
+ const text = `Session ended: ${session_id}\nStatus: ${status}\nAnnotations: ${summary.annotationCount}`;
1662
+ return { content: [{ type: 'text', text: hookOutput ? `${text}\n\n${hookOutput}` : text }] };
1577
1663
  });
1578
1664
  // ═══ handoff ═══
1579
1665
  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 +1671,33 @@ export function createMcpServer(rootPath) {
1585
1671
  const result = store.handoff(from_session, to_agent, context);
1586
1672
  return { content: [{ type: 'text', text: `Handoff complete.\nNew session: ${result.newSessionId}\nAgent: ${to_agent}\nAnnotations copied: ${result.annotationsCopied}` }] };
1587
1673
  });
1674
+ // ═══ pre_commit_check ═══
1675
+ 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 }) => {
1676
+ const { root } = getDb(repo);
1677
+ const report = await defaultOnPreCommit(root);
1678
+ return { content: [{ type: 'text', text: report }] };
1679
+ });
1680
+ // ═══ hook_onFileChange ═══
1681
+ server.tool('hook_onFileChange', 'Trigger the onFileChange hook. Call this when files are modified to get impact summary.', {
1682
+ files: z.array(z.string()).describe('List of changed file paths'),
1683
+ repo: z.string().optional(),
1684
+ }, async ({ files, repo }) => {
1685
+ const { root } = getDb(repo);
1686
+ const report = await defaultOnFileChange(files, root);
1687
+ return { content: [{ type: 'text', text: report }] };
1688
+ });
1689
+ // ═══ hook_preCompact ═══
1690
+ server.tool('hook_preCompact', 'Trigger pre-compaction hook. Saves a metrics snapshot before context window compaction.', { repo: z.string().optional() }, async ({ repo }) => {
1691
+ const { root, dbPath } = getDb(repo);
1692
+ const report = await defaultOnPreCompact(root, dbPath);
1693
+ return { content: [{ type: 'text', text: report }] };
1694
+ });
1695
+ // ═══ hook_postCompact ═══
1696
+ server.tool('hook_postCompact', 'Trigger post-compaction hook. Recalls annotations to restore context after compaction.', { repo: z.string().optional() }, async ({ repo }) => {
1697
+ const { root } = getDb(repo);
1698
+ const report = await defaultOnPostCompact(root);
1699
+ return { content: [{ type: 'text', text: report }] };
1700
+ });
1588
1701
  // ═══ semantic_search ═══
1589
1702
  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
1703
  const { db } = getDb(repo);
@@ -1946,18 +2059,308 @@ export function createMcpServer(rootPath) {
1946
2059
  }],
1947
2060
  };
1948
2061
  });
2062
+ // ═══ compare_impact ═══
2063
+ 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.', {
2064
+ name: z.string().describe('Symbol name to compare'),
2065
+ action: z.enum(['snapshot', 'compare']).describe("'snapshot' to save current state, 'compare' to diff against last snapshot"),
2066
+ repo: z.string().optional(),
2067
+ }, async ({ name, action, repo }) => {
2068
+ const { db, root, dbPath } = getDb(repo);
2069
+ const orchestrator = new Orchestrator({ rootPath: root, dbPath });
2070
+ try {
2071
+ if (action === 'snapshot') {
2072
+ const snap = orchestrator.snapshot(name, db);
2073
+ return { content: [{ type: 'text', text: `Snapshot saved for "${name}":\n Heat: ${snap.heatScore}\n Dependents: ${snap.dependents.length}\n Timestamp: ${snap.timestamp}` }] };
2074
+ }
2075
+ const diff = orchestrator.compare(name, db);
2076
+ if (!diff.before) {
2077
+ return { content: [{ type: 'text', text: `No snapshot found for "${name}". Call compare_impact with action: 'snapshot' first.` }] };
2078
+ }
2079
+ const lines = [];
2080
+ lines.push(`Impact diff for "${name}"\n`);
2081
+ lines.push(`Before: ${diff.before.dependents.length} dependents, heat ${diff.heatBefore}`);
2082
+ lines.push(`After: ${diff.after.dependents.length} dependents, heat ${diff.heatAfter}`);
2083
+ if (diff.newDependents.length > 0) {
2084
+ lines.push(`\n+ ${diff.newDependents.length} new dependents:`);
2085
+ for (const d of diff.newDependents)
2086
+ lines.push(` + ${d.name} in ${d.filePath}`);
2087
+ }
2088
+ if (diff.removedDependents.length > 0) {
2089
+ lines.push(`\n- ${diff.removedDependents.length} removed dependents:`);
2090
+ for (const d of diff.removedDependents)
2091
+ lines.push(` - ${d.name} in ${d.filePath}`);
2092
+ }
2093
+ if (diff.heatChanged) {
2094
+ lines.push(`\nHeat changed: ${diff.heatBefore} → ${diff.heatAfter} (${diff.heatAfter > diff.heatBefore ? 'increased' : 'decreased'})`);
2095
+ }
2096
+ if (diff.newDependents.length === 0 && diff.removedDependents.length === 0 && !diff.heatChanged) {
2097
+ lines.push(`\nNo changes detected in impact graph.`);
2098
+ }
2099
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
2100
+ }
2101
+ finally {
2102
+ db.close();
2103
+ }
2104
+ });
2105
+ // ═══ orchestrate ═══
2106
+ 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 }) => {
2107
+ const { root, dbPath } = getDb(repo);
2108
+ const orchestrator = new Orchestrator({ rootPath: root, dbPath, useEmoji: false });
2109
+ const report = await orchestrator.runAndFormat();
2110
+ return { content: [{ type: 'text', text: report }] };
2111
+ });
2112
+ // ═══ fix_apply ═══
2113
+ server.tool('fix_apply', 'Apply a security fix suggestion to a file. Creates a backup before modifying. CRITICAL rules require confirm: true.', {
2114
+ ruleId: z.string().describe('Security rule ID (e.g. "hardcoded_secret")'),
2115
+ file: z.string().describe('File path relative to repo root'),
2116
+ line: z.number().describe('Line number where the issue was found'),
2117
+ confirm: z.boolean().optional().default(false).describe('Confirmation required for CRITICAL rules'),
2118
+ repo: z.string().optional(),
2119
+ }, async ({ ruleId, file, line, confirm, repo }) => {
2120
+ const { root } = getDb(repo);
2121
+ const rules = loadRules();
2122
+ const rule = rules.find(r => r.id === ruleId);
2123
+ if (!rule)
2124
+ return { content: [{ type: 'text', text: `Rule not found: "${ruleId}"` }] };
2125
+ if (rule.severity === 'CRITICAL' && !confirm) {
2126
+ return { content: [{ type: 'text', text: `CRITICAL rule "${ruleId}" requires confirmation. Set confirm: true to proceed.` }] };
2127
+ }
2128
+ const fullPath = resolve(root, file);
2129
+ if (!existsSync(fullPath))
2130
+ return { content: [{ type: 'text', text: `File not found: ${file}` }] };
2131
+ const content = readFileSync(fullPath, 'utf-8');
2132
+ const lines = content.split('\n');
2133
+ if (line < 1 || line > lines.length)
2134
+ return { content: [{ type: 'text', text: `Line ${line} out of range (file has ${lines.length} lines).` }] };
2135
+ // Backup original
2136
+ const backupDir = join(root, '.milens', 'backups');
2137
+ mkdirSync(backupDir, { recursive: true });
2138
+ const backupPath = join(backupDir, `${file.replace(/[\\/]/g, '_')}_${Date.now()}.bak`);
2139
+ writeFileSync(backupPath, content, 'utf-8');
2140
+ // Apply fix: add comment above the affected line with the fix suggestion
2141
+ const targetLine = lines[line - 1];
2142
+ const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
2143
+ const fixComment = `${indent}// milens(fix): rule=${rule.id} — ${rule.fix ?? 'Review manually'}`;
2144
+ lines.splice(line - 1, 0, fixComment);
2145
+ const newContent = lines.join('\n');
2146
+ writeFileSync(fullPath, newContent, 'utf-8');
2147
+ 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}.` }] };
2148
+ });
2149
+ // ═══ test_generate ═══
2150
+ server.tool('test_generate', 'Generate a test file for a symbol using its test plan. Detects test framework and follows project conventions.', {
2151
+ symbol: z.string().describe('Symbol name to generate tests for'),
2152
+ repo: z.string().optional(),
2153
+ }, async ({ symbol, repo }) => {
2154
+ const { db, root } = getDb(repo);
2155
+ const plan = generateTestPlan(db, symbol);
2156
+ if (!plan)
2157
+ return { content: [{ type: 'text', text: `Symbol not found: "${symbol}"` }] };
2158
+ // Detect test framework
2159
+ const framework = detectTestFramework(root);
2160
+ const testExt = framework === 'pytest' ? '.py' : '.test.ts';
2161
+ // Determine test file path
2162
+ const srcFile = plan.file;
2163
+ const srcDir = dirname(srcFile);
2164
+ const srcName = basename(srcFile, srcFile.includes('.') ? '.' + srcFile.split('.').pop() : '');
2165
+ const testFileName = `${srcName}${testExt}`;
2166
+ const testDir = join(srcDir, '__tests__');
2167
+ const testPath = join(testDir, testFileName);
2168
+ // Don't overwrite existing test files
2169
+ if (existsSync(join(root, testPath))) {
2170
+ return { content: [{ type: 'text', text: `Test file already exists at ${testPath}. Skipping to avoid overwrite.` }] };
2171
+ }
2172
+ // Check if a sister test file exists alongside the source
2173
+ const altTestPath = join(srcDir, testFileName);
2174
+ const existingTestDir = existsSync(join(root, testDir));
2175
+ const altExists = existsSync(join(root, altTestPath));
2176
+ if (altExists) {
2177
+ return { content: [{ type: 'text', text: `Test file exists at ${altTestPath}. Skipping to avoid overwrite.` }] };
2178
+ }
2179
+ // Generate test code
2180
+ const testCode = generateTestCode(plan, framework, srcFile);
2181
+ // Write the test file
2182
+ const writePath = existingTestDir ? testPath : altTestPath;
2183
+ mkdirSync(dirname(join(root, writePath)), { recursive: true });
2184
+ writeFileSync(join(root, writePath), testCode, 'utf-8');
2185
+ return { content: [{ type: 'text', text: `Test file generated: ${writePath}\nFramework: ${framework}\nScenarios: ${plan.testScenarios.length}\nMock deps: ${plan.mockStrategy.length}` }] };
2186
+ });
2187
+ // ── Prompt: dead_code_remove ──
2188
+ server.prompt('dead_code_remove', 'Safe dead code removal workflow: detect → verify → remove → test.', { repo: z.string().optional().describe('Repository root path') }, ({ repo }) => ({
2189
+ messages: [{
2190
+ role: 'user',
2191
+ content: {
2192
+ type: 'text',
2193
+ text: `I need to safely remove dead code. Follow this workflow:\n\n` +
2194
+ `1. Run \`find_dead_code()\` to list symbols with zero incoming references\n` +
2195
+ `2. For each candidate symbol, verify it's truly unused:\n` +
2196
+ ` a. Run \`context({name: "symbolName"})\` to check for hidden callers\n` +
2197
+ ` b. Run \`grep({pattern: "symbolName"})\` to find all text references (templates, configs, docs, routes)\n` +
2198
+ ` c. Run \`impact({target: "symbolName", direction: "downstream"})\` to confirm no downstream impact\n` +
2199
+ `3. If neither context nor grep finds references → safe to remove\n` +
2200
+ `4. Before removal: \`edit_check({name: "symbolName"})\` for final safety check\n` +
2201
+ `5. Remove the symbol and its definition\n` +
2202
+ `6. Run test suite to verify no regressions\n` +
2203
+ `7. Report: which symbols removed, which skipped (and why)\n\n` +
2204
+ `IMPORTANT: Never auto-remove. Always ask for confirmation before deleting each symbol.` +
2205
+ (repo ? `\nRepo: ${repo}` : ''),
2206
+ },
2207
+ }],
2208
+ }));
1949
2209
  return server;
1950
2210
  }
2211
+ // ── Helpers ──
2212
+ function detectTestFramework(rootPath) {
2213
+ try {
2214
+ const pkgPath = resolve(rootPath, 'package.json');
2215
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
2216
+ const deps = { ...pkg.devDependencies, ...pkg.dependencies };
2217
+ if (deps.vitest)
2218
+ return 'vitest';
2219
+ if (deps.jest)
2220
+ return 'jest';
2221
+ if (deps.mocha)
2222
+ return 'mocha';
2223
+ }
2224
+ catch { }
2225
+ // Check for Python
2226
+ try {
2227
+ const cfg = readFileSync(resolve(rootPath, 'pytest.ini'), 'utf-8');
2228
+ return 'pytest';
2229
+ }
2230
+ catch { }
2231
+ try {
2232
+ const cfg = readFileSync(resolve(rootPath, 'setup.cfg'), 'utf-8');
2233
+ if (cfg.includes('[tool:pytest]'))
2234
+ return 'pytest';
2235
+ }
2236
+ catch { }
2237
+ return 'vitest'; // default (Vitest is most common for TS projects)
2238
+ }
2239
+ function generateTestCode(plan, framework, srcFile) {
2240
+ const lines = [];
2241
+ if (framework === 'pytest') {
2242
+ lines.push(`# Generated by milens — test plan for ${plan.symbol}`);
2243
+ lines.push(`import pytest`);
2244
+ lines.push(`from ${srcFile.replace(/[/\\]/g, '.').replace(/\.(ts|tsx|js|jsx|py)$/, '')} import ${plan.symbol}`);
2245
+ lines.push('');
2246
+ lines.push(`class Test${capitalize(plan.symbol)}:`);
2247
+ for (const s of plan.testScenarios) {
2248
+ lines.push(` def test_${s.name.toLowerCase().replace(/\s+/g, '_')}(self):`);
2249
+ lines.push(` """${s.description}"""`);
2250
+ lines.push(` pass # TODO: implement`);
2251
+ lines.push('');
2252
+ }
2253
+ }
2254
+ else {
2255
+ const hasTypescript = srcFile.endsWith('.ts') || srcFile.endsWith('.tsx');
2256
+ const ext = hasTypescript ? '.ts' : '.js';
2257
+ lines.push(`// Generated by milens — test plan for ${plan.symbol}`);
2258
+ if (framework === 'vitest') {
2259
+ lines.push(`import { describe, it, expect${plan.mockStrategy.length > 0 ? ', vi' : ''} } from 'vitest';`);
2260
+ lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2261
+ }
2262
+ else if (framework === 'mocha') {
2263
+ lines.push(`import { expect } from 'chai';`);
2264
+ lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2265
+ }
2266
+ else {
2267
+ lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2268
+ }
2269
+ // Mock imports
2270
+ for (const m of plan.mockStrategy) {
2271
+ if (framework === 'vitest') {
2272
+ lines.push(`vi.mock('${m.dependency}');`);
2273
+ }
2274
+ else if (framework === 'jest') {
2275
+ lines.push(`jest.mock('${m.dependency}');`);
2276
+ }
2277
+ }
2278
+ lines.push('');
2279
+ const describeFn = framework === 'mocha' ? `describe('${plan.symbol}'` : framework === 'vitest' ? `describe('${plan.symbol}', () =>` : `describe('${plan.symbol}', () =>`;
2280
+ const beforeEachHook = framework === 'mocha' ? ` beforeEach(() => {` : ` beforeEach(() => {`;
2281
+ const endBrace = framework === 'mocha' ? `});` : `});`;
2282
+ lines.push(`${describeFn} {`);
2283
+ lines.push(`${beforeEachHook}`);
2284
+ lines.push(` // Setup mocks`);
2285
+ lines.push(` });`);
2286
+ lines.push('');
2287
+ for (const s of plan.testScenarios) {
2288
+ const testFn = framework === 'mocha' ? ` it('${s.name}'` : ` it('${s.name}', () =>`;
2289
+ lines.push(` ${testFn} => {`);
2290
+ lines.push(` // ${s.description}`);
2291
+ lines.push(` const result = ${plan.symbol}();`);
2292
+ lines.push(` expect(result).toBeDefined();`);
2293
+ lines.push(` });`);
2294
+ lines.push('');
2295
+ }
2296
+ lines.push(`});`);
2297
+ }
2298
+ return lines.join('\n');
2299
+ }
2300
+ function capitalize(s) {
2301
+ return s.charAt(0).toUpperCase() + s.slice(1);
2302
+ }
2303
+ function relativeImport(srcFile, hasTypescript) {
2304
+ // Convert src/foo/bar.ts → ../foo/bar (relative import for __tests__/bar.test.ts)
2305
+ const withoutExt = srcFile.replace(/\.(ts|tsx|js|jsx)$/, '');
2306
+ return `.${hasTypescript ? '' : '.js'}/${withoutExt.split('/').pop()}`;
2307
+ }
1951
2308
  // ── Transport: stdio ──
1952
2309
  export async function startStdio(rootPath) {
1953
2310
  const server = createMcpServer(rootPath);
1954
2311
  const transport = new StdioServerTransport();
2312
+ // Start file watcher for auto re-index (respects hook config)
2313
+ let watcher = null;
2314
+ if (rootPath) {
2315
+ const { RepoRegistry } = await import('../store/registry.js');
2316
+ const { HookManager } = await import('./hooks.js');
2317
+ const reg = new RepoRegistry();
2318
+ const entry = reg.findByRoot(rootPath);
2319
+ if (entry) {
2320
+ const hookMgr = new HookManager();
2321
+ const hookConfig = hookMgr.loadConfig(rootPath);
2322
+ if (hookConfig.enabled && hookConfig.onFileChange) {
2323
+ watcher = new FileWatcher({ rootPath, dbPath: entry.dbPath });
2324
+ watcher.start();
2325
+ }
2326
+ }
2327
+ }
2328
+ // Cleanup on exit
2329
+ const cleanup = () => {
2330
+ if (watcher)
2331
+ watcher.stop();
2332
+ };
2333
+ process.on('SIGINT', cleanup);
2334
+ process.on('SIGTERM', cleanup);
1955
2335
  await server.connect(transport);
1956
2336
  }
1957
2337
  // ── Transport: HTTP (Streamable) ──
1958
2338
  export async function startHttp(port, rootPath) {
1959
2339
  const server = createMcpServer(rootPath);
1960
2340
  const sessions = new Map();
2341
+ // Start file watcher for auto re-index (respects hook config)
2342
+ let watcher = null;
2343
+ if (rootPath) {
2344
+ const { RepoRegistry } = await import('../store/registry.js');
2345
+ const { HookManager } = await import('./hooks.js');
2346
+ const reg = new RepoRegistry();
2347
+ const entry = reg.findByRoot(rootPath);
2348
+ if (entry) {
2349
+ const hookMgr = new HookManager();
2350
+ const hookConfig = hookMgr.loadConfig(rootPath);
2351
+ if (hookConfig.enabled && hookConfig.onFileChange) {
2352
+ watcher = new FileWatcher({ rootPath, dbPath: entry.dbPath });
2353
+ watcher.start();
2354
+ }
2355
+ }
2356
+ }
2357
+ // Cleanup on exit
2358
+ const cleanup = () => {
2359
+ if (watcher)
2360
+ watcher.stop();
2361
+ };
2362
+ process.on('SIGINT', cleanup);
2363
+ process.on('SIGTERM', cleanup);
1961
2364
  // Evict idle sessions every 5 minutes
1962
2365
  const SESSION_TTL = 30 * 60_000; // 30 minutes
1963
2366
  const evictTimer = setInterval(() => {