ctxman 3.1.1 → 3.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +109 -10
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-count.js +45 -6
  7. package/bin/cm-diagnose.js +199 -0
  8. package/bin/cm-focus.js +322 -0
  9. package/bin/cm-map.js +71 -18
  10. package/bin/cm-pack.js +36 -5
  11. package/bin/cm-policy.js +107 -2
  12. package/bin/cm-pr.js +18 -3
  13. package/bin/cm-search.js +84 -19
  14. package/bin/cm-task.js +192 -0
  15. package/bin/cm-who.js +119 -0
  16. package/bin/cm-why.js +41 -5
  17. package/index.d.ts +940 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/token-calculator.js +76 -4
  20. package/lib/api/mcp/server.js +843 -29
  21. package/lib/cache/CacheManager.js +161 -47
  22. package/lib/core/Analyzer.js +61 -5
  23. package/lib/count/index.js +74 -14
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/policy/audit.js +3 -1
  34. package/lib/policy/index.js +5 -0
  35. package/lib/policy/workspace.js +254 -0
  36. package/lib/pr/pr-context.js +292 -31
  37. package/lib/pr/pr-renderer.js +25 -10
  38. package/lib/rag/EmbeddingProvider.js +139 -2
  39. package/lib/rag/VectorStoreFactory.js +10 -4
  40. package/lib/rag/ask.js +186 -17
  41. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  42. package/lib/search/hybrid.js +324 -0
  43. package/lib/search/symbols.js +75 -11
  44. package/lib/task/index.js +630 -0
  45. package/lib/utils/file-utils.js +1 -0
  46. package/lib/utils/multi-repo-manager.js +4 -1
  47. package/lib/watch/FileWatcher.js +121 -31
  48. package/lib/watch/IncrementalAnalyzer.js +964 -16
  49. package/lib/who/index.js +893 -0
  50. package/lib/why/analyzer-bridge.js +223 -0
  51. package/lib/why/discussions.js +314 -0
  52. package/lib/why/index.js +356 -30
  53. package/lib/why/render.js +154 -9
  54. package/lib/workspace/index.js +1274 -0
  55. package/package.json +1 -1
@@ -19,10 +19,18 @@ import { PromptProvider } from './prompts.js';
19
19
  import { buildPrContext } from '../../pr/pr-context.js';
20
20
  import { resolveRootDir, resolveSandboxPath } from './sandbox.js';
21
21
  import { searchProject, DEFAULT_LIMIT, DEFAULT_MAX_TOKENS } from '../../search/bm25.js';
22
+ import { hybridSearch } from '../../search/hybrid.js';
22
23
  import { getSymbol } from '../../search/symbols.js';
23
24
  import { countForModel } from '../../count/index.js';
24
25
  import { explainHistory } from '../../why/index.js';
25
26
  import { packContext, describePack, TOOL_DEFAULT_BUDGET } from '../../pack/index.js';
