Files
16gagent/research/pr-37/pr-37-delivery-planning/sprint-planner.mjs
T
2026-06-06 10:40:48 +08:00

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 };