@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
1,719 lines (1,557 loc) • 183 kB
text/typescript
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 = '