fix(#2684,#2676): milestone.complete arg forwarding + parallel milestone phase routing (#2708)

* test(#2692): add behavioral --wave N test, annotate source-text assertions

Adds two behavioral tests for wave filtering via phase-plan-index:
- Verifies plans with wave frontmatter are correctly grouped by wave number
- Verifies plans with no wave field default to wave 1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#2684,#2676): milestone.complete version validation + parallel milestone phase routing

#2684: Confirms milestone.complete correctly validates and uses its version
argument end-to-end. The inline archive path in milestoneComplete already
forwarded version correctly; regression tests lock in that contract.

#2676: phase.complete applied getMilestonePhaseFilter unconditionally, using
STATE.md's primary milestone to scope the candidate set. When the completed
phase belongs to a parallel (secondary) milestone, the filter excluded all
phases from that milestone, leaving an empty candidate set and incorrectly
returning is_last_phase: true / next_phase: null.

Fix: before applying the milestone filter in Step E, check whether the
completed phase itself appears in the filtered set. If not, skip the filter
for both the directory scan and the ROADMAP.md fallback so phases from the
secondary milestone remain visible for next-phase detection.

Closes #2684
Closes #2676

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-25 12:10:01 -04:00
committed by GitHub
parent 8393f4b355
commit 8e21c9b1b7
3 changed files with 335 additions and 4 deletions

View File

@@ -1256,12 +1256,31 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
let nextPhaseNum: string | null = null;
let nextPhaseName: string | null = null;
let isLastPhase = true;
// Tracks whether the completed phase belongs to the primary milestone in STATE.md.
// When false (parallel-milestone case, Bug #2676), the milestone filter is bypassed
// for next-phase detection so phases from the same secondary milestone are visible.
let completedPhaseInPrimaryMilestone = true;
try {
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
const entries = await readdir(paths.phases, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone)
const allDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Guard: if the completed phase's directory is not in the current-milestone filter
// set, the filter was built from a different (primary) milestone in STATE.md.
// In that case skip the filter so we can find the true next phase on disk.
// This handles parallel-milestone workflows where STATE.md's `milestone:` field
// points at the primary milestone but the phase being completed belongs to a
// secondary in-flight milestone. (Bug #2676)
const completedDirInFilter = allDirs.some((d) => {
const dm = d.match(/^(\d+[A-Z]?(?:\.\d+)*)-?/i);
return dm && comparePhaseNum(dm[1], phaseNum) === 0 && isDirInMilestone(d);
});
completedPhaseInPrimaryMilestone = completedDirInFilter;
const effectiveFilter = completedDirInFilter ? isDirInMilestone : (_d: string) => true;
const dirs = allDirs
.filter(effectiveFilter)
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
@@ -1277,11 +1296,16 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
}
} catch { /* intentionally empty */ }
// Fallback: check ROADMAP.md for phases not yet scaffolded
// Fallback: check ROADMAP.md for phases not yet scaffolded.
// When the completed phase is from a parallel (non-primary) milestone, scan the
// full ROADMAP rather than the primary-milestone slice so 41.3 is visible when
// completing 41.2 for a secondary milestone. (Bug #2676)
if (isLastPhase && existsSync(paths.roadmap)) {
try {
const roadmapContent = await readFile(paths.roadmap, 'utf-8');
const roadmapForPhases = await extractCurrentMilestone(roadmapContent, projectDir);
const roadmapForPhases = completedPhaseInPrimaryMilestone
? await extractCurrentMilestone(roadmapContent, projectDir)
: roadmapContent;
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let pm: RegExpExecArray | null;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {