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.
- package/.agents/skills/adapters/SKILL.md +20 -0
- package/.agents/skills/analyzer/SKILL.md +35 -13
- package/.agents/skills/apps/SKILL.md +25 -1
- package/.agents/skills/docs/SKILL.md +32 -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 +45 -19
- package/.agents/skills/store/SKILL.md +40 -18
- package/.agents/skills/test/SKILL.md +57 -9
- package/LICENSE +21 -75
- package/README.md +260 -433
- package/dist/agents-md.d.ts.map +1 -1
- package/dist/agents-md.js +5 -3
- package/dist/agents-md.js.map +1 -1
- package/dist/analyzer/engine.d.ts +1 -0
- package/dist/analyzer/engine.d.ts.map +1 -1
- package/dist/analyzer/engine.js +36 -6
- package/dist/analyzer/engine.js.map +1 -1
- package/dist/cli.js +296 -19
- 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/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 +418 -15
- package/dist/server/mcp.js.map +1 -1
- package/dist/server/watcher.d.ts +39 -0
- package/dist/server/watcher.d.ts.map +1 -0
- package/dist/server/watcher.js +134 -0
- package/dist/server/watcher.js.map +1 -0
- package/dist/skills.js +51 -7
- 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 +24 -9
- package/docs/README.md +3 -5
- 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,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
|
-
|
|
775
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|