27
+ import { focusContext, describeFocus, DEFAULT_FOCUS_DEPTH } from '../../focus/index.js';
28
+ import {
29
+ resolveWorkspaceRepos,
30
+ mapWorkspace,
31
+ searchWorkspace,
32
+ packWorkspace,
33
+ } from '../../workspace/index.js';
26
34
  import {
27
35
  loadPolicy,
28
36
  guardExport,
@@ -31,8 +39,14 @@ import {
31
39
  readAuditLog,
32
40
  filterAuditEvents,
33
41
  parseSince,
42
+ checkWorkspacePolicies,
43
+ readWorkspaceAuditLogs,
34
44
  } from '../../policy/index.js';
35
45
  import { askCodebase, DEFAULT_ASK_LIMIT } from '../../rag/ask.js';
46
+ import { buildTaskContext, DEFAULT_TASK_BUDGET } from '../../task/index.js';
47
+ import { diagnoseFailure, DEFAULT_DIAGNOSE_BUDGET } from '../../diagnose/index.js';
48
+ import { findExperts, DEFAULT_WHO_LIMIT } from '../../who/index.js';
49
+ import { IncrementalAnalyzer } from '../../watch/IncrementalAnalyzer.js';
36
50
  import fs from 'fs';
37
51
  import path from 'path';
38
52
  import { createRequire } from 'module';
@@ -45,6 +59,7 @@ const FILE_PATH_TOOLS = new Set(['list_methods']);
45
59
  const DEFAULT_AUDIT_LIMIT = 50;
46
60
  // Characters of each ask_codebase snippet (the index stores whole files)
47
61
  const ASK_SNIPPET_CHARS = 2000;
62
+ const DEFAULT_GRAPH_LIMIT = 50;
48
63
 
49
64
  class MCPServer {
50
65
  /**
@@ -69,21 +84,47 @@ class MCPServer {
69
84
  }
70
85
  );
71
86
 
72
- this.analyzer = new Analyzer();
87
+ this.analyzer = new Analyzer({ cache: true });
73
88
  this.contextBuilder = new ContextBuilder();
89
+ this.incrementalAnalyzers = new Map();
74
90
  this.resourceProvider = new ResourceProvider(this.root ?? undefined);
75
91
  this.promptProvider = new PromptProvider(this.analyzer, this.contextBuilder);
76
92
  this.setupHandlers();
77
93
  this.setupErrorHandling();
78
94
  }
79
95
 
96
+ /**
97
+ * Get or create a cached IncrementalAnalyzer instance bound to the project's SQLite knowledge graph.
98
+ * @param {string} projectPath
99
+ * @returns {IncrementalAnalyzer}
100
+ */
101
+ getIncrementalAnalyzer(projectPath, repos = []) {
102
+ const resolved = path.resolve(projectPath);
103
+ const extraRepos = Array.isArray(repos)
104
+ ? repos.filter(Boolean).map((r) => path.resolve(r))
105
+ : [];
106
+ const cacheKey = [resolved, ...extraRepos].join('::');
107
+ let inc = this.incrementalAnalyzers.get(cacheKey);
108
+ if (!inc) {
109
+ inc = new IncrementalAnalyzer({
110
+ projectRoot: resolved,
111
+ repos: extraRepos,
112
+ sync: true,
113
+ hydrateGraphOnStart: false,
114
+ });
115
+ this.incrementalAnalyzers.set(cacheKey, inc);
116
+ }
117
+ return inc;
118
+ }
119
+
80
120
  setupHandlers() {
81
121
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
82
122
  return {
83
123
  tools: [
84
124
  {
85
125
  name: 'analyze_codebase',
86
- description: 'Analyze the codebase and return statistics and token counts',
126
+ description:
127
+ 'Analyze the codebase (single-repo or multi-repo workspace) and return statistics and token counts',
87
128
  inputSchema: {
88
129
  type: 'object',
89
130
  properties: {
@@ -91,13 +132,20 @@ class MCPServer {
91
132
  type: 'string',
92
133
  description: 'Absolute path to the project root',
93
134
  },
135
+ repos: {
136
+ type: 'array',
137
+ items: { type: 'string' },
138
+ description:
139
+ 'Additional repository roots to analyze together in a multi-repository workspace',
140
+ },
94
141
  },
95
142
  required: ['path'],
96
143
  },
97
144
  },
98
145
  {
99
146
  name: 'generate_context',
100
- description: 'Generate optimized context for LLMs from the codebase',
147
+ description:
148
+ 'Generate optimized context for LLMs from the codebase (single-repo or multi-repo workspace)',
101
149
  inputSchema: {
102
150
  type: 'object',
103
151
  properties: {
@@ -114,13 +162,20 @@ class MCPServer {
114
162
  type: 'number',
115
163
  description: 'Maximum tokens for the context',
116
164
  },
165
+ repos: {
166
+ type: 'array',
167
+ items: { type: 'string' },
168
+ description:
169
+ 'Additional repository roots to include in multi-repository context generation',
170
+ },
117
171
  },
118
172
  required: ['path'],
119
173
  },
120
174
  },
121
175
  {
122
176
  name: 'git_diff',
123
- description: 'Analyze git changes and return diff context',
177
+ description:
178
+ 'Analyze git changes (single-repo or multi-repo workspace) and return diff context',
124
179
  inputSchema: {
125
180
  type: 'object',
126
181
  properties: {
@@ -129,6 +184,12 @@ class MCPServer {
129
184
  type: 'string',
130
185
  description: 'Branch to compare against (default: main)',
131
186
  },
187
+ repos: {
188
+ type: 'array',
189
+ items: { type: 'string' },
190
+ description:
191
+ 'Additional repository roots to include in multi-repository git diff',
192
+ },
132
193
  },
133
194
  required: ['path'],
134
195
  },
@@ -136,13 +197,17 @@ class MCPServer {
136
197
  {
137
198
  name: 'search_code',
138
199
  description:
139
- 'Search source lines by regular expression (default) or plain substring ("semantic" is currently a substring match). "ranked" returns the methods/functions (or 40-line windows) that best match the query words by BM25, with their source.',
200
+ 'Search source lines by regular expression (default) or plain substring (single-repo or multi-repo workspace). "ranked" returns BM25-ranked methods/chunks; "semantic" runs vector embedding search projected onto chunks (with BM25 fallback); "hybrid" fuses BM25 and semantic embeddings via Reciprocal Rank Fusion (RRF).',
140
201
  inputSchema: {
141
202
  type: 'object',
142
203
  properties: {
143
204
  path: { type: 'string', description: 'Project root' },
144
205
  query: { type: 'string', description: 'Search query' },
145
- type: { type: 'string', enum: ['regex', 'semantic', 'ranked'], default: 'regex' },
206
+ type: {
207
+ type: 'string',
208
+ enum: ['regex', 'substring', 'semantic', 'ranked', 'hybrid'],
209
+ default: 'regex',
210
+ },
146
211
  limit: {
147
212
  type: 'number',
148
213
  description: `ranked: maximum number of chunks (default ${DEFAULT_LIMIT})`,
@@ -155,13 +220,19 @@ class MCPServer {
155
220
  type: 'boolean',
156
221
  description: 'ranked: redact secrets in the returned source (default true)',
157
222
  },
223
+ repos: {
224
+ type: 'array',
225
+ items: { type: 'string' },
226
+ description: 'Additional repository roots to search across in the workspace',
227
+ },
158
228
  },
159
229
  required: ['path', 'query'],
160
230
  },
161
231
  },
162
232
  {
163
233
  name: 'list_methods',
164
- description: 'List all methods/functions in a given file or project',
234
+ description:
235
+ 'List all methods/functions in a given file, project, or multi-repository workspace',
165
236
  inputSchema: {
166
237
  type: 'object',
167
238
  properties: {
@@ -171,7 +242,14 @@ class MCPServer {
171
242
  },
172
243
  file: {
173
244
  type: 'string',
174
- description: 'Optional: Relative path to a specific file within the project',
245
+ description:
246
+ 'Optional: Relative path (or <alias>/<path>) to a specific file within the project',
247
+ },
248
+ repos: {
249
+ type: 'array',
250
+ items: { type: 'string' },
251
+ description:
252
+ 'Additional repository roots to list methods across in a multi-repository workspace',
175
253
  },
176
254
  },
177
255
  required: ['path'],
@@ -195,6 +273,12 @@ class MCPServer {
195
273
  description:
196
274
  'Project-relative files or folders to rank higher, with the files next to them in the import graph',
197
275
  },
276
+ repos: {
277
+ type: 'array',
278
+ items: { type: 'string' },
279
+ description:
280
+ 'Additional repository roots to include in a multi-repository workspace map under one shared token budget',
281
+ },
198
282
  },
199
283
  required: ['path'],
200
284
  },
@@ -202,7 +286,7 @@ class MCPServer {
202
286
  {
203
287
  name: 'pr_context',
204
288
  description:
205
- 'Review context for a branch: summary, commit log, changed files, full source of changed methods, unified diff and related tests (secrets redacted)',
289
+ 'Review context for a branch (single-repo or multi-repo workspace): summary, commit log, changed files, full source of changed methods, unified diff and related tests (secrets redacted)',
206
290
  inputSchema: {
207
291
  type: 'object',
208
292
  properties: {
@@ -215,6 +299,12 @@ class MCPServer {
215
299
  head: { type: 'string', description: 'Head branch or commit (default: HEAD)' },
216
300
  budget: { type: 'number', description: 'Maximum tokens of the result' },
217
301
  style: { type: 'string', enum: ['markdown', 'xml', 'json'], default: 'markdown' },
302
+ repos: {
303
+ type: 'array',
304
+ items: { type: 'string' },
305
+ description:
306
+ 'Additional repository roots to include in multi-repository PR review context under one shared token budget',
307
+ },
218
308
  },
219
309
  required: ['path'],
220
310
  },
@@ -222,7 +312,7 @@ class MCPServer {
222
312
  {
223
313
  name: 'get_symbol',
224
314
  description:
225
- 'Full source of a method, function or class by name, with file and line range. Exact matches first, then case-insensitive; similar names are suggested when nothing matches. "Class.method" narrows to methods of a class.',
315
+ 'Full source of a method, function or class by name, with file and line range (single-repo or multi-repo workspace). Exact matches first, then case-insensitive; similar names are suggested when nothing matches. "Class.method" narrows to methods of a class.',
226
316
  inputSchema: {
227
317
  type: 'object',
228
318
  properties: {
@@ -230,12 +320,19 @@ class MCPServer {
230
320
  name: { type: 'string', description: 'Symbol name, or Class.method' },
231
321
  file: {
232
322
  type: 'string',
233
- description: 'Optional: only look in this project-relative file',
323
+ description:
324
+ 'Optional: only look in this project-relative (or <alias>/<path>) file',
234
325
  },
235
326
  redact: {
236
327
  type: 'boolean',
237
328
  description: 'Redact secrets in the source (default true)',
238
329
  },
330
+ repos: {
331
+ type: 'array',
332
+ items: { type: 'string' },
333
+ description:
334
+ 'Additional repository roots to search for symbols across under one shared token budget',
335
+ },
239
336
  },
240
337
  required: ['path', 'name'],
241
338
  },
@@ -243,7 +340,7 @@ class MCPServer {
243
340
  {
244
341
  name: 'count_tokens',
245
342
  description:
246
- "Token count of a project (or of a text) for a model, with the model's context window and whether it fits. Exact for OpenAI models; approximate for models with their own tokenizer (Claude, Gemini, ...). Nothing leaves the machine; for Claude's exact count run `ctxman count --model <id> --api`",
343
+ "Token count of a project or multi-repository workspace (or of a text) for a model, with the model's context window and whether it fits. Exact for OpenAI models; approximate for models with their own tokenizer (Claude, Gemini, ...). Nothing leaves the machine; for Claude's exact count run `ctxman count --model <id> --api`",
247
344
  inputSchema: {
248
345
  type: 'object',
249
346
  properties: {
@@ -260,13 +357,19 @@ class MCPServer {
260
357
  type: 'number',
261
358
  description: 'How many of the largest files to list (default 10)',
262
359
  },
360
+ repos: {
361
+ type: 'array',
362
+ items: { type: 'string' },
363
+ description:
364
+ 'Additional repository roots to count across in a multi-repository workspace (ignored when text is given)',
365
+ },
263
366
  },
264
367
  },
265
368
  },
266
369
  {
267
370
  name: 'explain_history',
268
371
  description:
269
- 'Explain why code looks the way it does, from the local git history (offline): commits that changed a file, symbol or line range with their messages, referenced issues/PRs/tickets, blame ownership, files that change together with it, related docs/ADRs/comments, TODO notes and tests. Returns Markdown.',
372
+ 'Explain why code looks the way it does, from the local git history (offline, single-repo or multi-repo workspace): commits that changed a file, symbol or line range with their messages, referenced issues/PRs/tickets, institutional memory citations (.plansmith/analyzer.sqlite), blame ownership, files that change together with it, related docs/ADRs/comments, TODO notes and tests. Returns Markdown.',
270
373
  inputSchema: {
271
374
  type: 'object',
272
375
  properties: {
@@ -277,7 +380,7 @@ class MCPServer {
277
380
  target: {
278
381
  type: 'string',
279
382
  description:
280
- 'File relative to path, optionally with a symbol or line range: "src/cart.js", "src/cart.js:total", "src/cart.js:Cart.total", "src/cart.js#L10-L40"',
383
+ 'File relative to path (or <alias>/<path> in multi-repo mode), optionally with a symbol or line range: "src/cart.js", "src/cart.js:total", "src/cart.js:Cart.total", "src/cart.js#L10-L40"',
281
384
  },
282
385
  limit: {
283
386
  type: 'number',
@@ -296,6 +399,32 @@ class MCPServer {
296
399
  type: 'boolean',
297
400
  description: "Include each commit's diff of the target (default false)",
298
401
  },
402
+ discussions: {
403
+ type: 'boolean',
404
+ description:
405
+ 'Fetch referenced GitHub/GitLab issue, PR and MR discussions over HTTPS (default false; uses GITHUB_TOKEN/GH_TOKEN or GITLAB_TOKEN when set)',
406
+ },
407
+ maxDiscussions: {
408
+ type: 'number',
409
+ description:
410
+ 'Maximum remote discussions to fetch when discussions is true (default 10)',
411
+ },
412
+ analyzer: {
413
+ type: 'boolean',
414
+ description:
415
+ 'Query local @plansmithai/analyzer SQLite database (.plansmith/analyzer.sqlite) for institutional memory citations',
416
+ },
417
+ analyzerDb: {
418
+ type: 'string',
419
+ description:
420
+ 'Explicit path to @plansmithai/analyzer SQLite database file or directory',
421
+ },
422
+ repos: {
423
+ type: 'array',
424
+ items: { type: 'string' },
425
+ description:
426
+ 'Additional repository roots to include in multi-repository history explanation and cross-repo ADR/doc/test discovery',
427
+ },
299
428
  },
300
429
  required: ['path', 'target'],
301
430
  },
@@ -349,18 +478,102 @@ class MCPServer {
349
478
  type: 'string',
350
479
  description: "Count with this model's tokenizer encoding and report the fit",
351
480
  },
481
+ repos: {
482
+ type: 'array',
483
+ items: { type: 'string' },
484
+ description:
485
+ 'Additional repository roots to pack into one workspace document under one shared token budget',
486
+ },
352
487
  },
353
488
  required: ['path'],
354
489
  },
355
490
  },
491
+ {
492
+ name: 'focus_context',
493
+ description:
494
+ 'Focused neighborhood context around a target file (or file:symbol): packs the target file together with its outgoing imports/callees and incoming importers/callers up to `depth` hops in the repository import and AST symbol call graph (directional traversal avoids shared-hub explosion), honouring .gitignore/.contextignore and export policy, with secrets redacted and trimmed to a token budget (target file kept first).',
495
+ inputSchema: {
496
+ type: 'object',
497
+ properties: {
498
+ path: { type: 'string', description: 'Project root' },
499
+ file: {
500
+ type: 'string',
501
+ description:
502
+ 'Project-relative (or <alias>/<path>) file or file:symbol to focus on (e.g. "src/auth/login.ts" or "src/auth/login.ts:authenticate")',
503
+ },
504
+ symbol: {
505
+ type: 'string',
506
+ description:
507
+ 'Optional function or method name inside `file` to traverse the cross-file AST symbol call graph around',
508
+ },
509
+ depth: {
510
+ type: 'number',
511
+ description: `Import/call graph hops (default ${DEFAULT_FOCUS_DEPTH}; 0 = target file only)`,
512
+ },
513
+ direction: {
514
+ type: 'string',
515
+ enum: ['both', 'imports', 'importers', 'calls', 'callers'],
516
+ default: 'both',
517
+ },
518
+ callGraph: {
519
+ type: 'boolean',
520
+ description:
521
+ 'Include cross-file AST symbol call graph edges alongside module imports (default true)',
522
+ },
523
+ includeTests: {
524
+ type: 'boolean',
525
+ description: 'Include test files in the neighborhood (default true)',
526
+ },
527
+ style: {
528
+ type: 'string',
529
+ enum: ['xml', 'markdown', 'plain', 'json'],
530
+ default: 'xml',
531
+ },
532
+ compress: {
533
+ type: 'boolean',
534
+ description: 'Keep signatures, replace function bodies with a placeholder',
535
+ },
536
+ lineNumbers: { type: 'boolean', description: 'Number every line' },
537
+ tree: {
538
+ type: 'boolean',
539
+ description: 'Directory tree at the top (default true)',
540
+ },
541
+ instructions: {
542
+ type: 'string',
543
+ description: 'Text placed at the end, after the files',
544
+ },
545
+ budget: {
546
+ type: 'number',
547
+ description: `Maximum tokens of the document (default ${TOOL_DEFAULT_BUDGET})`,
548
+ },
549
+ targetModel: {
550
+ type: 'string',
551
+ description: "Count with this model's tokenizer encoding and report the fit",
552
+ },
553
+ repos: {
554
+ type: 'array',
555
+ items: { type: 'string' },
556
+ description:
557
+ 'Additional repository roots to include in the workspace import/call graph neighborhood under one shared token budget',
558
+ },
559
+ },
560
+ required: ['path', 'file'],
561
+ },
562
+ },
356
563
  {
357
564
  name: 'check_policy',
358
565
  description:
359
- 'The effective export policy of a project (.ctxman/policy.json plus the organisation policy named by CTXMAN_POLICY): denied paths, redaction and token limits, allowed target models, AI-tool ignore files and audit logging. Reports whether the policy files are valid and, if not, every error.',
566
+ 'The effective export policy of a project or multi-repository workspace (.ctxman/policy.json plus the organisation policy named by CTXMAN_POLICY): denied paths, redaction and token limits, allowed target models, AI-tool ignore files and audit logging. Reports whether the policy files are valid and, if not, every error.',
360
567
  inputSchema: {
361
568
  type: 'object',
362
569
  properties: {
363
570
  path: { type: 'string', description: 'Project root' },
571
+ repos: {
572
+ type: 'array',
573
+ items: { type: 'string' },
574
+ description:
575
+ 'Additional repository roots to validate and summarize together in a multi-repository workspace',
576
+ },
364
577
  },
365
578
  required: ['path'],
366
579
  },
@@ -368,7 +581,7 @@ class MCPServer {
368
581
  {
369
582
  name: 'audit_log',
370
583
  description:
371
- "Exports recorded in the project's audit log while the export policy enables audit: when, by whom, which command, where to, which files, token count and a SHA-256 of the exported text. Newest last.",
584
+ "Exports recorded in the project's (or multi-repository workspace's) audit log while the export policy enables audit: when, by whom, which command, where to, which files, token count and a SHA-256 of the exported text. Newest last.",
372
585
  inputSchema: {
373
586
  type: 'object',
374
587
  properties: {
@@ -382,6 +595,12 @@ class MCPServer {
382
595
  type: 'number',
383
596
  description: `The last N events (default ${DEFAULT_AUDIT_LIMIT}; 0 = all)`,
384
597
  },
598
+ repos: {
599
+ type: 'array',
600
+ items: { type: 'string' },
601
+ description:
602
+ 'Additional repository roots whose export audit logs should be merged chronologically with repo attribution',
603
+ },
385
604
  },
386
605
  required: ['path'],
387
606
  },
@@ -389,7 +608,7 @@ class MCPServer {
389
608
  {
390
609
  name: 'ask_codebase',
391
610
  description:
392
- 'Semantic search: the files whose content is closest in meaning to a question, from a local embedding index (.ctxman/self-knowledge, built on first use; the first call downloads the embedding model and can take minutes). Needs the optional @xenova/transformers and @lancedb/lancedb packages. For exact names or keywords, search_code is faster.',
611
+ 'Semantic search: the files whose content is closest in meaning to a question, from a local SQLite embedding index (.ctxman/self-knowledge/index.sqlite via built-in node:sqlite, single-repo or multi-repo workspace). Uses @xenova/transformers when installed and automatically falls back to deterministic offline LocalHashEmbeddingProvider otherwise. For exact names or keywords, search_code is faster.',
393
612
  inputSchema: {
394
613
  type: 'object',
395
614
  properties: {
@@ -399,10 +618,152 @@ class MCPServer {
399
618
  type: 'number',
400
619
  description: `How many files (default ${DEFAULT_ASK_LIMIT})`,
401
620
  },
621
+ repos: {
622
+ type: 'array',
623
+ items: { type: 'string' },
624
+ description:
625
+ 'Additional repository roots to include in multi-repository semantic search',
626
+ },
402
627
  },
403
628
  required: ['path', 'query'],
404
629
  },
405
630
  },
631
+ {
632
+ name: 'task_context',
633
+ description:
634
+ 'Task-driven automatic context builder: fuses hybrid search, PageRank import/call neighborhood, and architectural map for a task description within a token budget (single-repo or multi-repo workspace).',
635
+ inputSchema: {
636
+ type: 'object',
637
+ properties: {
638
+ path: { type: 'string', description: 'Project root' },
639
+ task: { type: 'string', description: 'Natural-language task or issue description' },
640
+ budget: {
641
+ type: 'number',
642
+ description: `Maximum tokens (default ${DEFAULT_TASK_BUDGET})`,
643
+ },
644
+ format: { type: 'string', enum: ['xml', 'markdown', 'json'], default: 'xml' },
645
+ model: { type: 'string', description: 'Optional target LLM model ID' },
646
+ callGraph: {
647
+ type: 'boolean',
648
+ description:
649
+ 'Include cross-file AST symbol call graph edges alongside module imports (default true)',
650
+ },
651
+ repos: {
652
+ type: 'array',
653
+ items: { type: 'string' },
654
+ description:
655
+ 'Additional repository roots to include in a multi-repository task context bundle under one shared token budget',
656
+ },
657
+ },
658
+ required: ['path', 'task'],
659
+ },
660
+ },
661
+ {
662
+ name: 'diagnose_failure',
663
+ description:
664
+ 'Diagnose a stack trace, test failure, or error log: parses implicated repo files/lines (single-repo or multi-repo workspace), packs enclosing symbols and 1-hop graph neighbors, and surfaces recent commits that touched them.',
665
+ inputSchema: {
666
+ type: 'object',
667
+ properties: {
668
+ path: { type: 'string', description: 'Project root' },
669
+ trace: { type: 'string', description: 'Error stack trace or failure log text' },
670
+ budget: {
671
+ type: 'number',
672
+ description: `Maximum tokens (default ${DEFAULT_DIAGNOSE_BUDGET})`,
673
+ },
674
+ format: { type: 'string', enum: ['xml', 'markdown', 'json'], default: 'xml' },
675
+ callGraph: {
676
+ type: 'boolean',
677
+ description:
678
+ 'Include cross-file AST symbol call graph edges alongside module imports (default true)',
679
+ },
680
+ repos: {
681
+ type: 'array',
682
+ items: { type: 'string' },
683
+ description:
684
+ 'Additional repository roots to resolve stack frames and 1-hop neighbors across under one shared token budget',
685
+ },
686
+ },
687
+ required: ['path', 'trace'],
688
+ },
689
+ },
690
+ {
691
+ name: 'find_experts',
692
+ description:
693
+ 'Expert Finder ("Who knows about this?"): ranks code and domain experts for a file, symbol, directory, or natural-language topic (single-repo or multi-repo workspace) by fusing surviving git blame ownership, recency-decayed commit/churn history, and CODEOWNERS stewardship.',
694
+ inputSchema: {
695
+ type: 'object',
696
+ properties: {
697
+ path: { type: 'string', description: 'Project root inside a git repository' },
698
+ target: {
699
+ type: 'string',
700
+ description:
701
+ 'File path (<alias>/<path>), file:symbol, file#L10-L40, directory, or natural-language topic query',
702
+ },
703
+ limit: {
704
+ type: 'number',
705
+ description: `Maximum ranked experts to return (default ${DEFAULT_WHO_LIMIT})`,
706
+ },
707
+ halfLifeDays: {
708
+ type: 'number',
709
+ description: 'Recency exponential decay half-life in days (default 90)',
710
+ },
711
+ style: {
712
+ type: 'string',
713
+ enum: ['markdown', 'xml', 'json'],
714
+ default: 'markdown',
715
+ },
716
+ repos: {
717
+ type: 'array',
718
+ items: { type: 'string' },
719
+ description:
720
+ 'Additional repository roots inside git repositories to include in multi-repository expert ranking',
721
+ },
722
+ },
723
+ required: ['path', 'target'],
724
+ },
725
+ },
726
+ {
727
+ name: 'query_knowledge_graph',
728
+ description:
729
+ 'Query the persistent SQLite knowledge graph (.ctxman/self-knowledge/index.sqlite) or IncrementalAnalyzer cache for file nodes, method declarations, and import/dependency edges.',
730
+ inputSchema: {
731
+ type: 'object',
732
+ properties: {
733
+ path: { type: 'string', description: 'Project root' },
734
+ query: {
735
+ type: 'string',
736
+ description:
737
+ 'Optional substring or symbol query to filter graph nodes and method declarations',
738
+ },
739
+ file: {
740
+ type: 'string',
741
+ description:
742
+ 'Optional project-relative file path to inspect along with its dependency neighborhood',
743
+ },
744
+ depth: {
745
+ type: 'number',
746
+ description: 'Neighborhood hop depth when file is provided (default: 1, max: 4)',
747
+ },
748
+ limit: {
749
+ type: 'number',
750
+ description: `Maximum number of graph nodes to return (default ${DEFAULT_GRAPH_LIMIT})`,
751
+ },
752
+ refresh: {
753
+ type: 'boolean',
754
+ description:
755
+ 'Re-synchronize the IncrementalAnalyzer cache and SQLite knowledge graph before querying',
756
+ },
757
+ repos: {
758
+ type: 'array',
759
+ items: { type: 'string' },
760
+ description:
761
+ 'Optional additional repository roots to include in a multi-repository knowledge graph under <alias>/<path>',
762
+ },
763
+ },
764
+ required: ['path'],
765
+ },
766
+ },
406
767
  ],
407
768
  };
408
769
  });
@@ -468,6 +829,21 @@ class MCPServer {
468
829
  }),
469
830
  };
470
831
  }
832
+ if (args.repos !== undefined) {
833
+ if (!Array.isArray(args.repos)) {
834
+ throw new Error('repos must be an array of directory paths');
835
+ }
836
+ args = {
837
+ ...args,
838
+ repos: args.repos.map((repoPath) =>
839
+ resolveSandboxPath(repoPath, {
840
+ root: this.root,
841
+ expect: 'directory',
842
+ name: 'repos',
843
+ })
844
+ ),
845
+ };
846
+ }
471
847
 
472
848
  switch (name) {
473
849
  case 'analyze_codebase': {
@@ -476,6 +852,28 @@ class MCPServer {
476
852
  // Update resource provider project path
477
853
  this.resourceProvider.projectPath = projectPath;
478
854
 
855
+ if (args.repos?.length > 0) {
856
+ const resolvedRepos = resolveWorkspaceRepos([projectPath, ...args.repos]);
857
+ const allFiles = [];
858
+ for (const repo of resolvedRepos) {
859
+ const scanner = new Scanner(repo.root, { policy: repo.policy });
860
+ for (const file of scanner.scan()) {
861
+ const relPosix = `${repo.alias}/${file.relativePath.split(path.sep).join('/')}`;
862
+ allFiles.push({
863
+ ...file,
864
+ relativePath: relPosix,
865
+ });
866
+ }
867
+ }
868
+ const analysis = await this.analyzer.analyze(allFiles);
869
+ analysis.repos = resolvedRepos.map((r) => ({ alias: r.alias, root: r.root }));
870
+ const analysisId = Date.now().toString();
871
+ this.resourceProvider.cacheAnalysis(analysisId, analysis);
872
+ return {
873
+ content: [{ type: 'text', text: JSON.stringify(analysis, null, 2) }],
874
+ };
875
+ }
876
+
479
877
  const scanner = new Scanner(projectPath);
480
878
  const files = scanner.scan();
481
879
  const analysis = await this.analyzer.analyze(files);
@@ -499,20 +897,57 @@ class MCPServer {
499
897
  // Update resource provider project path
500
898
  this.resourceProvider.projectPath = projectPath;
501
899
 
502
- const scanner = new Scanner(projectPath);
503
- const files = scanner.scan();
504
- const analysis = await this.analyzer.analyze(files);
505
-
506
- // Configure ContextBuilder based on args
507
900
  const builderOptions = {
508
901
  targetTokens: args.maxTokens,
509
902
  useCase: args.template || 'custom',
510
903
  };
511
904
  const contextBuilder = new ContextBuilder(builderOptions);
905
+ const templateName = args.template || 'custom';
906
+
907
+ if (args.repos?.length > 0) {
908
+ const resolvedRepos = resolveWorkspaceRepos([projectPath, ...args.repos]);
909
+ const allFiles = [];
910
+ for (const repo of resolvedRepos) {
911
+ const scanner = new Scanner(repo.root, { policy: repo.policy });
912
+ for (const file of scanner.scan()) {
913
+ const relPosix = `${repo.alias}/${file.relativePath.split(path.sep).join('/')}`;
914
+ allFiles.push({
915
+ ...file,
916
+ relativePath: relPosix,
917
+ });
918
+ }
919
+ }
920
+ const analysis = await this.analyzer.analyze(allFiles);
921
+ const context = contextBuilder.build(analysis);
922
+ context.repos = resolvedRepos.map((r) => ({ alias: r.alias, root: r.root }));
923
+ const contextText = JSON.stringify(context, null, 2);
924
+ const exportedFiles = Object.values(context.files || {}).flat();
925
+ for (const repo of resolvedRepos) {
926
+ const prefix = `${repo.alias}/`;
927
+ const repoFiles = exportedFiles
928
+ .filter((f) => typeof f.path === 'string' && f.path.startsWith(prefix))
929
+ .map((f) => ({ ...f, path: f.path.slice(prefix.length) }));
930
+ guardExport(repo.policy, {
931
+ command: 'mcp generate_context',
932
+ root: repo.root,
933
+ destination: 'mcp',
934
+ format: 'json',
935
+ files: repoFiles,
936
+ text: contextText,
937
+ });
938
+ }
939
+ this.resourceProvider.cacheContext(templateName, contextText);
940
+ return {
941
+ content: [{ type: 'text', text: contextText }],
942
+ };
943
+ }
944
+
945
+ const scanner = new Scanner(projectPath);
946
+ const files = scanner.scan();
947
+ const analysis = await this.analyzer.analyze(files);
512
948
  const context = contextBuilder.build(analysis);
513
949
 
514
950
  // Cache context as a resource
515
- const templateName = args.template || 'custom';
516
951
  const contextText = JSON.stringify(context, null, 2);
517
952
  // Export policy: throws PolicyViolationError (an isError result) and logs the export
518
953
  guardExport(loadPolicy(projectPath), {
@@ -536,7 +971,6 @@ class MCPServer {
536
971
  }
537
972
  case 'git_diff': {
538
973
  const projectPath = args.path;
539
- const gitClient = new GitClient(projectPath);
540
974
  const branch = args.branch || 'main';
541
975
 
542
976
  if (!isValidGitRef(branch)) {
@@ -548,6 +982,47 @@ class MCPServer {
548
982
  };
549
983
  }
550
984
 
985
+ if (args.repos?.length > 0) {
986
+ const resolvedRepos = resolveWorkspaceRepos([projectPath, ...args.repos]);
987
+ const diffs = [];
988
+ const errors = [];
989
+ for (const repo of resolvedRepos) {
990
+ try {
991
+ const gitClient = new GitClient(repo.root);
992
+ const rawDiff = gitClient.exec([
993
+ 'diff',
994
+ branch,
995
+ '--',
996
+ ...(this.root ? ['.'] : []),
997
+ ]);
998
+ if (rawDiff && rawDiff.trim()) {
999
+ const prefixed = rawDiff
1000
+ .replace(
1001
+ /^diff --git a\/(.+) b\/(.+)$/gm,
1002
+ `diff --git a/${repo.alias}/$1 b/${repo.alias}/$2`
1003
+ )
1004
+ .replace(/^--- a\/(.+)$/gm, `--- a/${repo.alias}/$1`)
1005
+ .replace(/^\+\+\+ b\/(.+)$/gm, `+++ b/${repo.alias}/$1`);
1006
+ diffs.push(prefixed.trimEnd());
1007
+ }
1008
+ } catch (e) {
1009
+ errors.push(`${repo.alias}: ${e.message}`);
1010
+ }
1011
+ }
1012
+ if (errors.length === resolvedRepos.length) {
1013
+ return {
1014
+ content: [{ type: 'text', text: `Error executing git diff: ${errors[0]}` }],
1015
+ isError: true,
1016
+ };
1017
+ }
1018
+ const notes = errors.map((err) => `# Note: ${err}`).join('\n');
1019
+ const combined = [diffs.join('\n'), notes].filter(Boolean).join('\n\n');
1020
+ return {
1021
+ content: [{ type: 'text', text: combined || 'No changes detected.' }],
1022
+ };
1023
+ }
1024
+
1025
+ const gitClient = new GitClient(projectPath);
551
1026
  let diff;
552
1027
  try {
553
1028
  // With a root, keep the diff to that directory even if the repository is larger
@@ -565,6 +1040,17 @@ class MCPServer {
565
1040
  }
566
1041
  case 'search_code': {
567
1042
  const { path: projectPath, query, type = 'regex' } = args;
1043
+ if (
1044
+ args.repos?.length > 0 &&
1045
+ (type === 'ranked' || type === 'hybrid' || type === 'semantic')
1046
+ ) {
1047
+ const result = searchWorkspace([projectPath, ...args.repos], query, {
1048
+ limit: args.limit ?? DEFAULT_LIMIT,
1049
+ maxTokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
1050
+ redact: args.redact !== false,
1051
+ });
1052
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1053
+ }
568
1054
  if (type === 'ranked') {
569
1055
  const result = searchProject(projectPath, query, {
570
1056
  limit: args.limit ?? DEFAULT_LIMIT,
@@ -573,6 +1059,16 @@ class MCPServer {
573
1059
  });
574
1060
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
575
1061
  }
1062
+ if (type === 'hybrid' || type === 'semantic') {
1063
+ const result = await hybridSearch(projectPath, query, {
1064
+ mode: type,
1065
+ fallbackToBm25: true,
1066
+ limit: args.limit ?? DEFAULT_LIMIT,
1067
+ maxTokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
1068
+ redact: args.redact !== false,
1069
+ });
1070
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1071
+ }
576
1072
  let pattern = null;
577
1073
  if (type === 'regex') {
578
1074
  try {
@@ -584,6 +1080,37 @@ class MCPServer {
584
1080
  };
585
1081
  }
586
1082
  }
1083
+
1084
+ if (args.repos?.length > 0) {
1085
+ const resolvedRepos = resolveWorkspaceRepos([projectPath, ...args.repos]);
1086
+ const results = [];
1087
+ for (const repo of resolvedRepos) {
1088
+ const scanner = new Scanner(repo.root, { policy: repo.policy });
1089
+ for (const file of scanner.scan()) {
1090
+ try {
1091
+ const content = fs.readFileSync(file.path, 'utf-8');
1092
+ const lines = content.split('\n');
1093
+ const relPosix = `${repo.alias}/${file.relativePath.split(path.sep).join('/')}`;
1094
+ lines.forEach((line, index) => {
1095
+ const match = pattern ? pattern.test(line) : line.includes(query);
1096
+ if (match) {
1097
+ results.push({
1098
+ file: relPosix,
1099
+ line: index + 1,
1100
+ content: line.trim(),
1101
+ });
1102
+ }
1103
+ });
1104
+ } catch (_e) {
1105
+ // Skip read errors
1106
+ }
1107
+ }
1108
+ }
1109
+ return {
1110
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
1111
+ };
1112
+ }
1113
+
587
1114
  const scanner = new Scanner(projectPath);
588
1115
  const files = scanner.scan();
589
1116
  const results = [];
@@ -621,6 +1148,48 @@ class MCPServer {
621
1148
  file = path.basename(projectPath);
622
1149
  projectPath = path.dirname(projectPath);
623
1150
  }
1151
+
1152
+ if (args.repos?.length > 0) {
1153
+ const resolvedRepos = resolveWorkspaceRepos([projectPath, ...args.repos]);
1154
+ const targetPosix = file ? file.split(path.sep).join('/').replace(/^\.\//, '') : null;
1155
+ const allFiles = [];
1156
+ for (const repo of resolvedRepos) {
1157
+ const scanner = new Scanner(repo.root, { policy: repo.policy });
1158
+ for (const f of scanner.scan()) {
1159
+ const barePosix = f.relativePath.split(path.sep).join('/');
1160
+ const relPosix = `${repo.alias}/${barePosix}`;
1161
+ if (!targetPosix || relPosix === targetPosix || barePosix === targetPosix) {
1162
+ allFiles.push({
1163
+ ...f,
1164
+ relativePath: relPosix,
1165
+ });
1166
+ }
1167
+ }
1168
+ }
1169
+ if (targetPosix && allFiles.length === 0) {
1170
+ return {
1171
+ content: [{ type: 'text', text: `File not found in project: ${file}` }],
1172
+ isError: true,
1173
+ };
1174
+ }
1175
+ const methodAnalyzer = new Analyzer({ methodLevel: true, cache: true });
1176
+ const analysis = await methodAnalyzer.analyze(allFiles);
1177
+ const methods = [];
1178
+ if (analysis.files) {
1179
+ for (const analyzedFile of analysis.files) {
1180
+ if (analyzedFile.methods && analyzedFile.methods.length > 0) {
1181
+ methods.push({
1182
+ file: analyzedFile.relativePath,
1183
+ methods: analyzedFile.methods,
1184
+ });
1185
+ }
1186
+ }
1187
+ }
1188
+ return {
1189
+ content: [{ type: 'text', text: JSON.stringify(methods, null, 2) }],
1190
+ };
1191
+ }
1192
+
624
1193
  const scanner = new Scanner(projectPath);
625
1194
  // Optional `file`: one project-relative file instead of the whole project
626
1195
  const target = file ? path.normalize(file) : null;
@@ -635,7 +1204,7 @@ class MCPServer {
635
1204
  }
636
1205
 
637
1206
  // Use Analyzer with methodLevel: true
638
- const methodAnalyzer = new Analyzer({ methodLevel: true });
1207
+ const methodAnalyzer = new Analyzer({ methodLevel: true, cache: true });
639
1208
  const analysis = await methodAnalyzer.analyze(files);
640
1209
 
641
1210
  const methods = [];
@@ -655,10 +1224,18 @@ class MCPServer {
655
1224
  };
656
1225
  }
657
1226
  case 'repo_map': {
658
- const map = renderRepoMap(args.path, {
659
- tokens: args.tokens,
660
- focus: args.focus,
661
- });
1227
+ const map =
1228
+ args.repos?.length > 0
1229
+ ? mapWorkspace([args.path, ...args.repos], {
1230
+ tokens: args.tokens,
1231
+ focus: args.focus,
1232
+ callGraph: args.callGraph,
1233
+ })
1234
+ : renderRepoMap(args.path, {
1235
+ tokens: args.tokens,
1236
+ focus: args.focus,
1237
+ callGraph: args.callGraph,
1238
+ });
662
1239
  const notes = map.warnings.map((warning) => `Note: ${warning}`).join('\n');
663
1240
  return {
664
1241
  content: [
@@ -671,6 +1248,7 @@ class MCPServer {
671
1248
  }
672
1249
  case 'pr_context': {
673
1250
  const result = await buildPrContext(args.path, {
1251
+ repos: args.repos,
674
1252
  base: args.base,
675
1253
  head: args.head,
676
1254
  budget: args.budget == null ? undefined : Math.floor(args.budget),
@@ -682,6 +1260,7 @@ class MCPServer {
682
1260
  const result = getSymbol(args.path, args.name, {
683
1261
  file: args.file,
684
1262
  redact: args.redact !== false,
1263
+ repos: args.repos,
685
1264
  });
686
1265
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
687
1266
  }
@@ -692,6 +1271,7 @@ class MCPServer {
692
1271
  const { largestFiles, ...summary } = await countForModel(args.path, {
693
1272
  model: args.model,
694
1273
  text: args.text,
1274
+ repos: args.repos,
695
1275
  });
696
1276
  const top = Number.isInteger(args.top) && args.top >= 0 ? args.top : 10;
697
1277
  const result = { ...summary, largestFiles: largestFiles.slice(0, top) };
@@ -699,15 +1279,47 @@ class MCPServer {
699
1279
  }
700
1280
  case 'explain_history': {
701
1281
  const whole = (value) => (typeof value === 'number' ? Math.floor(value) : value);
1282
+ const analyzerDb =
1283
+ args.analyzerDb !== undefined
1284
+ ? resolveSandboxPath(args.analyzerDb, {
1285
+ root: this.root,
1286
+ expect: 'any',
1287
+ name: 'analyzerDb',
1288
+ })
1289
+ : undefined;
702
1290
  const result = await explainHistory(args.path, args.target, {
1291
+ repos: args.repos,
703
1292
  limit: whole(args.limit),
704
1293
  budget: whole(args.budget ?? 20000),
705
1294
  since: args.since,
706
1295
  patches: args.patches === true,
1296
+ discussions: args.discussions === true,
1297
+ maxDiscussions: whole(args.maxDiscussions),
1298
+ analyzer: args.analyzer === true,
1299
+ analyzerDb,
707
1300
  });
708
1301
  return { content: [{ type: 'text', text: result.text }] };
709
1302
  }
710
1303
  case 'pack_context': {
1304
+ if (args.repos?.length > 0) {
1305
+ const result = await packWorkspace([args.path, ...args.repos], {
1306
+ include: args.include,
1307
+ exclude: args.exclude,
1308
+ style: args.style,
1309
+ compress: args.compress,
1310
+ lineNumbers: args.lineNumbers,
1311
+ tree: args.tree,
1312
+ instructions: args.instructions,
1313
+ budget: args.budget == null ? TOOL_DEFAULT_BUDGET : Math.floor(args.budget),
1314
+ targetModel: args.targetModel,
1315
+ });
1316
+ return {
1317
+ content: [
1318
+ { type: 'text', text: result.text },
1319
+ { type: 'text', text: describePack(result) },
1320
+ ],
1321
+ };
1322
+ }
711
1323
  const result = await packContext(args.path, {
712
1324
  include: args.include,
713
1325
  exclude: args.exclude,
@@ -729,7 +1341,35 @@ class MCPServer {
729
1341
  ],
730
1342
  };
731
1343
  }
1344
+ case 'focus_context': {
1345
+ const result = await focusContext(args.path, args.file ?? args.target, {
1346
+ repos: args.repos,
1347
+ symbol: args.symbol,
1348
+ depth: args.depth == null ? undefined : Math.floor(args.depth),
1349
+ direction: args.direction,
1350
+ callGraph: args.callGraph,
1351
+ includeTests: args.includeTests,
1352
+ cwd: args.path,
1353
+ style: args.style,
1354
+ compress: args.compress,
1355
+ lineNumbers: args.lineNumbers,
1356
+ tree: args.tree,
1357
+ instructions: args.instructions,
1358
+ budget: args.budget == null ? TOOL_DEFAULT_BUDGET : Math.floor(args.budget),
1359
+ targetModel: args.targetModel,
1360
+ });
1361
+ return {
1362
+ content: [
1363
+ { type: 'text', text: result.text },
1364
+ { type: 'text', text: describeFocus(result) },
1365
+ ],
1366
+ };
1367
+ }
732
1368
  case 'check_policy': {
1369
+ if (args.repos?.length > 0) {
1370
+ const result = checkWorkspacePolicies(args.path, args.repos);
1371
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1372
+ }
733
1373
  let result;
734
1374
  try {
735
1375
  result = { valid: true, ...loadPolicy(args.path).toJSON() };
@@ -743,6 +1383,18 @@ class MCPServer {
743
1383
  const since = args.since === undefined ? null : parseSince(String(args.since));
744
1384
  const limit =
745
1385
  Number.isInteger(args.limit) && args.limit >= 0 ? args.limit : DEFAULT_AUDIT_LIMIT;
1386
+ if (args.repos?.length > 0) {
1387
+ const ws = readWorkspaceAuditLogs(args.path, args.repos, { since, limit });
1388
+ const result = {
1389
+ repos: ws.repos,
1390
+ files: ws.files,
1391
+ total: ws.total,
1392
+ events: ws.events,
1393
+ skippedLines: ws.skippedLines,
1394
+ };
1395
+ if (ws.policyErrors.length > 0) result.policyErrors = ws.policyErrors;
1396
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1397
+ }
746
1398
  // Only the policy's own logs: a tool argument never names a file to read
747
1399
  const { files, error } = auditLogFiles(args.path);
748
1400
  const { events, skipped, missing } = readAuditLog(files);
@@ -758,11 +1410,173 @@ class MCPServer {
758
1410
  }
759
1411
  case 'ask_codebase': {
760
1412
  const result = await askCodebase(args.path, args.query, {
1413
+ repos: args.repos,
761
1414
  limit: Number.isInteger(args.limit) ? args.limit : undefined,
762
1415
  maxChars: ASK_SNIPPET_CHARS,
763
1416
  });
764
1417
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
765
1418
  }
1419
+ case 'task_context': {
1420
+ const result = await buildTaskContext(
1421
+ args.path ?? args.dir ?? process.cwd(),
1422
+ args.task ?? args.query ?? '',
1423
+ {
1424
+ repos: args.repos,
1425
+ budget: args.budget == null ? DEFAULT_TASK_BUDGET : Math.floor(args.budget),
1426
+ format: args.format,
1427
+ model: args.model,
1428
+ callGraph: args.callGraph,
1429
+ }
1430
+ );
1431
+ return {
1432
+ content: [
1433
+ { type: 'text', text: result.text },
1434
+ {
1435
+ type: 'text',
1436
+ text: `Task Context: ${result.files.length} file(s), ${result.totalTokens}/${result.budget} tokens (${result.format}).`,
1437
+ },
1438
+ ],
1439
+ };
1440
+ }
1441
+ case 'diagnose_failure': {
1442
+ const result = await diagnoseFailure(
1443
+ args.path ?? args.dir ?? process.cwd(),
1444
+ args.trace ?? args.log ?? '',
1445
+ {
1446
+ repos: args.repos,
1447
+ budget: args.budget == null ? DEFAULT_DIAGNOSE_BUDGET : Math.floor(args.budget),
1448
+ format: args.format,
1449
+ callGraph: args.callGraph,
1450
+ }
1451
+ );
1452
+ return {
1453
+ content: [
1454
+ { type: 'text', text: result.text },
1455
+ {
1456
+ type: 'text',
1457
+ text: `Diagnosed: ${result.implicatedFiles.length} implicated file(s), ${result.totalTokens}/${result.budget} tokens.`,
1458
+ },
1459
+ ],
1460
+ };
1461
+ }
1462
+ case 'find_experts': {
1463
+ const result = await findExperts(
1464
+ args.path ?? process.cwd(),
1465
+ args.target ?? args.query ?? '',
1466
+ {
1467
+ repos: args.repos,
1468
+ limit: Number.isInteger(args.limit) ? args.limit : undefined,
1469
+ halfLifeDays: typeof args.halfLifeDays === 'number' ? args.halfLifeDays : undefined,
1470
+ style: args.style || 'markdown',
1471
+ }
1472
+ );
1473
+ return {
1474
+ content: [{ type: 'text', text: result.text }],
1475
+ };
1476
+ }
1477
+ case 'query_knowledge_graph': {
1478
+ const projectPath = args.path ?? process.cwd();
1479
+ const extraRepos = Array.isArray(args.repos) ? args.repos : [];
1480
+ const scanner = new Scanner(projectPath);
1481
+ scanner.getPolicy();
1482
+ for (const repoPath of extraRepos) {
1483
+ new Scanner(repoPath).getPolicy();
1484
+ }
1485
+
1486
+ const inc = this.getIncrementalAnalyzer(projectPath, extraRepos);
1487
+ await inc.ensureSyncReady(projectPath);
1488
+ const store = inc.vectorStore;
1489
+ const initialCounts = store.graphCounts();
1490
+
1491
+ let bootstrapped = false;
1492
+ if (args.refresh === true || initialCounts.nodes === 0) {
1493
+ await inc.bootstrapSync(projectPath, { repos: extraRepos });
1494
+ bootstrapped = true;
1495
+ }
1496
+
1497
+ const rawGraph = await inc.getGraph();
1498
+ let nodes = rawGraph.nodes.filter((n) => !inc.isPathDenied(n.path));
1499
+ let edges = rawGraph.edges.filter(
1500
+ (e) => !inc.isPathDenied(e.from) && !inc.isPathDenied(e.to)
1501
+ );
1502
+
1503
+ if (typeof args.file === 'string' && args.file.trim()) {
1504
+ const targetFile = args.file.trim().replace(/\\/g, '/').replace(/^\.\//, '');
1505
+ const maxDepth = Math.max(
1506
+ 1,
1507
+ Math.min(4, Number.isFinite(args.depth) ? Math.floor(args.depth) : 1)
1508
+ );
1509
+ const visited = new Set([targetFile]);
1510
+ let frontier = new Set([targetFile]);
1511
+
1512
+ for (let hop = 0; hop < maxDepth && frontier.size > 0; hop++) {
1513
+ const nextFrontier = new Set();
1514
+ for (const edge of edges) {
1515
+ if (frontier.has(edge.from) && !visited.has(edge.to)) {
1516
+ visited.add(edge.to);
1517
+ nextFrontier.add(edge.to);
1518
+ }
1519
+ if (frontier.has(edge.to) && !visited.has(edge.from)) {
1520
+ visited.add(edge.from);
1521
+ nextFrontier.add(edge.from);
1522
+ }
1523
+ }
1524
+ frontier = nextFrontier;
1525
+ }
1526
+
1527
+ nodes = nodes.filter((n) => visited.has(n.path));
1528
+ edges = edges.filter((e) => visited.has(e.from) && visited.has(e.to));
1529
+ }
1530
+
1531
+ if (typeof args.query === 'string' && args.query.trim()) {
1532
+ const needle = args.query.trim().toLowerCase();
1533
+ nodes = nodes.filter((n) => {
1534
+ if (n.path.toLowerCase().includes(needle)) return true;
1535
+ if (Array.isArray(n.methods)) {
1536
+ return n.methods.some((m) => {
1537
+ const name = typeof m === 'string' ? m : m?.name || '';
1538
+ const sig = typeof m === 'object' ? m?.signature || '' : '';
1539
+ return (
1540
+ name.toLowerCase().includes(needle) || sig.toLowerCase().includes(needle)
1541
+ );
1542
+ });
1543
+ }
1544
+ return false;
1545
+ });
1546
+ const matchedPaths = new Set(nodes.map((n) => n.path));
1547
+ edges = edges.filter((e) => matchedPaths.has(e.from) || matchedPaths.has(e.to));
1548
+ }
1549
+
1550
+ const limit = Math.max(
1551
+ 1,
1552
+ Math.min(
1553
+ 500,
1554
+ Number.isFinite(args.limit) ? Math.floor(args.limit) : DEFAULT_GRAPH_LIMIT
1555
+ )
1556
+ );
1557
+ const limitedNodes = nodes.slice(0, limit);
1558
+ const keptPaths = new Set(limitedNodes.map((n) => n.path));
1559
+ const matchingEdges = edges.filter((e) => keptPaths.has(e.from) || keptPaths.has(e.to));
1560
+
1561
+ const counts = store.graphCounts();
1562
+ const payload = {
1563
+ path: projectPath,
1564
+ bootstrapped,
1565
+ cached: inc.fileCache.size > 0 || inc.graphNodes.size > 0,
1566
+ stats: {
1567
+ totalNodes: counts.nodes,
1568
+ totalEdges: counts.edges,
1569
+ vectorDocuments: store.count(),
1570
+ cachedFiles: inc.fileCache.size,
1571
+ },
1572
+ nodes: limitedNodes,
1573
+ edges: matchingEdges,
1574
+ };
1575
+
1576
+ return {
1577
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1578
+ };
1579
+ }
766
1580
  default:
767
1581
  throw new Error(`Unknown tool: ${name}`);
768
1582
  }