Skip to main content
Glama
phase-service.ts43.7 kB
import { v4 as uuidv4 } from 'uuid'; import type { RepositoryFactory } from '../../infrastructure/factory/repository-factory.js'; import type { PlanService } from './plan-service.js'; import type { VersionHistoryService } from './version-history-service.js'; import type { LinkingService } from './linking-service.js'; import type { Phase, PhaseStatus, EffortEstimate, Tag, Milestone, PhasePriority, VersionHistory, VersionDiff } from '../entities/types.js'; import { validateEffortEstimate, validateTags, validatePriority, validateRequiredString, validateRequiredEnum, validateProgress, validateOptionalString, validatePhaseOrder } from './validators.js'; import { filterPhase } from '../utils/field-filter.js'; import { bulkUpdateEntities } from '../utils/bulk-operations.js'; // Constants const MAX_PHASES_BATCH_SIZE = 100; const PROGRESS_COMPLETE = 100; const PROGRESS_NEAR_COMPLETE = 80; // 80% progress threshold for "almost done" const DEFAULT_NEXT_ACTIONS_LIMIT = 5; const PRIORITY_CRITICAL = 0; const PRIORITY_HIGH = 1; const PRIORITY_MEDIUM = 2; const PRIORITY_LOW = 3; /** * Calculate the next valid order for a phase. * * - If explicitOrder is provided, validates it doesn't conflict with existing siblings * - Otherwise, calculates next order based on max existing sibling order (not count!) * This prevents duplicate paths when phases are deleted creating gaps in the sequence. * * @param siblings - Array of existing sibling phases (same parentId) * @param explicitOrder - Optional explicit order value provided by user * @returns The validated order value to use * @throws Error if explicit order conflicts with existing sibling */ function calculateNextOrder(siblings: Phase[], explicitOrder?: number): number { const maxSiblingOrder = siblings.length > 0 ? Math.max(...siblings.map((s) => s.order)) : 0; if (explicitOrder !== undefined) { const conflicting = siblings.find((s) => s.order === explicitOrder); if (conflicting) { throw new Error( `Order ${String(explicitOrder)} already exists for sibling phase "${conflicting.title}". ` + `Use a different order value or omit to auto-generate.` ); } return explicitOrder; } return maxSiblingOrder + 1; } // Input types export interface AddPhaseInput { planId: string; phase: { title: string; // REQUIRED description?: string; // Optional - default: '' objectives?: string[]; // Optional - default: [] deliverables?: string[]; // Optional - default: [] successCriteria?: string[]; // Optional - default: [] parentId?: string | null; // Optional - default: null (root phase) order?: number; estimatedEffort?: EffortEstimate; // Optional - default: {value:0, unit:'hours', confidence:'low'} schedule?: { estimatedEffort: EffortEstimate; }; tags?: Tag[]; // Optional - default: [] implementationNotes?: string; // undefined OK priority?: PhasePriority; // Optional - default: 'medium' status?: PhaseStatus; // Sprint 4: Optional - default: 'planned' }; } export interface UpdatePhaseInput { planId: string; phaseId: string; updates: Partial<{ title: string; description: string; objectives: string[]; deliverables: string[]; successCriteria: string[]; status: PhaseStatus; blockingReason: string; progress: number; schedule: { actualEffort?: number; startedAt?: string; completedAt?: string; }; milestones: Milestone[]; tags: Tag[]; implementationNotes: string; priority: PhasePriority; }>; } export interface MovePhaseInput { planId: string; phaseId: string; newParentId?: string | null; newOrder?: number; } export interface GetPhaseInput { planId: string; phaseId: string; fields?: string[]; // Fields to include: summary (default), ['*'] (all), or custom list excludeMetadata?: boolean; // Exclude metadata fields (createdAt, updatedAt, version, metadata) excludeComputed?: boolean; // Exclude computed fields (depth, path, childCount) } export interface GetPhaseResult { phase: Phase; } export interface GetPhasesInput { planId: string; phaseIds: string[]; fields?: string[]; // Fields to include: summary (default), ['*'] (all), or custom list excludeMetadata?: boolean; // Exclude metadata fields excludeComputed?: boolean; // Exclude computed fields } export interface GetPhasesResult { phases: Phase[]; notFound: string[]; // IDs that were not found } export interface GetPhaseTreeInput { planId: string; rootPhaseId?: string; includeCompleted?: boolean; fields?: string[]; // Summary by default; ['*'] for all, or specific fields maxDepth?: number; // Limit tree depth (0 = root only) excludeMetadata?: boolean; // Exclude metadata fields (createdAt, updatedAt, version, metadata) excludeComputed?: boolean; // Exclude computed fields (depth, path, childCount) } export interface DeletePhaseInput { planId: string; phaseId: string; deleteChildren?: boolean; } export interface UpdatePhaseStatusInput { planId: string; phaseId: string; status: PhaseStatus; progress?: number; actualEffort?: number; notes?: string; } export interface GetNextActionsInput { planId: string; limit?: number; } export interface NextAction { phaseId: string; phaseTitle: string; phasePath: string; action: 'start' | 'continue' | 'unblock' | 'complete'; reason: string; priority: 'high' | 'medium' | 'low'; } export interface GetNextActionsResult { actions: NextAction[]; summary: { totalPending: number; totalInProgress: number; totalBlocked: number; }; } export interface CompleteAndAdvanceInput { planId: string; phaseId: string; actualEffort?: number; notes?: string; } export interface CompleteAndAdvanceResult { completedPhaseId: string; nextPhaseId: string | null; success: true; } // Sprint 5: Array Field Operations interfaces export type PhaseArrayField = 'objectives' | 'deliverables' | 'successCriteria'; export interface ArrayAppendInput { planId: string; phaseId: string; field: PhaseArrayField; value: string; } export interface ArrayPrependInput { planId: string; phaseId: string; field: PhaseArrayField; value: string; } export interface ArrayInsertAtInput { planId: string; phaseId: string; field: PhaseArrayField; index: number; value: string; } export interface ArrayUpdateAtInput { planId: string; phaseId: string; field: PhaseArrayField; index: number; value: string; } export interface ArrayRemoveAtInput { planId: string; phaseId: string; field: PhaseArrayField; index: number; } export interface BulkUpdatePhasesInput { planId: string; updates: { phaseId: string; updates: Partial<{ title: string; description: string; objectives: string[]; deliverables: string[]; successCriteria: string[]; implementationNotes: string; estimatedEffort: { value: number; unit: 'hours' | 'days' | 'weeks' | 'story-points' | 'minutes'; confidence: 'low' | 'medium' | 'high'; }; actualEffort: { value: number; unit: 'hours' | 'days' | 'weeks' | 'story-points' | 'minutes'; }; schedule: { startDate: string; endDate: string; milestones: { date: string; description: string }[]; }; status: string; progress: number; priority: 'critical' | 'high' | 'medium' | 'low'; blockers: { description: string; severity: 'high' | 'medium' | 'low' }[]; }>; }[]; atomic?: boolean; } export interface BulkUpdatePhasesResult { updated: number; failed: number; results: { phaseId: string; success: boolean; error?: string; }[]; } export interface ArrayOperationResult { success: true; field: PhaseArrayField; newLength: number; } // Summary phase contains only essential fields for tree navigation export interface PhaseSummary { id: string; title: string; status: string; progress: number; path: string; childCount: number; // Additional fields when requested via fields parameter [key: string]: unknown; } // Output types export interface PhaseTreeNode { phase: Phase | PhaseSummary; children: PhaseTreeNode[]; depth: number; hasChildren: boolean; } export interface AddPhaseResult { phaseId: string; } export interface UpdatePhaseResult { success: boolean; phaseId: string; } export interface MovePhaseResult { success: boolean; phaseId: string; affectedPhaseIds?: string[]; } export interface GetPhaseTreeResult { tree: PhaseTreeNode[]; } export interface DeletePhaseResult { success: boolean; message: string; deletedPhaseIds: string[]; reparentedPhaseIds?: string[]; // Sprint 8: Bug #17 - IDs of phases that were re-parented } export interface UpdatePhaseStatusResult { success: boolean; phaseId: string; } export class PhaseService { constructor( private readonly repositoryFactory: RepositoryFactory, private readonly planService: PlanService, private readonly versionHistoryService?: VersionHistoryService, private readonly linkingService?: LinkingService // BUG-015 FIX: Optional for cascading link deletion ) {} private async ensurePlanExists(planId: string): Promise<void> { const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(planId); if (!exists) { throw new Error('Plan not found'); } } public async getPhase(input: GetPhaseInput): Promise<GetPhaseResult> { await this.ensurePlanExists(input.planId); const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const phase = phases.find((p) => p.id === input.phaseId); if (!phase) { throw new Error('Phase not found'); } // Apply field filtering with Lazy-Load support const filtered = filterPhase( phase, input.fields, input.excludeMetadata, input.excludeComputed ) as Phase; return { phase: filtered }; } public async getPhases(input: GetPhasesInput): Promise<GetPhasesResult> { await this.ensurePlanExists(input.planId); // Enforce max limit if (input.phaseIds.length > MAX_PHASES_BATCH_SIZE) { throw new Error(`Cannot fetch more than ${String(MAX_PHASES_BATCH_SIZE)} phases at once`); } // Handle empty array if (input.phaseIds.length === 0) { return { phases: [], notFound: [] }; } const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const allPhases = await repo.findAll(); const foundPhases: Phase[] = []; const notFound: string[] = []; // Collect found and not found IDs for (const id of input.phaseIds) { const phase = allPhases.find((p) => p.id === id); if (phase) { // Apply field filtering with Lazy-Load const filtered = filterPhase( phase, input.fields, input.excludeMetadata, input.excludeComputed ) as Phase; foundPhases.push(filtered); } else { notFound.push(id); } } return { phases: foundPhases, notFound }; } public async addPhase(input: AddPhaseInput): Promise<AddPhaseResult> { // Validate REQUIRED fields validateRequiredString(input.phase.title, 'title'); // Validate estimatedEffort format (support both direct and schedule.estimatedEffort) const effort = input.phase.estimatedEffort ?? input.phase.schedule?.estimatedEffort; validateEffortEstimate(effort, 'estimatedEffort'); // Validate tags format validateTags(input.phase.tags ?? []); // Validate optional string fields (BUG-003, BUG-029) validateOptionalString(input.phase.description, 'description'); validateOptionalString(input.phase.implementationNotes, 'implementationNotes'); // Validate priority if provided if (input.phase.priority !== undefined) { validatePriority(input.phase.priority); } // Sprint 4: Validate status if provided (BUG #7) if (input.phase.status !== undefined) { validateRequiredEnum( input.phase.status, 'status', ['planned', 'in_progress', 'completed', 'blocked', 'skipped'] ); } // BUG-017, BUG-033, BUG-034, BUG-045: Validate order if provided validatePhaseOrder(input.phase.order); const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const phaseId = uuidv4(); const now = new Date().toISOString(); // Calculate hierarchy const parentId = input.phase.parentId === undefined ? null : input.phase.parentId; const siblings = phases.filter((p) => p.parentId === parentId); const order = calculateNextOrder(siblings, input.phase.order); let depth = 0; let path = String(order); if (parentId !== null && parentId !== '') { const parent = phases.find((p) => p.id === parentId); if (!parent) { throw new Error('Parent phase not found'); } depth = parent.depth + 1; path = `${parent.path}.${String(order)}`; } const phase: Phase = { id: phaseId, type: 'phase', createdAt: now, updatedAt: now, version: 1, metadata: { createdBy: 'claude-code', tags: input.phase.tags ?? [], annotations: [], }, title: input.phase.title, // REQUIRED description: input.phase.description ?? '', // DEFAULT: empty string parentId, order, depth, path, objectives: input.phase.objectives ?? [], // DEFAULT: empty array deliverables: input.phase.deliverables ?? [], // DEFAULT: empty array successCriteria: input.phase.successCriteria ?? [], // DEFAULT: empty array schedule: { estimatedEffort: effort ?? { value: 0, unit: 'hours', confidence: 'low' }, }, status: input.phase.status ?? 'planned', // Sprint 4: Use input status or default to 'planned' progress: 0, implementationNotes: input.phase.implementationNotes, priority: input.phase.priority ?? 'medium', }; await repo.create(phase); await this.planService.updateStatistics(input.planId); return { phaseId }; } public async updatePhase(input: UpdatePhaseInput): Promise<UpdatePhaseResult> { const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const index = phases.findIndex((p) => p.id === input.phaseId); if (index === -1) { throw new Error('Phase not found'); } const phase = phases[index]; // Sprint 7: Save current version to history BEFORE updating if (this.versionHistoryService) { const currentSnapshot = JSON.parse(JSON.stringify(phase)) as Phase; await this.versionHistoryService.saveVersion( input.planId, input.phaseId, 'phase', currentSnapshot, phase.version, 'claude-code', 'Auto-saved before update' ); } // BUG #18: Validate title if provided in updates if (input.updates.title !== undefined) { validateRequiredString(input.updates.title, 'title'); phase.title = input.updates.title; } if (input.updates.description !== undefined) { // M-2 FIX: Validate optional string fields in update path (BUG-003, BUG-029) validateOptionalString(input.updates.description, 'description'); phase.description = input.updates.description; } if (input.updates.objectives !== undefined) phase.objectives = input.updates.objectives; if (input.updates.deliverables !== undefined) phase.deliverables = input.updates.deliverables; if (input.updates.successCriteria !== undefined) phase.successCriteria = input.updates.successCriteria; // BUGS #16, #17, #19: Validate status if provided in updates if (input.updates.status !== undefined) { validateRequiredEnum( input.updates.status, 'status', ['planned', 'in_progress', 'completed', 'blocked', 'skipped'] ); phase.status = input.updates.status; } // Sprint 2: Validate progress before assignment if (input.updates.progress !== undefined) { validateProgress(input.updates.progress); phase.progress = input.updates.progress; } if (input.updates.schedule !== undefined) { phase.schedule = { ...phase.schedule, ...input.updates.schedule }; } if (input.updates.milestones !== undefined) phase.milestones = input.updates.milestones; if (input.updates.tags !== undefined) { validateTags(input.updates.tags); phase.metadata.tags = input.updates.tags; } if (input.updates.implementationNotes !== undefined) { validateOptionalString(input.updates.implementationNotes, 'implementationNotes'); phase.implementationNotes = input.updates.implementationNotes; } if (input.updates.priority !== undefined) { validatePriority(input.updates.priority); phase.priority = input.updates.priority; } // FIX #12: Don't manually increment version - FileRepository.update() does it automatically await repo.update(phase.id, phase); return { success: true, phaseId: input.phaseId }; } public async movePhase(input: MovePhaseInput): Promise<MovePhaseResult> { // BUG-017, BUG-033, BUG-034, BUG-045: Validate newOrder if provided validatePhaseOrder(input.newOrder, 'newOrder'); const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const phase = phases.find((p) => p.id === input.phaseId); if (!phase) { throw new Error('Phase not found'); } const affectedPhases: Phase[] = []; const oldParentId = phase.parentId; const parentChanged = input.newParentId !== undefined && input.newParentId !== oldParentId; // Update parent if specified if (input.newParentId !== undefined) { phase.parentId = input.newParentId; if (input.newParentId !== null && input.newParentId !== '') { const parent = phases.find((p) => p.id === input.newParentId); if (!parent) throw new Error('New parent not found'); phase.depth = parent.depth + 1; } else { phase.depth = 0; } } // Update order if specified OR recalculate if parent changed (BUG-005) if (input.newOrder !== undefined) { phase.order = input.newOrder; } else if (parentChanged) { // BUG-005: Recalculate order when moving to new parent without explicit order const newSiblings = phases.filter( (p) => p.parentId === phase.parentId && p.id !== phase.id ); phase.order = calculateNextOrder(newSiblings); } // Recalculate path if (phase.parentId !== null && phase.parentId !== '') { const parent = phases.find((p) => p.id === phase.parentId); if (!parent) { throw new Error(`Parent phase not found: ${phase.parentId}`); } phase.path = `${parent.path}.${String(phase.order)}`; } else { phase.path = String(phase.order); } // Update children paths recursively const collectAffectedChildren = (parentId: string, parentPath: string): void => { const children = phases.filter((p) => p.parentId === parentId); for (const child of children) { child.path = `${parentPath}.${String(child.order)}`; child.depth = parentPath.split('.').length; affectedPhases.push(child); collectAffectedChildren(child.id, child.path); } }; collectAffectedChildren(phase.id, phase.path); // FIX #12: Don't manually increment version - FileRepository.update() does it automatically // Update main phase await repo.update(phase.id, phase); // Update all affected children for (const child of affectedPhases) { await repo.update(child.id, child); } return { success: true, phaseId: input.phaseId, affectedPhaseIds: affectedPhases.length > 0 ? affectedPhases.map((p) => p.id) : undefined, }; } public async getPhaseTree(input: GetPhaseTreeInput): Promise<GetPhaseTreeResult> { // BUG-027 FIX: Validate maxDepth is non-negative if (input.maxDepth !== undefined && input.maxDepth < 0) { throw new Error('maxDepth must be a non-negative integer'); } const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); let phases = await repo.findAll(); if (input.includeCompleted === false) { phases = phases.filter((p) => p.status !== 'completed'); } // Pre-calculate child counts for all phases const childCounts = new Map<string | null, number>(); for (const p of phases) { const parentKey = p.parentId ?? null; childCounts.set(parentKey, (childCounts.get(parentKey) ?? 0) + 1); } // Build phase data based on fields parameter const buildPhaseData = (phase: Phase): Phase | PhaseSummary => { // Add childCount to phase (computed field) const phaseWithChildCount = { ...phase, childCount: childCounts.get(phase.id) ?? 0, }; // Apply field filtering with Lazy-Load support const filtered = filterPhase( phaseWithChildCount, input.fields, input.excludeMetadata, input.excludeComputed ) as Phase | PhaseSummary; return filtered; }; const buildTree = (parentId: string | null, currentDepth: number): PhaseTreeNode[] => { return phases .filter((p) => p.parentId === parentId) .sort((a, b) => a.order - b.order) .map((phase) => { const hasChildPhases = (childCounts.get(phase.id) ?? 0) > 0; // Respect maxDepth: truncate children if we've reached the limit const shouldTruncate = input.maxDepth !== undefined && currentDepth >= input.maxDepth; const children = shouldTruncate ? [] : buildTree(phase.id, currentDepth + 1); return { phase: buildPhaseData(phase), children, depth: currentDepth, hasChildren: hasChildPhases, // True even if children truncated by maxDepth }; }); }; const rootId = input.rootPhaseId ?? null; const tree = buildTree(rootId, 0); return { tree }; } public async deletePhase(input: DeletePhaseInput): Promise<DeletePhaseResult> { const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const deletedIds: string[] = []; const reparentedIds: string[] = []; const collectChildren = (parentId: string): void => { const children = phases.filter((p) => p.parentId === parentId); for (const child of children) { deletedIds.push(child.id); collectChildren(child.id); } }; const phase = phases.find((p) => p.id === input.phaseId); if (!phase) { throw new Error('Phase not found'); } deletedIds.push(input.phaseId); if (input.deleteChildren === true) { collectChildren(input.phaseId); } else { // Sprint 8: Bug #17 - Re-parent children to deleted phase's parent const directChildren = phases.filter((p) => p.parentId === input.phaseId); if (directChildren.length > 0) { // New parent is the deleted phase's parent (null for root) const newParentId = phase.parentId; // Find new parent phase to calculate new depth and path const newParent = newParentId !== null ? phases.find((p) => p.id === newParentId) : null; const newParentDepth = newParent?.depth ?? -1; const newParentPath = newParent?.path ?? ''; // Calculate next order for siblings at new parent level const existingSiblings = phases.filter((p) => p.parentId === newParentId && p.id !== input.phaseId); let nextOrder = existingSiblings.length > 0 ? Math.max(...existingSiblings.map((s) => s.order)) + 1 : 1; // Re-parent each direct child for (const child of directChildren) { child.parentId = newParentId; child.depth = newParentDepth + 1; child.order = nextOrder; nextOrder++; // Calculate new path if (newParentPath !== '') { child.path = `${newParentPath}.${String(child.order)}`; } else { child.path = String(child.order); } reparentedIds.push(child.id); // Recursively update all descendants' paths this.updateDescendantPaths(child, phases); } // Save all re-parented phases and their descendants const affectedPhases = this.collectAllDescendants(directChildren, phases); for (const affectedPhase of [...directChildren, ...affectedPhases]) { await repo.update(affectedPhase.id, affectedPhase); } } } // BUG-015 FIX: Cascade delete all links for deleted phases if (this.linkingService) { for (const id of deletedIds) { await this.linkingService.deleteLinksForEntity(input.planId, id); } } // Delete all collected phases for (const id of deletedIds) { await repo.delete(id); } await this.planService.updateStatistics(input.planId); const result: DeletePhaseResult = { success: true, message: `Deleted ${String(deletedIds.length)} phase(s)`, deletedPhaseIds: deletedIds, }; if (reparentedIds.length > 0) { result.reparentedPhaseIds = reparentedIds; } return result; } /** * Sprint 8: Recursively update paths of all descendants after re-parenting */ private updateDescendantPaths(parent: Phase, allPhases: Phase[]): void { const children = allPhases.filter((p) => p.parentId === parent.id); for (const child of children) { child.depth = parent.depth + 1; child.path = `${parent.path}.${String(child.order)}`; this.updateDescendantPaths(child, allPhases); } } /** * Sprint 8: Collect all descendants of given phases (for batch update) */ private collectAllDescendants(parents: Phase[], allPhases: Phase[]): Phase[] { const descendants: Phase[] = []; const collectRecursive = (parentId: string): void => { const children = allPhases.filter((p) => p.parentId === parentId); for (const child of children) { descendants.push(child); collectRecursive(child.id); } }; for (const parent of parents) { collectRecursive(parent.id); } return descendants; } public async updatePhaseStatus(input: UpdatePhaseStatusInput): Promise<UpdatePhaseStatusResult> { const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const phase = phases.find((p) => p.id === input.phaseId); if (!phase) { throw new Error('Phase not found'); } const now = new Date().toISOString(); const autoUpdated: { startedAt?: string; completedAt?: string } = {}; // Auto-set timestamps based on status transition if (input.status === 'in_progress' && phase.status === 'planned') { phase.startedAt = now; autoUpdated.startedAt = now; } if (input.status === 'completed') { phase.completedAt = now; autoUpdated.completedAt = now; } // BUGS #16, #17, #19: Validate status validateRequiredEnum( input.status, 'status', ['planned', 'in_progress', 'completed', 'blocked', 'skipped'] ); if (input.status === 'blocked' && (input.notes === undefined || input.notes === '')) { throw new Error('Notes required when setting status to blocked'); } phase.status = input.status; // Sprint 2: Validate progress before assignment if (input.progress !== undefined) { validateProgress(input.progress); phase.progress = input.progress; } // BUG-039 FIX: Auto-set progress=100 when marking phase as completed // This must happen AFTER input.progress assignment to override any user-provided value if (input.status === 'completed') { phase.progress = PROGRESS_COMPLETE; } if (input.actualEffort !== undefined) { phase.schedule.actualEffort = input.actualEffort; } if (input.notes !== undefined && input.notes !== '') { phase.metadata.annotations.push({ id: uuidv4(), text: input.notes, author: 'claude-code', createdAt: now, }); } // FIX #12: Don't manually increment version - FileRepository.update() does it automatically await repo.update(phase.id, phase); await this.planService.updateStatistics(input.planId); return { success: true, phaseId: input.phaseId, }; } private comparePriority(a: Phase, b: Phase): number { const priorityOrder: Record<PhasePriority, number> = { critical: PRIORITY_CRITICAL, high: PRIORITY_HIGH, medium: PRIORITY_MEDIUM, low: PRIORITY_LOW, }; const aPrio = a.priority ?? 'medium'; const bPrio = b.priority ?? 'medium'; return priorityOrder[aPrio] - priorityOrder[bPrio]; } public async getNextActions(input: GetNextActionsInput): Promise<GetNextActionsResult> { // BUG-041 FIX: Validate limit is a positive integer if (input.limit !== undefined && input.limit < 1) { throw new Error('limit must be a positive integer (>= 1)'); } const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const limit = input.limit ?? DEFAULT_NEXT_ACTIONS_LIMIT; const actions: NextAction[] = []; // Collect stats const planned = phases.filter((p) => p.status === 'planned'); const inProgress = phases.filter((p) => p.status === 'in_progress'); const blocked = phases.filter((p) => p.status === 'blocked'); // Priority 1: Blocked phases need attention for (const phase of blocked.sort((a, b) => this.comparePriority(a, b))) { if (actions.length >= limit) break; actions.push({ phaseId: phase.id, phaseTitle: phase.title, phasePath: phase.path, action: 'unblock', reason: 'Phase is blocked and needs resolution', priority: 'high', }); } // Priority 2: In-progress phases near completion for (const phase of inProgress.sort((a, b) => { const progressDiff = b.progress - a.progress; if (progressDiff !== 0) return progressDiff; return this.comparePriority(a, b); })) { if (actions.length >= limit) break; if (phase.progress >= PROGRESS_NEAR_COMPLETE) { actions.push({ phaseId: phase.id, phaseTitle: phase.title, phasePath: phase.path, action: 'complete', reason: `Phase is ${String(phase.progress)}% complete`, priority: 'medium', }); } else { actions.push({ phaseId: phase.id, phaseTitle: phase.title, phasePath: phase.path, action: 'continue', reason: `Phase is ${String(phase.progress)}% complete`, priority: 'medium', }); } } // Priority 3: Planned phases ready to start (no blocking dependencies) const readyToStart = planned .filter((p) => { // A phase is ready if it has no parent or parent is completed if (p.parentId === null || p.parentId === '') return true; const parent = phases.find((x) => x.id === p.parentId); return !parent || parent.status === 'completed' || parent.status === 'in_progress'; }) .sort((a, b) => { const prioDiff = this.comparePriority(a, b); if (prioDiff !== 0) return prioDiff; return a.path.localeCompare(b.path); }); for (const phase of readyToStart) { if (actions.length >= limit) break; actions.push({ phaseId: phase.id, phaseTitle: phase.title, phasePath: phase.path, action: 'start', reason: 'Phase is ready to begin', priority: 'low', }); } return { actions, summary: { totalPending: planned.length, totalInProgress: inProgress.length, totalBlocked: blocked.length, }, }; } public async completeAndAdvance(input: CompleteAndAdvanceInput): Promise<CompleteAndAdvanceResult> { await this.ensurePlanExists(input.planId); const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const phases = await repo.findAll(); const currentPhase = phases.find((p) => p.id === input.phaseId); if (!currentPhase) { throw new Error('Phase not found'); } const now = new Date().toISOString(); // Validate current phase status if (currentPhase.status === 'completed') { throw new Error('Phase is already completed'); } if (currentPhase.status === 'skipped') { throw new Error('Cannot complete skipped phase'); } if (currentPhase.status === 'blocked') { throw new Error('Cannot complete blocked phase. Unblock it first.'); } // Complete current phase currentPhase.status = 'completed'; currentPhase.progress = PROGRESS_COMPLETE; currentPhase.completedAt = now; if (input.actualEffort !== undefined) { currentPhase.schedule.actualEffort = input.actualEffort; } if (input.notes !== undefined && input.notes !== '') { currentPhase.metadata.annotations.push({ id: uuidv4(), text: input.notes, author: 'claude-code', createdAt: now, }); } // Find and start next planned phase const nextPhase = this.findNextPlannedPhase(currentPhase, phases); if (nextPhase) { nextPhase.status = 'in_progress'; nextPhase.startedAt = now; } // FIX #12: Don't manually increment version - FileRepository.update() does it automatically await repo.update(currentPhase.id, currentPhase); if (nextPhase) { await repo.update(nextPhase.id, nextPhase); } await this.planService.updateStatistics(input.planId); return { completedPhaseId: currentPhase.id, nextPhaseId: nextPhase?.id ?? null, success: true as const, }; } /** * Finds the next planned phase following hierarchical traversal: * 1. Check for planned children (depth-first) * 2. Check for next planned sibling * 3. Recursively check parent's next sibling (move up) */ private findNextPlannedPhase(currentPhase: Phase, allPhases: Phase[]): Phase | null { // 1. Check for planned children (depth-first) const children = allPhases .filter((p) => p.parentId === currentPhase.id && p.status === 'planned') .sort((a, b) => a.order - b.order); if (children.length > 0) { return children[0]; } // 2. Check for next planned sibling const siblings = allPhases .filter( (p) => p.parentId === currentPhase.parentId && p.order > currentPhase.order && p.status === 'planned' ) .sort((a, b) => a.order - b.order); if (siblings.length > 0) { return siblings[0]; } // 3. Move up to parent and recursively find its next sibling if (currentPhase.parentId !== null && currentPhase.parentId !== '') { const parent = allPhases.find((p) => p.id === currentPhase.parentId); if (parent) { return this.findNextPlannedPhase(parent, allPhases); } } // 4. No next phase found return null; } /** * Sprint 5: Array Field Operations * Validate that field is a valid array field for Phase */ private validateArrayField(field: string): asserts field is PhaseArrayField { const validFields: PhaseArrayField[] = ['objectives', 'deliverables', 'successCriteria']; if (!validFields.includes(field as PhaseArrayField)) { throw new Error(`Field ${field} is not a valid array field. Valid fields: ${validFields.join(', ')}`); } } /** * Execute an array operation with common load/save logic * @param planId - Plan identifier * @param phaseId - Phase identifier * @param field - Array field to modify * @param operation - Function that transforms the current array to new array * @returns Operation result with success status and new array length */ private async executeArrayOperation( planId: string, phaseId: string, field: PhaseArrayField, operation: (currentArray: string[]) => string[] ): Promise<ArrayOperationResult> { await this.ensurePlanExists(planId); const repo = this.repositoryFactory.createRepository<Phase>('phase', planId); const phases = await repo.findAll(); const phase = phases.find((p) => p.id === phaseId); if (!phase) { throw new Error('Phase not found'); } const currentArray = phase[field]; const newArray = operation(currentArray); phase[field] = newArray; // FIX #12: Don't manually increment version - FileRepository.update() does it automatically await repo.update(phase.id, phase); return { success: true, field, newLength: newArray.length, }; } /** * Append item to end of array field */ public async arrayAppend(input: ArrayAppendInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.phaseId, input.field, (currentArray) => [...currentArray, input.value] ); } /** * Prepend item to beginning of array field */ public async arrayPrepend(input: ArrayPrependInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.phaseId, input.field, (currentArray) => [input.value, ...currentArray] ); } /** * Insert item at specific index in array field */ public async arrayInsertAt(input: ArrayInsertAtInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.phaseId, input.field, (currentArray) => { if (input.index < 0 || input.index > currentArray.length) { throw new Error(`Index ${String(input.index)} is out of bounds for array of length ${String(currentArray.length)}`); } const newArray = [...currentArray]; newArray.splice(input.index, 0, input.value); return newArray; } ); } /** * Update item at specific index in array field */ public async arrayUpdateAt(input: ArrayUpdateAtInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.phaseId, input.field, (currentArray) => { if (input.index < 0 || input.index >= currentArray.length) { throw new Error(`Index ${String(input.index)} is out of bounds for array of length ${String(currentArray.length)}`); } const newArray = [...currentArray]; newArray[input.index] = input.value; return newArray; } ); } /** * Remove item at specific index in array field */ public async arrayRemoveAt(input: ArrayRemoveAtInput): Promise<ArrayOperationResult> { this.validateArrayField(input.field); return this.executeArrayOperation( input.planId, input.phaseId, input.field, (currentArray) => { if (input.index < 0 || input.index >= currentArray.length) { throw new Error(`Index ${String(input.index)} is out of bounds for array of length ${String(currentArray.length)}`); } const newArray = [...currentArray]; newArray.splice(input.index, 1); return newArray; } ); } /** * Sprint 7: Get version history */ public async getHistory(input: { planId: string; phaseId: string; limit?: number; offset?: number }): Promise<VersionHistory<Phase>> { if (!this.versionHistoryService) { throw new Error('Version history service not available'); } const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } const history = await this.versionHistoryService.getHistory({ planId: input.planId, entityId: input.phaseId, entityType: 'phase', limit: input.limit, offset: input.offset, }); return history as VersionHistory<Phase>; } /** * Sprint 7: Compare two versions */ public async diff(input: { planId: string; phaseId: string; version1: number; version2: number }): Promise<VersionDiff> { if (!this.versionHistoryService) { throw new Error('Version history service not available'); } const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); const current = await repo.findById(input.phaseId); return this.versionHistoryService.diff({ planId: input.planId, entityId: input.phaseId, entityType: 'phase', version1: input.version1, version2: input.version2, currentEntityData: current, currentVersion: current.version, }); } /** * Sprint 9: Bulk update multiple phases in one call * REFACTOR: Uses common bulkUpdateEntities utility */ public async bulkUpdatePhases(input: BulkUpdatePhasesInput): Promise<BulkUpdatePhasesResult> { const repo = this.repositoryFactory.createRepository<Phase>('phase', input.planId); // Create storage adapter for bulkUpdateEntities utility const storageAdapter = { loadEntities: (_planId: string, _entityType: string): Promise<Record<string, unknown>[]> => { return repo.findAll() as unknown as Promise<Record<string, unknown>[]>; }, saveEntities: async (_planId: string, _entityType: string, entities: Record<string, unknown>[]): Promise<void> => { const phases = entities as unknown as Phase[]; // For atomic rollback, we need to restore all entities // Clear and recreate - this is the rollback scenario const current = await repo.findAll(); const currentIds = new Set(current.map(e => e.id)); const newIds = new Set(phases.map(e => e.id)); // Delete entities that no longer exist for (const id of currentIds) { if (!newIds.has(id)) { await repo.delete(id); } } // Update/create entities for (const phase of phases) { if (currentIds.has(phase.id)) { // Direct save to bypass version check during rollback await repo.update(phase.id, phase); } else { await repo.create(phase); } } }, }; return bulkUpdateEntities<'phaseId'>({ entityType: 'phases', entityIdField: 'phaseId', updateFn: (phaseId, updates) => // eslint-disable-next-line @typescript-eslint/no-empty-function -- return void from updateFn this.updatePhase({ planId: input.planId, phaseId, updates }).then(() => {}), planId: input.planId, updates: input.updates, atomic: input.atomic, storage: storageAdapter, }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server