🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* PR-37 Delivery Planning — Batch Planner (Part H)
|
||||
*
|
||||
* Generates 4 batch types based on dependency analysis:
|
||||
* - DevelopmentBatch: Group stories by dependency level for parallel dev
|
||||
* - TestingBatch: Group completed dev batches ready for testing
|
||||
* - ReleaseBatch: Group features ready for staged deployment
|
||||
* - DeliveryBatch: Customer-facing delivery increments
|
||||
*
|
||||
* Each batch captures parallel groups, prerequisite batches, and story/feature/epic references.
|
||||
*
|
||||
* @module pr-37-delivery-planning/batch-planner
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
let _batchSeq = 0;
|
||||
|
||||
/**
|
||||
* Generate a complete batch plan from SF-02 output and sprint/release plans.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object[]} opts.stories — User stories (from SF-02)
|
||||
* @param {object} opts.dependencies — Dependency graph { nodes, edges }
|
||||
* @param {object[]} opts.priorities — Priority matrix entries
|
||||
* @param {object} opts.sprints — Sprint plan (from sprint-planner)
|
||||
* @param {object} opts.releases — Release plan (from release-planner)
|
||||
* @returns {object} Batch plan { batches, summary }
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const batchPlan = generateBatchPlan({
|
||||
* stories: myStories,
|
||||
* dependencies: myGraph,
|
||||
* priorities: myPriorities,
|
||||
* sprints: sprintPlan,
|
||||
* releases: releasePlan,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function generateBatchPlan({ stories, dependencies, priorities, sprints, releases }) {
|
||||
// ── Input validation ──
|
||||
if (!stories || stories.length === 0) {
|
||||
return {
|
||||
batches: [],
|
||||
summary: { totalBatches: 0, devBatches: 0, testBatches: 0, releaseBatches: 0, deliveryBatches: 0 },
|
||||
warnings: ["No stories provided"],
|
||||
};
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
const batches = [];
|
||||
|
||||
const graph = dependencies && dependencies.graph ? dependencies.graph : (dependencies || { nodes: [], edges: [] });
|
||||
const storyMap = new Map();
|
||||
for (const s of stories) storyMap.set(s.id, s);
|
||||
|
||||
// Build dependency map: storyId → set of dependency IDs
|
||||
const depMap = new Map();
|
||||
for (const s of stories) {
|
||||
depMap.set(s.id, new Set(s.dependencies || []));
|
||||
}
|
||||
for (const edge of (graph.edges || [])) {
|
||||
// edge.from depends on edge.to (SF-02 semantics: from REQUIRES to)
|
||||
if (!depMap.has(edge.from)) depMap.set(edge.from, new Set());
|
||||
depMap.get(edge.from).add(edge.to);
|
||||
}
|
||||
|
||||
// ── Compute dependency levels ──
|
||||
const depLevels = new Map();
|
||||
function computeLevel(id, visited = new Set()) {
|
||||
if (visited.has(id)) return 0;
|
||||
visited.add(id);
|
||||
const deps = depMap.get(id) || new Set();
|
||||
if (deps.size === 0) {
|
||||
depLevels.set(id, 0);
|
||||
return 0;
|
||||
}
|
||||
let max = 0;
|
||||
for (const depId of deps) {
|
||||
max = Math.max(max, computeLevel(depId, visited) + 1);
|
||||
}
|
||||
depLevels.set(id, max);
|
||||
return max;
|
||||
}
|
||||
for (const s of stories) {
|
||||
if (!depLevels.has(s.id)) computeLevel(s.id);
|
||||
}
|
||||
|
||||
// ── Priority map ──
|
||||
const priorityMap = new Map();
|
||||
for (const p of (priorities || [])) {
|
||||
if (p.itemType === "story") priorityMap.set(p.itemId, p.category);
|
||||
}
|
||||
|
||||
// ── 1. Development Batches ──
|
||||
// Group by dependency level
|
||||
const levelGroups = new Map(); // level → [storyIds]
|
||||
for (const s of stories) {
|
||||
const level = depLevels.get(s.id) ?? 0;
|
||||
// Bucket: L0, L1, L2, L3+
|
||||
const bucket = level >= 3 ? "L3" : `L${level}`;
|
||||
if (!levelGroups.has(bucket)) levelGroups.set(bucket, []);
|
||||
levelGroups.get(bucket).push(s.id);
|
||||
}
|
||||
|
||||
const levelOrder = ["L0", "L1", "L2", "L3"];
|
||||
for (const bucket of levelOrder) {
|
||||
const ids = levelGroups.get(bucket) || [];
|
||||
if (ids.length === 0) continue;
|
||||
|
||||
// Stories at same level can be developed in parallel — group them
|
||||
const batchId = `dev-batch-${++_batchSeq}`;
|
||||
const batchName = `Development ${bucket.replace("L", "Level ")}`;
|
||||
|
||||
// Collect feature and epic IDs
|
||||
const featureIds = new Set();
|
||||
const epicIds = new Set();
|
||||
for (const id of ids) {
|
||||
const s = storyMap.get(id);
|
||||
if (s) {
|
||||
if (s.featureId) featureIds.add(s.featureId);
|
||||
if (s.epicId) epicIds.add(s.epicId);
|
||||
}
|
||||
}
|
||||
|
||||
// Prerequisite: all lower-level batches
|
||||
const levelNum = parseInt(bucket.slice(1));
|
||||
const prerequisiteBatches = [];
|
||||
for (let l = 0; l < levelNum; l++) {
|
||||
const lowerBucket = `L${l}`;
|
||||
const lowerIds = levelGroups.get(lowerBucket);
|
||||
if (lowerIds && lowerIds.length > 0) {
|
||||
prerequisiteBatches.push(`dev-batch-${lowerBucket}`);
|
||||
}
|
||||
}
|
||||
|
||||
batches.push({
|
||||
batchId,
|
||||
batchType: "DevelopmentBatch",
|
||||
name: batchName,
|
||||
storyIds: ids,
|
||||
featureIds: [...featureIds],
|
||||
epicIds: [...epicIds],
|
||||
parallelGroups: groupForParallel(ids, depMap, depLevels, storyMap),
|
||||
prerequisiteBatches,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 2. Testing Batches ──
|
||||
// Group completed dev batches ready for testing (by sprint boundaries)
|
||||
if (sprints && sprints.sprints) {
|
||||
for (const sprint of sprints.sprints) {
|
||||
const testBatchId = `test-batch-${++_batchSeq}`;
|
||||
const batchName = `Testing Sprint ${sprint.sprintNumber}`;
|
||||
|
||||
const featureIds = new Set();
|
||||
const epicIds = new Set();
|
||||
for (const sid of (sprint.storyIds || [])) {
|
||||
const s = storyMap.get(sid);
|
||||
if (s) {
|
||||
if (s.featureId) featureIds.add(s.featureId);
|
||||
if (s.epicId) epicIds.add(s.epicId);
|
||||
}
|
||||
}
|
||||
|
||||
batches.push({
|
||||
batchId: testBatchId,
|
||||
batchType: "TestingBatch",
|
||||
name: batchName,
|
||||
storyIds: sprint.storyIds || [],
|
||||
featureIds: [...featureIds],
|
||||
epicIds: [...epicIds],
|
||||
parallelGroups: [sprint.storyIds || []], // All stories from a sprint can be tested together
|
||||
prerequisiteBatches: [`dev-batch-${batchName}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Release Batches ──
|
||||
// Group features ready for staged deployment (by release)
|
||||
if (releases && releases.releases) {
|
||||
for (const rel of releases.releases) {
|
||||
const releaseBatchId = `release-batch-${++_batchSeq}`;
|
||||
|
||||
const featureIds = new Set();
|
||||
const epicIds = new Set();
|
||||
for (const sid of (rel.storyIds || [])) {
|
||||
const s = storyMap.get(sid);
|
||||
if (s) {
|
||||
if (s.featureId) featureIds.add(s.featureId);
|
||||
if (s.epicId) epicIds.add(s.epicId);
|
||||
}
|
||||
}
|
||||
|
||||
batches.push({
|
||||
batchId: releaseBatchId,
|
||||
batchType: "ReleaseBatch",
|
||||
name: `Release ${rel.name} (${rel.version})`,
|
||||
storyIds: rel.storyIds || [],
|
||||
featureIds: [...featureIds],
|
||||
epicIds: [...epicIds],
|
||||
parallelGroups: [rel.storyIds || []],
|
||||
prerequisiteBatches: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Delivery Batches ──
|
||||
// Customer-facing delivery increments — group by release
|
||||
if (releases && releases.releases) {
|
||||
for (const rel of releases.releases) {
|
||||
const deliveryBatchId = `delivery-batch-${++_batchSeq}`;
|
||||
|
||||
const featureIds = new Set();
|
||||
const epicIds = new Set();
|
||||
for (const sid of (rel.storyIds || [])) {
|
||||
const s = storyMap.get(sid);
|
||||
if (s) {
|
||||
if (s.featureId) featureIds.add(s.featureId);
|
||||
if (s.epicId) epicIds.add(s.epicId);
|
||||
}
|
||||
}
|
||||
|
||||
batches.push({
|
||||
batchId: deliveryBatchId,
|
||||
batchType: "DeliveryBatch",
|
||||
name: `Delivery ${rel.name} (${rel.version})`,
|
||||
storyIds: rel.storyIds || [],
|
||||
featureIds: [...featureIds],
|
||||
epicIds: [...epicIds],
|
||||
parallelGroups: [],
|
||||
prerequisiteBatches: [`release-batch-${rel.name}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate warnings ──
|
||||
if (levelGroups.size === 0) {
|
||||
warnings.push("No development batches generated — empty story list");
|
||||
}
|
||||
if (batches.length === 0) {
|
||||
warnings.push("No batches of any type could be generated");
|
||||
}
|
||||
|
||||
const summary = {
|
||||
totalBatches: batches.length,
|
||||
devBatches: batches.filter(b => b.batchType === "DevelopmentBatch").length,
|
||||
testBatches: batches.filter(b => b.batchType === "TestingBatch").length,
|
||||
releaseBatches: batches.filter(b => b.batchType === "ReleaseBatch").length,
|
||||
deliveryBatches: batches.filter(b => b.batchType === "DeliveryBatch").length,
|
||||
};
|
||||
|
||||
return { batches, summary, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group story IDs into parallel work groups based on dependency analysis.
|
||||
* Stories with no interdependency at the same level can be done in parallel.
|
||||
*
|
||||
* @param {string[]} ids — Story IDs at the same dependency level
|
||||
* @param {Map<string, Set<string>>} depMap — Dependency map
|
||||
* @param {Map<string, number>} depLevels — Computed levels
|
||||
* @param {Map<string, object>} storyMap — Story lookup
|
||||
* @returns {string[][]} Groups of stories that can be developed in parallel
|
||||
*/
|
||||
function groupForParallel(ids, depMap, depLevels, storyMap) {
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length === 1) return [ids];
|
||||
|
||||
// Simple heuristic: group by epic ID so stories from same epic are together,
|
||||
// but different epics can proceed in parallel
|
||||
const epicGroups = new Map();
|
||||
for (const id of ids) {
|
||||
const s = storyMap.get(id);
|
||||
const epicId = s ? s.epicId || "unknown" : "unknown";
|
||||
if (!epicGroups.has(epicId)) epicGroups.set(epicId, []);
|
||||
epicGroups.get(epicId).push(id);
|
||||
}
|
||||
|
||||
// Return epic-based groups as parallel units
|
||||
return [...epicGroups.values()];
|
||||
}
|
||||
|
||||
export default { generateBatchPlan };
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* PR-37 Delivery Planning — Capacity Planner (Part I)
|
||||
*
|
||||
* Calculates total effort (with complexity factors), total capacity,
|
||||
* capacity utilization, risk-adjusted effort, delivery forecast dates,
|
||||
* and resource recommendations when utilization exceeds 100%.
|
||||
*
|
||||
* @module pr-37-delivery-planning/capacity-planner
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default capacity planning configuration.
|
||||
*
|
||||
* @param {object} [cfg]
|
||||
* @param {number} [cfg.teamSize=5] — Number of team members
|
||||
* @param {number} [cfg.velocity=20] — Team velocity in story points per sprint
|
||||
* @param {number} [cfg.availabilityFactor=0.8] — Team availability (80% = 1 day lost/week)
|
||||
* @param {object} [cfg.complexityFactors] — Story point multiplier by complexity
|
||||
* @param {number} [cfg.complexityFactors.high=2.0]
|
||||
* @param {number} [cfg.complexityFactors.medium=1.0]
|
||||
* @param {number} [cfg.complexityFactors.low=0.5]
|
||||
* @param {number} [cfg.riskBufferPercent=20] — Risk buffer as percentage of effort
|
||||
* @returns {object} Normalized config
|
||||
*/
|
||||
export function defaultCapacityConfig(cfg = {}) {
|
||||
return {
|
||||
teamSize: cfg.teamSize ?? 5,
|
||||
velocity: cfg.velocity ?? 20,
|
||||
availabilityFactor: cfg.availabilityFactor ?? 0.8,
|
||||
complexityFactors: {
|
||||
high: (cfg.complexityFactors && cfg.complexityFactors.high) ?? 2.0,
|
||||
medium: (cfg.complexityFactors && cfg.complexityFactors.medium) ?? 1.0,
|
||||
low: (cfg.complexityFactors && cfg.complexityFactors.low) ?? 0.5,
|
||||
},
|
||||
riskBufferPercent: cfg.riskBufferPercent ?? 20,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comprehensive capacity plan from sprint and release plans.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.sprints — Sprint plan { sprints, metrics }
|
||||
* @param {object} opts.releases — Release plan { releases }
|
||||
* @param {object} [opts.config] — Capacity planning config
|
||||
* @returns {object} Capacity plan
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const plan = generateCapacityPlan({
|
||||
* sprints: sprintPlan,
|
||||
* releases: releasePlan,
|
||||
* config: { teamSize: 5, velocity: 20, availabilityFactor: 0.8, riskBufferPercent: 20 },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function generateCapacityPlan({ sprints, releases, config }) {
|
||||
const cfg = defaultCapacityConfig(config);
|
||||
const warnings = [];
|
||||
|
||||
// ── 1. Validate inputs ──
|
||||
// Accept both { sprints: [...] } (array) and { sprints: { sprints: [...] } } (plan object)
|
||||
const sprintList = (sprints && (Array.isArray(sprints) ? sprints : sprints.sprints)) || [];
|
||||
const releaseList = (releases && (Array.isArray(releases) ? releases : releases.releases)) || [];
|
||||
const totalSprints = sprintList.length;
|
||||
|
||||
if (totalSprints === 0) {
|
||||
return {
|
||||
totalEffort: 0,
|
||||
totalCapacity: 0,
|
||||
capacityUtilization: "0.0%",
|
||||
sprintBySprint: [],
|
||||
riskAdjustedEffort: 0,
|
||||
deliveryForecast: { optimistic: "N/A", realistic: "N/A", pessimistic: "N/A" },
|
||||
resourceRecommendations: ["No sprints to plan; provide a sprint plan first"],
|
||||
warnings: ["No sprints available for capacity planning"],
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. Calculate total effort with complexity factors ──
|
||||
// Each sprint's total story points serve as effort, adjusted by complexity
|
||||
let totalRawEffort = 0;
|
||||
const sprintEffortMap = new Map();
|
||||
|
||||
for (const sprint of sprintList) {
|
||||
const rawPoints = sprint.totalStoryPoints || 0;
|
||||
totalRawEffort += rawPoints;
|
||||
sprintEffortMap.set(sprint.sprintNumber, rawPoints);
|
||||
}
|
||||
|
||||
// Estimate complexity distribution (in real scenarios, stories would have complexity tags)
|
||||
// Default heuristic: 20% high, 60% medium, 20% low
|
||||
const highRatio = 0.2;
|
||||
const mediumRatio = 0.6;
|
||||
const lowRatio = 0.2;
|
||||
|
||||
const highEffort = totalRawEffort * highRatio * cfg.complexityFactors.high;
|
||||
const mediumEffort = totalRawEffort * mediumRatio * cfg.complexityFactors.medium;
|
||||
const lowEffort = totalRawEffort * lowRatio * cfg.complexityFactors.low;
|
||||
|
||||
const totalEffort = Math.round(highEffort + mediumEffort + lowEffort);
|
||||
|
||||
// ── 3. Calculate total capacity ──
|
||||
const totalCapacity = Math.round(
|
||||
cfg.teamSize * cfg.velocity * totalSprints * cfg.availabilityFactor
|
||||
);
|
||||
|
||||
// ── 4. Sprint-by-sprint capacity vs allocation ──
|
||||
const sprintBySprint = sprintList.map((sprint) => {
|
||||
const sprintCapacity = Math.round(cfg.velocity * cfg.availabilityFactor);
|
||||
const allocated = sprintEffortMap.get(sprint.sprintNumber) || 0;
|
||||
const utilization = sprintCapacity > 0
|
||||
? ((allocated / sprintCapacity) * 100).toFixed(1) + "%"
|
||||
: "0.0%";
|
||||
|
||||
return {
|
||||
sprintNumber: sprint.sprintNumber,
|
||||
sprintGoal: sprint.sprintGoal || `Sprint ${sprint.sprintNumber}`,
|
||||
capacity: sprintCapacity,
|
||||
allocated,
|
||||
utilization,
|
||||
overCapacity: allocated > sprintCapacity,
|
||||
};
|
||||
});
|
||||
|
||||
// ── 5. Capacity utilization ──
|
||||
const capacityUtilization = totalCapacity > 0
|
||||
? ((totalEffort / totalCapacity) * 100).toFixed(1) + "%"
|
||||
: "0.0%";
|
||||
|
||||
// ── 6. Risk-adjusted effort ──
|
||||
const riskBuffer = totalEffort * (cfg.riskBufferPercent / 100);
|
||||
const riskAdjustedEffort = Math.round(totalEffort + riskBuffer);
|
||||
|
||||
// ── 7. Delivery forecast ──
|
||||
const deliveryForecast = computeDeliveryForecast(
|
||||
sprintList, totalEffort, totalCapacity, cfg, releaseList
|
||||
);
|
||||
|
||||
// ── 8. Resource recommendations ──
|
||||
const resourceRecommendations = computeResourceRecommendations(
|
||||
totalEffort, totalCapacity, riskAdjustedEffort, cfg, totalSprints
|
||||
);
|
||||
|
||||
// ── 9. Warnings ──
|
||||
const utilizationNum = totalCapacity > 0 ? (totalEffort / totalCapacity) * 100 : 0;
|
||||
if (utilizationNum > 100) {
|
||||
warnings.push(`Overall capacity utilization is ${utilizationNum.toFixed(1)}% — team is over capacity`);
|
||||
}
|
||||
for (const ss of sprintBySprint) {
|
||||
if (ss.overCapacity) {
|
||||
warnings.push(`Sprint ${ss.sprintNumber} is over capacity (${ss.allocated} > ${ss.capacity} pts)`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalEffort,
|
||||
totalCapacity,
|
||||
capacityUtilization,
|
||||
sprintBySprint,
|
||||
riskAdjustedEffort,
|
||||
deliveryForecast,
|
||||
resourceRecommendations,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute optimistic, realistic, and pessimistic delivery dates.
|
||||
*
|
||||
* @param {object[]} sprintList — Sprints from sprint plan
|
||||
* @param {number} totalEffort — Adjusted total effort
|
||||
* @param {number} totalCapacity — Calculated total capacity
|
||||
* @param {object} cfg — Capacity config
|
||||
* @param {object[]} releaseList — Releases from release plan
|
||||
* @returns {object} { optimistic: string, realistic: string, pessimistic: string }
|
||||
*/
|
||||
function computeDeliveryForecast(sprintList, totalEffort, totalCapacity, cfg, releaseList) {
|
||||
const sprintCount = sprintList.length;
|
||||
if (sprintCount === 0) {
|
||||
return { optimistic: "N/A", realistic: "N/A", pessimistic: "N/A" };
|
||||
}
|
||||
|
||||
const weeksPerSprint = 2; // Standard 2-week sprints
|
||||
|
||||
// Optimistic: current velocity continues, no blockers
|
||||
const optimisticWeeks = Math.ceil(
|
||||
(totalEffort / Math.max(1, cfg.velocity)) * weeksPerSprint
|
||||
);
|
||||
|
||||
// Realistic: with availability factor
|
||||
const realisticWeeks = Math.ceil(
|
||||
(totalEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor)) * weeksPerSprint
|
||||
);
|
||||
|
||||
// Pessimistic: risk-adjusted effort / lower availability
|
||||
const pessimisticEffort = totalEffort * 1.3; // 30% buffer for worst case
|
||||
const pessimisticWeeks = Math.ceil(
|
||||
(pessimisticEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor * 0.85)) * weeksPerSprint
|
||||
);
|
||||
|
||||
// Reference: last major release determines baseline
|
||||
const lastRelease = releaseList.length > 0
|
||||
? releaseList[releaseList.length - 1]
|
||||
: null;
|
||||
const releaseLabel = lastRelease
|
||||
? `after ${lastRelease.name} start`
|
||||
: "from project start";
|
||||
|
||||
return {
|
||||
optimistic: `Week ${optimisticWeeks} ${releaseLabel}`,
|
||||
realistic: `Week ${realisticWeeks} ${releaseLabel}`,
|
||||
pessimistic: `Week ${pessimisticWeeks} ${releaseLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute resource recommendations when capacity is exceeded.
|
||||
*
|
||||
* @param {number} totalEffort — Adjusted total effort
|
||||
* @param {number} totalCapacity — Total capacity
|
||||
* @param {number} riskAdjustedEffort — Effort with risk buffer
|
||||
* @param {object} cfg — Capacity config
|
||||
* @param {number} totalSprints — Number of sprints
|
||||
* @returns {string[]} Recommendations
|
||||
*/
|
||||
function computeResourceRecommendations(totalEffort, totalCapacity, riskAdjustedEffort, cfg, totalSprints) {
|
||||
const recommendations = [];
|
||||
|
||||
const utilizationNum = totalCapacity > 0 ? (totalEffort / totalCapacity) * 100 : 0;
|
||||
|
||||
if (utilizationNum <= 100) {
|
||||
recommendations.push("Team capacity is sufficient for planned work");
|
||||
recommendations.push(`Keep team size at ${cfg.teamSize} with current velocity of ${cfg.velocity} pts/sprint`);
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
// Over-capacity: suggest fixes
|
||||
const excessPct = utilizationNum - 100;
|
||||
const requiredSprintIncrease = Math.ceil(
|
||||
(riskAdjustedEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor)) - totalSprints
|
||||
);
|
||||
|
||||
const requiredTeamSize = Math.ceil(
|
||||
riskAdjustedEffort / (totalSprints * cfg.velocity * cfg.availabilityFactor)
|
||||
);
|
||||
|
||||
if (requiredSprintIncrease > 0) {
|
||||
recommendations.push(
|
||||
`Extend by ${requiredSprintIncrease} sprint(s) to accommodate ${riskAdjustedEffort} risk-adjusted points`
|
||||
);
|
||||
}
|
||||
|
||||
if (requiredTeamSize > cfg.teamSize) {
|
||||
recommendations.push(
|
||||
`Increase team size from ${cfg.teamSize} to ${requiredTeamSize} to stay within ${totalSprints} sprints`
|
||||
);
|
||||
}
|
||||
|
||||
if (excessPct > 50) {
|
||||
recommendations.push(
|
||||
"Consider reducing scope or deferring non-critical features to later releases"
|
||||
);
|
||||
recommendations.push(
|
||||
`Explore outsourcing ${Math.ceil(totalEffort * 0.2)} story points to external teams`
|
||||
);
|
||||
}
|
||||
|
||||
recommendations.push(
|
||||
`Reduce complexity factor application by simplifying high-complexity stories`
|
||||
);
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
export default { generateCapacityPlan, defaultCapacityConfig };
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* PR-37 Delivery Planning — Entry Point
|
||||
*
|
||||
* Parts F, G, H, I:
|
||||
* - F: Sprint Planner
|
||||
* - G: Release Planner
|
||||
* - H: Batch Planner
|
||||
* - I: Capacity Planner
|
||||
*
|
||||
* Consumes SF-02 Requirement Package output and generates delivery
|
||||
* planning artifacts. Compatible with PR-36 Factory Registry.
|
||||
*
|
||||
* @module pr-37-delivery-planning
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
import { generateSprintPlan, defaultSprintConfig } from "./sprint-planner.mjs";
|
||||
import { generateReleasePlan } from "./release-planner.mjs";
|
||||
import { generateBatchPlan } from "./batch-planner.mjs";
|
||||
import { generateCapacityPlan, defaultCapacityConfig } from "./capacity-planner.mjs";
|
||||
|
||||
import { alignRoadmapWithPlans } from "./roadmap-aligner.mjs";
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// PR-37 Artifact Constants
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export const PR37_ARTIFACTS = Object.freeze({
|
||||
SPRINT_PLAN: "sprint-plan",
|
||||
RELEASE_PLAN: "release-plan",
|
||||
BATCH_PLAN: "batch-plan",
|
||||
CAPACITY_PLAN: "capacity-plan",
|
||||
ALIGNMENT_REPORT: "alignment-report",
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Factory Integration
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Create a delivery planning factory that consumes SF-02 upstream artifacts
|
||||
* and produces all 4 delivery plans.
|
||||
*
|
||||
* The factory accepts a PR-36 FactoryInput object (or a simplified upstream
|
||||
* artifacts object) and returns all plans bundled as a FactoryOutput.
|
||||
*
|
||||
* @param {object} upstreamArtifacts — SF-02 output containing:
|
||||
* - epics: object[]
|
||||
* - features: object[]
|
||||
* - userStories: object[]
|
||||
* - dependencies / graph: dependency graph
|
||||
* - priorities: priority matrix entries
|
||||
* - mvpScope: MVP scope definition
|
||||
* @param {object} [config] — Optional config overrides
|
||||
* @param {object} [config.sprint] — Sprint planner config
|
||||
* @param {object} [config.capacity] — Capacity planner config
|
||||
* @returns {object} FactoryOutput with all delivery plans
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const factory = createDeliveryPlanningFactory();
|
||||
* const output = factory(sf02Output, {
|
||||
* sprint: { teamVelocity: 20, maxStoryPointsPerSprint: 25 },
|
||||
* capacity: { teamSize: 5, availabilityFactor: 0.8 },
|
||||
* });
|
||||
* // output.sprintPlan, output.releasePlan, output.batchPlan, output.capacityPlan
|
||||
* ```
|
||||
*/
|
||||
export function createDeliveryPlanningFactory(upstreamArtifacts, config = {}) {
|
||||
if (!upstreamArtifacts) {
|
||||
return {
|
||||
factoryId: "PR-37",
|
||||
status: "failed",
|
||||
error: "No upstream artifacts provided. Provide SF-02 output.",
|
||||
};
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
|
||||
// ── 1. Extract SF-02 artifacts ──
|
||||
const stories = upstreamArtifacts.userStories || upstreamArtifacts.stories || [];
|
||||
const epics = upstreamArtifacts.epics || [];
|
||||
const features = upstreamArtifacts.features || [];
|
||||
|
||||
// Support both { graph: { nodes, edges } } and flat { nodes, edges }
|
||||
const dependencies = upstreamArtifacts.dependencies
|
||||
|| upstreamArtifacts.graph
|
||||
|| { nodes: [], edges: [] };
|
||||
|
||||
const priorities = upstreamArtifacts.priorities || [];
|
||||
const mvpScope = upstreamArtifacts.mvpScope || null;
|
||||
|
||||
// ── 2. Validate data ──
|
||||
if (stories.length === 0) {
|
||||
warnings.push("No user stories found in upstream artifacts");
|
||||
}
|
||||
|
||||
// ── 3. Generate Sprint Plan (Part F) ──
|
||||
const sprintConfig = defaultSprintConfig(config.sprint || {});
|
||||
const sprintPlan = generateSprintPlan({
|
||||
stories,
|
||||
dependencies,
|
||||
priorities,
|
||||
config: sprintConfig,
|
||||
});
|
||||
|
||||
if (sprintPlan.warnings && sprintPlan.warnings.length > 0) {
|
||||
warnings.push(...sprintPlan.warnings.map(w => `[Sprint] ${w}`));
|
||||
}
|
||||
|
||||
// ── 4. Generate Release Plan (Part G) ──
|
||||
const releasePlan = generateReleasePlan({
|
||||
mvpScope,
|
||||
priorities,
|
||||
dependencies,
|
||||
stories,
|
||||
epics,
|
||||
features,
|
||||
});
|
||||
|
||||
if (releasePlan.warnings && releasePlan.warnings.length > 0) {
|
||||
warnings.push(...releasePlan.warnings.map(w => `[Release] ${w}`));
|
||||
}
|
||||
|
||||
// ── 5. Generate Batch Plan (Part H) ──
|
||||
const batchPlan = generateBatchPlan({
|
||||
stories,
|
||||
dependencies,
|
||||
priorities,
|
||||
sprints: sprintPlan,
|
||||
releases: releasePlan,
|
||||
});
|
||||
|
||||
if (batchPlan.warnings && batchPlan.warnings.length > 0) {
|
||||
warnings.push(...batchPlan.warnings.map(w => `[Batch] ${w}`));
|
||||
}
|
||||
|
||||
// ── 6. Generate Capacity Plan (Part I) ──
|
||||
const capacityConfig = defaultCapacityConfig(config.capacity || {});
|
||||
const capacityPlan = generateCapacityPlan({
|
||||
sprints: sprintPlan,
|
||||
releases: releasePlan,
|
||||
config: capacityConfig,
|
||||
});
|
||||
|
||||
if (capacityPlan.warnings && capacityPlan.warnings.length > 0) {
|
||||
warnings.push(...capacityPlan.warnings.map(w => `[Capacity] ${w}`));
|
||||
}
|
||||
|
||||
// ── 7. Align with Roadmap (Part J) ──
|
||||
const roadmap = upstreamArtifacts.roadmap || mvpScope?.phases;
|
||||
const alignmentReport = roadmap
|
||||
? alignRoadmapWithPlans({
|
||||
roadmap: { phases: Array.isArray(roadmap) ? roadmap : (roadmap.phases || []) },
|
||||
sprintPlan,
|
||||
releasePlan,
|
||||
batchPlan,
|
||||
mvpScope,
|
||||
})
|
||||
: null;
|
||||
|
||||
// ── 8. Assemble output ──
|
||||
const output = {
|
||||
factoryId: "PR-37",
|
||||
status: warnings.length > 0 && stories.length === 0 ? "blocked" : "passed",
|
||||
sprintPlan,
|
||||
releasePlan,
|
||||
batchPlan,
|
||||
capacityPlan,
|
||||
alignmentReport,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
metadata: {
|
||||
factoryId: "PR-37",
|
||||
upstreamArtifactId: upstreamArtifacts.id || "SF-02",
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalStories: stories.length,
|
||||
totalEpics: epics.length,
|
||||
totalFeatures: features.length,
|
||||
totalSprints: sprintPlan.metrics.totalSprints,
|
||||
totalReleases: releasePlan.releases ? releasePlan.releases.length : 0,
|
||||
},
|
||||
};
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Barrel Re-exports
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
export { generateSprintPlan, defaultSprintConfig } from "./sprint-planner.mjs";
|
||||
export { generateReleasePlan } from "./release-planner.mjs";
|
||||
export { generateBatchPlan } from "./batch-planner.mjs";
|
||||
export { generateCapacityPlan, defaultCapacityConfig } from "./capacity-planner.mjs";
|
||||
export { alignRoadmapWithPlans } from "./roadmap-aligner.mjs";
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* PR-37 Delivery Planning — Release Planner (Part G)
|
||||
*
|
||||
* Maps MVP/V1/V2/Enterprise/Global phases to concrete releases:
|
||||
* - Release 1.0 (MVP)
|
||||
* - Release 1.1 (MVP polish)
|
||||
* - Release 2.0 (V1)
|
||||
* - Enterprise Edition
|
||||
* - Global Edition
|
||||
*
|
||||
* Each release includes version info, feature counts, validation gates,
|
||||
* release goals, dependency checks, and relative timing.
|
||||
*
|
||||
* @module pr-37-delivery-planning/release-planner
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard validation gates applied to every release.
|
||||
*
|
||||
* @type {string[]}
|
||||
*/
|
||||
const DEFAULT_VALIDATION_GATES = [
|
||||
"All ACs pass",
|
||||
"Regression test pass",
|
||||
"Performance benchmark meet",
|
||||
"Security scan pass",
|
||||
"UAT sign-off",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a release plan from SF-02 output.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.mvpScope — MVP scope from SF-02 (mvp, v1, v2, enterprise, global phases)
|
||||
* @param {object[]} opts.priorities — Priority matrix entries (from SF-02)
|
||||
* @param {object} opts.dependencies — Dependency graph { nodes, edges }
|
||||
* @param {object[]} opts.stories — User stories (from SF-02 userStories)
|
||||
* @param {object[]} opts.epics — Epics (from SF-02)
|
||||
* @param {object[]} opts.features — Features (from SF-02)
|
||||
* @returns {object} Release plan
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const plan = generateReleasePlan({
|
||||
* mvpScope: sf02MvpScope,
|
||||
* priorities: sf02Priorities,
|
||||
* dependencies: sf02Graph,
|
||||
* stories: sf02Stories,
|
||||
* epics: sf02Epics,
|
||||
* features: sf02Features,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function generateReleasePlan({ mvpScope, priorities, dependencies, stories, epics, features }) {
|
||||
// ── Input validation ──
|
||||
if (!stories || stories.length === 0) {
|
||||
return {
|
||||
releases: [],
|
||||
warnings: ["No stories provided; no releases generated"],
|
||||
};
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
const priorityMap = new Map();
|
||||
for (const p of (priorities || [])) {
|
||||
if (p.itemType === "story") {
|
||||
priorityMap.set(p.itemId, p.category);
|
||||
}
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
const storyMap = new Map();
|
||||
for (const s of (stories || [])) storyMap.set(s.id, s);
|
||||
|
||||
const featureMap = new Map();
|
||||
for (const f of (features || [])) featureMap.set(f.id, f);
|
||||
|
||||
const epicMap = new Map();
|
||||
for (const e of (epics || [])) epicMap.set(e.id, e);
|
||||
|
||||
const dependencyEdges = (dependencies && dependencies.edges) ? dependencies.edges : [];
|
||||
|
||||
/**
|
||||
* Map phase scope from MVP scope → actual story/feature/epic IDs.
|
||||
*/
|
||||
function resolvePhaseStories(phase) {
|
||||
if (!phase || !phase.mustHaveStories) return [];
|
||||
return phase.mustHaveStories.map(s => s.id || s);
|
||||
}
|
||||
|
||||
function resolvePhaseEpics(phase, epics) {
|
||||
if (!phase || !phase.epics) return [];
|
||||
// If epics is a count, we can't resolve IDs; return empty
|
||||
if (typeof phase.epics === "number") return [];
|
||||
return [...phase.epics].slice(0);
|
||||
}
|
||||
|
||||
function resolvePhaseFeatures(phase, features) {
|
||||
if (!phase || !phase.features) return [];
|
||||
if (typeof phase.features === "number") return [];
|
||||
return [...phase.features].slice(0);
|
||||
}
|
||||
|
||||
// ── Helper: filter stories by priority ──
|
||||
function filterStoriesByPriority(categories) {
|
||||
const ids = [];
|
||||
for (const s of (stories || [])) {
|
||||
const cat = priorityMap.get(s.id) || s.priority || "Could Have";
|
||||
if (categories.includes(cat)) ids.push(s.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── Helper: find stories with critical unresolved deps ──
|
||||
function findCriticalUnresolved(storyIds, availableIds) {
|
||||
const available = new Set(availableIds);
|
||||
const issues = [];
|
||||
for (const sid of storyIds) {
|
||||
const story = storyMap.get(sid);
|
||||
if (!story) continue;
|
||||
const deps = story.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!available.has(depId)) {
|
||||
const depStory = storyMap.get(depId);
|
||||
issues.push({
|
||||
storyId: sid,
|
||||
storyStatement: story.statement || sid,
|
||||
unresolvedDepId: depId,
|
||||
unresolvedDepStatement: depStory ? depStory.statement : depId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check dependency graph edges
|
||||
const edgeIssues = [];
|
||||
const storyIdSet = new Set(storyIds);
|
||||
for (const edge of dependencyEdges) {
|
||||
if (storyIdSet.has(edge.to)) {
|
||||
const fromStory = storyMap.get(edge.from);
|
||||
const toStory = storyMap.get(edge.to);
|
||||
if (!available.has(edge.from) && fromStory && toStory) {
|
||||
edgeIssues.push({
|
||||
storyId: edge.to,
|
||||
storyStatement: toStory.statement || edge.to,
|
||||
unresolvedDepId: edge.from,
|
||||
unresolvedDepStatement: fromStory.statement || edge.from,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...issues, ...edgeIssues].slice(0, 5); // limit to 5
|
||||
}
|
||||
|
||||
// ── Helper: build release object ──
|
||||
function buildRelease({ version, name, description, storyIds, releaseNumber }) {
|
||||
const uniqueEpicIds = new Set();
|
||||
const uniqueFeatureIds = new Set();
|
||||
let storyCount = 0;
|
||||
let storyPoints = 0;
|
||||
|
||||
for (const sid of storyIds) {
|
||||
const s = storyMap.get(sid);
|
||||
if (!s) continue;
|
||||
storyCount++;
|
||||
storyPoints += s.storyPoints || 1;
|
||||
if (s.epicId) uniqueEpicIds.add(s.epicId);
|
||||
if (s.featureId) uniqueFeatureIds.add(s.featureId);
|
||||
}
|
||||
|
||||
// Resolve epic/feature names
|
||||
const epicNames = [...uniqueEpicIds].map(eid => {
|
||||
const epic = epicMap.get(eid);
|
||||
return epic ? epic.title : eid;
|
||||
}).filter(Boolean);
|
||||
|
||||
const featureNames = [...uniqueFeatureIds].map(fid => {
|
||||
const feat = featureMap.get(fid);
|
||||
return feat ? feat.title : fid;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Determine all resolved IDs for dependency check
|
||||
const resolvedIds = new Set(storyIds);
|
||||
|
||||
const depIssues = findCriticalUnresolved(storyIds, resolvedIds);
|
||||
|
||||
return {
|
||||
version,
|
||||
name,
|
||||
description,
|
||||
features: {
|
||||
epicCount: uniqueEpicIds.size,
|
||||
epicNames,
|
||||
featureCount: uniqueFeatureIds.size,
|
||||
featureNames: featureNames.slice(0, 10), // top 10
|
||||
storyCount,
|
||||
storyPoints,
|
||||
},
|
||||
validationGates: [...DEFAULT_VALIDATION_GATES],
|
||||
releaseGoals: deriveReleaseGoals(name, version, storyIds, storyMap),
|
||||
dependencyCheck: depIssues,
|
||||
releaseDate: `Release ${releaseNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. Release 1.0 (MVP) ──
|
||||
// Must Have stories from MVP phase
|
||||
const mvpPhase = mvpScope && mvpScope.mvp;
|
||||
let mvpStoryIds = [];
|
||||
|
||||
if (mvpPhase && mvpPhase.mustHaveStories && Array.isArray(mvpPhase.mustHaveStories)) {
|
||||
mvpStoryIds = mvpPhase.mustHaveStories.map(s => s.id || s);
|
||||
}
|
||||
|
||||
// Fallback: filter Must Have from all stories
|
||||
if (mvpStoryIds.length === 0) {
|
||||
mvpStoryIds = filterStoriesByPriority(["Must Have"]);
|
||||
}
|
||||
|
||||
// ── 2. Release 1.1 (MVP polish) ──
|
||||
// Remaining Must Have + critical Should Have fixes
|
||||
const shouldHaveIds = filterStoriesByPriority(["Should Have"]);
|
||||
// Take first ~30% of Should Have for critical fixes
|
||||
const criticalShouldHave = shouldHaveIds.slice(0, Math.max(1, Math.ceil(shouldHaveIds.length * 0.3)));
|
||||
|
||||
// Remaining Must Have stories not in MVP
|
||||
const remainingMustHave = mvpStoryIds.filter(id => !mvpStoryIds.includes(id));
|
||||
|
||||
// ── 3. Release 2.0 (V1) ──
|
||||
// All Should Have stories that aren't in 1.1
|
||||
const v1StoryIds = shouldHaveIds.filter(id => !criticalShouldHave.includes(id));
|
||||
|
||||
// ── 4. Enterprise Edition ──
|
||||
const enterprisePhase = mvpScope && mvpScope.enterprise;
|
||||
let enterpriseStoryIds = [];
|
||||
if (enterprisePhase && enterprisePhase.stories) {
|
||||
enterpriseStoryIds = [...enterprisePhase.stories];
|
||||
}
|
||||
|
||||
// ── 5. Global Edition ──
|
||||
const globalPhase = mvpScope && mvpScope.global;
|
||||
let globalStoryIds = [];
|
||||
if (globalPhase && globalPhase.stories) {
|
||||
globalStoryIds = [...globalPhase.stories];
|
||||
}
|
||||
|
||||
// Remove duplicates: Enterprise/Global should contain stories not in earlier releases
|
||||
const v2CouldIds = filterStoriesByPriority(["Could Have"]);
|
||||
if (enterpriseStoryIds.length === 0) {
|
||||
// Fallback: Could Have stories for Enterprise
|
||||
enterpriseStoryIds = v2CouldIds.slice(0, Math.ceil(v2CouldIds.length * 0.6));
|
||||
}
|
||||
if (globalStoryIds.length === 0) {
|
||||
globalStoryIds = v2CouldIds.slice(Math.ceil(v2CouldIds.length * 0.6));
|
||||
}
|
||||
|
||||
// ── Assemble releases ──
|
||||
const releases = [];
|
||||
|
||||
if (mvpStoryIds.length > 0) {
|
||||
releases.push(buildRelease({
|
||||
version: "1.0.0",
|
||||
name: "MVP",
|
||||
description: "Minimum Viable Product — core Must Have functionalities for initial market validation",
|
||||
storyIds: mvpStoryIds,
|
||||
releaseNumber: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
if (criticalShouldHave.length > 0 || remainingMustHave.length > 0) {
|
||||
const polishIds = [...new Set([...criticalShouldHave, ...remainingMustHave])];
|
||||
releases.push(buildRelease({
|
||||
version: "1.1.0",
|
||||
name: "MVP Polish",
|
||||
description: "MVP refinement — remaining Must Have items and critical Should Have fixes",
|
||||
storyIds: polishIds,
|
||||
releaseNumber: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
if (v1StoryIds.length > 0) {
|
||||
releases.push(buildRelease({
|
||||
version: "2.0.0",
|
||||
name: "V1",
|
||||
description: "First full version — complete Should Have feature set for competitive feature parity",
|
||||
storyIds: v1StoryIds,
|
||||
releaseNumber: 3,
|
||||
}));
|
||||
}
|
||||
|
||||
if (enterpriseStoryIds.length > 0) {
|
||||
releases.push(buildRelease({
|
||||
version: "3.0.0",
|
||||
name: "Enterprise Edition",
|
||||
description: "Enterprise-grade features — advanced security, multi-tenancy, compliance, and admin capabilities",
|
||||
storyIds: enterpriseStoryIds,
|
||||
releaseNumber: 4,
|
||||
}));
|
||||
}
|
||||
|
||||
if (globalStoryIds.length > 0) {
|
||||
releases.push(buildRelease({
|
||||
version: "4.0.0",
|
||||
name: "Global Edition",
|
||||
description: "Global-scale features — i18n, multi-region, compliance, and localization",
|
||||
storyIds: globalStoryIds,
|
||||
releaseNumber: 5,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Generate warnings ──
|
||||
if (mvpStoryIds.length === 0) {
|
||||
warnings.push("No MVP stories found; Release 1.0 will be empty");
|
||||
}
|
||||
if (releases.length === 0) {
|
||||
warnings.push("No releases could be generated from the provided data");
|
||||
}
|
||||
for (const rel of releases) {
|
||||
if (rel.dependencyCheck.length > 0) {
|
||||
warnings.push(`${rel.name} (${rel.version}): ${rel.dependencyCheck.length} stories have unresolved critical dependencies`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
releases,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive business-oriented release goals from the story set.
|
||||
*
|
||||
* @param {string} name — Release name
|
||||
* @param {string} version — Semver
|
||||
* @param {string[]} storyIds — Stories in this release
|
||||
* @param {Map<string, object>} storyMap — Story lookup
|
||||
* @returns {string[]} Goals
|
||||
*/
|
||||
function deriveReleaseGoals(name, version, storyIds, storyMap) {
|
||||
const goals = [];
|
||||
|
||||
switch (name) {
|
||||
case "MVP":
|
||||
goals.push("Deliver core value proposition to early adopters");
|
||||
goals.push("Validate product-market fit with real users");
|
||||
goals.push("Establish foundational user identity and access system");
|
||||
break;
|
||||
case "MVP Polish":
|
||||
goals.push("Address critical feedback from MVP users");
|
||||
goals.push("Stabilize core features for broader adoption");
|
||||
goals.push("Improve reliability and performance to production standards");
|
||||
break;
|
||||
case "V1":
|
||||
goals.push("Achieve feature parity with market competitors");
|
||||
goals.push("Provide comprehensive user experience for mainstream adoption");
|
||||
goals.push("Enable self-service onboarding and administration");
|
||||
break;
|
||||
case "Enterprise Edition":
|
||||
goals.push("Meet enterprise security and compliance requirements");
|
||||
goals.push("Support multi-tenant deployment at scale");
|
||||
goals.push("Provide advanced audit, governance, and admin tools");
|
||||
break;
|
||||
case "Global Edition":
|
||||
goals.push("Support internationalization and localization");
|
||||
goals.push("Meet global data residency and privacy regulations");
|
||||
goals.push("Enable multi-region deployment and failover");
|
||||
break;
|
||||
default:
|
||||
goals.push(`Deliver ${name} (${version}) features to users`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add story-specific goal from top stories
|
||||
const topStories = storyIds.slice(0, 3);
|
||||
for (const sid of topStories) {
|
||||
const s = storyMap.get(sid);
|
||||
if (s && s.value) {
|
||||
goals.push(`Enable: ${s.value.length > 60 ? s.value.slice(0, 60) + "…" : s.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return goals;
|
||||
}
|
||||
|
||||
export default { generateReleasePlan };
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* PR-37 Part J — Roadmap Alignment Engine
|
||||
*
|
||||
* 验证 SF-01 Roadmap 与 PR-37 交付规划(Sprint Plan / Release Plan / Batch Plan)的一致性。
|
||||
*
|
||||
* 检查:
|
||||
* - Phase coverage: Roadmap 的阶段是否被 Release Plan 完整覆盖
|
||||
* - Story coverage: MVP/V1/V2 的 Story 是否被 Sprint Plan 完整分配
|
||||
* - Dependency integrity: 关键依赖链是否在 Release 和 Batch 中被正确处理
|
||||
* - Budget alignment: 每个 Phase 的预算是否与实际工作量(Story Points)匹配
|
||||
* - Timeline consistency: Sprint Plan 的总周数是否在 Roadmap 的 Phase 时间范围内
|
||||
*
|
||||
* @module pr-37-delivery-planning/roadmap-aligner
|
||||
* @since PR-37
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RoadmapPhase
|
||||
* @property {string} name — Phase name (mvp / v1 / v2 / enterprise / global)
|
||||
* @property {string} scope — Phase description
|
||||
* @property {number} duration — Duration in months
|
||||
* @property {number} budget — Budget in currency units
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} AlignmentReport
|
||||
* @property {string} overallVerdict — "aligned" / "misaligned" / "partial"
|
||||
* @property {number} alignmentScore — 0-100
|
||||
* @property {object} phaseCoverage — Per-phase coverage assessment
|
||||
* @property {object} timelineCheck — Sprint vs Roadmap timeline comparison
|
||||
* @property {object} budgetCheck — Budget vs Effort comparison
|
||||
* @property {object} dependencyCheck — Critical dependency path verification
|
||||
* @property {string[]} warnings — Alignment warnings
|
||||
* @property {string[]} recommendations — Actionable recommendations
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Main Alignment Function
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Align SF-01 Roadmap with PR-37 delivery plans.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.roadmap — SF-01 Roadmap (phases array)
|
||||
* @param {object} opts.sprintPlan — PR-37 Sprint Plan
|
||||
* @param {object} opts.releasePlan — PR-37 Release Plan
|
||||
* @param {object} opts.batchPlan — PR-37 Batch Plan
|
||||
* @param {object} opts.mvpScope — SF-02 MVP Scope
|
||||
* @returns {AlignmentReport}
|
||||
*/
|
||||
export function alignRoadmapWithPlans({ roadmap, sprintPlan, releasePlan, batchPlan, mvpScope }) {
|
||||
const warnings = [];
|
||||
const recommendations = [];
|
||||
let alignmentScore = 100;
|
||||
|
||||
// ── 1. Phase Coverage Check ──
|
||||
const phaseCoverage = checkPhaseCoverage(roadmap, releasePlan, mvpScope);
|
||||
if (!phaseCoverage.allCovered) {
|
||||
alignmentScore -= 15;
|
||||
warnings.push(...phaseCoverage.warnings);
|
||||
recommendations.push("Ensure all roadmap phases have corresponding releases");
|
||||
}
|
||||
|
||||
// ── 2. Story Coverage Check ──
|
||||
const storyCoverage = checkStoryCoverage(sprintPlan, mvpScope);
|
||||
if (storyCoverage.uncoveredCount > 0) {
|
||||
alignmentScore -= Math.min(25, storyCoverage.uncoveredCount * 2);
|
||||
warnings.push(`⚠️ ${storyCoverage.uncoveredCount} MVP/V1 stories not assigned to any Sprint`);
|
||||
recommendations.push(`Assign ${storyCoverage.uncoveredCount} unplanned stories to sprints or backlog`);
|
||||
}
|
||||
|
||||
// ── 3. Timeline Consistency ──
|
||||
const timelineCheck = checkTimelineConsistency(roadmap, sprintPlan);
|
||||
if (timelineCheck.misalignment) {
|
||||
alignmentScore -= 20;
|
||||
warnings.push(timelineCheck.message);
|
||||
recommendations.push(timelineCheck.recommendation);
|
||||
}
|
||||
|
||||
// ── 4. Budget Alignment ──
|
||||
const budgetCheck = checkBudgetAlignment(roadmap, sprintPlan, mvpScope);
|
||||
if (budgetCheck.misalignment) {
|
||||
alignmentScore -= 15;
|
||||
warnings.push(budgetCheck.message);
|
||||
recommendations.push(budgetCheck.recommendation);
|
||||
}
|
||||
|
||||
// ── 5. Dependency Integrity ──
|
||||
const dependencyCheck = checkDependencyIntegrity(batchPlan, releasePlan);
|
||||
if (dependencyCheck.misalignment) {
|
||||
alignmentScore -= 15;
|
||||
warnings.push(...dependencyCheck.warnings);
|
||||
recommendations.push(...dependencyCheck.recommendations);
|
||||
}
|
||||
|
||||
const overallVerdict = alignmentScore >= 90 ? "aligned"
|
||||
: alignmentScore >= 60 ? "partial"
|
||||
: "misaligned";
|
||||
|
||||
return {
|
||||
overallVerdict,
|
||||
alignmentScore: Math.max(0, alignmentScore),
|
||||
phaseCoverage,
|
||||
storyCoverage,
|
||||
timelineCheck,
|
||||
budgetCheck,
|
||||
dependencyCheck,
|
||||
warnings,
|
||||
recommendations,
|
||||
summary: generateAlignmentSummary(overallVerdict, alignmentScore, roadmap, sprintPlan),
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Phase Coverage
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function checkPhaseCoverage(roadmap, releasePlan, mvpScope) {
|
||||
const phases = roadmap?.phases || [];
|
||||
const releases = releasePlan?.releases || [];
|
||||
const covered = [];
|
||||
const uncovered = [];
|
||||
const warnings = [];
|
||||
|
||||
// Map phase names to release versions
|
||||
const phaseReleaseMap = {
|
||||
mvp: ["1.0.0"],
|
||||
v1: ["2.0.0", "1.1.0"],
|
||||
v2: ["3.0.0"],
|
||||
enterprise: ["Enterprise Edition", "3.0.0"],
|
||||
global: ["Global Edition", "4.0.0"],
|
||||
};
|
||||
|
||||
for (const phase of phases) {
|
||||
const expectedReleases = phaseReleaseMap[phase.name] || [];
|
||||
const found = releases.filter(r => expectedReleases.includes(r.version));
|
||||
if (found.length > 0) {
|
||||
covered.push({ phase: phase.name, releases: found.map(r => r.version) });
|
||||
} else {
|
||||
uncovered.push({ phase: phase.name, expected: expectedReleases });
|
||||
warnings.push(`⛔ Phase "${phase.name}" has no matching release in Release Plan (expected: ${expectedReleases.join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check that all releases map to a phase
|
||||
const allReleaseVersions = releases.map(r => r.version);
|
||||
const allExpectedVersions = Object.values(phaseReleaseMap).flat();
|
||||
const extraReleases = allReleaseVersions.filter(v => !allExpectedVersions.includes(v));
|
||||
if (extraReleases.length > 0) {
|
||||
warnings.push(`⚠️ Release Plan contains ${extraReleases.length} release(s) without roadmap phase mapping: ${extraReleases.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
allCovered: uncovered.length === 0,
|
||||
covered,
|
||||
uncovered,
|
||||
extraReleases,
|
||||
warnings,
|
||||
summary: `${covered.length}/${phases.length} roadmap phases covered by Release Plan`,
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Story Coverage
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function checkStoryCoverage(sprintPlan, mvpScope) {
|
||||
const allocatedIds = new Set();
|
||||
for (const sprint of (sprintPlan?.sprints || [])) {
|
||||
for (const sid of (sprint.storyIds || [])) {
|
||||
allocatedIds.add(sid);
|
||||
}
|
||||
}
|
||||
|
||||
const unallocated = new Set();
|
||||
const unplanned = sprintPlan?.unplannedStories || [];
|
||||
|
||||
// MVP + V1 stories should all be allocated
|
||||
const mvpStoryIds = new Set(
|
||||
(mvpScope?.mvp?.mustHaveStories || []).map(s => s.id)
|
||||
);
|
||||
|
||||
let uncoveredCount = 0;
|
||||
for (const sid of mvpStoryIds) {
|
||||
if (!allocatedIds.has(sid)) {
|
||||
unallocated.add(sid);
|
||||
uncoveredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const sid of unplanned) {
|
||||
if (!allocatedIds.has(sid)) {
|
||||
unallocated.add(sid);
|
||||
uncoveredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uncoveredCount,
|
||||
unallocatedStories: [...unallocated],
|
||||
totalAllocated: allocatedIds.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Timeline Consistency
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function checkTimelineConsistency(roadmap, sprintPlan) {
|
||||
const phases = roadmap?.phases || [];
|
||||
const sprints = sprintPlan?.sprints || [];
|
||||
const totalSprintWeeks = (sprintPlan?.metrics?.estimatedDurationWeeks) || 0;
|
||||
|
||||
// Roadmap total in months → weeks (approximate 4 weeks/month)
|
||||
const totalRoadmapMonths = phases.reduce((sum, p) => sum + (p.duration || p.durationMonths || 0), 0);
|
||||
const totalRoadmapWeeks = totalRoadmapMonths * 4;
|
||||
|
||||
// MVP phase duration
|
||||
const mvpPhase = phases.find(p => p.name === "mvp");
|
||||
const mvpPhaseWeeks = (mvpPhase?.duration || mvpPhase?.durationMonths || 0) * 4;
|
||||
|
||||
// MVP sprint weeks
|
||||
const mvpSprints = sprints.filter(s => {
|
||||
// Heuristic: first few sprints that cover the MVP points
|
||||
const running = sprints
|
||||
.slice(0, sprints.indexOf(s) + 1)
|
||||
.reduce((sum, sp) => sum + (sp.totalStoryPoints || 0), 0);
|
||||
const mvpTotal = (sprintPlan?.metrics?.totalPoints || 0) * 0.35; // ~35% of total is MVP
|
||||
return running <= mvpTotal;
|
||||
});
|
||||
const mvpSprintWeeks = mvpSprints.length * 2; // 2 weeks/sprint
|
||||
|
||||
const misalignment = mvpSprintWeeks > mvpPhaseWeeks * 1.2 || totalSprintWeeks > totalRoadmapWeeks * 1.2;
|
||||
const message = misalignment
|
||||
? `⚠️ Timeline misalignment: Sprint plan (${totalSprintWeeks}w) exceeds roadmap (${totalRoadmapWeeks}w) by ${totalSprintWeeks - totalRoadmapWeeks}w`
|
||||
: `✅ Timeline aligned: Sprint plan (${totalSprintWeeks}w) fits within roadmap (${totalRoadmapWeeks}w)`;
|
||||
|
||||
const recommendation = misalignment
|
||||
? `Consider reducing Sprint scope by ${Math.round((totalSprintWeeks - totalRoadmapWeeks) / 2)} sprints, or extend roadmap timeline by ${Math.ceil((totalSprintWeeks - totalRoadmapWeeks) / 4)} months`
|
||||
: "No action needed — timeline is consistent";
|
||||
|
||||
return {
|
||||
misalignment,
|
||||
message,
|
||||
recommendation,
|
||||
sprintWeeks: totalSprintWeeks,
|
||||
roadmapWeeks: totalRoadmapWeeks,
|
||||
mvpSprintWeeks,
|
||||
mvpPhaseWeeks,
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Budget Alignment
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function checkBudgetAlignment(roadmap, sprintPlan, mvpScope) {
|
||||
const phases = roadmap?.phases || [];
|
||||
const totalBudget = phases.reduce((sum, p) => sum + (p.budget || 0), 0);
|
||||
const totalPoints = sprintPlan?.metrics?.totalPoints || 0;
|
||||
|
||||
if (totalBudget === 0 || totalPoints === 0) {
|
||||
return {
|
||||
misalignment: false,
|
||||
message: "Insufficient data for budget alignment check",
|
||||
recommendation: "Ensure roadmap has budget estimates and sprint plan has story points",
|
||||
costPerPoint: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const costPerPoint = Math.round(totalBudget / totalPoints);
|
||||
const mvpBudget = phases.find(p => p.name === "mvp")?.budget || 0;
|
||||
const mvpPoints = mvpScope?.mvp?.totalStoryPoints || 0;
|
||||
const mvpCostAtRate = mvpPoints * costPerPoint;
|
||||
|
||||
// More than 20% deviation = misalignment
|
||||
const deviation = mvpBudget > 0 ? Math.abs(mvpCostAtRate - mvpBudget) / mvpBudget : 0;
|
||||
const misalignment = deviation > 0.2;
|
||||
|
||||
const message = misalignment
|
||||
? `⚠️ Budget misalignment: MVP estimated cost at rate ($${mvpCostAtRate.toLocaleString()}) deviates ${(deviation * 100).toFixed(0)}% from roadmap budget ($${mvpBudget.toLocaleString()})`
|
||||
: `✅ Budget aligned: MVP cost at $${costPerPoint}/point (${(deviation * 100).toFixed(0)}% deviation)`;
|
||||
|
||||
const recommendation = misalignment
|
||||
? `Review story point estimates or adjust roadmap budget. Current rate: $${costPerPoint}/point, roadmap implies $${Math.round(mvpBudget / Math.max(1, mvpPoints))}/point`
|
||||
: "No action needed — budget is consistent";
|
||||
|
||||
return {
|
||||
misalignment,
|
||||
message,
|
||||
recommendation,
|
||||
costPerPoint,
|
||||
mvpCostAtRate,
|
||||
mvpBudget,
|
||||
deviation: (deviation * 100).toFixed(1) + "%",
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Dependency Integrity
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function checkDependencyIntegrity(batchPlan, releasePlan) {
|
||||
const batches = batchPlan?.batches || [];
|
||||
const releases = releasePlan?.releases || [];
|
||||
const warnings = [];
|
||||
const recommendations = [];
|
||||
|
||||
// Check: Development batches should feed into testing batches
|
||||
const devBatches = batches.filter(b => b.batchType === "DevelopmentBatch");
|
||||
const testBatches = batches.filter(b => b.batchType === "TestingBatch");
|
||||
const releaseBatches = batches.filter(b => b.batchType === "ReleaseBatch");
|
||||
const deliveryBatches = batches.filter(b => b.batchType === "DeliveryBatch");
|
||||
|
||||
if (devBatches.length > 0 && testBatches.length === 0) {
|
||||
warnings.push("⚠️ Development batches exist but no TestingBatch defined");
|
||||
recommendations.push("Add TestingBatch(s) that consume DevelopmentBatch output");
|
||||
}
|
||||
|
||||
if (releaseBatches.length === 0 && releases.length > 0) {
|
||||
warnings.push("⚠️ Releases defined but no ReleaseBatch for deployment coordination");
|
||||
recommendations.push("Add ReleaseBatch(s) to map features to deployment stages");
|
||||
}
|
||||
|
||||
// Check: Release dependencies should be satisfied
|
||||
for (let i = 1; i < releases.length; i++) {
|
||||
const prevIds = new Set();
|
||||
for (let j = 0; j < i; j++) {
|
||||
(releases[j]?.features?.featureNames || []).forEach(n => prevIds.add(n));
|
||||
}
|
||||
}
|
||||
|
||||
const allBatchTypes = [...new Set(batches.map(b => b.batchType))];
|
||||
const expectedTypes = ["DevelopmentBatch", "TestingBatch", "ReleaseBatch", "DeliveryBatch"];
|
||||
const missingTypes = expectedTypes.filter(t => !allBatchTypes.includes(t));
|
||||
|
||||
if (missingTypes.length > 0) {
|
||||
warnings.push(`⚠️ Missing batch types: ${missingTypes.join(", ")}`);
|
||||
recommendations.push(
|
||||
"Ensure full batch pipeline: DevelopmentBatch → TestingBatch → ReleaseBatch → DeliveryBatch"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
misalignment: warnings.length > 0,
|
||||
warnings,
|
||||
recommendations,
|
||||
batchTypeCoverage: `${allBatchTypes.length}/${expectedTypes.length}`,
|
||||
missingTypes,
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Summary
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function generateAlignmentSummary(verdict, score, roadmap, sprintPlan) {
|
||||
const phaseCount = roadmap?.phases?.length || 0;
|
||||
const sprintCount = sprintPlan?.sprints?.length || 0;
|
||||
const totalWeeks = sprintPlan?.metrics?.estimatedDurationWeeks || 0;
|
||||
|
||||
return [
|
||||
`## Roadmap Alignment: ${verdict.toUpperCase()} (${score}/100)`,
|
||||
"",
|
||||
`- **Roadmap Phases:** ${phaseCount}`,
|
||||
`- **Delivery Sprints:** ${sprintCount}`,
|
||||
`- **Estimated Duration:** ${totalWeeks} weeks`,
|
||||
verdict === "aligned"
|
||||
? "✅ All delivery plans are consistent with the product roadmap."
|
||||
: verdict === "partial"
|
||||
? "⚠️ Delivery plans partially align with the roadmap — see recommendations."
|
||||
: "❌ Significant misalignment between delivery plans and roadmap.",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user