diff --git a/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts index 2e99c36..1ce15e3 100644 --- a/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts +++ b/packages/mcp-server/src/adapters/__tests__/plan-adapter.test.ts @@ -19,13 +19,8 @@ const createMockRepositoryIndexer = () => { // 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(), + assembleContext: vi.fn(), + formatContextPackage: vi.fn(), })); describe('PlanAdapter', () => { @@ -66,65 +61,52 @@ describe('PlanAdapter', () => { // 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: [], + vi.mocked(utils.assembleContext).mockResolvedValue({ + issue: { + number: 29, + title: 'Plan + Status Adapters', + body: 'Implement plan and status adapters', + labels: ['enhancement'], + author: 'testuser', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + state: 'open', + comments: [], }, - { - id: 'task-2', - description: 'Create StatusAdapter class', - relevantCode: [], + relevantCode: [ + { + file: 'src/adapters/search-adapter.ts', + name: 'SearchAdapter', + type: 'class', + snippet: 'class SearchAdapter { }', + relevanceScore: 0.85, + reason: 'Similar pattern', + }, + ], + codebasePatterns: { + testPattern: '*.test.ts', + testLocation: '__tests__/', }, - { - id: 'task-3', - description: 'Write unit tests', - relevantCode: [], + relatedHistory: [], + metadata: { + generatedAt: '2024-01-01T00:00:00Z', + tokensUsed: 500, + codeSearchUsed: true, + historySearchUsed: false, + repositoryPath: '/test/repo', }, - ]); + }); - vi.mocked(utils.addEstimatesToTasks).mockImplementation((tasks) => - tasks.map((task) => ({ - ...task, - estimatedHours: 4, - })) + vi.mocked(utils.formatContextPackage).mockReturnValue( + '# Issue #29: Plan + Status Adapters\n\nImplement plan and status adapters\n\n## Relevant Code\n\n### SearchAdapter (class)\n**File:** `src/adapters/search-adapter.ts`' ); - - 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'); + expect(adapter.metadata.version).toBe('2.0.0'); + expect(adapter.metadata.description).toContain('context'); }); }); @@ -135,7 +117,6 @@ describe('PlanAdapter', () => { repositoryPath: '/test/repo', defaultFormat: 'compact', timeout: 5000, - hasCoordinator: false, }); }); }); @@ -145,12 +126,13 @@ describe('PlanAdapter', () => { const definition = adapter.getToolDefinition(); expect(definition.name).toBe('dev_plan'); - expect(definition.description).toContain('plan'); + expect(definition.description).toContain('context'); 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'); + expect(definition.inputSchema.properties).toHaveProperty('includeCode'); + expect(definition.inputSchema.properties).toHaveProperty('includePatterns'); + expect(definition.inputSchema.properties).toHaveProperty('tokenBudget'); }); it('should have correct required fields', () => { @@ -165,14 +147,6 @@ describe('PlanAdapter', () => { 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', () => { @@ -209,30 +183,18 @@ describe('PlanAdapter', () => { 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 () => { + describe('context assembly', () => { + it('should assemble context with compact format 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'); + expect(result.data?.content).toContain('Issue #29'); }); - it('should generate verbose plan when requested', async () => { + it('should return verbose JSON when requested', async () => { const result = await adapter.execute( { issue: 29, format: 'verbose' }, mockExecutionContext @@ -240,160 +202,95 @@ describe('PlanAdapter', () => { 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"'); + expect(result.data?.content).toContain('"issue"'); + expect(result.data?.content).toContain('"relevantCode"'); }); - it('should include plan object in verbose mode', async () => { + it('should include context 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(); + expect(result.data?.context).toBeDefined(); + expect(result.data?.context?.issue?.number).toBe(29); }); - it('should use Explorer by default', async () => { + it('should not include context object in compact mode', async () => { const result = await adapter.execute({ issue: 29 }, mockExecutionContext); expect(result.success).toBe(true); - expect(mockIndexer.search).toHaveBeenCalled(); + expect(result.data?.context).toBeUndefined(); }); - it('should skip Explorer when disabled', async () => { + it('should include relevant code in context', async () => { const result = await adapter.execute( - { issue: 29, useExplorer: false }, + { issue: 29, format: 'verbose' }, 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) - ); + expect(result.data?.context?.relevantCode).toBeDefined(); + expect(result.data?.context?.relevantCode?.length).toBeGreaterThan(0); }); - it('should generate simple plan when requested', async () => { + it('should include codebase patterns', async () => { const result = await adapter.execute( - { issue: 29, detailLevel: 'simple' }, + { issue: 29, format: 'verbose' }, 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, - }) - ); + expect(result.data?.context?.codebasePatterns).toBeDefined(); + expect(result.data?.context?.codebasePatterns?.testPattern).toBe('*.test.ts'); }); - it('should generate detailed plan by default', async () => { + it('should include metadata with tokens and duration', async () => { const result = await adapter.execute({ issue: 29 }, mockExecutionContext); expect(result.success).toBe(true); + expect(result.metadata?.tokens).toBeDefined(); + expect(result.metadata?.duration_ms).toBeDefined(); + expect(result.metadata?.timestamp).toBeDefined(); + }); + it('should pass options to assembleContext', async () => { const utils = await import('@lytics/dev-agent-subagents'); - expect(utils.breakdownIssue).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Array), + + await adapter.execute( + { issue: 29, includeCode: false, includePatterns: false, tokenBudget: 2000 }, + mockExecutionContext + ); + + expect(utils.assembleContext).toHaveBeenCalledWith( + 29, + mockIndexer, + '/test/repo', expect.objectContaining({ - detailLevel: 'detailed', - maxTasks: 15, + includeCode: false, + includePatterns: false, + tokenBudget: 2000, }) ); }); }); - 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')); + vi.mocked(utils.assembleContext).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( + vi.mocked(utils.assembleContext).mockRejectedValue( new Error('GitHub CLI (gh) is not installed') ); @@ -406,91 +303,51 @@ describe('PlanAdapter', () => { it('should handle timeout', async () => { const utils = await import('@lytics/dev-agent-subagents'); - vi.mocked(utils.fetchGitHubIssue).mockImplementation( + vi.mocked(utils.assembleContext).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?.code).toBe('CONTEXT_TIMEOUT'); expect(result.error?.message).toContain('timeout'); - expect(result.error?.suggestion).toBeDefined(); - }, 10000); // 10 second timeout for this test + }, 10000); it('should handle unknown errors', async () => { const utils = await import('@lytics/dev-agent-subagents'); - vi.mocked(utils.fetchGitHubIssue).mockRejectedValue(new Error('Unknown error')); + vi.mocked(utils.assembleContext).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?.code).toBe('CONTEXT_ASSEMBLY_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')); + vi.mocked(utils.assembleContext).mockRejectedValue(new Error('Test error')); await adapter.execute({ issue: 29 }, mockExecutionContext); expect(mockExecutionContext.logger.error).toHaveBeenCalledWith( - 'Plan generation failed', + 'Context assembly 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 return tokenBudget when provided', () => { + const tokens = adapter.estimateTokens({ tokenBudget: 2000 }); + expect(tokens).toBe(2000); }); - it('should use defaults when no args provided', () => { - const estimate = adapter.estimateTokens({}); - expect(estimate).toBe(600); // Default is compact + detailed + it('should return default tokenBudget when not provided', () => { + const tokens = adapter.estimateTokens({}); + expect(tokens).toBe(4000); }); }); }); diff --git a/packages/mcp-server/src/adapters/built-in/plan-adapter.ts b/packages/mcp-server/src/adapters/built-in/plan-adapter.ts index c8f0ba8..ea436e5 100644 --- a/packages/mcp-server/src/adapters/built-in/plan-adapter.ts +++ b/packages/mcp-server/src/adapters/built-in/plan-adapter.ts @@ -1,64 +1,17 @@ /** * Plan Adapter - * Generates development plans from GitHub issues + * Assembles context for development planning from GitHub issues * - * Routes through PlannerAgent when coordinator is available, - * falls back to direct utility calls otherwise. + * Philosophy: Provide raw, structured context - let the LLM do the reasoning */ import type { RepositoryIndexer } from '@lytics/dev-agent-core'; -import type { - Plan as AgentPlan, - PlanTask as AgentPlanTask, - PlanningResult, - RelevantCode, -} from '@lytics/dev-agent-subagents'; -import { - addEstimatesToTasks, - breakdownIssue, - calculateTotalEstimate, - cleanDescription, - extractAcceptanceCriteria, - fetchGitHubIssue, - inferPriority, -} from '@lytics/dev-agent-subagents'; +import type { ContextAssemblyOptions, ContextPackage } from '@lytics/dev-agent-subagents'; +import { assembleContext, formatContextPackage } from '@lytics/dev-agent-subagents'; +import { estimateTokensForText, startTimer } from '../../formatters/utils'; 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 */ @@ -79,20 +32,20 @@ export interface PlanAdapterConfig { defaultFormat?: 'compact' | 'verbose'; /** - * Timeout for plan generation (milliseconds) + * Timeout for context assembly (milliseconds) */ timeout?: number; } /** * Plan Adapter - * Implements the dev_plan tool for generating implementation plans from GitHub issues + * Implements the dev_plan tool for assembling implementation context from GitHub issues */ export class PlanAdapter extends ToolAdapter { readonly metadata = { name: 'plan-adapter', - version: '1.0.0', - description: 'GitHub issue planning adapter', + version: '2.0.0', + description: 'GitHub issue context assembler', author: 'Dev-Agent Team', }; @@ -110,14 +63,12 @@ export class PlanAdapter extends ToolAdapter { } async initialize(context: AdapterContext): Promise { - // Store coordinator and logger from base class this.initializeBase(context); context.logger.info('PlanAdapter initialized', { repositoryPath: this.repositoryPath, defaultFormat: this.defaultFormat, timeout: this.timeout, - hasCoordinator: this.hasCoordinator(), }); } @@ -125,7 +76,7 @@ export class PlanAdapter extends ToolAdapter { return { name: 'dev_plan', description: - 'Generate implementation plan from GitHub issue with tasks, estimates, and dependencies', + 'Assemble context for implementing a GitHub issue. Returns issue details, relevant code snippets, and codebase patterns for LLM consumption.', inputSchema: { type: 'object', properties: { @@ -136,20 +87,23 @@ export class PlanAdapter extends ToolAdapter { format: { type: 'string', enum: ['compact', 'verbose'], - description: - 'Output format: "compact" for markdown checklist (default), "verbose" for detailed JSON', + description: 'Output format: "compact" for markdown (default), "verbose" for JSON', default: this.defaultFormat, }, - useExplorer: { + includeCode: { type: 'boolean', - description: 'Find relevant code using semantic search (default: true)', + description: 'Include relevant code snippets (default: true)', default: true, }, - detailLevel: { - type: 'string', - enum: ['simple', 'detailed'], - description: 'Task granularity: "simple" (4-8 tasks) or "detailed" (10-15 tasks)', - default: 'detailed', + includePatterns: { + type: 'boolean', + description: 'Include detected codebase patterns (default: true)', + default: true, + }, + tokenBudget: { + type: 'number', + description: 'Maximum tokens for output (default: 4000)', + default: 4000, }, }, required: ['issue'], @@ -161,8 +115,9 @@ export class PlanAdapter extends ToolAdapter { const { issue, format = this.defaultFormat, - useExplorer = true, - detailLevel = 'detailed', + includeCode = true, + includePatterns = true, + tokenBudget = 4000, } = args; // Validate issue number @@ -187,71 +142,45 @@ export class PlanAdapter extends ToolAdapter { }; } - // 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', { + const timer = startTimer(); + + context.logger.debug('Assembling context', { issue, format, - useExplorer, - detailLevel, - viaAgent: this.hasCoordinator(), + includeCode, + includePatterns, + tokenBudget, }); - let plan: Plan; + const options: ContextAssemblyOptions = { + includeCode: includeCode as boolean, + includePatterns: includePatterns as boolean, + includeHistory: false, // TODO: Enable when GitHub indexer integration is ready + maxCodeResults: 10, + tokenBudget: tokenBudget as number, + }; - // Try routing through PlannerAgent if coordinator is available - if (this.hasCoordinator()) { - const agentPlan = await this.executeViaAgent( - issue as number, - useExplorer as boolean, - detailLevel as 'simple' | 'detailed' - ); + const contextPackage = await this.withTimeout( + assembleContext(issue as number, this.indexer, this.repositoryPath, options), + this.timeout + ); - if (agentPlan) { - plan = this.convertAgentPlan(agentPlan); - } else { - // Fall through to direct execution if agent dispatch failed - context.logger.debug('Agent dispatch returned null, falling back to direct execution'); - plan = await this.withTimeout( - this.generatePlan( - issue as number, - useExplorer as boolean, - detailLevel as 'simple' | 'detailed', - context - ), - this.timeout - ); - } - } else { - // Direct execution (no coordinator) - plan = await this.withTimeout( - this.generatePlan( - issue as number, - useExplorer as boolean, - detailLevel as 'simple' | 'detailed', - context - ), - this.timeout - ); - } + // Format output + const content = + format === 'verbose' + ? JSON.stringify(contextPackage, null, 2) + : formatContextPackage(contextPackage); - // Format plan based on format parameter - const content = format === 'verbose' ? this.formatVerbose(plan) : this.formatCompact(plan); + const tokens = estimateTokensForText(content); + const duration_ms = timer.elapsed(); - context.logger.info('Plan generated', { + context.logger.info('Context assembled', { issue, - taskCount: plan.tasks.length, - totalEstimate: plan.totalEstimate, + codeResults: contextPackage.relevantCode.length, + hasPatterns: !!contextPackage.codebasePatterns.testPattern, + tokens, + duration_ms, }); return { @@ -260,243 +189,70 @@ export class PlanAdapter extends ToolAdapter { issue, format, content, - plan: format === 'verbose' ? plan : undefined, // Include full plan in verbose mode + context: format === 'verbose' ? contextPackage : undefined, }, - }; - } 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, + metadata: { + tokens, + duration_ms, + timestamp: new Date().toISOString(), + cached: false, }, }; + } catch (error) { + context.logger.error('Context assembly failed', { error }); + return this.handleError(error, issue as number); } } /** - * Execute planning via PlannerAgent through the coordinator - * Returns agent plan, or null if dispatch fails - */ - private async executeViaAgent( - issueNumber: number, - useExplorer: boolean, - detailLevel: 'simple' | 'detailed' - ): Promise { - const payload = { - action: 'plan', - issueNumber, - useExplorer, - detailLevel, - }; - - // Dispatch to PlannerAgent - const response = await this.dispatchToAgent('planner', payload); - - if (!response) { - return null; - } - - // Check for error response - if (response.type === 'error') { - this.logger?.warn('PlannerAgent returned error', { - error: response.payload.error, - }); - return null; - } - - // Extract plan from response payload - const result = response.payload as unknown as PlanningResult; - return result.plan; - } - - /** - * Convert agent plan format to adapter plan format - * (They're nearly identical, but we ensure type safety) - */ - private convertAgentPlan(agentPlan: AgentPlan): Plan { - return { - issueNumber: agentPlan.issueNumber, - title: agentPlan.title, - description: agentPlan.description, - tasks: agentPlan.tasks.map((task: AgentPlanTask) => ({ - id: task.id, - description: task.description, - relevantCode: task.relevantCode?.map((code: RelevantCode) => ({ - path: code.path, - reason: code.reason, - score: code.score, - })), - estimatedHours: task.estimatedHours, - dependencies: undefined, // Not in agent plan - priority: task.priority, - phase: task.phase, - })), - totalEstimate: agentPlan.totalEstimate, - priority: agentPlan.priority, - metadata: agentPlan.metadata, - }; - } - - /** - * Generate a development plan from a GitHub issue (direct execution) + * Handle errors with appropriate error codes */ - 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 }); + private handleError(error: unknown, issueNumber: number): ToolResult { + if (error instanceof Error) { + if (error.message.includes('timeout')) { + return { + success: false, + error: { + code: 'CONTEXT_TIMEOUT', + message: `Context assembly timeout after ${this.timeout / 1000}s.`, + suggestion: 'Try reducing tokenBudget or disabling some options.', + }, + }; + } - for (const task of tasks) { - try { - const results = await this.indexer.search(task.description, { - limit: 3, - scoreThreshold: 0.6, - }); + if (error.message.includes('not found') || error.message.includes('404')) { + return { + success: false, + error: { + code: 'ISSUE_NOT_FOUND', + message: `GitHub issue #${issueNumber} not found`, + suggestion: 'Check the issue number or ensure you are in a GitHub repository.', + }, + }; + } - 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 }); - } + 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.', + }, + }; } } - // 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', + success: false, + error: { + code: 'CONTEXT_ASSEMBLY_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, }, }; } - /** - * 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 */ @@ -510,14 +266,7 @@ export class PlanAdapter extends ToolAdapter { } 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; + const { tokenBudget = 4000 } = args; + return tokenBudget as number; } } diff --git a/packages/subagents/src/index.ts b/packages/subagents/src/index.ts index 6bff1fa..63605dc 100644 --- a/packages/subagents/src/index.ts +++ b/packages/subagents/src/index.ts @@ -48,6 +48,17 @@ export * from './github/utils'; export { CoordinatorLogger } from './logger'; // Agent modules export { PlannerAgent } from './planner'; +// Types - Context Assembler +export type { + CodebasePatterns, + ContextAssemblyOptions, + ContextMetadata, + ContextPackage, + IssueComment, + IssueContext, + RelatedHistory, + RelevantCodeContext, +} from './planner/context-types'; // Types - Planner export type { Plan, @@ -60,6 +71,7 @@ export type { // Planner utilities export { addEstimatesToTasks, + assembleContext, breakdownIssue, calculateTotalEstimate, cleanDescription, @@ -68,6 +80,7 @@ export { extractEstimate, extractTechnicalRequirements, fetchGitHubIssue, + formatContextPackage, formatEstimate, formatJSON, formatMarkdown, diff --git a/packages/subagents/src/planner/context-types.ts b/packages/subagents/src/planner/context-types.ts new file mode 100644 index 0000000..f821a97 --- /dev/null +++ b/packages/subagents/src/planner/context-types.ts @@ -0,0 +1,142 @@ +/** + * Context Assembler Types + * Types for assembling context packages for LLM consumption + * + * Philosophy: Provide raw, structured context - let the LLM do the reasoning + */ + +/** + * GitHub issue with full context + */ +export interface IssueContext { + /** Issue number */ + number: number; + /** Issue title */ + title: string; + /** Issue body (markdown) */ + body: string; + /** Labels attached to the issue */ + labels: string[]; + /** Issue author username */ + author: string; + /** ISO timestamp of creation */ + createdAt: string; + /** ISO timestamp of last update */ + updatedAt: string; + /** Issue state */ + state: 'open' | 'closed'; + /** Comments on the issue */ + comments: IssueComment[]; +} + +/** + * A comment on an issue + */ +export interface IssueComment { + /** Comment author */ + author: string; + /** Comment body */ + body: string; + /** ISO timestamp */ + createdAt: string; +} + +/** + * Relevant code found via semantic search + */ +export interface RelevantCodeContext { + /** File path */ + file: string; + /** Component name (function, class, etc.) */ + name: string; + /** Component type */ + type: string; + /** Code snippet */ + snippet: string; + /** Semantic similarity score (0-1) */ + relevanceScore: number; + /** Why this code is relevant */ + reason: string; +} + +/** + * Patterns detected in the codebase + */ +export interface CodebasePatterns { + /** Test file naming pattern (e.g., "*.test.ts") */ + testPattern?: string; + /** Test location pattern (e.g., "__tests__/") */ + testLocation?: string; + /** Common import patterns */ + importPatterns?: string[]; + /** Detected naming conventions */ + namingConventions?: string; +} + +/** + * Related historical issue or PR + */ +export interface RelatedHistory { + /** Issue or PR */ + type: 'issue' | 'pr'; + /** Number */ + number: number; + /** Title */ + title: string; + /** Current state */ + state: 'open' | 'closed' | 'merged'; + /** Relevance score (0-1) */ + relevanceScore: number; + /** Brief summary if available */ + summary?: string; +} + +/** + * Complete context package for LLM consumption + */ +export interface ContextPackage { + /** The GitHub issue with full details */ + issue: IssueContext; + /** Relevant code from semantic search */ + relevantCode: RelevantCodeContext[]; + /** Detected codebase patterns */ + codebasePatterns: CodebasePatterns; + /** Related closed issues/PRs */ + relatedHistory: RelatedHistory[]; + /** Metadata about the context assembly */ + metadata: ContextMetadata; +} + +/** + * Metadata about context assembly + */ +export interface ContextMetadata { + /** When the context was assembled */ + generatedAt: string; + /** Approximate token count */ + tokensUsed: number; + /** Whether code search was used */ + codeSearchUsed: boolean; + /** Whether history search was used */ + historySearchUsed: boolean; + /** Repository path */ + repositoryPath: string; +} + +/** + * Options for context assembly + */ +export interface ContextAssemblyOptions { + /** Include code snippets from search (default: true) */ + includeCode?: boolean; + /** Include related issues/PRs (default: true) */ + includeHistory?: boolean; + /** Include codebase patterns (default: true) */ + includePatterns?: boolean; + /** Maximum code results (default: 10) */ + maxCodeResults?: number; + /** Maximum history results (default: 5) */ + maxHistoryResults?: number; + /** Token budget for output (default: 4000) */ + tokenBudget?: number; +} diff --git a/packages/subagents/src/planner/index.ts b/packages/subagents/src/planner/index.ts index 531952f..f3ce71d 100644 --- a/packages/subagents/src/planner/index.ts +++ b/packages/subagents/src/planner/index.ts @@ -212,5 +212,6 @@ export class PlannerAgent implements Agent { } } +export type * from './context-types'; // Export types export type * from './types'; diff --git a/packages/subagents/src/planner/types.ts b/packages/subagents/src/planner/types.ts index 4cb24e7..0dd0d41 100644 --- a/packages/subagents/src/planner/types.ts +++ b/packages/subagents/src/planner/types.ts @@ -3,6 +3,15 @@ * Type definitions for GitHub issue analysis and task planning */ +/** + * GitHub issue comment + */ +export interface GitHubComment { + author?: string; + body: string; + createdAt?: string; +} + /** * GitHub issue data from gh CLI */ @@ -13,8 +22,10 @@ export interface GitHubIssue { state: 'open' | 'closed'; labels: string[]; assignees: string[]; + author?: string; createdAt: string; updatedAt: string; + comments?: GitHubComment[]; } /** diff --git a/packages/subagents/src/planner/utils/breakdown.ts b/packages/subagents/src/planner/utils/breakdown.ts index 79c816b..cd1482b 100644 --- a/packages/subagents/src/planner/utils/breakdown.ts +++ b/packages/subagents/src/planner/utils/breakdown.ts @@ -1,12 +1,17 @@ /** * Task Breakdown Utilities * Pure functions for breaking issues into actionable tasks + * + * @deprecated These utilities use heuristics that duplicate LLM capabilities. + * Use assembleContext() to provide raw context and let the LLM do reasoning. */ import type { BreakdownOptions, GitHubIssue, PlanTask } from '../types'; /** * Break down a GitHub issue into tasks + * + * @deprecated Use assembleContext() instead - let LLMs do task breakdown */ export function breakdownIssue( issue: GitHubIssue, diff --git a/packages/subagents/src/planner/utils/context-assembler.ts b/packages/subagents/src/planner/utils/context-assembler.ts new file mode 100644 index 0000000..c4cfe73 --- /dev/null +++ b/packages/subagents/src/planner/utils/context-assembler.ts @@ -0,0 +1,341 @@ +/** + * Context Assembler + * Assembles rich context packages for LLM consumption + * + * Philosophy: Provide raw, structured context - let the LLM do the reasoning + */ + +import type { RepositoryIndexer } from '@lytics/dev-agent-core'; +import type { + CodebasePatterns, + ContextAssemblyOptions, + ContextMetadata, + ContextPackage, + IssueContext, + RelatedHistory, + RelevantCodeContext, +} from '../context-types'; +import type { GitHubIssue } from '../types'; +import { fetchGitHubIssue } from './github'; + +/** Default options for context assembly */ +const DEFAULT_OPTIONS: Required = { + includeCode: true, + includeHistory: true, + includePatterns: true, + maxCodeResults: 10, + maxHistoryResults: 5, + tokenBudget: 4000, +}; + +/** + * Assemble a context package for a GitHub issue + * + * @param issueNumber - GitHub issue number + * @param indexer - Repository indexer for code search + * @param repositoryPath - Path to repository + * @param options - Assembly options + * @returns Complete context package + */ +export async function assembleContext( + issueNumber: number, + indexer: RepositoryIndexer | null, + repositoryPath: string, + options: ContextAssemblyOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // 1. Fetch issue with comments + const issue = await fetchGitHubIssue(issueNumber, repositoryPath, { includeComments: true }); + const issueContext = convertToIssueContext(issue); + + // 2. Search for relevant code + let relevantCode: RelevantCodeContext[] = []; + if (opts.includeCode && indexer) { + relevantCode = await findRelevantCode(issue, indexer, opts.maxCodeResults); + } + + // 3. Detect codebase patterns + let codebasePatterns: CodebasePatterns = {}; + if (opts.includePatterns && indexer) { + codebasePatterns = await detectCodebasePatterns(indexer); + } + + // 4. Find related history (TODO: implement when GitHub indexer is available) + const relatedHistory: RelatedHistory[] = []; + // if (opts.includeHistory && githubIndexer) { + // relatedHistory = await findRelatedHistory(issue, githubIndexer, opts.maxHistoryResults); + // } + + // 5. Calculate approximate token count + const tokensUsed = estimateTokens(issueContext, relevantCode, codebasePatterns, relatedHistory); + + // 6. Assemble metadata + const metadata: ContextMetadata = { + generatedAt: new Date().toISOString(), + tokensUsed, + codeSearchUsed: opts.includeCode && indexer !== null, + historySearchUsed: opts.includeHistory && relatedHistory.length > 0, + repositoryPath, + }; + + return { + issue: issueContext, + relevantCode, + codebasePatterns, + relatedHistory, + metadata, + }; +} + +/** + * Convert GitHubIssue to IssueContext + */ +function convertToIssueContext(issue: GitHubIssue): IssueContext { + return { + number: issue.number, + title: issue.title, + body: issue.body || '', + labels: issue.labels, + author: issue.author || 'unknown', + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + state: issue.state, + comments: (issue.comments || []).map((c) => ({ + author: c.author || 'unknown', + body: c.body || '', + createdAt: c.createdAt || new Date().toISOString(), + })), + }; +} + +/** + * Find relevant code using semantic search + */ +async function findRelevantCode( + issue: GitHubIssue, + indexer: RepositoryIndexer, + maxResults: number +): Promise { + // Build search query from issue title and body + const searchQuery = buildSearchQuery(issue); + + try { + const results = await indexer.search(searchQuery, { + limit: maxResults, + scoreThreshold: 0.5, + }); + + return results.map((r) => ({ + file: (r.metadata.path as string) || (r.metadata.file as string) || '', + name: (r.metadata.name as string) || 'unknown', + type: (r.metadata.type as string) || 'unknown', + snippet: (r.metadata.snippet as string) || '', + relevanceScore: r.score, + reason: inferRelevanceReason(r.metadata, issue), + })); + } catch { + // Return empty array if search fails + return []; + } +} + +/** + * Build a search query from issue content + */ +function buildSearchQuery(issue: GitHubIssue): string { + // Combine title and first part of body for search + const bodyPreview = (issue.body || '').slice(0, 500); + + // Extract key terms (simple heuristic) + const combined = `${issue.title} ${bodyPreview}`; + + // Remove markdown artifacts + const cleaned = combined + .replace(/```[\s\S]*?```/g, '') // Remove code blocks + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links, keep text + .replace(/[#*_`]/g, '') // Remove markdown formatting + .trim(); + + return cleaned; +} + +/** + * Infer why a code result is relevant + */ +function inferRelevanceReason(metadata: Record, issue: GitHubIssue): string { + const name = (metadata.name as string) || ''; + const type = (metadata.type as string) || ''; + const title = issue.title.toLowerCase(); + + // Simple heuristics for reason + if (title.includes(name.toLowerCase())) { + return `Name matches issue title`; + } + + if (type === 'function' || type === 'method') { + return `Similar function pattern`; + } + + if (type === 'class') { + return `Related class structure`; + } + + if (type === 'interface' || type === 'type') { + return `Relevant type definition`; + } + + return `Semantic similarity`; +} + +/** + * Detect codebase patterns from indexed data + */ +async function detectCodebasePatterns(indexer: RepositoryIndexer): Promise { + // Search for test files to detect test pattern + let testPattern: string | undefined; + let testLocation: string | undefined; + + try { + const testResults = await indexer.search('test describe it expect', { + limit: 5, + scoreThreshold: 0.5, + }); + + if (testResults.length > 0) { + const testPath = (testResults[0].metadata.path as string) || ''; + if (testPath.includes('.test.')) { + testPattern = '*.test.ts'; + } else if (testPath.includes('.spec.')) { + testPattern = '*.spec.ts'; + } + + if (testPath.includes('__tests__')) { + testLocation = '__tests__/'; + } else if (testPath.includes('/test/')) { + testLocation = 'test/'; + } + } + } catch { + // Ignore errors in pattern detection + } + + return { + testPattern, + testLocation, + }; +} + +/** + * Estimate token count for context package + */ +function estimateTokens( + issue: IssueContext, + code: RelevantCodeContext[], + patterns: CodebasePatterns, + history: RelatedHistory[] +): number { + // Rough estimation: ~4 chars per token + let chars = 0; + + // Issue content + chars += issue.title.length; + chars += issue.body.length; + chars += issue.comments.reduce((sum, c) => sum + c.body.length, 0); + + // Code snippets + chars += code.reduce( + (sum, c) => sum + (c.snippet?.length || 0) + c.file.length + c.name.length, + 0 + ); + + // Patterns (small) + chars += JSON.stringify(patterns).length; + + // History + chars += history.reduce((sum, h) => sum + h.title.length + (h.summary?.length || 0), 0); + + return Math.ceil(chars / 4); +} + +/** + * Format context package for LLM consumption + */ +export function formatContextPackage(context: ContextPackage): string { + const lines: string[] = []; + + // Issue section + lines.push(`# Issue #${context.issue.number}: ${context.issue.title}`); + lines.push(''); + lines.push( + `**Author:** ${context.issue.author} | **State:** ${context.issue.state} | **Labels:** ${context.issue.labels.join(', ') || 'none'}` + ); + lines.push(''); + lines.push('## Description'); + lines.push(''); + lines.push(context.issue.body || '_No description provided_'); + lines.push(''); + + // Comments + if (context.issue.comments.length > 0) { + lines.push('## Comments'); + lines.push(''); + for (const comment of context.issue.comments) { + lines.push(`**${comment.author}** (${comment.createdAt}):`); + lines.push(comment.body); + lines.push(''); + } + } + + // Relevant code + if (context.relevantCode.length > 0) { + lines.push('## Relevant Code'); + lines.push(''); + for (const code of context.relevantCode) { + lines.push(`### ${code.name} (${code.type})`); + lines.push( + `**File:** \`${code.file}\` | **Relevance:** ${(code.relevanceScore * 100).toFixed(0)}%` + ); + lines.push(`**Reason:** ${code.reason}`); + lines.push(''); + if (code.snippet) { + lines.push('```typescript'); + lines.push(code.snippet); + lines.push('```'); + lines.push(''); + } + } + } + + // Codebase patterns + if (context.codebasePatterns.testPattern || context.codebasePatterns.testLocation) { + lines.push('## Codebase Patterns'); + lines.push(''); + if (context.codebasePatterns.testPattern) { + lines.push(`- **Test naming:** ${context.codebasePatterns.testPattern}`); + } + if (context.codebasePatterns.testLocation) { + lines.push(`- **Test location:** ${context.codebasePatterns.testLocation}`); + } + lines.push(''); + } + + // Related history + if (context.relatedHistory.length > 0) { + lines.push('## Related History'); + lines.push(''); + for (const item of context.relatedHistory) { + const typeLabel = item.type === 'pr' ? 'PR' : 'Issue'; + lines.push(`- **${typeLabel} #${item.number}:** ${item.title} (${item.state})`); + } + lines.push(''); + } + + // Metadata + lines.push('---'); + lines.push( + `*Context assembled at ${context.metadata.generatedAt} | ~${context.metadata.tokensUsed} tokens*` + ); + + return lines.join('\n'); +} diff --git a/packages/subagents/src/planner/utils/estimation.ts b/packages/subagents/src/planner/utils/estimation.ts index f4a54f3..f9cc9f4 100644 --- a/packages/subagents/src/planner/utils/estimation.ts +++ b/packages/subagents/src/planner/utils/estimation.ts @@ -1,12 +1,17 @@ /** * Effort Estimation Utilities * Pure functions for estimating task effort + * + * @deprecated These utilities use heuristics that duplicate LLM capabilities. + * Use assembleContext() to provide raw context and let the LLM estimate effort. */ import type { PlanTask } from '../types'; /** * Estimate hours for a single task based on description + * + * @deprecated Use assembleContext() instead - let LLMs estimate effort */ export function estimateTaskHours(description: string): number { // Simple heuristic-based estimation diff --git a/packages/subagents/src/planner/utils/github.ts b/packages/subagents/src/planner/utils/github.ts index b409890..4ffb915 100644 --- a/packages/subagents/src/planner/utils/github.ts +++ b/packages/subagents/src/planner/utils/github.ts @@ -4,7 +4,15 @@ */ import { execSync } from 'node:child_process'; -import type { GitHubIssue } from '../types'; +import type { GitHubComment, GitHubIssue } from '../types'; + +/** + * Options for fetching GitHub issues + */ +export interface FetchIssueOptions { + /** Include issue comments (default: false) */ + includeComments?: boolean; +} /** * Check if gh CLI is installed @@ -22,28 +30,55 @@ 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) + * @param options - Fetch options * @throws Error if gh CLI fails or issue not found */ export async function fetchGitHubIssue( issueNumber: number, - repositoryPath?: string + repositoryPath?: string, + options: FetchIssueOptions = {} ): Promise { if (!isGhInstalled()) { throw new Error('GitHub CLI (gh) not installed'); } try { - const output = execSync( - `gh issue view ${issueNumber} --json number,title,body,state,labels,assignees,createdAt,updatedAt`, - { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - cwd: repositoryPath, // Run in the repository directory - } - ); + // Build fields list + const fields = [ + 'number', + 'title', + 'body', + 'state', + 'labels', + 'assignees', + 'author', + 'createdAt', + 'updatedAt', + ]; + if (options.includeComments) { + fields.push('comments'); + } + + const output = execSync(`gh issue view ${issueNumber} --json ${fields.join(',')}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: repositoryPath, // Run in the repository directory + }); const data = JSON.parse(output); + // Parse comments if included + let comments: GitHubComment[] | undefined; + if (options.includeComments && data.comments) { + comments = data.comments.map( + (c: { author?: { login: string }; body: string; createdAt?: string }) => ({ + author: c.author?.login, + body: c.body, + createdAt: c.createdAt, + }) + ); + } + return { number: data.number, title: data.title, @@ -51,8 +86,10 @@ export async function fetchGitHubIssue( state: data.state.toLowerCase() as 'open' | 'closed', labels: data.labels?.map((l: { name: string }) => l.name) || [], assignees: data.assignees?.map((a: { login: string }) => a.login) || [], + author: data.author?.login, createdAt: data.createdAt, updatedAt: data.updatedAt, + comments, }; } catch (error) { if (error instanceof Error && error.message.includes('not found')) { diff --git a/packages/subagents/src/planner/utils/index.ts b/packages/subagents/src/planner/utils/index.ts index 8cb7211..7487903 100644 --- a/packages/subagents/src/planner/utils/index.ts +++ b/packages/subagents/src/planner/utils/index.ts @@ -9,6 +9,11 @@ export { groupTasksByPhase, validateTasks, } from './breakdown'; +// Context assembly utilities +export { + assembleContext, + formatContextPackage, +} from './context-assembler'; // Estimation utilities export { addEstimatesToTasks, @@ -25,6 +30,7 @@ export { } from './formatting'; // GitHub utilities export { + type FetchIssueOptions, fetchGitHubIssue, isGhInstalled, isGitHubRepo,