diff --git a/packages/cli/src/commands/gh.ts b/packages/cli/src/commands/gh.ts index e452826..e178fa8 100644 --- a/packages/cli/src/commands/gh.ts +++ b/packages/cli/src/commands/gh.ts @@ -100,10 +100,10 @@ export const ghCommand = new Command('gh') ) .addCommand( new Command('search') - .description('Search GitHub issues and PRs') + .description('Search GitHub issues and PRs (defaults to open issues)') .argument('', 'Search query') - .option('--type ', 'Filter by type (issue, pull_request)') - .option('--state ', 'Filter by state (open, closed, merged)') + .option('--type ', 'Filter by type (default: issue)', 'issue') + .option('--state ', 'Filter by state (default: open)', 'open') .option('--author ', 'Filter by author') .option('--label ', 'Filter by labels') .option('--limit ', 'Number of results', Number.parseInt, 10) @@ -142,10 +142,10 @@ export const ghCommand = new Command('gh') spinner.text = 'Searching...'; - // Search + // Search with smart defaults (type: issue, state: open) const results = await ghIndexer.search(query, { - type: options.type as 'issue' | 'pull_request' | undefined, - state: options.state as 'open' | 'closed' | 'merged' | undefined, + type: options.type as 'issue' | 'pull_request', + state: options.state as 'open' | 'closed' | 'merged', author: options.author, labels: options.label, limit: options.limit, diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index 5fcb169..eb631d2 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -5,7 +5,7 @@ */ import { RepositoryIndexer } from '@lytics/dev-agent-core'; -import { SearchAdapter } from '../src/adapters/built-in/search-adapter'; +import { PlanAdapter, SearchAdapter, StatusAdapter } from '../src/adapters/built-in'; import { MCPServer } from '../src/server/mcp-server'; // Get config from environment @@ -31,6 +31,20 @@ async function main() { defaultLimit: 10, }); + const statusAdapter = new StatusAdapter({ + repositoryIndexer: indexer, + repositoryPath, + vectorStorePath, + defaultSection: 'summary', + }); + + const planAdapter = new PlanAdapter({ + repositoryIndexer: indexer, + repositoryPath, + defaultFormat: 'compact', + timeout: 60000, // 60 seconds + }); + // Create MCP server const server = new MCPServer({ serverInfo: { @@ -42,7 +56,7 @@ async function main() { logLevel, }, transport: 'stdio', - adapters: [searchAdapter], + adapters: [searchAdapter, statusAdapter, planAdapter], }); // Handle graceful shutdown diff --git a/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts new file mode 100644 index 0000000..ee71110 --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts @@ -0,0 +1,495 @@ +/** + * Tests for PlanAdapter + */ + +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlanAdapter } from '../built-in/plan-adapter'; +import type { AdapterContext, ToolExecutionContext } from '../types'; + +// Mock RepositoryIndexer +const createMockRepositoryIndexer = () => { + return { + search: vi.fn(), + getStats: vi.fn(), + initialize: vi.fn(), + close: vi.fn(), + } as unknown as RepositoryIndexer; +}; + +// Mock planner utilities +vi.mock('@lytics/dev-agent-subagents', () => ({ + fetchGitHubIssue: vi.fn(), + extractAcceptanceCriteria: vi.fn(), + inferPriority: vi.fn(), + cleanDescription: vi.fn(), + breakdownIssue: vi.fn(), + addEstimatesToTasks: vi.fn(), + calculateTotalEstimate: vi.fn(), +})); + +describe('PlanAdapter', () => { + let adapter: PlanAdapter; + let mockIndexer: RepositoryIndexer; + let mockContext: AdapterContext; + let mockExecutionContext: ToolExecutionContext; + + beforeEach(async () => { + mockIndexer = createMockRepositoryIndexer(); + + adapter = new PlanAdapter({ + repositoryIndexer: mockIndexer, + repositoryPath: '/test/repo', + defaultFormat: 'compact', + timeout: 5000, // Short timeout for tests + }); + + mockContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + config: {}, + }; + + mockExecutionContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; + + // Setup default mock responses + const utils = await import('@lytics/dev-agent-subagents'); + + vi.mocked(utils.fetchGitHubIssue).mockResolvedValue({ + number: 29, + title: 'Plan + Status Adapters', + body: '## Description\nImplement plan and status adapters\n\n## Acceptance Criteria\n- [ ] PlanAdapter works\n- [ ] StatusAdapter works', + labels: ['enhancement'], + }); + + vi.mocked(utils.extractAcceptanceCriteria).mockReturnValue([ + 'PlanAdapter works', + 'StatusAdapter works', + ]); + + vi.mocked(utils.inferPriority).mockReturnValue('medium'); + + vi.mocked(utils.cleanDescription).mockReturnValue('Implement plan and status adapters'); + + vi.mocked(utils.breakdownIssue).mockReturnValue([ + { + id: 'task-1', + description: 'Create PlanAdapter class', + relevantCode: [], + }, + { + id: 'task-2', + description: 'Create StatusAdapter class', + relevantCode: [], + }, + { + id: 'task-3', + description: 'Write unit tests', + relevantCode: [], + }, + ]); + + vi.mocked(utils.addEstimatesToTasks).mockImplementation((tasks) => + tasks.map((task) => ({ + ...task, + estimatedHours: 4, + })) + ); + + vi.mocked(utils.calculateTotalEstimate).mockReturnValue('2 days'); + + // Mock indexer search + vi.mocked(mockIndexer.search).mockResolvedValue([ + { + id: 'doc-1', + text: 'class SearchAdapter', + metadata: { path: 'src/adapters/search-adapter.ts' }, + score: 0.85, + }, + ]); + }); + + describe('metadata', () => { + it('should have correct metadata', () => { + expect(adapter.metadata.name).toBe('plan-adapter'); + expect(adapter.metadata.version).toBe('1.0.0'); + expect(adapter.metadata.description).toContain('planning'); + }); + }); + + describe('initialize', () => { + it('should initialize successfully', async () => { + await adapter.initialize(mockContext); + expect(mockContext.logger.info).toHaveBeenCalledWith('PlanAdapter initialized', { + repositoryPath: '/test/repo', + defaultFormat: 'compact', + timeout: 5000, + }); + }); + }); + + describe('getToolDefinition', () => { + it('should return correct tool definition', () => { + const definition = adapter.getToolDefinition(); + + expect(definition.name).toBe('dev_plan'); + expect(definition.description).toContain('plan'); + expect(definition.inputSchema.type).toBe('object'); + expect(definition.inputSchema.properties).toHaveProperty('issue'); + expect(definition.inputSchema.properties).toHaveProperty('format'); + expect(definition.inputSchema.properties).toHaveProperty('useExplorer'); + expect(definition.inputSchema.properties).toHaveProperty('detailLevel'); + }); + + it('should have correct required fields', () => { + const definition = adapter.getToolDefinition(); + expect(definition.inputSchema.required).toEqual(['issue']); + }); + + it('should have correct format enum values', () => { + const definition = adapter.getToolDefinition(); + const formatProperty = definition.inputSchema.properties?.format; + + expect(formatProperty).toBeDefined(); + expect(formatProperty?.enum).toEqual(['compact', 'verbose']); + }); + + it('should have correct detailLevel enum values', () => { + const definition = adapter.getToolDefinition(); + const detailLevelProperty = definition.inputSchema.properties?.detailLevel; + + expect(detailLevelProperty).toBeDefined(); + expect(detailLevelProperty?.enum).toEqual(['simple', 'detailed']); + }); + }); + + describe('execute', () => { + describe('validation', () => { + it('should reject invalid issue number (not a number)', async () => { + const result = await adapter.execute({ issue: 'invalid' }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_ISSUE'); + expect(result.error?.message).toContain('positive number'); + }); + + it('should reject invalid issue number (negative)', async () => { + const result = await adapter.execute({ issue: -1 }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_ISSUE'); + }); + + it('should reject invalid issue number (zero)', async () => { + const result = await adapter.execute({ issue: 0 }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_ISSUE'); + }); + + it('should reject invalid format', async () => { + const result = await adapter.execute( + { issue: 29, format: 'invalid' }, + mockExecutionContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_FORMAT'); + expect(result.error?.message).toContain('compact'); + }); + + it('should reject invalid detail level', async () => { + const result = await adapter.execute( + { issue: 29, detailLevel: 'invalid' }, + mockExecutionContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_DETAIL_LEVEL'); + }); + }); + + describe('plan generation', () => { + it('should generate compact plan by default', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.format).toBe('compact'); + expect(result.data?.content).toContain('Plan for #29'); + expect(result.data?.content).toContain('Plan + Status Adapters'); + expect(result.data?.content).toContain('Implementation Steps'); + }); + + it('should generate verbose plan when requested', async () => { + const result = await adapter.execute( + { issue: 29, format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.format).toBe('verbose'); + expect(result.data?.content).toContain('"issueNumber": 29'); + expect(result.data?.content).toContain('"title"'); + expect(result.data?.content).toContain('"tasks"'); + }); + + it('should include plan object in verbose mode', async () => { + const result = await adapter.execute( + { issue: 29, format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.plan).toBeDefined(); + expect(result.data?.plan?.issueNumber).toBe(29); + expect(result.data?.plan?.tasks).toHaveLength(3); + }); + + it('should not include plan object in compact mode', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.plan).toBeUndefined(); + }); + + it('should use Explorer by default', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(mockIndexer.search).toHaveBeenCalled(); + }); + + it('should skip Explorer when disabled', async () => { + const result = await adapter.execute( + { issue: 29, useExplorer: false }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(mockIndexer.search).not.toHaveBeenCalled(); + }); + + it('should handle Explorer search failures gracefully', async () => { + vi.mocked(mockIndexer.search).mockRejectedValue(new Error('Search failed')); + + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); // Should still succeed + expect(mockExecutionContext.logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Explorer search failed'), + expect.any(Object) + ); + }); + + it('should generate simple plan when requested', async () => { + const result = await adapter.execute( + { issue: 29, detailLevel: 'simple' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Plan for #29'); + + const utils = await import('@lytics/dev-agent-subagents'); + expect(utils.breakdownIssue).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Array), + expect.objectContaining({ + detailLevel: 'simple', + maxTasks: 8, + }) + ); + }); + + it('should generate detailed plan by default', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + + const utils = await import('@lytics/dev-agent-subagents'); + expect(utils.breakdownIssue).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Array), + expect.objectContaining({ + detailLevel: 'detailed', + maxTasks: 15, + }) + ); + }); + }); + + describe('compact formatting', () => { + it('should format tasks as numbered list', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('1. Create PlanAdapter class'); + expect(result.data?.content).toContain('2. Create StatusAdapter class'); + expect(result.data?.content).toContain('3. Write unit tests'); + }); + + it('should include estimates in compact format', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('(4h)'); + }); + + it('should include total estimate', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('**Estimate:** 2 days'); + }); + + it('should include priority', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('**Priority:** medium'); + }); + + it('should include next step', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Next Step'); + expect(result.data?.content).toContain('Start with: **Create PlanAdapter class**'); + }); + + it('should include relevant code paths in compact format', async () => { + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(true); + // Should show file paths for relevant code + expect(result.data?.content).toMatch(/See.*search-adapter\.ts/); + }); + }); + + describe('error handling', () => { + it('should handle issue not found', async () => { + const utils = await import('@lytics/dev-agent-subagents'); + vi.mocked(utils.fetchGitHubIssue).mockRejectedValue(new Error('Issue #999 not found')); + + const result = await adapter.execute({ issue: 999 }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('ISSUE_NOT_FOUND'); + expect(result.error?.message).toContain('not found'); + expect(result.error?.suggestion).toContain('dev gh index'); + }); + + it('should handle GitHub CLI errors', async () => { + const utils = await import('@lytics/dev-agent-subagents'); + vi.mocked(utils.fetchGitHubIssue).mockRejectedValue( + new Error('GitHub CLI (gh) is not installed') + ); + + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('GITHUB_ERROR'); + expect(result.error?.suggestion).toContain('gh'); + }); + + it('should handle timeout', async () => { + const utils = await import('@lytics/dev-agent-subagents'); + vi.mocked(utils.fetchGitHubIssue).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 10000)) + ); + + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('PLANNER_TIMEOUT'); + expect(result.error?.message).toContain('timeout'); + expect(result.error?.suggestion).toBeDefined(); + }, 10000); // 10 second timeout for this test + + it('should handle unknown errors', async () => { + const utils = await import('@lytics/dev-agent-subagents'); + vi.mocked(utils.fetchGitHubIssue).mockRejectedValue(new Error('Unknown error')); + + const result = await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('PLANNING_FAILED'); + expect(result.error?.message).toBe('Unknown error'); + }); + + it('should log errors', async () => { + const utils = await import('@lytics/dev-agent-subagents'); + vi.mocked(utils.fetchGitHubIssue).mockRejectedValue(new Error('Test error')); + + await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(mockExecutionContext.logger.error).toHaveBeenCalledWith( + 'Plan generation failed', + expect.any(Object) + ); + }); + }); + + describe('logging', () => { + it('should log debug information', async () => { + await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(mockExecutionContext.logger.debug).toHaveBeenCalledWith( + 'Generating plan', + expect.objectContaining({ issue: 29 }) + ); + }); + + it('should log completion', async () => { + await adapter.execute({ issue: 29 }, mockExecutionContext); + + expect(mockExecutionContext.logger.info).toHaveBeenCalledWith( + 'Plan generated', + expect.objectContaining({ + issue: 29, + taskCount: 3, + totalEstimate: '2 days', + }) + ); + }); + }); + }); + + describe('estimateTokens', () => { + it('should estimate tokens for compact simple plan', () => { + const estimate = adapter.estimateTokens({ format: 'compact', detailLevel: 'simple' }); + expect(estimate).toBe(300); + }); + + it('should estimate tokens for compact detailed plan', () => { + const estimate = adapter.estimateTokens({ format: 'compact', detailLevel: 'detailed' }); + expect(estimate).toBe(600); + }); + + it('should estimate tokens for verbose simple plan', () => { + const estimate = adapter.estimateTokens({ format: 'verbose', detailLevel: 'simple' }); + expect(estimate).toBe(800); + }); + + it('should estimate tokens for verbose detailed plan', () => { + const estimate = adapter.estimateTokens({ format: 'verbose', detailLevel: 'detailed' }); + expect(estimate).toBe(1500); + }); + + it('should use defaults when no args provided', () => { + const estimate = adapter.estimateTokens({}); + expect(estimate).toBe(600); // Default is compact + detailed + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts new file mode 100644 index 0000000..7c0f896 --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/status-adapter.test.ts @@ -0,0 +1,447 @@ +/** + * Tests for StatusAdapter + */ + +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { StatusAdapter } from '../built-in/status-adapter'; +import type { AdapterContext, ToolExecutionContext } from '../types'; + +// Mock RepositoryIndexer +const createMockRepositoryIndexer = () => { + return { + getStats: vi.fn(), + search: vi.fn(), + initialize: vi.fn(), + close: vi.fn(), + } as unknown as RepositoryIndexer; +}; + +// Mock GitHubIndexer +vi.mock('@lytics/dev-agent-subagents', () => ({ + GitHubIndexer: vi.fn().mockImplementation(() => ({ + initialize: vi.fn().mockResolvedValue(undefined), + getStats: vi.fn().mockReturnValue({ + repository: 'lytics/dev-agent', + totalDocuments: 59, + byType: { issue: 47, pull_request: 12 }, + byState: { open: 35, closed: 15, merged: 9 }, + lastIndexed: '2025-11-24T10:00:00Z', + indexDuration: 12400, + }), + isIndexed: vi.fn().mockReturnValue(true), + })), +})); + +describe('StatusAdapter', () => { + let adapter: StatusAdapter; + let mockIndexer: RepositoryIndexer; + let mockContext: AdapterContext; + let mockExecutionContext: ToolExecutionContext; + + beforeEach(() => { + mockIndexer = createMockRepositoryIndexer(); + + adapter = new StatusAdapter({ + repositoryIndexer: mockIndexer, + repositoryPath: '/test/repo', + vectorStorePath: '/test/.dev-agent/vectors.lance', + defaultSection: 'summary', + }); + + mockContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + config: {}, + }; + + mockExecutionContext = { + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; + + // Setup default mock responses + vi.mocked(mockIndexer.getStats).mockResolvedValue({ + filesScanned: 2341, + documentsExtracted: 1234, + documentsIndexed: 1234, + vectorsStored: 1234, + duration: 18300, + errors: [], + startTime: new Date('2025-11-24T08:00:00Z'), + endTime: new Date('2025-11-24T08:00:18Z'), + repositoryPath: '/test/repo', + }); + }); + + describe('metadata', () => { + it('should have correct metadata', () => { + expect(adapter.metadata.name).toBe('status-adapter'); + expect(adapter.metadata.version).toBe('1.0.0'); + expect(adapter.metadata.description).toContain('status'); + }); + }); + + describe('initialize', () => { + it('should initialize successfully', async () => { + await adapter.initialize(mockContext); + expect(mockContext.logger.info).toHaveBeenCalledWith('StatusAdapter initialized', { + repositoryPath: '/test/repo', + defaultSection: 'summary', + }); + }); + + it('should handle GitHub indexer initialization failure gracefully', async () => { + const { GitHubIndexer } = await import('@lytics/dev-agent-subagents'); + vi.mocked(GitHubIndexer).mockImplementationOnce(() => { + throw new Error('GitHub not available'); + }); + + await adapter.initialize(mockContext); + expect(mockContext.logger.debug).toHaveBeenCalledWith( + 'GitHub indexer not available', + expect.any(Object) + ); + }); + }); + + describe('getToolDefinition', () => { + it('should return correct tool definition', () => { + const definition = adapter.getToolDefinition(); + + expect(definition.name).toBe('dev_status'); + expect(definition.description).toContain('status'); + expect(definition.inputSchema.type).toBe('object'); + expect(definition.inputSchema.properties).toHaveProperty('section'); + expect(definition.inputSchema.properties).toHaveProperty('format'); + }); + + it('should have correct section enum values', () => { + const definition = adapter.getToolDefinition(); + const sectionProperty = definition.inputSchema.properties?.section; + + expect(sectionProperty).toBeDefined(); + expect(sectionProperty?.enum).toEqual(['summary', 'repo', 'indexes', 'github', 'health']); + }); + + it('should have correct format enum values', () => { + const definition = adapter.getToolDefinition(); + const formatProperty = definition.inputSchema.properties?.format; + + expect(formatProperty).toBeDefined(); + expect(formatProperty?.enum).toEqual(['compact', 'verbose']); + }); + }); + + describe('execute', () => { + describe('validation', () => { + it('should reject invalid section', async () => { + const result = await adapter.execute({ section: 'invalid' }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_SECTION'); + expect(result.error?.message).toContain('summary'); + }); + + it('should reject invalid format', async () => { + const result = await adapter.execute( + { section: 'summary', format: 'invalid' }, + mockExecutionContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_FORMAT'); + expect(result.error?.message).toContain('compact'); + }); + }); + + describe('summary section', () => { + it('should return compact summary by default', async () => { + const result = await adapter.execute({}, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.section).toBe('summary'); + expect(result.data?.format).toBe('compact'); + expect(result.data?.content).toContain('Dev-Agent Status'); + expect(result.data?.content).toContain('Repository:'); + expect(result.data?.content).toContain('2341 files indexed'); + }); + + it('should return verbose summary when requested', async () => { + const result = await adapter.execute( + { section: 'summary', format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Detailed'); + expect(result.data?.content).toContain('Repository'); + expect(result.data?.content).toContain('Vector Indexes'); + expect(result.data?.content).toContain('Health Checks'); + }); + + it('should handle repository not indexed', async () => { + vi.mocked(mockIndexer.getStats).mockResolvedValue(null); + + const result = await adapter.execute({}, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('not indexed'); + }); + + it('should include GitHub section in summary', async () => { + await adapter.initialize(mockContext); + + const result = await adapter.execute({}, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('GitHub'); + // GitHub stats may or may not be available depending on initialization + const content = result.data?.content || ''; + const hasGitHub = content.includes('GitHub'); + expect(hasGitHub).toBe(true); + }); + }); + + describe('repo section', () => { + it('should return repository status in compact format', async () => { + const result = await adapter.execute({ section: 'repo' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Repository Index'); + expect(result.data?.content).toContain('2341'); + expect(result.data?.content).toContain('1234'); + }); + + it('should return repository status in verbose format', async () => { + const result = await adapter.execute( + { section: 'repo', format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Documents Indexed:'); + expect(result.data?.content).toContain('Vectors Stored:'); + }); + + it('should handle repository not indexed', async () => { + vi.mocked(mockIndexer.getStats).mockResolvedValue(null); + + const result = await adapter.execute({ section: 'repo' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Not indexed'); + expect(result.data?.content).toContain('dev index'); + }); + }); + + describe('indexes section', () => { + it('should return indexes status in compact format', async () => { + await adapter.initialize(mockContext); + + const result = await adapter.execute({ section: 'indexes' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Vector Indexes'); + expect(result.data?.content).toContain('Code Index'); + expect(result.data?.content).toContain('GitHub Index'); + expect(result.data?.content).toContain('1234 embeddings'); + }); + + it('should return indexes status in verbose format', async () => { + await adapter.initialize(mockContext); + + const result = await adapter.execute( + { section: 'indexes', format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Code Index'); + expect(result.data?.content).toContain('Documents:'); + expect(result.data?.content).toContain('GitHub Index'); + // GitHub section should be present, may show stats or "Not indexed" + const content = result.data?.content || ''; + const hasGitHubInfo = content.includes('Not indexed') || content.includes('Documents:'); + expect(hasGitHubInfo).toBe(true); + }); + }); + + describe('github section', () => { + it('should return GitHub status in compact format', async () => { + await adapter.initialize(mockContext); + + const result = await adapter.execute({ section: 'github' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('GitHub Integration'); + // May show stats or "Not indexed" depending on initialization + }); + + it('should return GitHub status in verbose format', async () => { + await adapter.initialize(mockContext); + + const result = await adapter.execute( + { section: 'github', format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('GitHub Integration'); + // May include Configuration or Not indexed message + }); + + it('should handle GitHub not indexed', async () => { + // Create adapter without initializing (no GitHub indexer) + const newAdapter = new StatusAdapter({ + repositoryIndexer: mockIndexer, + repositoryPath: '/test/repo', + vectorStorePath: '/test/.dev-agent/vectors.lance', + }); + + const result = await newAdapter.execute({ section: 'github' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Not indexed'); + expect(result.data?.content).toContain('dev gh index'); + }); + }); + + describe('health section', () => { + it('should return health status in compact format', async () => { + const result = await adapter.execute({ section: 'health' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Health Checks'); + expect(result.data?.content).toContain('✅'); + }); + + it('should return health status in verbose format', async () => { + const result = await adapter.execute( + { section: 'health', format: 'verbose' }, + mockExecutionContext + ); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('Health Checks'); + // Verbose includes details + expect(result.data?.content.length).toBeGreaterThan(100); + }); + }); + + describe('error handling', () => { + it('should handle errors during status generation', async () => { + vi.mocked(mockIndexer.getStats).mockRejectedValue(new Error('Database error')); + + const result = await adapter.execute({ section: 'summary' }, mockExecutionContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('STATUS_FAILED'); + expect(result.error?.message).toBe('Database error'); + }); + + it('should log errors', async () => { + vi.mocked(mockIndexer.getStats).mockRejectedValue(new Error('Test error')); + + await adapter.execute({ section: 'summary' }, mockExecutionContext); + + expect(mockExecutionContext.logger.error).toHaveBeenCalledWith( + 'Status check failed', + expect.any(Object) + ); + }); + }); + + describe('logging', () => { + it('should log debug information', async () => { + await adapter.execute({ section: 'summary' }, mockExecutionContext); + + expect(mockExecutionContext.logger.debug).toHaveBeenCalledWith('Executing status check', { + section: 'summary', + format: 'compact', + }); + }); + + it('should log completion', async () => { + await adapter.execute({ section: 'summary' }, mockExecutionContext); + + expect(mockExecutionContext.logger.info).toHaveBeenCalledWith('Status check completed', { + section: 'summary', + format: 'compact', + }); + }); + }); + }); + + describe('estimateTokens', () => { + it('should estimate tokens for compact summary', () => { + const estimate = adapter.estimateTokens({ section: 'summary', format: 'compact' }); + expect(estimate).toBe(200); + }); + + it('should estimate tokens for verbose summary', () => { + const estimate = adapter.estimateTokens({ section: 'summary', format: 'verbose' }); + expect(estimate).toBe(800); + }); + + it('should estimate tokens for compact section', () => { + const estimate = adapter.estimateTokens({ section: 'repo', format: 'compact' }); + expect(estimate).toBe(150); + }); + + it('should estimate tokens for verbose section', () => { + const estimate = adapter.estimateTokens({ section: 'repo', format: 'verbose' }); + expect(estimate).toBe(500); + }); + + it('should use defaults when no args provided', () => { + const estimate = adapter.estimateTokens({}); + expect(estimate).toBe(200); // Default is summary + compact + }); + }); + + describe('time formatting', () => { + it('should format recent times correctly', async () => { + const now = new Date(); + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + + vi.mocked(mockIndexer.getStats).mockResolvedValue({ + filesScanned: 100, + documentsExtracted: 50, + documentsIndexed: 50, + vectorsStored: 50, + duration: 1000, + errors: [], + startTime: twoHoursAgo, + endTime: twoHoursAgo, + repositoryPath: '/test/repo', + }); + + const result = await adapter.execute({ section: 'summary' }, mockExecutionContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('ago'); + }); + }); + + describe('storage size formatting', () => { + it('should format bytes correctly', async () => { + // This is tested implicitly in the status checks + // We can't easily test the private method directly, but we can verify + // the output contains formatted storage sizes + const result = await adapter.execute({ section: 'indexes' }, mockExecutionContext); + + expect(result.success).toBe(true); + // Should contain some size format (KB, MB, GB, or B) + expect(result.data?.content).toMatch(/\d+(\.\d+)?\s*(B|KB|MB|GB)/); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/built-in/index.ts b/packages/mcp-server/src/adapters/built-in/index.ts index e7b6c2c..82506b2 100644 --- a/packages/mcp-server/src/adapters/built-in/index.ts +++ b/packages/mcp-server/src/adapters/built-in/index.ts @@ -3,4 +3,6 @@ * Production-ready adapters included with the MCP server */ +export { PlanAdapter, type PlanAdapterConfig } from './plan-adapter'; export { SearchAdapter, type SearchAdapterConfig } from './search-adapter'; +export { StatusAdapter, type StatusAdapterConfig } from './status-adapter'; diff --git a/packages/mcp-server/src/adapters/built-in/plan-adapter.ts b/packages/mcp-server/src/adapters/built-in/plan-adapter.ts new file mode 100644 index 0000000..649fefe --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/plan-adapter.ts @@ -0,0 +1,413 @@ +/** + * Plan Adapter + * Generates development plans from GitHub issues + */ + +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { + addEstimatesToTasks, + breakdownIssue, + calculateTotalEstimate, + cleanDescription, + extractAcceptanceCriteria, + fetchGitHubIssue, + inferPriority, +} from '@lytics/dev-agent-subagents'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +/** + * Plan task (local type definition - matches planner/types.ts) + */ +interface PlanTask { + id: string; + description: string; + relevantCode?: Array<{ + path: string; + reason: string; + score: number; + }>; + estimatedHours?: number; + dependencies?: string[]; + priority?: 'low' | 'medium' | 'high'; + phase?: string; +} + +/** + * Development plan (local type definition - matches planner/types.ts) + */ +interface Plan { + issueNumber: number; + title: string; + description: string; + tasks: PlanTask[]; + totalEstimate: string; + priority: 'low' | 'medium' | 'high'; + metadata: { + generatedAt: string; + explorerUsed: boolean; + strategy: string; + }; +} + +/** + * Plan adapter configuration + */ +export interface PlanAdapterConfig { + /** + * Repository indexer instance (for finding relevant code) + */ + repositoryIndexer: RepositoryIndexer; + + /** + * Repository path + */ + repositoryPath: string; + + /** + * Default format mode + */ + defaultFormat?: 'compact' | 'verbose'; + + /** + * Timeout for plan generation (milliseconds) + */ + timeout?: number; +} + +/** + * Plan Adapter + * Implements the dev_plan tool for generating implementation plans from GitHub issues + */ +export class PlanAdapter extends ToolAdapter { + readonly metadata = { + name: 'plan-adapter', + version: '1.0.0', + description: 'GitHub issue planning adapter', + author: 'Dev-Agent Team', + }; + + private indexer: RepositoryIndexer; + private repositoryPath: string; + private defaultFormat: 'compact' | 'verbose'; + private timeout: number; + + constructor(config: PlanAdapterConfig) { + super(); + this.indexer = config.repositoryIndexer; + this.repositoryPath = config.repositoryPath; + this.defaultFormat = config.defaultFormat ?? 'compact'; + this.timeout = config.timeout ?? 60000; // 60 seconds default + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('PlanAdapter initialized', { + repositoryPath: this.repositoryPath, + defaultFormat: this.defaultFormat, + timeout: this.timeout, + }); + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_plan', + description: + 'Generate implementation plan from GitHub issue with tasks, estimates, and dependencies', + inputSchema: { + type: 'object', + properties: { + issue: { + type: 'number', + description: 'GitHub issue number (e.g., 29)', + }, + format: { + type: 'string', + enum: ['compact', 'verbose'], + description: + 'Output format: "compact" for markdown checklist (default), "verbose" for detailed JSON', + default: this.defaultFormat, + }, + useExplorer: { + type: 'boolean', + description: 'Find relevant code using semantic search (default: true)', + default: true, + }, + detailLevel: { + type: 'string', + enum: ['simple', 'detailed'], + description: 'Task granularity: "simple" (4-8 tasks) or "detailed" (10-15 tasks)', + default: 'detailed', + }, + }, + required: ['issue'], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + issue, + format = this.defaultFormat, + useExplorer = true, + detailLevel = 'detailed', + } = args; + + // Validate issue number + if (typeof issue !== 'number' || issue < 1) { + return { + success: false, + error: { + code: 'INVALID_ISSUE', + message: 'Issue must be a positive number', + }, + }; + } + + // Validate format + if (format !== 'compact' && format !== 'verbose') { + return { + success: false, + error: { + code: 'INVALID_FORMAT', + message: 'Format must be either "compact" or "verbose"', + }, + }; + } + + // Validate detailLevel + if (detailLevel !== 'simple' && detailLevel !== 'detailed') { + return { + success: false, + error: { + code: 'INVALID_DETAIL_LEVEL', + message: 'Detail level must be either "simple" or "detailed"', + }, + }; + } + + try { + context.logger.debug('Generating plan', { issue, format, useExplorer, detailLevel }); + + // Generate plan with timeout + const plan = await this.withTimeout( + this.generatePlan( + issue as number, + useExplorer as boolean, + detailLevel as 'simple' | 'detailed', + context + ), + this.timeout + ); + + // Format plan based on format parameter + const content = format === 'verbose' ? this.formatVerbose(plan) : this.formatCompact(plan); + + context.logger.info('Plan generated', { + issue, + taskCount: plan.tasks.length, + totalEstimate: plan.totalEstimate, + }); + + return { + success: true, + data: { + issue, + format, + content, + plan: format === 'verbose' ? plan : undefined, // Include full plan in verbose mode + }, + }; + } catch (error) { + context.logger.error('Plan generation failed', { error }); + + // Handle specific error types + if (error instanceof Error) { + if (error.message.includes('timeout')) { + return { + success: false, + error: { + code: 'PLANNER_TIMEOUT', + message: `Planning timeout after ${this.timeout / 1000}s. Issue may be too complex.`, + suggestion: 'Try breaking the issue into smaller sub-issues or increase timeout.', + }, + }; + } + + if (error.message.includes('not found') || error.message.includes('404')) { + return { + success: false, + error: { + code: 'ISSUE_NOT_FOUND', + message: `GitHub issue #${issue} not found`, + suggestion: 'Check the issue number or run "dev gh index" to sync GitHub data.', + }, + }; + } + + if (error.message.includes('GitHub') || error.message.includes('gh')) { + return { + success: false, + error: { + code: 'GITHUB_ERROR', + message: error.message, + suggestion: 'Ensure GitHub CLI (gh) is installed and authenticated.', + }, + }; + } + } + + return { + success: false, + error: { + code: 'PLANNING_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, + }, + }; + } + } + + /** + * Generate a development plan from a GitHub issue + */ + private async generatePlan( + issueNumber: number, + useExplorer: boolean, + detailLevel: 'simple' | 'detailed', + context: ToolExecutionContext + ): Promise { + // Fetch GitHub issue + context.logger.debug('Fetching GitHub issue', { issueNumber }); + const issue = await fetchGitHubIssue(issueNumber, this.repositoryPath); + + // Parse issue content + const acceptanceCriteria = extractAcceptanceCriteria(issue.body); + const priority = inferPriority(issue.labels); + const description = cleanDescription(issue.body); + + // Break down into tasks + const maxTasks = detailLevel === 'simple' ? 8 : 15; + let tasks = breakdownIssue(issue, acceptanceCriteria, { + detailLevel, + maxTasks, + includeEstimates: false, + }); + + // Find relevant code if Explorer enabled + if (useExplorer) { + context.logger.debug('Finding relevant code', { taskCount: tasks.length }); + + for (const task of tasks) { + try { + const results = await this.indexer.search(task.description, { + limit: 3, + scoreThreshold: 0.6, + }); + + task.relevantCode = results.map((r) => ({ + path: (r.metadata as { path?: string }).path || '', + reason: 'Similar pattern found', + score: r.score, + })); + } catch (error) { + // Continue without Explorer context for this task + context.logger.debug('Explorer search failed for task', { task: task.id, error }); + } + } + } + + // Add effort estimates + tasks = addEstimatesToTasks(tasks); + const totalEstimate = calculateTotalEstimate(tasks); + + return { + issueNumber, + title: issue.title, + description, + tasks, + totalEstimate, + priority, + metadata: { + generatedAt: new Date().toISOString(), + explorerUsed: useExplorer, + strategy: detailLevel === 'simple' ? 'sequential' : 'parallel', + }, + }; + } + + /** + * Format plan as compact markdown checklist + */ + private formatCompact(plan: Plan): string { + const lines: string[] = []; + + // Header + lines.push(`## Plan for #${plan.issueNumber}: ${plan.title}`); + lines.push(''); + lines.push( + `**Estimate:** ${plan.totalEstimate} | **Priority:** ${plan.priority} | **Tasks:** ${plan.tasks.length}` + ); + lines.push(''); + + // Tasks as checklist + lines.push('### Implementation Steps'); + lines.push(''); + + for (let i = 0; i < plan.tasks.length; i++) { + const task = plan.tasks[i]; + const estimate = task.estimatedHours ? ` (${task.estimatedHours}h)` : ''; + lines.push(`${i + 1}. ${task.description}${estimate}`); + + // Add relevant code if available (compact: just file paths) + if (task.relevantCode && task.relevantCode.length > 0) { + const paths = task.relevantCode + .slice(0, 2) + .map((c: { path: string; reason: string; score: number }) => c.path) + .join(', '); + lines.push(` _See:_ \`${paths}\``); + } + + lines.push(''); + } + + // Next step + lines.push('### Next Step'); + lines.push(''); + lines.push( + `Start with: **${plan.tasks[0]?.description}**${plan.tasks[0]?.estimatedHours ? ` (~${plan.tasks[0].estimatedHours}h)` : ''}` + ); + + return lines.join('\n'); + } + + /** + * Format plan as verbose JSON with all details + */ + private formatVerbose(plan: Plan): string { + return JSON.stringify(plan, null, 2); + } + + /** + * Execute a promise with a timeout + */ + private async withTimeout(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs) + ), + ]); + } + + estimateTokens(args: Record): number { + const { format = this.defaultFormat, detailLevel = 'detailed' } = args; + + // Rough estimates based on format and detail level + if (format === 'verbose') { + return detailLevel === 'simple' ? 800 : 1500; + } + + // Compact estimates + return detailLevel === 'simple' ? 300 : 600; + } +} diff --git a/packages/mcp-server/src/adapters/built-in/status-adapter.ts b/packages/mcp-server/src/adapters/built-in/status-adapter.ts new file mode 100644 index 0000000..131690c --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/status-adapter.ts @@ -0,0 +1,664 @@ +/** + * Status Adapter + * Provides repository status, indexing statistics, and health checks + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { GitHubIndexer } from '@lytics/dev-agent-subagents'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +/** + * Status section types + */ +export type StatusSection = 'summary' | 'repo' | 'indexes' | 'github' | 'health'; + +/** + * Status adapter configuration + */ +export interface StatusAdapterConfig { + /** + * Repository indexer instance + */ + repositoryIndexer: RepositoryIndexer; + + /** + * Repository path + */ + repositoryPath: string; + + /** + * Vector storage path + */ + vectorStorePath: string; + + /** + * Default section to display + */ + defaultSection?: StatusSection; +} + +/** + * Status Adapter + * Implements the dev_status tool for repository status and health checks + */ +export class StatusAdapter extends ToolAdapter { + readonly metadata = { + name: 'status-adapter', + version: '1.0.0', + description: 'Repository status and health monitoring adapter', + author: 'Dev-Agent Team', + }; + + private repositoryIndexer: RepositoryIndexer; + private repositoryPath: string; + private vectorStorePath: string; + private defaultSection: StatusSection; + private githubIndexer?: GitHubIndexer; + + constructor(config: StatusAdapterConfig) { + super(); + this.repositoryIndexer = config.repositoryIndexer; + this.repositoryPath = config.repositoryPath; + this.vectorStorePath = config.vectorStorePath; + this.defaultSection = config.defaultSection ?? 'summary'; + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('StatusAdapter initialized', { + repositoryPath: this.repositoryPath, + defaultSection: this.defaultSection, + }); + + // Initialize GitHub indexer lazily + try { + this.githubIndexer = new GitHubIndexer({ + vectorStorePath: `${this.vectorStorePath}-github`, + statePath: '.dev-agent/github-state.json', + autoUpdate: true, + staleThreshold: 15 * 60 * 1000, + }); + await this.githubIndexer.initialize(); + } catch (error) { + context.logger.debug('GitHub indexer not available', { error }); + // Not fatal, GitHub section will show "not indexed" + } + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_status', + description: 'Get repository indexing status, configuration, and health checks', + inputSchema: { + type: 'object', + properties: { + section: { + type: 'string', + enum: ['summary', 'repo', 'indexes', 'github', 'health'], + description: + 'Which section to display: "summary" (overview), "repo" (repository details), "indexes" (vector storage), "github" (GitHub integration), "health" (system checks)', + default: this.defaultSection, + }, + format: { + type: 'string', + enum: ['compact', 'verbose'], + description: + 'Output format: "compact" for brief info (default), "verbose" for full details', + default: 'compact', + }, + }, + required: [], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { section = this.defaultSection, format = 'compact' } = args; + + // Validate section + const validSections: StatusSection[] = ['summary', 'repo', 'indexes', 'github', 'health']; + if (!validSections.includes(section as StatusSection)) { + return { + success: false, + error: { + code: 'INVALID_SECTION', + message: `Section must be one of: ${validSections.join(', ')}`, + }, + }; + } + + // Validate format + if (format !== 'compact' && format !== 'verbose') { + return { + success: false, + error: { + code: 'INVALID_FORMAT', + message: 'Format must be either "compact" or "verbose"', + }, + }; + } + + try { + context.logger.debug('Executing status check', { section, format }); + + // Generate status content based on section + const content = await this.generateStatus( + section as StatusSection, + format as string, + context + ); + + context.logger.info('Status check completed', { section, format }); + + return { + success: true, + data: { + section, + format, + content, + }, + }; + } catch (error) { + context.logger.error('Status check failed', { error }); + return { + success: false, + error: { + code: 'STATUS_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, + }, + }; + } + } + + /** + * Generate status content for a specific section + */ + private async generateStatus( + section: StatusSection, + format: string, + _context: ToolExecutionContext + ): Promise { + switch (section) { + case 'summary': + return this.generateSummary(format); + case 'repo': + return this.generateRepoStatus(format); + case 'indexes': + return this.generateIndexesStatus(format); + case 'github': + return this.generateGitHubStatus(format); + case 'health': + return this.generateHealthStatus(format); + default: + throw new Error(`Unknown section: ${section}`); + } + } + + /** + * Generate summary (overview of all sections) + */ + private async generateSummary(format: string): Promise { + const repoStats = await this.repositoryIndexer.getStats(); + const githubStats = this.githubIndexer?.getStats() ?? null; + + if (format === 'verbose') { + return this.generateVerboseSummary(repoStats, githubStats); + } + + // Compact summary + const lines: string[] = ['## Dev-Agent Status', '']; + + // Repository + if (repoStats) { + const timeAgo = this.formatTimeAgo(repoStats.startTime); + lines.push( + `**Repository:** ${this.repositoryPath} (${repoStats.filesScanned} files indexed)` + ); + lines.push(`**Last Scan:** ${timeAgo}`); + } else { + lines.push(`**Repository:** ${this.repositoryPath} (not indexed)`); + } + + lines.push(''); + + // Indexes + if (repoStats) { + const codeIcon = '✅'; + const githubIcon = githubStats ? '✅' : '⚠️'; + lines.push( + `**Indexes:** ${codeIcon} Code (${repoStats.documentsExtracted} components) | ${githubIcon} GitHub ${githubStats ? `(${githubStats.totalDocuments} items)` : '(not indexed)'}` + ); + } + + lines.push(''); + + // Storage + if (repoStats) { + const storageSize = await this.getStorageSize(); + lines.push(`**Storage:** ${this.formatBytes(storageSize)} (LanceDB)`); + } + + lines.push(''); + + // Health + const health = await this.checkHealth(); + const healthIcon = health.every((check) => check.status === 'ok') ? '✅' : '⚠️'; + lines.push( + `**Health:** ${healthIcon} ${health.filter((c) => c.status === 'ok').length}/${health.length} checks passed` + ); + + return lines.join('\n'); + } + + /** + * Generate verbose summary with all details + */ + private generateVerboseSummary( + repoStats: Awaited>, + githubStats: ReturnType['getStats']> + ): string { + const lines: string[] = ['## Dev-Agent Status (Detailed)', '']; + + // Repository + lines.push('### Repository'); + lines.push(`- **Path:** ${this.repositoryPath}`); + if (repoStats) { + lines.push(`- **Files Indexed:** ${repoStats.filesScanned}`); + lines.push(`- **Components:** ${repoStats.documentsExtracted}`); + const startTimeISO = + typeof repoStats.startTime === 'string' + ? repoStats.startTime + : repoStats.startTime.toISOString(); + lines.push(`- **Last Scan:** ${startTimeISO} (${this.formatTimeAgo(repoStats.startTime)})`); + } else { + lines.push('- **Status:** Not indexed'); + } + lines.push(''); + + // Indexes + lines.push('### Vector Indexes'); + if (repoStats) { + lines.push(`- **Code Index:** ${repoStats.vectorsStored} vectors`); + } else { + lines.push('- **Code Index:** Not initialized'); + } + if (githubStats) { + lines.push(`- **GitHub Index:** ${githubStats.totalDocuments} documents`); + lines.push(` - Issues: ${githubStats.byType.issue || 0}`); + lines.push(` - Pull Requests: ${githubStats.byType.pull_request || 0}`); + } else { + lines.push('- **GitHub Index:** Not indexed'); + } + lines.push(''); + + // Health + lines.push('### Health Checks'); + const checks = this.checkHealthSync(); + for (const check of checks) { + const icon = check.status === 'ok' ? '✅' : check.status === 'warning' ? '⚠️' : '❌'; + lines.push(`${icon} **${check.name}:** ${check.message}`); + } + + return lines.join('\n'); + } + + /** + * Generate repository status + */ + private async generateRepoStatus(format: string): Promise { + const stats = await this.repositoryIndexer.getStats(); + + const lines: string[] = ['## Repository Index', '']; + + if (!stats) { + lines.push('**Status:** Not indexed'); + lines.push(''); + lines.push('Run `dev index` to index your repository'); + return lines.join('\n'); + } + + lines.push(`**Path:** ${this.repositoryPath}`); + lines.push(`**Indexed Files:** ${stats.filesScanned}`); + lines.push(`**Components:** ${stats.documentsExtracted}`); + + if (format === 'verbose') { + lines.push(`**Documents Indexed:** ${stats.documentsIndexed}`); + lines.push(`**Vectors Stored:** ${stats.vectorsStored}`); + } + + const startTimeISO = + typeof stats.startTime === 'string' ? stats.startTime : stats.startTime.toISOString(); + lines.push(`**Last Scan:** ${startTimeISO} (${this.formatTimeAgo(stats.startTime)})`); + + if (format === 'verbose' && stats.errors.length > 0) { + lines.push(''); + lines.push('**Errors:**'); + for (const error of stats.errors.slice(0, 5)) { + lines.push(`- ${error.message}`); + } + if (stats.errors.length > 5) { + lines.push(`- ... and ${stats.errors.length - 5} more`); + } + } + + return lines.join('\n'); + } + + /** + * Generate indexes status + */ + private async generateIndexesStatus(format: string): Promise { + const repoStats = await this.repositoryIndexer.getStats(); + const githubStats = this.githubIndexer?.getStats() ?? null; + const storageSize = await this.getStorageSize(); + + const lines: string[] = ['## Vector Indexes', '']; + + // Code Index + lines.push('### Code Index'); + if (repoStats) { + lines.push(`- **Storage:** LanceDB (${this.vectorStorePath})`); + lines.push(`- **Vectors:** ${repoStats.vectorsStored} embeddings`); + if (format === 'verbose') { + lines.push(`- **Documents:** ${repoStats.documentsIndexed}`); + lines.push(`- **Model:** all-MiniLM-L6-v2 (384-dim)`); + } + lines.push(`- **Size:** ${this.formatBytes(storageSize)}`); + lines.push(`- **Last Updated:** ${this.formatTimeAgo(repoStats.startTime)}`); + } else { + lines.push('- **Status:** Not initialized'); + } + + lines.push(''); + + // GitHub Index + lines.push('### GitHub Index'); + if (githubStats) { + lines.push(`- **Storage:** LanceDB (${this.vectorStorePath}-github)`); + lines.push(`- **Documents:** ${githubStats.totalDocuments}`); + if (format === 'verbose') { + lines.push(`- **By Type:**`); + lines.push(` - Issues: ${githubStats.byType.issue || 0}`); + lines.push(` - Pull Requests: ${githubStats.byType.pull_request || 0}`); + lines.push(`- **By State:**`); + lines.push(` - Open: ${githubStats.byState.open || 0}`); + lines.push(` - Closed: ${githubStats.byState.closed || 0}`); + if (githubStats.byState.merged) { + lines.push(` - Merged: ${githubStats.byState.merged}`); + } + } + lines.push( + `- **Last Sync:** ${githubStats.lastIndexed} (${this.formatTimeAgo(new Date(githubStats.lastIndexed))})` + ); + } else { + lines.push('- **Status:** Not indexed'); + lines.push('- Run `dev gh index` to sync GitHub data'); + } + + return lines.join('\n'); + } + + /** + * Generate GitHub status + */ + private async generateGitHubStatus(format: string): Promise { + const stats = this.githubIndexer?.getStats() ?? null; + + const lines: string[] = ['## GitHub Integration', '']; + + if (!stats) { + lines.push('**Status:** Not indexed'); + lines.push(''); + lines.push('Run `dev gh index` to sync GitHub data'); + return lines.join('\n'); + } + + lines.push(`**Repository:** ${stats.repository}`); + lines.push(`**Total Documents:** ${stats.totalDocuments}`); + lines.push(''); + + lines.push('**By Type:**'); + lines.push(`- Issues: ${stats.byType.issue || 0}`); + lines.push(`- Pull Requests: ${stats.byType.pull_request || 0}`); + lines.push(''); + + lines.push('**By State:**'); + lines.push(`- Open: ${stats.byState.open || 0}`); + lines.push(`- Closed: ${stats.byState.closed || 0}`); + if (stats.byState.merged) { + lines.push(`- Merged: ${stats.byState.merged}`); + } + lines.push(''); + + lines.push( + `**Last Sync:** ${stats.lastIndexed} (${this.formatTimeAgo(new Date(stats.lastIndexed))})` + ); + + if (format === 'verbose') { + lines.push(''); + lines.push('**Configuration:**'); + lines.push('- Auto-update: Enabled (every 15 minutes)'); + lines.push('- Authentication: GitHub CLI (gh)'); + } + + return lines.join('\n'); + } + + /** + * Generate health status + */ + private async generateHealthStatus(format: string): Promise { + const checks = await this.checkHealth(); + + const lines: string[] = ['## Health Checks', '']; + + for (const check of checks) { + const icon = check.status === 'ok' ? '✅' : check.status === 'warning' ? '⚠️' : '❌'; + lines.push(`${icon} **${check.name}:** ${check.message}`); + if (format === 'verbose' && check.details) { + lines.push(` ${check.details}`); + } + } + + return lines.join('\n'); + } + + /** + * Check system health + */ + private async checkHealth(): Promise< + Array<{ name: string; status: 'ok' | 'warning' | 'error'; message: string; details?: string }> + > { + const checks: Array<{ + name: string; + status: 'ok' | 'warning' | 'error'; + message: string; + details?: string; + }> = []; + + // Repository access + try { + await fs.promises.access(this.repositoryPath, fs.constants.R_OK); + checks.push({ + name: 'Repository Access', + status: 'ok', + message: 'Can read source files', + }); + } catch { + checks.push({ + name: 'Repository Access', + status: 'error', + message: 'Cannot access repository', + details: `Path: ${this.repositoryPath}`, + }); + } + + // Vector storage + const stats = await this.repositoryIndexer.getStats(); + if (stats) { + checks.push({ + name: 'Vector Storage', + status: 'ok', + message: 'LanceDB operational', + details: `${stats.vectorsStored} vectors stored`, + }); + } else { + checks.push({ + name: 'Vector Storage', + status: 'warning', + message: 'Not initialized', + details: 'Run "dev index" to initialize', + }); + } + + // GitHub CLI + try { + const { execSync } = await import('node:child_process'); + execSync('gh --version', { stdio: 'ignore' }); + checks.push({ + name: 'GitHub CLI', + status: 'ok', + message: 'Installed and operational', + }); + } catch { + checks.push({ + name: 'GitHub CLI', + status: 'warning', + message: 'Not available', + details: 'Install gh CLI for GitHub integration', + }); + } + + // Disk space + try { + const storageSize = await this.getStorageSize(); + const storageMB = storageSize / (1024 * 1024); + if (storageMB > 100) { + checks.push({ + name: 'Storage Size', + status: 'warning', + message: `Large storage (${this.formatBytes(storageSize)})`, + details: 'Consider cleaning old indexes', + }); + } else { + checks.push({ + name: 'Storage Size', + status: 'ok', + message: this.formatBytes(storageSize), + }); + } + } catch { + checks.push({ + name: 'Storage Size', + status: 'warning', + message: 'Cannot determine size', + }); + } + + return checks; + } + + /** + * Synchronous health checks (for verbose summary) + */ + private checkHealthSync(): Array<{ + name: string; + status: 'ok' | 'warning' | 'error'; + message: string; + }> { + const checks: Array<{ name: string; status: 'ok' | 'warning' | 'error'; message: string }> = []; + + // Repository access + try { + fs.accessSync(this.repositoryPath, fs.constants.R_OK); + checks.push({ name: 'Repository', status: 'ok', message: 'Accessible' }); + } catch { + checks.push({ name: 'Repository', status: 'error', message: 'Not accessible' }); + } + + // Vector storage (check if directory exists) + try { + const vectorDir = path.dirname(this.vectorStorePath); + fs.accessSync(vectorDir, fs.constants.R_OK); + checks.push({ name: 'Vector Storage', status: 'ok', message: 'Available' }); + } catch { + checks.push({ name: 'Vector Storage', status: 'warning', message: 'Not initialized' }); + } + + return checks; + } + + /** + * Get total storage size for vector indexes + */ + private async getStorageSize(): Promise { + try { + const getDirectorySize = async (dirPath: string): Promise => { + try { + const stats = await fs.promises.stat(dirPath); + if (!stats.isDirectory()) { + return stats.size; + } + + const files = await fs.promises.readdir(dirPath); + const sizes = await Promise.all( + files.map((file) => getDirectorySize(path.join(dirPath, file))) + ); + return sizes.reduce((acc, size) => acc + size, 0); + } catch { + return 0; + } + }; + + const vectorDir = path.dirname(this.vectorStorePath); + return await getDirectorySize(vectorDir); + } catch { + return 0; + } + } + + /** + * Format bytes to human-readable string + */ + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; + } + + /** + * Format time ago (e.g., "2 hours ago") + */ + private formatTimeAgo(date: Date | string): string { + const now = new Date(); + const dateObj = typeof date === 'string' ? new Date(date) : date; + const seconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000); + + if (seconds < 60) return `${seconds} seconds ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`; + const months = Math.floor(days / 30); + return `${months} month${months === 1 ? '' : 's'} ago`; + } + + estimateTokens(args: Record): number { + const { section = this.defaultSection, format = 'compact' } = args; + + // Rough estimates based on section and format + if (format === 'verbose') { + return section === 'summary' ? 800 : 500; + } + + // Compact estimates + return section === 'summary' ? 200 : 150; + } +} diff --git a/packages/mcp-server/src/server/mcp-server.ts b/packages/mcp-server/src/server/mcp-server.ts index 4931cbe..a274107 100644 --- a/packages/mcp-server/src/server/mcp-server.ts +++ b/packages/mcp-server/src/server/mcp-server.ts @@ -215,6 +215,21 @@ export class MCPServer { }; } + /** + * Map semantic error codes to JSON-RPC numeric codes + */ + private mapErrorCode(code?: string): number { + const codeMap: Record = { + INVALID_PARAMS: -32602, + NOT_FOUND: -32001, + TIMEOUT: -32002, + INTERNAL_ERROR: -32603, + GITHUB_CLI_ERROR: -32003, + INDEXER_ERROR: -32004, + }; + return code ? codeMap[code] || -32001 : -32001; + } + /** * Handle tools/call request */ @@ -233,7 +248,7 @@ export class MCPServer { if (!result.success) { throw { - code: result.error?.code ? Number.parseInt(result.error.code, 10) : -32001, + code: this.mapErrorCode(result.error?.code), message: result.error?.message || 'Tool execution failed', data: { details: result.error?.details, @@ -242,7 +257,17 @@ export class MCPServer { }; } - return result.data; + // Format response according to MCP protocol + // The content field must be an array of content blocks + return { + content: [ + { + type: 'text', + text: + typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2), + }, + ], + }; } /** diff --git a/packages/subagents/src/github/indexer.ts b/packages/subagents/src/github/indexer.ts index 28e8182..e79dba6 100644 --- a/packages/subagents/src/github/indexer.ts +++ b/packages/subagents/src/github/indexer.ts @@ -177,10 +177,16 @@ export class GitHubIndexer { // Convert back to GitHubSearchResult format and apply filters const results: GitHubSearchResult[] = []; + const seenIds = new Set(); for (const result of vectorResults) { const doc = JSON.parse(result.metadata.document as string) as GitHubDocument; + // Deduplicate by document ID + const docId = `${doc.type}-${doc.number}`; + if (seenIds.has(docId)) continue; + seenIds.add(docId); + // Apply filters if (options.type && doc.type !== options.type) continue; if (options.state && doc.state !== options.state) continue; diff --git a/packages/subagents/src/planner/utils/github.ts b/packages/subagents/src/planner/utils/github.ts index 3c39384..b409890 100644 --- a/packages/subagents/src/planner/utils/github.ts +++ b/packages/subagents/src/planner/utils/github.ts @@ -20,9 +20,14 @@ export function isGhInstalled(): boolean { /** * Fetch GitHub issue using gh CLI + * @param issueNumber - GitHub issue number + * @param repositoryPath - Optional path to repository (defaults to current directory) * @throws Error if gh CLI fails or issue not found */ -export async function fetchGitHubIssue(issueNumber: number): Promise { +export async function fetchGitHubIssue( + issueNumber: number, + repositoryPath?: string +): Promise { if (!isGhInstalled()) { throw new Error('GitHub CLI (gh) not installed'); } @@ -33,6 +38,7 @@ export async function fetchGitHubIssue(issueNumber: number): Promise