/** * PR-37 Delivery Planning — Sprint Planner (Part F) * * Consumes SF-02 output (epics, features, userStories, dependency graph, * priority matrix, MVP scope) and generates a sprint plan. * * Algorithm: * 1. Filter Must Have stories from priority matrix * 2. Topological sort by dependency graph * 3. Assign stories to sprints based on capacity + dependencies + priority * 4. Compute sprint-level critical path and risks * 5. Aggregate sprint metrics * * @module pr-37-delivery-planning/sprint-planner * @since PR-37 */ /** * Default sprint planning configuration. * * @param {object} [cfg] * @param {number} [cfg.sprintDurationWeeks=2] — Length of each sprint in weeks * @param {number} [cfg.teamVelocity=20] — Team velocity in story points per sprint * @param {number} [cfg.maxStoryPointsPerSprint=25] — Hard cap on story points per sprint * @param {number} [cfg.teamSize=5] — Team size (informational) * @returns {object} Normalized config */ export function defaultSprintConfig(cfg = {}) { return { sprintDurationWeeks: cfg.sprintDurationWeeks ?? 2, teamVelocity: cfg.teamVelocity ?? 20, maxStoryPointsPerSprint: cfg.maxStoryPointsPerSprint ?? 25, teamSize: cfg.teamSize ?? 5, }; } /** * Build a dependency map { storyId → set of dependency storyIds }. * * @param {object[]} stories — User stories with optional .dependencies array * @param {object} graph — Dependency graph { nodes, edges } from SF-02 * @returns {Map>} */ export function buildDependencyMap(stories, graph) { const depMap = new Map(); // Initialize every story with an empty set for (const s of stories) { if (!depMap.has(s.id)) depMap.set(s.id, new Set()); } // Process explicit story .dependencies arrays for (const s of stories) { if (s.dependencies && Array.isArray(s.dependencies)) { const deps = depMap.get(s.id) || new Set(); for (const depId of s.dependencies) { deps.add(depId); } depMap.set(s.id, deps); } } // Process edges from dependency graph (story → story edges) // In SF-02 edge semantics: edge.from depends on edge.to // (from = the thing that REQUIRES, to = the PREREQUISITE) if (graph && graph.edges) { for (const edge of graph.edges) { // edge.from depends on edge.to → add edge.to as a dependency of edge.from const deps = depMap.get(edge.from) || new Set(); deps.add(edge.to); depMap.set(edge.from, deps); } } return depMap; } /** * Compute dependency levels (L0 = no deps, L1 = 1 level deep, etc.) * using Kahn's algorithm topological sort but also computing depth. * * @param {string[]} storyIds — Stories to sort * @param {Map>} depMap — Dependency map * @returns {string[]} Topologically sorted story IDs */ export function topologicalSort(storyIds, depMap) { const sorted = []; const visited = new Set(); const visiting = new Set(); function visit(id) { if (visited.has(id)) return; if (visiting.has(id)) { // Circular dependency detected; resolve by including anyway return; } visiting.add(id); const deps = depMap.get(id); if (deps) { for (const depId of deps) { visit(depId); } } visiting.delete(id); visited.add(id); sorted.push(id); } for (const id of storyIds) { if (!visited.has(id)) visit(id); } return sorted; } /** * Compute dependency level (0 = no unresolved upstream deps) for each story. * * @param {string[]} sortedIds — Topologically sorted IDs * @param {Map>} depMap * @returns {Map} storyId → dependency level */ export function computeDependencyLevels(sortedIds, depMap) { const levels = new Map(); for (const id of sortedIds) { const deps = depMap.get(id); if (!deps || deps.size === 0) { levels.set(id, 0); } else { let maxDepLevel = 0; for (const depId of deps) { const dl = levels.get(depId); if (dl !== undefined && dl + 1 > maxDepLevel) { maxDepLevel = dl + 1; } } levels.set(id, maxDepLevel); } } return levels; } /** * Find the longest dependency chain within a set of story IDs. * * @param {string[]} storyIds — IDs in a sprint * @param {Map>} depMap — Full dependency map * @param {Map} depLevels — Computed dependency levels * @returns {string[]} Chain of story IDs (longest path found) */ export function findCriticalPath(storyIds, depMap, depLevels) { if (storyIds.length === 0) return []; // Build reverse map: depId → set of dependents (in this sprint) const dependents = new Map(); const idSet = new Set(storyIds); for (const id of storyIds) { const deps = depMap.get(id); if (deps) { for (const depId of deps) { if (idSet.has(depId)) { if (!dependents.has(depId)) dependents.set(depId, []); dependents.get(depId).push(id); } } } } // Find root nodes (no deps in this sprint) and do DFS for longest path const roots = storyIds.filter(id => { const deps = depMap.get(id); return !deps || ![...deps].some(d => idSet.has(d)); }); let longestPath = []; function dfs(currentId, path) { const newPath = [...path, currentId]; const kids = dependents.get(currentId) || []; if (kids.length === 0) { if (newPath.length > longestPath.length) { longestPath = newPath; } return; } for (const kid of kids) { dfs(kid, newPath); } } for (const root of roots) { dfs(root, []); } return longestPath.length > 0 ? longestPath : storyIds.slice(0, 1); } /** * Identify risk stories — those with dependencies on stories outside this sprint * that aren't yet completed in earlier sprints. * * @param {string[]} sprintStoryIds — Stories assigned to this sprint * @param {Map>} depMap — Full dependency map * @param {Set} completedIds — Stories assigned to earlier sprints * @returns {string[]} Story IDs with unresolved external dependencies */ export function identifyRisks(sprintStoryIds, depMap, completedIds) { const risks = []; for (const id of sprintStoryIds) { const deps = depMap.get(id); if (deps) { for (const depId of deps) { if (!completedIds.has(depId)) { risks.push(id); break; } } } } return risks; } /** * Generate a full sprint plan from SF-02 output. * * @param {object} opts * @param {object[]} opts.stories — User stories (from SF-02 userStories) * @param {object} opts.dependencies — Dependency graph { nodes, edges } * @param {object[]} opts.priorities — Priority matrix entries (from SF-02) * @param {object} [opts.config] — Sprint planning config * @returns {object} Sprint plan * * @example * ```js * const plan = generateSprintPlan({ * stories: myStories, * dependencies: myGraph, * priorities: myPriorities, * config: { sprintDurationWeeks: 2, teamVelocity: 20, maxStoryPointsPerSprint: 25 } * }); * ``` */ export function generateSprintPlan({ stories, dependencies, priorities, config }) { const cfg = defaultSprintConfig(config); const warnings = []; const unplannedStories = []; // ── 1. Input validation ── if (!stories || stories.length === 0) { return { sprints: [], metrics: { totalSprints: 0, totalPoints: 0, avgPointsPerSprint: 0, estimatedDurationWeeks: 0 }, unplannedStories: [], warnings: ["No stories provided"], }; } const graph = dependencies && dependencies.graph ? dependencies.graph : (dependencies || { nodes: [], edges: [] }); // ── 2. Build story map ── const storyMap = new Map(); for (const s of stories) storyMap.set(s.id, s); // ── 3. Filter Must Have stories from priorities ── const priorityMap = new Map(); for (const p of (priorities || [])) { if (p.itemType === "story") { priorityMap.set(p.itemId, p.category); } } const mustHaveIds = new Set(); const shouldHaveIds = new Set(); const couldHaveIds = new Set(); for (const s of stories) { const cat = priorityMap.get(s.id) || s.priority || "Could Have"; if (cat === "Must Have") mustHaveIds.add(s.id); else if (cat === "Should Have") shouldHaveIds.add(s.id); else if (cat === "Could Have") couldHaveIds.add(s.id); } // ── 4. Build dependency map ── const allDepMap = buildDependencyMap(stories, graph); // ── 5. Topological sort Must Have stories ── const mustHaveList = [...mustHaveIds]; const sortedMustHave = topologicalSort(mustHaveList, allDepMap); // Also include Should Have and Could Have for later allocation const shouldHaveList = [...shouldHaveIds]; const couldHaveList = [...couldHaveIds]; const allSorted = [ ...sortedMustHave, ...topologicalSort(shouldHaveList, allDepMap).filter(id => !mustHaveIds.has(id)), ...topologicalSort(couldHaveList, allDepMap).filter(id => !mustHaveIds.has(id) && !shouldHaveIds.has(id)), ]; // ── 6. Compute dependency levels ── const depLevels = computeDependencyLevels(allSorted, allDepMap); // ── 7. Assign stories to sprints ── const sprints = []; const allocated = new Set(); const allocatedInSprints = new Set(); let sprintNumber = 0; // Sort all stories by: dependency level (ascending), then priority const queue = allSorted .filter(id => storyMap.has(id)) .sort((a, b) => { // Base sort: lower dep level first, then Must > Should > Could const levelA = depLevels.get(a) || 0; const levelB = depLevels.get(b) || 0; if (levelA !== levelB) return levelA - levelB; // Priority tiebreaker const priA = priorityMap.get(a) || "Could Have"; const priB = priorityMap.get(b) || "Could Have"; const priOrder = { "Must Have": 0, "Should Have": 1, "Could Have": 2 }; return (priOrder[priA] ?? 3) - (priOrder[priB] ?? 3); }); let idx = 0; while (idx < queue.length) { sprintNumber++; const sprintCapacity = Math.min(cfg.maxStoryPointsPerSprint, cfg.teamVelocity); const sprintStories = []; let sprintPoints = 0; while (idx < queue.length && sprintPoints < sprintCapacity) { const sid = queue[idx]; const story = storyMap.get(sid); if (!story) { idx++; continue; } const points = story.storyPoints || 1; // Check all dependencies are allocated to earlier sprints or this sprint const deps = allDepMap.get(sid); const allDepsResolved = !deps || [...deps].every(depId => allocatedInSprints.has(depId) || depId === sid); // Don't include stories whose deps aren't resolved yet if (!allDepsResolved) { idx++; continue; } // Check capacity if (sprintPoints + points > sprintCapacity) { // Try next story (could be smaller) idx++; continue; } sprintStories.push(sid); sprintPoints += points; allocated.add(sid); allocatedInSprints.add(sid); idx++; } if (sprintStories.length === 0) { // No stories fit in this sprint — break to avoid infinite loop break; } const sprintGoal = buildSprintGoal(sprintStories, stories, sprintNumber); // Determine epics and features covered const epicIds = new Set(); const featureIds = new Set(); for (const sid of sprintStories) { const st = storyMap.get(sid); if (st) { if (st.epicId) epicIds.add(st.epicId); if (st.featureId) featureIds.add(st.featureId); } } // Critical path within this sprint const criticalPath = findCriticalPath(sprintStories, allDepMap, depLevels); // Identify risks const completedIds = new Set(); for (let i = 0; i < sprints.length; i++) { for (const sid of sprints[i].storyIds) completedIds.add(sid); } const risks = identifyRisks(sprintStories, allDepMap, completedIds); const startWeek = 1 + (sprintNumber - 1) * cfg.sprintDurationWeeks; const endWeek = startWeek + cfg.sprintDurationWeeks - 1; sprints.push({ sprintNumber, sprintGoal, startDate: `Week ${startWeek}`, endDate: `Week ${endWeek}`, storyIds: sprintStories, totalStoryPoints: sprintPoints, epicIds: [...epicIds], featureIds: [...featureIds], criticalPath, risks, }); if (sprintPoints > cfg.teamVelocity) { warnings.push(`Sprint ${sprintNumber} exceeds team velocity (${sprintPoints} > ${cfg.teamVelocity} pts)`); } } // ── 8. Identify unplanned stories ── for (const id of allSorted) { if (!allocated.has(id) && storyMap.has(id)) { unplannedStories.push(id); } } // Also check for stories not in the sorted queue at all for (const s of stories) { if (!allocated.has(s.id) && !unplannedStories.includes(s.id)) { unplannedStories.push(s.id); } } // ── 9. Calculate metrics ── const totalPoints = sprints.reduce((acc, sp) => acc + sp.totalStoryPoints, 0); const totalSprints = sprints.length; const estimatedDurationWeeks = totalSprints * cfg.sprintDurationWeeks; return { sprints, metrics: { totalSprints, totalPoints, avgPointsPerSprint: totalSprints > 0 ? Math.round(totalPoints / totalSprints) : 0, estimatedDurationWeeks, }, unplannedStories, warnings, }; } /** * Build a human-readable sprint goal from the assigned stories. * * @param {string[]} storyIds — Stories in this sprint * @param {object[]} stories — All stories * @param {number} sprintNumber — Sprint number * @returns {string} Sprint goal description */ function buildSprintGoal(storyIds, stories, sprintNumber) { const storyMap = new Map(); for (const s of stories) storyMap.set(s.id, s); // Extract the most common goal themes const goals = []; const seen = new Set(); for (const id of storyIds) { const s = storyMap.get(id); if (s && s.goal) { const shortGoal = s.goal.length > 40 ? s.goal.slice(0, 40) + "…" : s.goal; if (!seen.has(shortGoal)) { seen.add(shortGoal); goals.push(shortGoal); } } } const topGoals = goals.slice(0, 3); return `Sprint ${sprintNumber}: ${topGoals.join(" / ")}`; } export default { generateSprintPlan, defaultSprintConfig, buildDependencyMap, topologicalSort };