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.
- package/.agents/skills/adapters/SKILL.md +20 -0
- package/.agents/skills/analyzer/SKILL.md +38 -16
- package/.agents/skills/apps/SKILL.md +25 -1
- package/.agents/skills/docs/SKILL.md +33 -5
- package/.agents/skills/milens/SKILL.md +36 -6
- package/.agents/skills/milens-architect/SKILL.md +128 -0
- package/.agents/skills/milens-debugger/SKILL.md +141 -0
- package/.agents/skills/orchestrator/SKILL.md +59 -0
- package/.agents/skills/parser/SKILL.md +35 -14
- package/.agents/skills/root/SKILL.md +39 -17
- package/.agents/skills/scripts/SKILL.md +21 -3
- package/.agents/skills/security/SKILL.md +32 -11
- package/.agents/skills/server/SKILL.md +47 -20
- package/.agents/skills/store/SKILL.md +40 -18
- package/.agents/skills/test/SKILL.md +57 -9
- package/LICENSE +21 -75
- package/README.md +294 -467
- package/adapters/claude-code/CLAUDE.md +36 -15
- package/adapters/codex/.codex/codex.md +38 -23
- package/adapters/copilot/.github/copilot-instructions.md +29 -22
- package/adapters/gemini/.gemini/context.md +33 -10
- package/adapters/opencode/AGENTS.md +36 -15
- package/dist/agents-md.d.ts.map +1 -1
- package/dist/agents-md.js +56 -5
- package/dist/agents-md.js.map +1 -1
- package/dist/analyzer/engine.d.ts +4 -0
- package/dist/analyzer/engine.d.ts.map +1 -1
- package/dist/analyzer/engine.js +378 -14
- package/dist/analyzer/engine.js.map +1 -1
- package/dist/analyzer/resolver.d.ts +2 -0
- package/dist/analyzer/resolver.d.ts.map +1 -1
- package/dist/analyzer/resolver.js +187 -9
- package/dist/analyzer/resolver.js.map +1 -1
- package/dist/analyzer/review.d.ts.map +1 -1
- package/dist/analyzer/review.js +254 -32
- package/dist/analyzer/review.js.map +1 -1
- package/dist/analyzer/scope-resolver.d.ts +42 -0
- package/dist/analyzer/scope-resolver.d.ts.map +1 -0
- package/dist/analyzer/scope-resolver.js +687 -0
- package/dist/analyzer/scope-resolver.js.map +1 -0
- package/dist/cli.js +590 -20
- package/dist/cli.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts +65 -0
- package/dist/orchestrator/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator/orchestrator.js +178 -0
- package/dist/orchestrator/orchestrator.js.map +1 -0
- package/dist/orchestrator/reporter.d.ts +15 -0
- package/dist/orchestrator/reporter.d.ts.map +1 -0
- package/dist/orchestrator/reporter.js +38 -0
- package/dist/orchestrator/reporter.js.map +1 -0
- package/dist/parser/extract.d.ts +6 -1
- package/dist/parser/extract.d.ts.map +1 -1
- package/dist/parser/extract.js +14 -2
- package/dist/parser/extract.js.map +1 -1
- package/dist/parser/lang-css.d.ts.map +1 -1
- package/dist/parser/lang-css.js +7 -1
- package/dist/parser/lang-css.js.map +1 -1
- package/dist/parser/lang-go.d.ts.map +1 -1
- package/dist/parser/lang-go.js +16 -0
- package/dist/parser/lang-go.js.map +1 -1
- package/dist/parser/lang-html.d.ts +4 -0
- package/dist/parser/lang-html.d.ts.map +1 -1
- package/dist/parser/lang-html.js +40 -1
- package/dist/parser/lang-html.js.map +1 -1
- package/dist/parser/lang-java.d.ts.map +1 -1
- package/dist/parser/lang-java.js +12 -0
- package/dist/parser/lang-java.js.map +1 -1
- package/dist/parser/lang-js.d.ts.map +1 -1
- package/dist/parser/lang-js.js +3 -0
- package/dist/parser/lang-js.js.map +1 -1
- package/dist/parser/lang-php.d.ts.map +1 -1
- package/dist/parser/lang-php.js +11 -0
- package/dist/parser/lang-php.js.map +1 -1
- package/dist/parser/lang-py.d.ts.map +1 -1
- package/dist/parser/lang-py.js +14 -0
- package/dist/parser/lang-py.js.map +1 -1
- package/dist/parser/lang-ruby.d.ts.map +1 -1
- package/dist/parser/lang-ruby.js +20 -0
- package/dist/parser/lang-ruby.js.map +1 -1
- package/dist/parser/lang-rust.d.ts.map +1 -1
- package/dist/parser/lang-rust.js +27 -4
- package/dist/parser/lang-rust.js.map +1 -1
- package/dist/parser/lang-ts.d.ts.map +1 -1
- package/dist/parser/lang-ts.js +3 -0
- package/dist/parser/lang-ts.js.map +1 -1
- package/dist/parser/lang-vue.d.ts +17 -1
- package/dist/parser/lang-vue.d.ts.map +1 -1
- package/dist/parser/lang-vue.js +177 -0
- package/dist/parser/lang-vue.js.map +1 -1
- package/dist/parser/language-provider.d.ts +27 -0
- package/dist/parser/language-provider.d.ts.map +1 -0
- package/dist/parser/language-provider.js +2 -0
- package/dist/parser/language-provider.js.map +1 -0
- package/dist/security/rules.d.ts.map +1 -1
- package/dist/security/rules.js +4 -1
- package/dist/security/rules.js.map +1 -1
- package/dist/server/hooks.d.ts +3 -0
- package/dist/server/hooks.d.ts.map +1 -1
- package/dist/server/hooks.js +79 -0
- package/dist/server/hooks.js.map +1 -1
- package/dist/server/mcp-prompts.d.ts.map +1 -1
- package/dist/server/mcp-prompts.js +1 -1
- package/dist/server/mcp-prompts.js.map +1 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +638 -61
- package/dist/server/mcp.js.map +1 -1
- package/dist/server/watcher.d.ts +47 -0
- package/dist/server/watcher.d.ts.map +1 -0
- package/dist/server/watcher.js +136 -0
- package/dist/server/watcher.js.map +1 -0
- package/dist/skills.js +201 -36
- package/dist/skills.js.map +1 -1
- package/dist/store/annotations.d.ts.map +1 -1
- package/dist/store/annotations.js +18 -15
- package/dist/store/annotations.js.map +1 -1
- package/dist/store/confidence.d.ts +10 -0
- package/dist/store/confidence.d.ts.map +1 -1
- package/dist/store/confidence.js +28 -1
- package/dist/store/confidence.js.map +1 -1
- package/dist/store/db.d.ts +16 -0
- package/dist/store/db.d.ts.map +1 -1
- package/dist/store/db.js +121 -7
- package/dist/store/db.js.map +1 -1
- package/dist/store/schema.sql +25 -10
- package/dist/uninstall.d.ts +54 -0
- package/dist/uninstall.d.ts.map +1 -0
- package/dist/uninstall.js +795 -0
- package/dist/uninstall.js.map +1 -0
- package/docs/README.md +7 -6
- package/package.json +4 -3
package/dist/server/mcp.js
CHANGED
|
@@ -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 =
|
|
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', '
|
|
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', '
|
|
456
|
-
pattern: z.string().describe('Text
|
|
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('
|
|
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
|
-
|
|
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 = [
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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', '
|
|
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
|
-
|
|
775
|
-
|
|
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
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
1370
|
-
|
|
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
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|