/** * RoadmapStore - Data layer for standalone roadmap persistence. * * Manages CRUD operations for roadmaps, milestones, and features. * Provides deterministic ordering via covering indexes and atomic reorder/move operations. * * Ordering invariants: * - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based * - feature ordering is scoped to a single milestone and must be contiguous + 0-based * - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC * - cross-milestone feature moves atomically renumber both affected milestone scopes */ import { EventEmitter } from "node:events"; import type { Database } from "./db.js"; import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapCreateInput, RoadmapUpdateInput, RoadmapMilestoneCreateInput, RoadmapMilestoneUpdateInput, RoadmapFeatureCreateInput, RoadmapFeatureUpdateInput, RoadmapMilestoneReorderInput, RoadmapFeatureReorderInput, RoadmapFeatureMoveInput, RoadmapMilestoneWithFeatures, RoadmapWithHierarchy, RoadmapExportBundle, RoadmapMissionPlanningHandoff, RoadmapFeatureTaskPlanningHandoff, RoadmapFeatureSourceRef, } from "./roadmap-types.js"; import { applyRoadmapMilestoneReorder, applyRoadmapFeatureReorder, moveRoadmapFeature, } from "./roadmap-ordering.js"; // ── Event Types ───────────────────────────────────────────────────── export interface RoadmapStoreEvents { /** Emitted when a roadmap is created */ "roadmap:created": [Roadmap]; /** Emitted when a roadmap is updated */ "roadmap:updated": [Roadmap]; /** Emitted when a roadmap is deleted */ "roadmap:deleted": [string]; /** Emitted when a milestone is created */ "milestone:created": [RoadmapMilestone]; /** Emitted when a milestone is updated */ "milestone:updated": [RoadmapMilestone]; /** Emitted when a milestone is deleted */ "milestone:deleted": [string]; /** Emitted when a milestone is reordered */ "milestone:reordered": [{ roadmapId: string; milestones: RoadmapMilestone[] }]; /** Emitted when a feature is created */ "feature:created": [RoadmapFeature]; /** Emitted when a feature is updated */ "feature:updated": [RoadmapFeature]; /** Emitted when a feature is deleted */ "feature:deleted": [RoadmapFeature]; /** Emitted when features are reordered within a milestone */ "feature:reordered": [{ milestoneId: string; features: RoadmapFeature[] }]; /** Emitted when a feature is moved (including cross-milestone moves) */ "feature:moved": [{ feature: RoadmapFeature; fromMilestoneId: string; toMilestoneId: string }]; } // ── Row Interfaces ────────────────────────────────────────────────── /** Database row shape for roadmaps. */ interface RoadmapRow { id: string; title: string; description: string | null; createdAt: string; updatedAt: string; } /** Database row shape for roadmap_milestones. */ interface RoadmapMilestoneRow { id: string; roadmapId: string; title: string; description: string | null; orderIndex: number; createdAt: string; updatedAt: string; } /** Database row shape for roadmap_features. */ interface RoadmapFeatureRow { id: string; milestoneId: string; title: string; description: string | null; orderIndex: number; createdAt: string; updatedAt: string; } // ── RoadmapStore Class ────────────────────────────────────────────── export class RoadmapStore extends EventEmitter { /** * Creates a new RoadmapStore instance. * * @param db - Shared Database instance (same instance used by TaskStore) */ constructor(private db: Database) { super(); this.setMaxListeners(50); } // ── ID Generators ─────────────────────────────────────────────────── private generateRoadmapId(): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 6).toUpperCase(); return `RM-${timestamp.toString(36).toUpperCase()}-${random}`; } private generateMilestoneId(): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 6).toUpperCase(); return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`; } private generateFeatureId(): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 6).toUpperCase(); return `RF-${timestamp.toString(36).toUpperCase()}-${random}`; } // ── Row-to-Object Converters ─────────────────────────────────────── private rowToRoadmap(row: RoadmapRow): Roadmap { return { id: row.id, title: row.title, description: row.description || undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } private rowToMilestone(row: RoadmapMilestoneRow): RoadmapMilestone { return { id: row.id, roadmapId: row.roadmapId, title: row.title, description: row.description || undefined, orderIndex: row.orderIndex, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } private rowToFeature(row: RoadmapFeatureRow): RoadmapFeature { return { id: row.id, milestoneId: row.milestoneId, title: row.title, description: row.description || undefined, orderIndex: row.orderIndex, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } // ── Roadmap CRUD ───────────────────────────────────────────────── /** * Create a new roadmap. * * @param input - Roadmap creation input * @returns The created roadmap */ createRoadmap(input: RoadmapCreateInput): Roadmap { const now = new Date().toISOString(); const id = this.generateRoadmapId(); const roadmap: Roadmap = { id, title: input.title, description: input.description, createdAt: now, updatedAt: now, }; this.db.prepare(` INSERT INTO roadmaps (id, title, description, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?) `).run( roadmap.id, roadmap.title, roadmap.description ?? null, roadmap.createdAt, roadmap.updatedAt, ); this.db.bumpLastModified(); this.emit("roadmap:created", roadmap); return roadmap; } /** * Get a roadmap by ID. * * @param id - Roadmap ID * @returns The roadmap, or undefined if not found */ getRoadmap(id: string): Roadmap | undefined { const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id) as unknown as RoadmapRow | undefined; if (!row) return undefined; return this.rowToRoadmap(row); } /** * List all roadmaps, ordered by creation date (newest first). * * @returns Array of roadmaps */ listRoadmaps(): Roadmap[] { const rows = this.db.prepare( "SELECT * FROM roadmaps ORDER BY createdAt DESC" ).all(); return (rows as unknown as RoadmapRow[]).map((row) => this.rowToRoadmap(row)); } /** * Update a roadmap. * * @param id - Roadmap ID * @param updates - Partial roadmap updates * @returns The updated roadmap * @throws Error if roadmap not found */ updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap { const roadmap = this.getRoadmap(id); if (!roadmap) { throw new Error(`Roadmap ${id} not found`); } const updated: Roadmap = { ...roadmap, ...updates, id, // Prevent changing ID createdAt: roadmap.createdAt, // Prevent changing creation time updatedAt: new Date().toISOString(), }; this.db.prepare(` UPDATE roadmaps SET title = ?, description = ?, updatedAt = ? WHERE id = ? `).run( updated.title, updated.description ?? null, updated.updatedAt, updated.id, ); this.db.bumpLastModified(); this.emit("roadmap:updated", updated); return updated; } /** * Delete a roadmap and all its milestones/features (cascading). * * @param id - Roadmap ID * @throws Error if roadmap not found */ deleteRoadmap(id: string): void { const roadmap = this.getRoadmap(id); if (!roadmap) { throw new Error(`Roadmap ${id} not found`); } // SQLite FK cascade will handle milestones and features this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id); this.db.bumpLastModified(); this.emit("roadmap:deleted", id); } // ── Milestone CRUD ──────────────────────────────────────────────── /** * Add a milestone to a roadmap. * Automatically computes the orderIndex (max + 1). * * @param roadmapId - Parent roadmap ID * @param input - Milestone creation input * @returns The created milestone * @throws Error if roadmap not found */ createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone { const roadmap = this.getRoadmap(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const now = new Date().toISOString(); const id = this.generateMilestoneId(); // Compute next orderIndex const existingMilestones = this.listMilestones(roadmapId); const orderIndex = existingMilestones.length > 0 ? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1 : 0; const milestone: RoadmapMilestone = { id, roadmapId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now, }; this.db.prepare(` INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( milestone.id, milestone.roadmapId, milestone.title, milestone.description ?? null, milestone.orderIndex, milestone.createdAt, milestone.updatedAt, ); this.db.bumpLastModified(); this.emit("milestone:created", milestone); return milestone; } /** * Get a milestone by ID. * * @param id - Milestone ID * @returns The milestone, or undefined if not found */ getMilestone(id: string): RoadmapMilestone | undefined { const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id) as unknown as RoadmapMilestoneRow | undefined; if (!row) return undefined; return this.rowToMilestone(row); } /** * List milestones for a roadmap, ordered deterministically. * * Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC * to ensure consistent results when stored order data is incomplete or conflicting. * * @param roadmapId - Roadmap ID * @returns Array of milestones in deterministic order */ listMilestones(roadmapId: string): RoadmapMilestone[] { const rows = this.db.prepare( "SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC" ).all(roadmapId); return (rows as unknown as RoadmapMilestoneRow[]).map((row) => this.rowToMilestone(row)); } /** * Update a milestone. * * @param id - Milestone ID * @param updates - Partial milestone updates * @returns The updated milestone * @throws Error if milestone not found */ updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone { const milestone = this.getMilestone(id); if (!milestone) { throw new Error(`Milestone ${id} not found`); } const updated: RoadmapMilestone = { ...milestone, ...updates, id, // Prevent changing ID roadmapId: milestone.roadmapId, // Prevent moving to different roadmap createdAt: milestone.createdAt, // Prevent changing creation time updatedAt: new Date().toISOString(), }; this.db.prepare(` UPDATE roadmap_milestones SET title = ?, description = ?, updatedAt = ? WHERE id = ? `).run( updated.title, updated.description ?? null, updated.updatedAt, updated.id, ); this.db.bumpLastModified(); this.emit("milestone:updated", updated); return updated; } /** * Delete a milestone and all its features (cascading). * * @param id - Milestone ID * @throws Error if milestone not found */ deleteMilestone(id: string): void { const milestone = this.getMilestone(id); if (!milestone) { throw new Error(`Milestone ${id} not found`); } // SQLite FK cascade will handle features this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id); this.db.bumpLastModified(); this.emit("milestone:deleted", id); } // ── Feature CRUD ───────────────────────────────────────────────── /** * Add a feature to a milestone. * Automatically computes the orderIndex (max + 1). * * @param milestoneId - Parent milestone ID * @param input - Feature creation input * @returns The created feature * @throws Error if milestone not found */ createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature { const milestone = this.getMilestone(milestoneId); if (!milestone) { throw new Error(`Milestone ${milestoneId} not found`); } const now = new Date().toISOString(); const id = this.generateFeatureId(); // Compute next orderIndex const existingFeatures = this.listFeatures(milestoneId); const orderIndex = existingFeatures.length > 0 ? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1 : 0; const feature: RoadmapFeature = { id, milestoneId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now, }; this.db.prepare(` INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( feature.id, feature.milestoneId, feature.title, feature.description ?? null, feature.orderIndex, feature.createdAt, feature.updatedAt, ); this.db.bumpLastModified(); this.emit("feature:created", feature); return feature; } /** * Get a feature by ID. * * @param id - Feature ID * @returns The feature, or undefined if not found */ getFeature(id: string): RoadmapFeature | undefined { const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id) as unknown as RoadmapFeatureRow | undefined; if (!row) return undefined; return this.rowToFeature(row); } /** * List features for a milestone, ordered deterministically. * * Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC * to ensure consistent results when stored order data is incomplete or conflicting. * * @param milestoneId - Milestone ID * @returns Array of features in deterministic order */ listFeatures(milestoneId: string): RoadmapFeature[] { const rows = this.db.prepare( "SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC" ).all(milestoneId); return (rows as unknown as RoadmapFeatureRow[]).map((row) => this.rowToFeature(row)); } /** * Update a feature. * * @param id - Feature ID * @param updates - Partial feature updates * @returns The updated feature * @throws Error if feature not found */ updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature { const feature = this.getFeature(id); if (!feature) { throw new Error(`Feature ${id} not found`); } const updated: RoadmapFeature = { ...feature, ...updates, id, // Prevent changing ID milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead) createdAt: feature.createdAt, // Prevent changing creation time updatedAt: new Date().toISOString(), }; this.db.prepare(` UPDATE roadmap_features SET title = ?, description = ?, updatedAt = ? WHERE id = ? `).run( updated.title, updated.description ?? null, updated.updatedAt, updated.id, ); this.db.bumpLastModified(); this.emit("feature:updated", updated); return updated; } /** * Delete a feature. * * @param id - Feature ID * @throws Error if feature not found */ deleteFeature(id: string): void { const feature = this.getFeature(id); if (!feature) { throw new Error(`Feature ${id} not found`); } this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id); this.db.bumpLastModified(); this.emit("feature:deleted", feature); } // ── Reorder Operations ──────────────────────────────────────────── /** * Reorder milestones within a roadmap. * * Applies an explicit reorder input and persists the full normalized order. * The input must contain all milestone IDs exactly once. * * @param input - Reorder input with complete milestone ID list * @returns The reordered milestones in their new order * @throws Error if milestone set is incomplete, duplicate, or not found */ reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[] { // Validate roadmap exists const roadmap = this.getRoadmap(input.roadmapId); if (!roadmap) { throw new Error(`Roadmap ${input.roadmapId} not found`); } // Load current milestones with deterministic ordering const milestones = this.listMilestones(input.roadmapId); // Apply the reorder using the pure ordering helper const reordered = applyRoadmapMilestoneReorder(milestones, input); // Persist in a transaction this.db.transaction(() => { for (const milestone of reordered) { this.db.prepare(` UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ? `).run(milestone.orderIndex, new Date().toISOString(), milestone.id); } }); this.db.bumpLastModified(); this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered }); return reordered; } /** * Reorder features within a milestone. * * Applies an explicit reorder input and persists the full normalized order. * The input must contain all feature IDs for the milestone exactly once. * * @param input - Reorder input with complete feature ID list * @returns The reordered features in their new order * @throws Error if feature set is incomplete, duplicate, or not found */ reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[] { // Validate milestone exists and belongs to the roadmap const milestone = this.getMilestone(input.milestoneId); if (!milestone) { throw new Error(`Milestone ${input.milestoneId} not found`); } if (milestone.roadmapId !== input.roadmapId) { throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`); } // Load current features with deterministic ordering const features = this.listFeatures(input.milestoneId); // Apply the reorder using the pure ordering helper const reordered = applyRoadmapFeatureReorder(features, input); // Persist in a transaction this.db.transaction(() => { for (const feature of reordered) { this.db.prepare(` UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ? `).run(feature.orderIndex, new Date().toISOString(), feature.id); } }); this.db.bumpLastModified(); this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered }); return reordered; } /** * Move a feature, including cross-milestone moves. * * Atomically renumbers both the source and destination milestone scopes. * * @param input - Move input with source/destination milestone info * @returns The moved feature and both affected milestone feature lists * @throws Error if feature or milestone not found, or scope validation fails */ moveFeature(input: RoadmapFeatureMoveInput): { movedFeature: RoadmapFeature; sourceMilestoneFeatures: RoadmapFeature[]; targetMilestoneFeatures: RoadmapFeature[]; } { // Validate roadmap exists const roadmap = this.getRoadmap(input.roadmapId); if (!roadmap) { throw new Error(`Roadmap ${input.roadmapId} not found`); } // Validate both milestones exist and belong to the roadmap const fromMilestone = this.getMilestone(input.fromMilestoneId); const toMilestone = this.getMilestone(input.toMilestoneId); if (!fromMilestone) { throw new Error(`Source milestone ${input.fromMilestoneId} not found`); } if (!toMilestone) { throw new Error(`Destination milestone ${input.toMilestoneId} not found`); } if (fromMilestone.roadmapId !== input.roadmapId) { throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`); } if (toMilestone.roadmapId !== input.roadmapId) { throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`); } // Load features from both milestones with deterministic ordering const sourceFeatures = this.listFeatures(input.fromMilestoneId); const targetFeatures = this.listFeatures(input.toMilestoneId); // For same-milestone moves, pass only one list to avoid duplication // For cross-milestone moves, pass the combined list const allFeatures = input.fromMilestoneId === input.toMilestoneId ? sourceFeatures : [...sourceFeatures, ...targetFeatures]; // Apply the move using the pure ordering helper const result = moveRoadmapFeature(allFeatures, input); // Persist in a transaction this.db.transaction(() => { // Update all affected features for (const feature of result.affectedFeatures) { this.db.prepare(` UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ? `).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id); } }); this.db.bumpLastModified(); this.emit("feature:moved", { feature: result.movedFeature, fromMilestoneId: input.fromMilestoneId, toMilestoneId: input.toMilestoneId, }); return { movedFeature: result.movedFeature, sourceMilestoneFeatures: result.sourceMilestoneFeatures, targetMilestoneFeatures: result.targetMilestoneFeatures, }; } // ── Hierarchy Operations ─────────────────────────────────────────── /** * Get a milestone with all of its features in deterministic order. * * @param id - Milestone ID * @returns The milestone with features, or undefined if not found */ getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined { const milestone = this.getMilestone(id); if (!milestone) return undefined; return { ...milestone, features: this.listFeatures(id), }; } /** * Get a roadmap with its full hierarchy (milestones → features). * * @param id - Roadmap ID * @returns The roadmap with hierarchy, or undefined if not found */ getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined { const roadmap = this.getRoadmap(id); if (!roadmap) return undefined; return { ...roadmap, milestones: this.listMilestones(id).map((milestone) => ({ ...milestone, features: this.listFeatures(milestone.id), })), }; } // ── Export / Handoff Operations ──────────────────────────────────── /** * Get a flat export bundle for a roadmap. * * Returns all roadmap data in a flat structure suitable for persistence, * APIs, import/export, and sync jobs. Entities are separated so downstream * persistence layers can upsert by table/collection. * * @param roadmapId - Roadmap ID * @returns The export bundle with ordered entities * @throws Error if roadmap not found */ getRoadmapExport(roadmapId: string): RoadmapExportBundle { const roadmap = this.getRoadmap(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const milestones = this.listMilestones(roadmapId); const allFeatures: RoadmapFeature[] = []; for (const milestone of milestones) { const features = this.listFeatures(milestone.id); allFeatures.push(...features); } return { roadmap, milestones, features: allFeatures, }; } /** * Get a mission planning handoff payload for a roadmap. * * Converts the roadmap into a mission planning structure while preserving * source IDs and deterministic order. Does not couple to MissionStore internals. * * @param roadmapId - Roadmap ID * @returns The mission planning handoff payload * @throws Error if roadmap not found */ getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff { const roadmap = this.getRoadmap(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const milestones = this.listMilestones(roadmapId); return { sourceRoadmapId: roadmap.id, title: roadmap.title, description: roadmap.description, milestones: milestones.map((milestone) => { const features = this.listFeatures(milestone.id); return { sourceMilestoneId: milestone.id, title: milestone.title, description: milestone.description, orderIndex: milestone.orderIndex, features: features.map((feature) => ({ sourceFeatureId: feature.id, title: feature.title, description: feature.description, orderIndex: feature.orderIndex, })), }; }), }; } /** * Get a task planning handoff payload for a single roadmap feature. * * Returns a self-contained handoff payload for converting a roadmap feature * into task planning flows without coupling to MissionStore internals. * * @param roadmapId - Parent roadmap ID (for validation) * @param milestoneId - Parent milestone ID (for validation) * @param featureId - Feature ID to generate handoff for * @returns The task planning handoff payload * @throws Error if any entity is not found or if ownership validation fails */ getRoadmapFeatureHandoff( roadmapId: string, milestoneId: string, featureId: string, ): RoadmapFeatureTaskPlanningHandoff { // Validate roadmap exists const roadmap = this.getRoadmap(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } // Validate milestone exists and belongs to roadmap const milestone = this.getMilestone(milestoneId); if (!milestone) { throw new Error(`Milestone ${milestoneId} not found`); } if (milestone.roadmapId !== roadmapId) { throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`); } // Validate feature exists and belongs to milestone const feature = this.getFeature(featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } if (feature.milestoneId !== milestoneId) { throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`); } // Build the source reference with ordering context const source: RoadmapFeatureSourceRef = { roadmapId: roadmap.id, milestoneId: milestone.id, featureId: feature.id, roadmapTitle: roadmap.title, milestoneTitle: milestone.title, milestoneOrderIndex: milestone.orderIndex, featureOrderIndex: feature.orderIndex, }; return { source, title: feature.title, description: feature.description, }; } /** * Get a mission planning handoff payload for a roadmap. * * Alias for getRoadmapMissionHandoff() for API consistency. * Converts the roadmap into a mission planning structure while preserving * source IDs and deterministic order. * * @param roadmapId - Roadmap ID * @returns The mission planning handoff payload * @throws Error if roadmap not found */ getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff { return this.getRoadmapMissionHandoff(roadmapId); } /** * List all task planning handoff payloads for a roadmap. * * Returns a flat list of all feature handoffs in deterministic order * (milestone order index, then feature order index). * * @param roadmapId - Roadmap ID * @returns Array of task planning handoff payloads for all features * @throws Error if roadmap not found */ listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[] { // Validate roadmap exists const roadmap = this.getRoadmap(roadmapId); if (!roadmap) { throw new Error(`Roadmap ${roadmapId} not found`); } const milestones = this.listMilestones(roadmapId); const handoffs: RoadmapFeatureTaskPlanningHandoff[] = []; for (const milestone of milestones) { const features = this.listFeatures(milestone.id); for (const feature of features) { const source: RoadmapFeatureSourceRef = { roadmapId: roadmap.id, milestoneId: milestone.id, featureId: feature.id, roadmapTitle: roadmap.title, milestoneTitle: milestone.title, milestoneOrderIndex: milestone.orderIndex, featureOrderIndex: feature.orderIndex, }; handoffs.push({ source, title: feature.title, description: feature.description, }); } } return handoffs; } }