@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
612 lines (522 loc) • 19.2 kB
text/typescript
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));
}
}