UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

612 lines (522 loc) 19.2 kB
import { ConfigManager } from '../../config/config-manager.js'; import { StorageManager } from '../../storage/storage-manager.js'; import { ProductRoadmap, RoadmapTheme, Initiative, Feature, Milestone, Release, CreateRoadmapOptions, AddThemeOptions, CreateInitiativeOptions, AddFeatureOptions, RoadmapMetrics, ThemeMetrics, ValueMetrics, TimelineView, TimelineItem, RoadmapReview, ReviewDecision, ReviewFeedback } from './types.js'; import { promises as fs } from 'fs'; import path from 'path'; export class RoadmapManager { private configManager: ConfigManager; private storageManager: StorageManager; private roadmapDataPath: string; private roadmaps: Map<string, ProductRoadmap>; private themes: Map<string, RoadmapTheme>; private initiatives: Map<string, Initiative>; private features: Map<string, Feature>; private milestones: Map<string, Milestone>; private releases: Map<string, Release>; private reviews: Map<string, RoadmapReview>; constructor(configManager: ConfigManager) { this.configManager = configManager; this.storageManager = new StorageManager(); this.roadmapDataPath = ''; this.roadmaps = new Map(); this.themes = new Map(); this.initiatives = new Map(); this.features = new Map(); this.milestones = new Map(); this.releases = new Map(); this.reviews = new Map(); } async initialize(): Promise<void> { const location = await this.storageManager.getStorageLocation(); this.roadmapDataPath = path.join(location.data, 'roadmaps'); await this.loadExistingData(); } private async loadExistingData(): Promise<void> { try { // Load roadmaps const roadmapsPath = path.join(this.roadmapDataPath, 'roadmaps.json'); if (await this.fileExists(roadmapsPath)) { const roadmapsData = JSON.parse(await fs.readFile(roadmapsPath, 'utf-8')); roadmapsData.forEach((roadmap: ProductRoadmap) => { roadmap.createdAt = new Date(roadmap.createdAt); roadmap.updatedAt = new Date(roadmap.updatedAt); this.roadmaps.set(roadmap.id, roadmap); }); } // Load themes const themesPath = path.join(this.roadmapDataPath, 'themes.json'); if (await this.fileExists(themesPath)) { const themesData = JSON.parse(await fs.readFile(themesPath, 'utf-8')); themesData.forEach((theme: RoadmapTheme) => { this.themes.set(theme.id, theme); }); } // Load initiatives const initiativesPath = path.join(this.roadmapDataPath, 'initiatives.json'); if (await this.fileExists(initiativesPath)) { const initiativesData = JSON.parse(await fs.readFile(initiativesPath, 'utf-8')); initiativesData.forEach((initiative: Initiative) => { this.initiatives.set(initiative.id, initiative); }); } // Load features const featuresPath = path.join(this.roadmapDataPath, 'features.json'); if (await this.fileExists(featuresPath)) { const featuresData = JSON.parse(await fs.readFile(featuresPath, 'utf-8')); featuresData.forEach((feature: Feature) => { this.features.set(feature.id, feature); }); } // Load milestones const milestonesPath = path.join(this.roadmapDataPath, 'milestones.json'); if (await this.fileExists(milestonesPath)) { const milestonesData = JSON.parse(await fs.readFile(milestonesPath, 'utf-8')); milestonesData.forEach((milestone: Milestone) => { milestone.date = new Date(milestone.date); this.milestones.set(milestone.id, milestone); }); } // Load releases const releasesPath = path.join(this.roadmapDataPath, 'releases.json'); if (await this.fileExists(releasesPath)) { const releasesData = JSON.parse(await fs.readFile(releasesPath, 'utf-8')); releasesData.forEach((release: Release) => { release.date = new Date(release.date); this.releases.set(release.id, release); }); } } catch (error) { console.error('Error loading roadmap data:', error); } } private async fileExists(filePath: string): Promise<boolean> { try { await fs.access(filePath); return true; } catch { return false; } } async createRoadmap(options: CreateRoadmapOptions): Promise<ProductRoadmap> { const roadmap: ProductRoadmap = { id: `roadmap-${this.generateId()}`, name: options.name, vision: options.vision, timeHorizon: options.timeHorizon, status: 'draft', themes: [], milestones: [], releases: [], createdAt: new Date(), updatedAt: new Date(), owner: options.owner, stakeholders: options.stakeholders || [], metrics: { featuresPlanned: 0, featuresCompleted: 0, initiativesActive: 0, velocityTrend: 'stable', onTimeDelivery: 100, valueDelivered: 0 } }; this.roadmaps.set(roadmap.id, roadmap); await this.saveRoadmaps(); return roadmap; } async addTheme(options: AddThemeOptions): Promise<RoadmapTheme> { const roadmap = this.roadmaps.get(options.roadmapId); if (!roadmap) { throw new Error(`Roadmap ${options.roadmapId} not found`); } const theme: RoadmapTheme = { id: `theme-${this.generateId()}`, name: options.name, description: options.description, objectives: options.objectives, initiatives: [], priority: options.priority, timeframe: { startQuarter: options.startQuarter, endQuarter: options.endQuarter }, status: 'planned', metrics: { initiativesTotal: 0, initiativesCompleted: 0, featuresTotal: 0, featuresCompleted: 0, progressPercentage: 0, valueScore: 0 } }; this.themes.set(theme.id, theme); roadmap.themes.push(theme); roadmap.updatedAt = new Date(); await Promise.all([this.saveThemes(), this.saveRoadmaps()]); return theme; } async createInitiative(options: CreateInitiativeOptions): Promise<Initiative> { const roadmap = this.roadmaps.get(options.roadmapId); const theme = this.themes.get(options.themeId); if (!roadmap) { throw new Error(`Roadmap ${options.roadmapId} not found`); } if (!theme) { throw new Error(`Theme ${options.themeId} not found`); } const initiative: Initiative = { id: `initiative-${this.generateId()}`, themeId: options.themeId, title: options.title, description: options.description, features: [], epicIds: [], value: options.estimatedValue, effort: options.estimatedEffort, risks: options.risks || [], dependencies: [], status: 'ideation' }; this.initiatives.set(initiative.id, initiative); theme.initiatives.push(initiative); // Update theme metrics theme.metrics.initiativesTotal++; this.updateRoadmapMetrics(roadmap); await Promise.all([ this.saveInitiatives(), this.saveThemes(), this.saveRoadmaps() ]); return initiative; } async addFeature(options: AddFeatureOptions): Promise<Feature> { const roadmap = this.roadmaps.get(options.roadmapId); const initiative = this.initiatives.get(options.initiativeId); if (!roadmap) { throw new Error(`Roadmap ${options.roadmapId} not found`); } if (!initiative) { throw new Error(`Initiative ${options.initiativeId} not found`); } const feature: Feature = { id: `feature-${this.generateId()}`, initiativeId: options.initiativeId, name: options.name, description: options.description, userStories: [], priority: 0, // Will be set by prioritization businessValue: options.businessValue, technicalComplexity: options.technicalComplexity, targetRelease: options.targetRelease, status: 'proposed' }; this.features.set(feature.id, feature); initiative.features.push(feature); // Update metrics const theme = this.themes.get(initiative.themeId); if (theme) { theme.metrics.featuresTotal++; } roadmap.metrics.featuresPlanned++; this.updateRoadmapMetrics(roadmap); await Promise.all([ this.saveFeatures(), this.saveInitiatives(), this.saveThemes(), this.saveRoadmaps() ]); return feature; } async createMilestone(roadmapId: string, milestone: Omit<Milestone, 'id'>): Promise<Milestone> { const roadmap = this.roadmaps.get(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const newMilestone: Milestone = { ...milestone, id: `milestone-${this.generateId()}`, date: new Date(milestone.date), status: 'upcoming' }; this.milestones.set(newMilestone.id, newMilestone); roadmap.milestones.push(newMilestone); roadmap.updatedAt = new Date(); await Promise.all([this.saveMilestones(), this.saveRoadmaps()]); return newMilestone; } async planRelease(roadmapId: string, release: Omit<Release, 'id' | 'status'>): Promise<Release> { const roadmap = this.roadmaps.get(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const newRelease: Release = { ...release, id: `release-${this.generateId()}`, date: new Date(release.date), status: 'planning' }; this.releases.set(newRelease.id, newRelease); roadmap.releases.push(newRelease); roadmap.updatedAt = new Date(); await Promise.all([this.saveReleases(), this.saveRoadmaps()]); return newRelease; } async updateFeatureStatus(featureId: string, status: Feature['status']): Promise<Feature> { const feature = this.features.get(featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } feature.status = status; // Update metrics if completed if (status === 'completed') { const initiative = this.initiatives.get(feature.initiativeId); if (initiative) { const theme = this.themes.get(initiative.themeId); if (theme) { theme.metrics.featuresCompleted++; this.updateThemeProgress(theme); } } // Find and update roadmap for (const [, roadmap] of this.roadmaps) { if (roadmap.themes.some(t => t.id === initiative?.themeId)) { roadmap.metrics.featuresCompleted++; this.updateRoadmapMetrics(roadmap); break; } } } await this.saveFeatures(); return feature; } async updateInitiativeStatus(initiativeId: string, status: Initiative['status']): Promise<Initiative> { const initiative = this.initiatives.get(initiativeId); if (!initiative) { throw new Error(`Initiative ${initiativeId} not found`); } initiative.status = status; // Update theme metrics if completed if (status === 'launched') { const theme = this.themes.get(initiative.themeId); if (theme) { theme.metrics.initiativesCompleted++; this.updateThemeProgress(theme); } } await this.saveInitiatives(); return initiative; } async generateTimeline(roadmapId: string, viewType: TimelineView['type']): Promise<TimelineView> { const roadmap = this.roadmaps.get(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const items: TimelineItem[] = []; const now = new Date(); // Add themes to timeline for (const theme of roadmap.themes) { const startDate = this.parseQuarter(theme.timeframe.startQuarter); const endDate = this.parseQuarter(theme.timeframe.endQuarter); items.push({ id: theme.id, type: 'theme', name: theme.name, startDate, endDate, status: theme.status, dependencies: [], progress: theme.metrics.progressPercentage }); } // Add milestones for (const milestone of roadmap.milestones) { items.push({ id: milestone.id, type: 'milestone', name: milestone.name, startDate: milestone.date, endDate: milestone.date, status: milestone.status, dependencies: milestone.dependencies }); } // Add releases for (const release of roadmap.releases) { items.push({ id: release.id, type: 'release', name: `${release.version} - ${release.name}`, startDate: release.date, endDate: release.date, status: release.status, dependencies: [] }); } // Sort by start date items.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); return { type: viewType, startDate: items[0]?.startDate || now, endDate: items[items.length - 1]?.endDate || now, items }; } async getRoadmapHealth(roadmapId: string): Promise<{ health: 'excellent' | 'good' | 'at-risk' | 'critical'; metrics: RoadmapMetrics; risks: string[]; recommendations: string[]; }> { const roadmap = this.roadmaps.get(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const risks: string[] = []; const recommendations: string[] = []; // Analyze on-time delivery if (roadmap.metrics.onTimeDelivery < 70) { risks.push('Low on-time delivery rate'); recommendations.push('Review capacity planning and estimates'); } // Check velocity trend if (roadmap.metrics.velocityTrend === 'decreasing') { risks.push('Decreasing velocity trend'); recommendations.push('Investigate blockers and team capacity'); } // Calculate health score let healthScore = 100; if (roadmap.metrics.onTimeDelivery < 80) healthScore -= 20; if (roadmap.metrics.velocityTrend === 'decreasing') healthScore -= 20; if (roadmap.metrics.featuresCompleted / roadmap.metrics.featuresPlanned < 0.5) healthScore -= 20; const health = healthScore >= 80 ? 'excellent' : healthScore >= 60 ? 'good' : healthScore >= 40 ? 'at-risk' : 'critical'; return { health, metrics: roadmap.metrics, risks, recommendations }; } async getRoadmaps(): Promise<ProductRoadmap[]> { return Array.from(this.roadmaps.values()) .filter(r => r.status !== 'archived') .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); } async getRoadmap(roadmapId: string): Promise<ProductRoadmap | null> { return this.roadmaps.get(roadmapId) || null; } async getThemeDetails(themeId: string): Promise<{ theme: RoadmapTheme; initiatives: Initiative[]; features: Feature[]; } | null> { const theme = this.themes.get(themeId); if (!theme) return null; const initiatives = theme.initiatives.map(i => this.initiatives.get(typeof i === 'string' ? i : i.id) ).filter(Boolean) as Initiative[]; const features: Feature[] = []; for (const initiative of initiatives) { const initiativeFeatures = initiative.features.map(f => this.features.get(typeof f === 'string' ? f : f.id) ).filter(Boolean) as Feature[]; features.push(...initiativeFeatures); } return { theme, initiatives, features }; } private updateRoadmapMetrics(roadmap: ProductRoadmap): void { // Count active initiatives let activeInitiatives = 0; for (const theme of roadmap.themes) { for (const initiative of theme.initiatives) { const init = typeof initiative === 'string' ? this.initiatives.get(initiative) : initiative; if (init && ['validated', 'scheduled', 'in-development'].includes(init.status)) { activeInitiatives++; } } } roadmap.metrics.initiativesActive = activeInitiatives; // Calculate value delivered let totalValue = 0; let completedValue = 0; for (const feature of this.features.values()) { if (feature.status === 'completed') { completedValue += feature.businessValue.score; } totalValue += feature.businessValue.score; } roadmap.metrics.valueDelivered = totalValue > 0 ? Math.round((completedValue / totalValue) * 100) : 0; } private updateThemeProgress(theme: RoadmapTheme): void { const total = theme.metrics.initiativesTotal + theme.metrics.featuresTotal; const completed = theme.metrics.initiativesCompleted + theme.metrics.featuresCompleted; theme.metrics.progressPercentage = total > 0 ? Math.round((completed / total) * 100) : 0; } private parseQuarter(quarter: string): Date { // Parse "Q1 2024" format const [q, year] = quarter.split(' '); const quarterNum = parseInt(q.substring(1)); const yearNum = parseInt(year); // Q1 = Jan, Q2 = Apr, Q3 = Jul, Q4 = Oct const month = (quarterNum - 1) * 3; return new Date(yearNum, month, 1); } private generateId(): string { return `${Date.now().toString(16)}-${Math.random().toString(16).substr(2, 8)}`; } // Save methods private async saveRoadmaps(): Promise<void> { const roadmapsPath = path.join(this.roadmapDataPath, 'roadmaps.json'); await fs.mkdir(path.dirname(roadmapsPath), { recursive: true }); await fs.writeFile(roadmapsPath, JSON.stringify(Array.from(this.roadmaps.values()), null, 2)); } private async saveThemes(): Promise<void> { const themesPath = path.join(this.roadmapDataPath, 'themes.json'); await fs.mkdir(path.dirname(themesPath), { recursive: true }); await fs.writeFile(themesPath, JSON.stringify(Array.from(this.themes.values()), null, 2)); } private async saveInitiatives(): Promise<void> { const initiativesPath = path.join(this.roadmapDataPath, 'initiatives.json'); await fs.mkdir(path.dirname(initiativesPath), { recursive: true }); await fs.writeFile(initiativesPath, JSON.stringify(Array.from(this.initiatives.values()), null, 2)); } private async saveFeatures(): Promise<void> { const featuresPath = path.join(this.roadmapDataPath, 'features.json'); await fs.mkdir(path.dirname(featuresPath), { recursive: true }); await fs.writeFile(featuresPath, JSON.stringify(Array.from(this.features.values()), null, 2)); } private async saveMilestones(): Promise<void> { const milestonesPath = path.join(this.roadmapDataPath, 'milestones.json'); await fs.mkdir(path.dirname(milestonesPath), { recursive: true }); await fs.writeFile(milestonesPath, JSON.stringify(Array.from(this.milestones.values()), null, 2)); } private async saveReleases(): Promise<void> { const releasesPath = path.join(this.roadmapDataPath, 'releases.json'); await fs.mkdir(path.dirname(releasesPath), { recursive: true }); await fs.writeFile(releasesPath, JSON.stringify(Array.from(this.releases.values()), null, 2)); } }