UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

1,719 lines (1,557 loc) 183 kB
import { JSONSchema7 } from 'json-schema'; import { randomUUID } from 'crypto'; import { createTool, createSuccessResult, createErrorResult } from '../../core/tool-framework.js'; import { ToolRegistration, RequestContext } from '../../core/types.js'; import { CommonSchemas } from '../../core/validation.js'; /** * Agile Management Tools - 12-Factor MCP Implementation * * Implements Factor 2: Deterministic Execution with structured outputs * Implements Factor 3: Stateless Processes with RequestContext * Implements Factor 4: Structured Outputs for LLM consumption */ // Status transition validation type StoryStatus = 'todo' | 'in_progress' | 'review' | 'done' | 'blocked' | 'cancelled'; interface StatusTransitionResult { isValid: boolean; error?: string; suggestedAction?: string; } /** * Validates story status transitions based on workflow rules * Key rule: Stories must be in 'todo' status before moving to 'in_progress' */ function validateStatusTransition( fromStatus: StoryStatus, toStatus: StoryStatus, skipValidation = false ): StatusTransitionResult { // Allow bypass for admin overrides if (skipValidation) { return { isValid: true }; } // Allow transitions within the same status if (fromStatus === toStatus) { return { isValid: true }; } // Allow transitions to in_progress from todo and blocked if (toStatus === 'in_progress') { if (fromStatus === 'todo' || fromStatus === 'blocked') { return { isValid: true }; } // Block other transitions to in_progress return { isValid: false, error: `Cannot transition from "${fromStatus}" to "in_progress"`, suggestedAction: 'Stories must be in "todo" status before moving to "in_progress"' }; } // Define other allowed transitions const allowedTransitions: Record<StoryStatus, StoryStatus[]> = { todo: ['in_progress', 'blocked', 'cancelled'], in_progress: ['review', 'blocked', 'cancelled'], review: ['done', 'in_progress', 'blocked', 'cancelled'], done: ['review'], // Can reopen if needed blocked: ['todo', 'in_progress', 'review', 'cancelled'], cancelled: ['todo'] // Can reactivate cancelled stories }; const allowed = allowedTransitions[fromStatus] || []; if (!allowed.includes(toStatus)) { return { isValid: false, error: `Cannot transition from "${fromStatus}" to "${toStatus}"`, suggestedAction: `Allowed transitions from "${fromStatus}": ${allowed.join(', ')}` }; } return { isValid: true }; } // Input type interfaces interface CreateSprintInput { name: string; goal: string; duration?: number; startDate?: string; team?: string[]; } interface AddStoryToSprintInput { storyId: string; sprintId: string; } interface CreateStoryInput { title: string; description?: string; acceptanceCriteria?: string[]; storyPoints?: number; priority?: 'low' | 'medium' | 'high' | 'critical'; tags?: string[]; assignedTo?: string; epicId?: string; sprintId?: string; status?: 'todo' | 'in_progress' | 'review' | 'done'; } interface UpdateStoryStatusInput { storyId: string; status: 'todo' | 'in_progress' | 'review' | 'done'; notes?: string; skipWorkflowValidation?: boolean; } interface GetSprintStatsInput { sprintId: string; } interface ListStoriesInput { sprintId?: string; status?: 'todo' | 'in_progress' | 'review' | 'done'; assignedTo?: string; limit?: number; offset?: number; } interface CreateEpicInput { title: string; description: string; goals?: string[]; priority?: 'low' | 'medium' | 'high' | 'critical'; owner?: string; targetDate?: string; successCriteria?: string[]; repositories?: string[]; } interface GetEpicInput { epicId: string; } interface ListEpicsInput { status?: 'active' | 'completed' | 'on_hold'; owner?: string; limit?: number; offset?: number; } interface ListSprintsInput { status?: 'active' | 'completed'; includeMetrics?: boolean; limit?: number; offset?: number; } interface GetSprintInput { sprintId: string; } interface ListBacklogInput { epicId?: string; priority?: 'low' | 'medium' | 'high' | 'critical'; tags?: string[]; limit?: number; offset?: number; } interface UpdateEpicInput { epicId: string; title?: string; description?: string; goals?: string[]; priority?: 'low' | 'medium' | 'high' | 'critical'; owner?: string; targetDate?: string; successCriteria?: string[]; repositories?: string[]; status?: 'active' | 'completed' | 'on_hold'; } interface UpdateSprintInput { sprintId: string; name?: string; goal?: string; status?: 'active' | 'completed'; endDate?: string; team?: string[]; } interface UpdateStoryInput { storyId: string; title?: string; description?: string; acceptanceCriteria?: string[]; storyPoints?: number; priority?: 'low' | 'medium' | 'high' | 'critical'; tags?: string[]; assignedTo?: string; epicId?: string; status?: 'todo' | 'in_progress' | 'review' | 'done'; } interface GetStoryInput { storyId: string; } interface AssignStoryToSprintInput { storyId: string; sprintId: string; } interface RecordStandupInput { sprintId: string; teamMember: string; yesterday: string; today: string; blockers: string[]; mood?: 'great' | 'good' | 'okay' | 'struggling'; } interface GenerateBurndownInput { sprintId: string; } interface GenerateVelocityInput { teamId?: string; numberOfSprints?: number; } // Phase 1 New Interfaces interface CompleteSprintInput { sprintId: string; carryoverStories?: string[]; completionNotes?: string; completedBy?: string; } interface BlockStoryInput { storyId: string; blockerType: 'dependency' | 'external' | 'technical' | 'resource'; reason: string; blockerStoryId?: string; } interface UnblockStoryInput { blockerId: string; resolution: string; resolvedBy?: string; } interface AssignStoryInput { storyId: string; assignee: string; notes?: string; } interface AddStoryCommentInput { storyId: string; content: string; author: string; threadId?: string; parentCommentId?: string; } interface SplitStoryInput { storyId: string; newStories: Array<{ title: string; description?: string; acceptanceCriteria?: string[]; storyPoints?: number; priority?: 'low' | 'medium' | 'high' | 'critical'; }>; notes?: string; } interface LinkStoryToEpicInput { storyId: string; epicId: string; notes?: string; } // Phase 2 Epic Management Interfaces interface GetEpicProgressInput { epicId: string; } interface GetEpicTimelineInput { epicId: string; includeDependencies?: boolean; } interface CloseEpicInput { epicId: string; completionNotes?: string; businessValueRealized?: string; closedBy?: string; } interface GetEpicStoriesInput { epicId: string; status?: 'todo' | 'in_progress' | 'review' | 'done' | 'blocked'; sprintId?: string; assignee?: string; } interface ValidateEpicRequirementsInput { epicId: string; } interface GenerateEpicReportInput { epicId: string; includeMetrics?: boolean; includeTimeline?: boolean; } // Phase 2 Advanced Reporting Interfaces interface GenerateSprintReportInput { sprintId: string; includeVelocity?: boolean; includeBlockers?: boolean; } interface GetTeamVelocityTrendInput { teamId?: string; numberOfSprints?: number; startDate?: string; endDate?: string; } interface GetCycleTimeMetricsInput { sprintId?: string; storyIds?: string[]; dateRange?: { start: string; end: string; }; } interface GenerateRetrospectiveTemplateInput { sprintId: string; includeMetrics?: boolean; templateType?: 'standard' | 'starfish' | 'sailboat'; } interface GetCrossSprintAnalyticsInput { numberOfSprints?: number; teamId?: string; epicId?: string; } interface GenerateEpicBurndownInput { epicId: string; granularity?: 'daily' | 'weekly' | 'sprint'; } interface GetDependencyReportInput { sprintId?: string; epicId?: string; includeResolved?: boolean; } /** * Create a new sprint */ const createSprintTool = createTool<CreateSprintInput, any>({ name: 'create_agile_sprint', description: 'Create a new sprint with goals, timeline, and team assignment', category: 'agile-management', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Sprint name', minLength: 1, maxLength: 200 }, goal: { type: 'string', description: 'Sprint goal and objectives', minLength: 1, maxLength: 1000 }, duration: { type: 'integer', description: 'Sprint duration in days', minimum: 1, maximum: 90, default: 14 }, startDate: { type: 'string', format: 'date', description: 'Sprint start date (YYYY-MM-DD format)' }, team: { type: 'array', items: { type: 'string' }, description: 'Team member names or IDs', maxItems: 20 } }, required: ['name', 'goal'], additionalProperties: false } as JSONSchema7, async execute(input: CreateSprintInput, context: RequestContext) { try { const sprintId = randomUUID(); const now = Date.now(); const startDate = input.startDate ? new Date(input.startDate) : new Date(); const endDate = new Date(startDate); endDate.setDate(endDate.getDate() + (input.duration || 14)); // Validate start date is not in the past if (startDate < new Date(Date.now() - 24 * 60 * 60 * 1000)) { return createErrorResult({ code: 'INVALID_INPUT', message: 'Start date cannot be in the past', category: 'validation' }); } // Check for duplicate sprint names const existingSprintCheck = await context.db.get( 'SELECT id FROM agile_sprints WHERE name = ? AND project_id = ?', [input.name, context.projectId || 'default'] ); if (existingSprintCheck.success && existingSprintCheck.data) { return createErrorResult({ code: 'DUPLICATE_RESOURCE', message: 'A sprint with this name already exists', category: 'validation' }); } // Insert sprint into database const result = await context.db.run( `INSERT INTO agile_sprints (id, name, goal, duration, start_date, end_date, status, team, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ sprintId, input.name, input.goal, input.duration || 14, startDate.getTime(), endDate.getTime(), 'active', JSON.stringify(input.team || []), context.projectId || 'default', now, now ] ); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to create sprint', details: { error: result.error }, category: 'system' }); } return createSuccessResult({ sprint: { id: sprintId, name: input.name, goal: input.goal, duration: input.duration || 14, startDate: startDate.toISOString(), endDate: endDate.toISOString(), status: 'active', team: input.team || [], storyCount: 0, completedStories: 0 }, message: `Sprint "${input.name}" created successfully`, nextSteps: [ 'Add user stories to the sprint backlog', 'Conduct sprint planning session', 'Set story estimates and assignments' ] }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to create sprint: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Create a new user story */ const createStoryTool = createTool<CreateStoryInput, any>({ name: 'create_user_story', description: 'Create a new user story with acceptance criteria and estimates', category: 'agile-management', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Story title', minLength: 1, maxLength: 200 }, description: { type: 'string', description: 'Detailed story description', maxLength: 2000 }, acceptanceCriteria: { type: 'array', items: { type: 'string', maxLength: 500 }, description: 'List of acceptance criteria', maxItems: 10 }, storyPoints: { type: 'integer', description: 'Story complexity in points', minimum: 1, maximum: 100 }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], default: 'medium', description: 'Story priority level' }, tags: { type: 'array', items: { type: 'string', maxLength: 50 }, description: 'Story tags for categorization', maxItems: 10 }, assignedTo: { type: 'string', description: 'Team member assigned to the story', maxLength: 100 }, epicId: { type: 'string', description: 'Parent epic ID', pattern: '^[a-zA-Z0-9-]+$' }, sprintId: { type: 'string', description: 'Sprint ID to assign the story to', pattern: '^[a-zA-Z0-9-]+$' }, status: { type: 'string', description: 'Initial status for the story', enum: ['todo', 'in_progress', 'review', 'done'], default: 'todo' } }, required: ['title'], additionalProperties: false } as JSONSchema7, async execute(input: CreateStoryInput, context: RequestContext) { try { const storyId = randomUUID(); const now = Date.now(); // Validate epic exists if provided if (input.epicId) { const epicCheck = await context.db.get( 'SELECT id FROM agile_epics WHERE id = ? AND project_id = ?', [input.epicId, context.projectId || 'default'] ); if (!epicCheck.success || !epicCheck.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Epic not found', details: { epicId: input.epicId }, category: 'validation' }); } } // Insert story into database const result = await context.db.run( `INSERT INTO agile_stories (id, title, description, acceptance_criteria, story_points, priority, tags, assignee, epic_id, sprint_id, status, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ storyId, input.title, input.description || '', JSON.stringify(input.acceptanceCriteria || []), input.storyPoints || null, input.priority || 'medium', JSON.stringify(input.tags || []), input.assignedTo || null, input.epicId || null, input.sprintId || null, input.status || 'todo', context.projectId || 'default', now, now ] ); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to create story', details: { error: result.error }, category: 'system' }); } return createSuccessResult({ story: { id: storyId, title: input.title, description: input.description || '', acceptanceCriteria: input.acceptanceCriteria || [], storyPoints: input.storyPoints || null, priority: input.priority || 'medium', tags: input.tags || [], assignedTo: input.assignedTo || null, epicId: input.epicId || null, status: 'todo', createdAt: new Date(now).toISOString() }, message: `Story "${input.title}" created successfully`, nextSteps: [ 'Add story to a sprint backlog', 'Refine acceptance criteria if needed', 'Estimate story points with the team' ] }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to create story: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Add a story to a sprint */ const addStoryToSprintTool = createTool<AddStoryToSprintInput, any>({ name: 'add_story_to_sprint', description: 'Add an existing user story to a sprint backlog', category: 'agile-management', inputSchema: { type: 'object', properties: { storyId: { type: 'string', description: 'Story ID to add to sprint', pattern: '^[a-zA-Z0-9-]+$' }, sprintId: { type: 'string', description: 'Sprint ID to add story to', pattern: '^[a-zA-Z0-9-]+$' } }, required: ['storyId', 'sprintId'], additionalProperties: false } as JSONSchema7, async execute(input: AddStoryToSprintInput, context: RequestContext) { try { // Verify story exists and is not already in a sprint const storyCheck = await context.db.get( 'SELECT id, title, sprint_id FROM agile_stories WHERE id = ? AND project_id = ?', [input.storyId, context.projectId || 'default'] ); if (!storyCheck.success || !storyCheck.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Story not found', details: { storyId: input.storyId }, category: 'validation' }); } if (storyCheck.data.sprint_id) { return createErrorResult({ code: 'INVALID_STATE', message: 'Story is already assigned to a sprint', details: { storyId: input.storyId, currentSprintId: storyCheck.data.sprint_id }, category: 'validation' }); } // Verify sprint exists const sprintCheck = await context.db.get( 'SELECT id, name, status FROM agile_sprints WHERE id = ? AND project_id = ?', [input.sprintId, context.projectId || 'default'] ); if (!sprintCheck.success || !sprintCheck.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Sprint not found', details: { sprintId: input.sprintId }, category: 'validation' }); } // Add story to sprint const result = await context.db.run( 'UPDATE agile_stories SET sprint_id = ?, updated_at = ? WHERE id = ?', [input.sprintId, Date.now(), input.storyId] ); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to add story to sprint', details: { error: result.error }, category: 'system' }); } return createSuccessResult({ message: `Story "${storyCheck.data.title}" added to sprint "${sprintCheck.data.name}"`, storyId: input.storyId, sprintId: input.sprintId, sprintName: sprintCheck.data.name, storyTitle: storyCheck.data.title }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to add story to sprint: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Update story status */ const updateStoryStatusTool = createTool<UpdateStoryStatusInput, any>({ name: 'update_story_status', description: 'Update the status of a user story (todo, in_progress, review, done)', category: 'agile-management', inputSchema: { type: 'object', properties: { storyId: { type: 'string', description: 'Story ID to update', pattern: '^[a-zA-Z0-9-]+$' }, status: { type: 'string', enum: ['todo', 'in_progress', 'review', 'done'], description: 'New status for the story' }, notes: { type: 'string', description: 'Optional notes about the status change', maxLength: 1000 }, skipWorkflowValidation: { type: 'boolean', description: 'Skip workflow validation (admin override)', default: false } }, required: ['storyId', 'status'], additionalProperties: false } as JSONSchema7, async execute(input: UpdateStoryStatusInput, context: RequestContext) { try { // Verify story exists const storyCheck = await context.db.get( 'SELECT id, title, status FROM agile_stories WHERE id = ? AND project_id = ?', [input.storyId, context.projectId || 'default'] ); if (!storyCheck.success || !storyCheck.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Story not found', details: { storyId: input.storyId }, category: 'validation' }); } const oldStatus = storyCheck.data.status; // Validate status transition const validation = validateStatusTransition( oldStatus as StoryStatus, input.status as StoryStatus, input.skipWorkflowValidation ); if (!validation.isValid) { return createErrorResult({ code: 'WORKFLOW_VALIDATION_ERROR', message: validation.error || 'Invalid status transition', details: { currentStatus: oldStatus, attemptedStatus: input.status, suggestedAction: validation.suggestedAction }, category: 'validation' }); } // Update story status const result = await context.db.run( 'UPDATE agile_stories SET status = ?, updated_at = ? WHERE id = ?', [input.status, Date.now(), input.storyId] ); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to update story status', details: { error: result.error }, category: 'system' }); } // Log status change (always log, with special notes for workflow bypass) const historyNotes = input.skipWorkflowValidation ? `${input.notes || ''} [WORKFLOW VALIDATION BYPASSED]`.trim() : input.notes || `Status changed from ${oldStatus} to ${input.status}`; await context.db.run( `INSERT INTO story_history (id, story_id, action, old_value, new_value, notes, user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ randomUUID(), input.storyId, 'status_change', oldStatus, input.status, historyNotes, context.userId || 'system', Date.now() ] ); return createSuccessResult({ message: `Story "${storyCheck.data.title}" status updated from ${oldStatus} to ${input.status}`, storyId: input.storyId, storyTitle: storyCheck.data.title, oldStatus, newStatus: input.status, notes: input.notes || null, workflowValidationBypassed: input.skipWorkflowValidation || false }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to update story status: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Get sprint statistics */ const getSprintStatsTool = createTool<GetSprintStatsInput, any>({ name: 'get_sprint_stats', description: 'Get detailed statistics and progress for a sprint', category: 'agile-management', readOnly: true, inputSchema: { type: 'object', properties: { sprintId: { type: 'string', description: 'Sprint ID to get statistics for', pattern: '^[a-zA-Z0-9-]+$' } }, required: ['sprintId'], additionalProperties: false } as JSONSchema7, async execute(input: GetSprintStatsInput, context: RequestContext) { try { // Get sprint basic info const sprintResult = await context.db.get( 'SELECT * FROM agile_sprints WHERE id = ? AND project_id = ?', [input.sprintId, context.projectId || 'default'] ); if (!sprintResult.success || !sprintResult.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Sprint not found', details: { sprintId: input.sprintId }, category: 'validation' }); } const sprint = sprintResult.data; // Get story statistics const statsResult = await context.db.query( `SELECT COUNT(*) as total_stories, COUNT(CASE WHEN status = 'done' THEN 1 END) as completed_stories, COUNT(CASE WHEN status = 'in_progress' THEN 1 END) as in_progress_stories, COUNT(CASE WHEN status = 'review' THEN 1 END) as review_stories, COUNT(CASE WHEN status = 'todo' THEN 1 END) as todo_stories, SUM(story_points) as total_points, SUM(CASE WHEN status = 'done' THEN story_points ELSE 0 END) as completed_points FROM agile_stories WHERE sprint_id = ?`, [input.sprintId] ); const stats = statsResult.data?.[0] || {}; // Calculate progress metrics const totalStories = stats.total_stories || 0; const completedStories = stats.completed_stories || 0; const totalPoints = stats.total_points || 0; const completedPoints = stats.completed_points || 0; const storyProgress = totalStories > 0 ? (completedStories / totalStories) * 100 : 0; const pointsProgress = totalPoints > 0 ? (completedPoints / totalPoints) * 100 : 0; // Calculate days elapsed and remaining const now = Date.now(); const startDate = new Date(sprint.start_date); const endDate = new Date(sprint.end_date); const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); const daysElapsed = Math.max(0, Math.ceil((now - startDate.getTime()) / (1000 * 60 * 60 * 24))); const daysRemaining = Math.max(0, Math.ceil((endDate.getTime() - now) / (1000 * 60 * 60 * 24))); return createSuccessResult({ sprint: { id: sprint.id, name: sprint.name, goal: sprint.goal, status: sprint.status, startDate: new Date(sprint.start_date).toISOString(), endDate: new Date(sprint.end_date).toISOString(), team: JSON.parse(sprint.team_members || '[]') }, progress: { stories: { total: totalStories, completed: completedStories, inProgress: stats.in_progress_stories || 0, review: stats.review_stories || 0, todo: stats.todo_stories || 0, completionPercentage: Math.round(storyProgress * 100) / 100 }, storyPoints: { total: totalPoints, completed: completedPoints, remaining: totalPoints - completedPoints, completionPercentage: Math.round(pointsProgress * 100) / 100 }, timeline: { totalDays, daysElapsed, daysRemaining, timeElapsedPercentage: totalDays > 0 ? Math.round((daysElapsed / totalDays) * 100) : 0 } }, burndown: { idealBurnRate: totalPoints / totalDays, actualBurnRate: daysElapsed > 0 ? completedPoints / daysElapsed : 0, projectedCompletion: completedPoints > 0 && daysElapsed > 0 ? Math.ceil(totalPoints / (completedPoints / daysElapsed)) : totalDays } }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to get sprint stats: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * List stories with filtering */ const listStoriesTool = createTool<ListStoriesInput, any>({ name: 'list_stories', description: 'List user stories with optional filtering by sprint, status, or assignee', category: 'agile-management', readOnly: true, inputSchema: { type: 'object', properties: { sprintId: { type: 'string', description: 'Filter by sprint ID', pattern: '^[a-zA-Z0-9-]+$' }, status: { type: 'string', enum: ['todo', 'in_progress', 'review', 'done'], description: 'Filter by story status' }, assignedTo: { type: 'string', description: 'Filter by assigned team member', maxLength: 100 }, limit: { type: 'integer', description: 'Maximum number of stories to return', minimum: 1, maximum: 100, default: 20 }, offset: { type: 'integer', description: 'Number of stories to skip', minimum: 0, default: 0 } }, additionalProperties: false } as JSONSchema7, async execute(input: ListStoriesInput, context: RequestContext) { try { let sql = 'SELECT * FROM agile_stories WHERE project_id = ?'; const params: any[] = [context.projectId || 'default']; if (input.sprintId) { sql += ' AND sprint_id = ?'; params.push(input.sprintId); } if (input.status) { sql += ' AND status = ?'; params.push(input.status); } if (input.assignedTo) { sql += ' AND assigned_to = ?'; params.push(input.assignedTo); } sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; params.push(input.limit || 20, input.offset || 0); const result = await context.db.query(sql, params); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to list stories', details: { error: result.error }, category: 'system' }); } const stories = (result.data || []).map((story: any) => ({ id: story.id, title: story.title, description: story.description, acceptanceCriteria: JSON.parse(story.acceptance_criteria || '[]'), storyPoints: story.story_points, priority: story.priority, status: story.status, tags: JSON.parse(story.tags || '[]'), assignedTo: story.assigned_to, sprintId: story.sprint_id, epicId: story.epic_id, createdAt: new Date(story.created_at).toISOString(), updatedAt: new Date(story.updated_at).toISOString() })); return createSuccessResult({ stories, count: stories.length, hasMore: stories.length === (input.limit || 20), filters: { sprintId: input.sprintId || null, status: input.status || null, assignedTo: input.assignedTo || null } }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to list stories: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * List stories with comprehensive details */ const listStoriesWithDetailsTool = createTool({ name: 'list_stories_with_details', description: 'List stories with comprehensive details including documentation fields, status, and metadata. Use this to audit documentation completeness, review story details in bulk, or generate reports.', inputSchema: { type: 'object' as const, properties: { sprintId: { type: 'string', description: 'Filter stories by sprint ID' }, epicId: { type: 'string', description: 'Filter stories by epic ID' }, status: { type: 'string', enum: ['backlog', 'todo', 'in_progress', 'review', 'testing', 'done', 'blocked', 'cancelled'], description: 'Filter by status' }, includeFields: { type: 'array', items: { type: 'string', enum: [ 'id', 'title', 'description', 'status', 'storyPoints', 'priority', 'assignee', 'epic', 'sprint', 'tags', 'acceptanceCriteria', 'implementationDocumentUrl', 'designDocumentUrl', 'documentationStatus', 'documentationLastReviewed', 'documentationReviewers', 'groomedWithUserFeedback', 'blockedReason', 'startDate', 'dueDate', 'actualStartDate', 'actualEndDate', 'estimatedHours', 'actualHours', 'completionPercentage', 'createdAt', 'updatedAt' ] }, description: 'Specific fields to include in the response', default: ['id', 'title', 'status', 'storyPoints', 'implementationDocumentUrl', 'designDocumentUrl', 'documentationStatus'] }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 50, description: 'Maximum number of stories to return' } }, required: [], additionalProperties: false } as JSONSchema7, async execute(input: any, context: RequestContext) { try { const { sprintId, epicId, status, includeFields = ['id', 'title', 'status', 'storyPoints', 'implementationDocumentUrl', 'designDocumentUrl', 'documentationStatus'], limit = 50 } = input; // Build query let sql = 'SELECT * FROM agile_stories WHERE 1=1'; const params: any[] = []; if (sprintId) { sql += ' AND sprint_id = ?'; params.push(sprintId); } if (epicId) { sql += ' AND epic_id = ?'; params.push(epicId); } if (status) { sql += ' AND status = ?'; params.push(status); } sql += ` ORDER BY created_at DESC LIMIT ?`; params.push(limit); const result = await context.db.query(sql, params); if (!result.success) { throw new Error(result.error || 'Failed to query stories'); } // Map to include only requested fields const stories = (result.data || []).map((story: any) => { const mappedStory: any = {}; // Field mapping from database columns to API fields const fieldMapping: Record<string, string> = { 'id': 'id', 'title': 'title', 'description': 'description', 'status': 'status', 'storyPoints': 'story_points', 'priority': 'priority', 'assignee': 'assigned_to', 'epic': 'epic_id', 'sprint': 'sprint_id', 'tags': 'tags', 'acceptanceCriteria': 'acceptance_criteria', 'implementationDocumentUrl': 'implementation_doc_url', 'designDocumentUrl': 'design_doc_url', 'documentationStatus': 'documentation_status', 'documentationLastReviewed': 'documentation_last_reviewed', 'documentationReviewers': 'documentation_reviewers', 'groomedWithUserFeedback': 'groomed_with_user_feedback', 'blockedReason': 'blocked_reason', 'startDate': 'start_date', 'dueDate': 'due_date', 'actualStartDate': 'actual_start_date', 'actualEndDate': 'actual_end_date', 'estimatedHours': 'estimated_hours', 'actualHours': 'actual_hours', 'completionPercentage': 'completion_percentage', 'createdAt': 'created_at', 'updatedAt': 'updated_at' }; includeFields.forEach((field: string) => { const dbField = fieldMapping[field]; if (dbField && story[dbField] !== undefined) { // Parse JSON fields if (['tags', 'acceptanceCriteria', 'documentationReviewers'].includes(field)) { try { mappedStory[field] = JSON.parse(story[dbField] || '[]'); } catch { mappedStory[field] = []; } } else if (['createdAt', 'updatedAt'].includes(field) && story[dbField]) { mappedStory[field] = new Date(story[dbField]).toISOString(); } else { mappedStory[field] = story[dbField]; } } }); return mappedStory; }); // Generate summary statistics const totalStories = stories.length; const missingDocumentation = stories.filter((s: any) => (includeFields.includes('implementationDocumentUrl') && !s.implementationDocumentUrl) || (includeFields.includes('designDocumentUrl') && !s.designDocumentUrl) ).length; const statusCounts = stories.reduce((acc: any, story: any) => { if (story.status) { acc[story.status] = (acc[story.status] || 0) + 1; } return acc; }, {}); return createSuccessResult({ stories, summary: { total: totalStories, missingDocumentation, statusDistribution: statusCounts, filters: { sprintId, epicId, status, fieldsIncluded: includeFields } } }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to retrieve stories with details: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Create a new epic */ const createEpicTool = createTool<CreateEpicInput, any>({ name: 'create_epic', description: 'Create a new epic to group related user stories', category: 'agile-management', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Epic title', minLength: 1, maxLength: 200 }, description: { type: 'string', description: 'Detailed epic description', minLength: 1, maxLength: 5000 }, goals: { type: 'array', items: { type: 'string', maxLength: 500 }, description: 'Epic goals and objectives', maxItems: 10 }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], default: 'medium', description: 'Epic priority level' }, owner: { type: 'string', description: 'Epic owner email or name', maxLength: 100 }, targetDate: { type: 'string', format: 'date', description: 'Target completion date' }, successCriteria: { type: 'array', items: { type: 'string', maxLength: 500 }, description: 'Success criteria for the epic', maxItems: 10 }, repositories: { type: 'array', items: { type: 'string', maxLength: 100 }, description: 'Repositories involved in this epic', maxItems: 10 } }, required: ['title', 'description'], additionalProperties: false } as JSONSchema7, async execute(input: CreateEpicInput, context: RequestContext) { try { const epicId = randomUUID(); const now = Date.now(); // Insert epic into database const result = await context.db.run( `INSERT INTO agile_epics (id, title, description, goals, priority, owner, target_date, success_criteria, repositories, status, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ epicId, input.title, input.description, JSON.stringify(input.goals || []), input.priority || 'medium', input.owner || null, input.targetDate ? new Date(input.targetDate).getTime() : null, JSON.stringify(input.successCriteria || []), JSON.stringify(input.repositories || []), 'planning', context.projectId || 'default', now, now ] ); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to create epic', details: { error: result.error }, category: 'system' }); } return createSuccessResult({ epic: { id: epicId, title: input.title, description: input.description, goals: input.goals || [], priority: input.priority || 'medium', owner: input.owner || null, targetDate: input.targetDate || null, successCriteria: input.successCriteria || [], repositories: input.repositories || [], status: 'active', storyCount: 0, completedStories: 0 }, message: `Epic "${input.title}" created successfully`, nextSteps: [ 'Add user stories to this epic', 'Define success criteria', 'Assign to sprints' ] }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to create epic: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Get epic details */ const getEpicTool = createTool<GetEpicInput, any>({ name: 'get_epic', description: 'Get detailed information about a specific epic', category: 'agile-management', readOnly: true, inputSchema: { type: 'object', properties: { epicId: { type: 'string', description: 'Epic ID', pattern: '^[a-zA-Z0-9-]+$' } }, required: ['epicId'], additionalProperties: false } as JSONSchema7, async execute(input: GetEpicInput, context: RequestContext) { try { // Get epic details const epicResult = await context.db.get( 'SELECT * FROM agile_epics WHERE id = ? AND project_id = ?', [input.epicId, context.projectId || 'default'] ); if (!epicResult.success || !epicResult.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Epic not found', details: { epicId: input.epicId }, category: 'validation' }); } const epic = epicResult.data; // Get story count for this epic const storyCountResult = await context.db.query( `SELECT COUNT(*) as total_stories, COUNT(CASE WHEN status = 'done' THEN 1 END) as completed_stories FROM agile_stories WHERE epic_id = ?`, [input.epicId] ); const stats = storyCountResult.data?.[0] || { total_stories: 0, completed_stories: 0 }; return createSuccessResult({ epic: { id: epic.id, title: epic.title, description: epic.description, goals: JSON.parse(epic.goals || '[]'), priority: epic.priority, owner: epic.owner, targetDate: epic.target_date ? new Date(epic.target_date).toISOString() : null, successCriteria: JSON.parse(epic.success_criteria || '[]'), repositories: JSON.parse(epic.repositories || '[]'), status: epic.status, storyCount: stats.total_stories || 0, completedStories: stats.completed_stories || 0, progress: stats.total_stories > 0 ? Math.round((stats.completed_stories / stats.total_stories) * 100) : 0, createdAt: new Date(epic.created_at).toISOString(), updatedAt: new Date(epic.updated_at).toISOString() } }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to get epic: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * List epics with filtering */ const listEpicsTool = createTool<ListEpicsInput, any>({ name: 'list_epics', description: 'List epics with optional filtering by status or owner', category: 'agile-management', readOnly: true, inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['active', 'completed', 'cancelled'], description: 'Filter by epic status' }, owner: { type: 'string', description: 'Filter by epic owner', maxLength: 100 }, limit: { type: 'integer', description: 'Maximum number of epics to return', minimum: 1, maximum: 100, default: 20 }, offset: { type: 'integer', description: 'Number of epics to skip', minimum: 0, default: 0 } }, additionalProperties: false } as JSONSchema7, async execute(input: ListEpicsInput, context: RequestContext) { try { let sql = 'SELECT * FROM agile_epics WHERE project_id = ?'; const params: any[] = [context.projectId || 'default']; if (input.status) { sql += ' AND status = ?'; params.push(input.status); } if (input.owner) { sql += ' AND owner = ?'; params.push(input.owner); } sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; params.push(input.limit || 20, input.offset || 0); const result = await context.db.query(sql, params); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to list epics', details: { error: result.error }, category: 'system' }); } // Get story counts for each epic const epics = await Promise.all((result.data || []).map(async (epic: any) => { const storyCountResult = await context.db.query( `SELECT COUNT(*) as total_stories, COUNT(CASE WHEN status = 'done' THEN 1 END) as completed_stories FROM agile_stories WHERE epic_id = ?`, [epic.id] ); const stats = storyCountResult.data?.[0] || { total_stories: 0, completed_stories: 0 }; return { id: epic.id, title: epic.title, description: epic.description, priority: epic.priority, owner: epic.owner, status: epic.status, storyCount: stats.total_stories || 0, completedStories: stats.completed_stories || 0, progress: stats.total_stories > 0 ? Math.round((stats.completed_stories / stats.total_stories) * 100) : 0, targetDate: epic.target_date ? new Date(epic.target_date).toISOString() : null, createdAt: new Date(epic.created_at).toISOString() }; })); return createSuccessResult({ epics, count: epics.length, hasMore: epics.length === (input.limit || 20), filters: { status: input.status || null, owner: input.owner || null } }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to list epics: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * List sprints with filtering */ const listSprintsTool = createTool<ListSprintsInput, any>({ name: 'list_agile_sprints', description: 'List all sprints with optional filtering and metrics', category: 'agile-management', readOnly: true, inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['active', 'completed', 'cancelled'], description: 'Filter by sprint status' }, includeMetrics: { type: 'boolean', description: 'Include sprint metrics', default: false }, limit: { type: 'integer', description: 'Maximum number of sprints to return', minimum: 1, maximum: 100, default: 20 }, offset: { type: 'integer', description: 'Number of sprints to skip', minimum: 0, default: 0 } }, additionalProperties: false } as JSONSchema7, async execute(input: ListSprintsInput, context: RequestContext) { try { let sql = 'SELECT * FROM agile_sprints WHERE project_id = ?'; const params: any[] = [context.projectId || 'default']; if (input.status) { sql += ' AND status = ?'; params.push(input.status); } sql += ' ORDER BY start_date DESC LIMIT ? OFFSET ?'; params.push(input.limit || 20, input.offset || 0); const result = await context.db.query(sql, params); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to list sprints', details: { error: result.error }, category: 'system' }); } let sprints = (result.data || []).map((sprint: any) => ({ id: sprint.id, name: sprint.name, goal: sprint.goal, status: sprint.status, duration: sprint.duration_days, startDate: new Date(sprint.start_date).toISOString(), endDate: new Date(sprint.end_date).toISOString(), team: JSON.parse(sprint.team_members || '[]'), createdAt: new Date(sprint.created_at).toISOString() })); // Add metrics if requested if (input.includeMetrics) { sprints = await Promise.all(sprints.map(async (sprint: any) => { const metricsResult = await context.db.query( `SELECT COUNT(*) as total_stories, COUNT(CASE WHEN status = '