🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
@@ -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 };