fix(state): scope phase counting to current milestone

Extract getMilestonePhaseFilter() from milestone.cjs closure into core.cjs
as a shared helper. Apply it in buildStateFrontmatter and cmdPhaseComplete
so multi-milestone projects count only current milestone's phases instead
of all directories on disk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j2h4u
2026-03-02 20:27:20 +05:00
committed by Lex Christopherson
parent 29beea437e
commit b863ed6de5
7 changed files with 340 additions and 5 deletions

View File

@@ -409,6 +409,35 @@ function getMilestoneInfo(cwd) {
}
}
/**
* Returns a filter function that checks whether a phase directory belongs
* to the current milestone based on ROADMAP.md phase headings.
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
*/
function getMilestonePhaseFilter(cwd) {
const milestonePhaseNums = new Set();
try {
const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmap)) !== null) {
milestonePhaseNums.add(m[1]);
}
} catch {}
if (milestonePhaseNums.size === 0) return () => true;
const normalized = new Set(
[...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase())
);
return function isDirInMilestone(dirName) {
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
if (!m) return false;
return normalized.has(m[1].toLowerCase());
};
}
module.exports = {
MODEL_PROFILES,
output,
@@ -428,5 +457,6 @@ module.exports = {
pathExistsInternal,
generateSlugInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
toPosixPath,
};

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, output, error } = require('./core.cjs');
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, output, error } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd } = require('./state.cjs');
@@ -789,8 +789,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
let isLastPhase = true;
try {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone)
.sort((a, b) => comparePhaseNum(a, b));
// Find the next phase directory after current
for (const dir of dirs) {

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { loadConfig, getMilestoneInfo, output, error } = require('./core.cjs');
const { loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
function cmdStateLoad(cwd, raw) {
@@ -549,8 +549,10 @@ function buildStateFrontmatter(bodyContent, cwd) {
try {
const phasesDir = path.join(cwd, '.planning', 'phases');
if (fs.existsSync(phasesDir)) {
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name);
.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone);
let diskTotalPlans = 0;
let diskTotalSummaries = 0;
let diskCompletedPhases = 0;
@@ -563,7 +565,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
diskTotalSummaries += summaries;
if (plans > 0 && summaries >= plans) diskCompletedPhases++;
}
if (totalPhases === null) totalPhases = phaseDirs.length;
totalPhases = phaseDirs.length;
completedPhases = diskCompletedPhases;
totalPlans = diskTotalPlans;
completedPlans = diskTotalSummaries;