474 lines
14 KiB
JavaScript
474 lines
14 KiB
JavaScript
/**
|
|
* 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<string, Set<string>>}
|
|
*/
|
|
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<string, Set<string>>} 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<string, Set<string>>} depMap
|
|
* @returns {Map<string, number>} 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<string, Set<string>>} depMap — Full dependency map
|
|
* @param {Map<string, number>} 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<string, Set<string>>} depMap — Full dependency map
|
|
* @param {Set<string>} 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 };
|