* feat(sdk): add queued_phases to init.manager (closes #2497) Surfaces the milestone immediately AFTER the active one so the /gsd-manager dashboard can preview upcoming phases without mixing them into the active phases grid. Changes: - roadmap.ts: exports two new helpers - extractPhasesFromSection(section): parses phase number / name / goal / depends_on using the same pattern initManager uses for the active milestone, so queued phases have identical shape. - extractNextMilestoneSection(content, projectDir): resolves the current milestone via the STATE-first path (matching upstream PR #2508) then scans for the next ## milestone heading. Shipped milestones are stripped first so they can't shadow the real next. Returns null when the active milestone is the last one. - init-complex.ts: initManager now exposes - queued_phases: Array<{ number, name, display_name, goal, depends_on, dep_phases, deps_display }> - queued_milestone_version: string | null - queued_milestone_name: string | null Existing phases array is unchanged — callers that only care about the active milestone see no behavior difference. Scope note: PR #2508 (merged upstream 2026-04-21) superseded the #2495 + #2496 portions of this branch's original submission. This commit is the rebased remainder contributing only #2497 on top of upstream's new helpers. Test coverage (7 new tests, all passing): - roadmap.test.ts: +5 tests - extractPhasesFromSection parses multiple phases with goal + deps - extractPhasesFromSection returns [] when no phase headings - extractNextMilestoneSection returns the milestone after the STATE-resolved active one - extractNextMilestoneSection returns null when active is last - extractNextMilestoneSection returns null when no version found - init-complex.test.ts: +4 tests under `queued_phases (#2497)` - surfaces next milestone with version + name metadata - queued entries carry name / deps_display / display_name - queued phases are NOT mixed into active phases list - returns [] + nulls when active is the last milestone All 51 tests in roadmap.test.ts + init-complex.test.ts pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(workflows): render queued_phases section in /gsd-manager dashboard Surfaces the new `queued_phases` / `queued_milestone_version` / `queued_milestone_name` fields from init.manager (SDK #2497) in a compact preview section directly below the main active-milestone table. Changes to workflows/manager.md: - Initialize step: parse the optional trio (queued_milestone_version, queued_milestone_name, queued_phases) alongside the existing init.manager fields. Treat missing as empty for backward compatibility with older SDK versions. - Dashboard step: new "Queued section (next milestone preview)" rendered between the main active-milestone grid and the Recommendations section. Renders only when queued_phases is non-empty; skipped entirely when absent or empty (e.g. active milestone is the last one). - Queued rows render without D/P/E columns since the phases haven't been discussed yet — just number, display_name, deps_display, and a fixed "· Queued" status. - Success criterion added: queued section renders when non-empty and is skipped when absent. Queued phases are deliberately NOT eligible for the Continue action menu; they live in a future milestone. The preview exists for situational awareness only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,121 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
|
||||
return content.slice(sectionStart, sectionEnd);
|
||||
}
|
||||
|
||||
// ─── Next-milestone helpers (issue #2497) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Phase shape returned by extractPhasesFromSection — mirrors the fields used
|
||||
* by the current-milestone phases array in initManager so consumers can
|
||||
* render queued phases uniformly.
|
||||
*/
|
||||
export interface QueuedPhase {
|
||||
number: string;
|
||||
name: string;
|
||||
goal: string | null;
|
||||
depends_on: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract phase entries from an arbitrary ROADMAP milestone section.
|
||||
*
|
||||
* Parses `#### Phase N: Name` / `### Phase N: Name` / `## Phase N: Name`
|
||||
* headings and, for each, captures goal + depends_on via the same patterns
|
||||
* used by initManager's current-milestone phase parsing. Used by
|
||||
* `initManager` to populate `queued_phases` (#2497).
|
||||
*/
|
||||
export function extractPhasesFromSection(section: string): QueuedPhase[] {
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
const phases: QueuedPhase[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(section)) !== null) {
|
||||
const phaseNum = m[1];
|
||||
const phaseName = m[2].replace(/\(INSERTED\)/i, '').trim();
|
||||
const sectionStart = m.index;
|
||||
const rest = section.slice(sectionStart);
|
||||
const nextHeader = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
const end = nextHeader ? sectionStart + (nextHeader.index ?? 0) : section.length;
|
||||
const body = section.slice(sectionStart, end);
|
||||
const goalMatch = body.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const dependsMatch = body.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
phases.push({
|
||||
number: phaseNum,
|
||||
name: phaseName,
|
||||
goal: goalMatch ? goalMatch[1].trim() : null,
|
||||
depends_on: dependsMatch ? dependsMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return phases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the milestone section that comes immediately AFTER the active one.
|
||||
*
|
||||
* Used by initManager to surface `queued_phases` without conflating the
|
||||
* active milestone's phase list with the next one (#2497). Returns null
|
||||
* when no subsequent milestone section exists (active is the last one).
|
||||
*
|
||||
* Reuses the same current-version resolution path as `getMilestoneInfo`:
|
||||
* STATE.md frontmatter first, then in-flight emoji markers in ROADMAP.
|
||||
* Shipped milestones are stripped first so they can't shadow the real
|
||||
* "next" one.
|
||||
*/
|
||||
export async function extractNextMilestoneSection(
|
||||
content: string,
|
||||
projectDir: string,
|
||||
): Promise<{ version: string; name: string; section: string } | null> {
|
||||
const cleaned = stripShippedMilestones(content);
|
||||
|
||||
// Resolve current version via STATE.md (priority) then in-flight markers.
|
||||
let currentVersion: string | null = null;
|
||||
const fromState = await parseMilestoneFromState(projectDir);
|
||||
if (fromState?.version) {
|
||||
const raw = fromState.version.trim();
|
||||
currentVersion = /^v\d/i.test(raw) ? raw : `v${raw}`;
|
||||
}
|
||||
if (!currentVersion) {
|
||||
const inProgressMatch = cleaned.match(/(?:🚧|🟡)\s*\*\*v(\d+(?:\.\d+)+)\s/);
|
||||
if (inProgressMatch) currentVersion = 'v' + inProgressMatch[1];
|
||||
}
|
||||
if (!currentVersion) return null;
|
||||
|
||||
// Find the current milestone ## heading.
|
||||
const escaped = escapeRegex(currentVersion);
|
||||
const currentHeadingPattern = new RegExp(
|
||||
`^##\\s+[^\\n]*${escaped}[^\\n]*$`,
|
||||
'mi',
|
||||
);
|
||||
const currentMatch = cleaned.match(currentHeadingPattern);
|
||||
if (!currentMatch || currentMatch.index === undefined) return null;
|
||||
|
||||
// Look for the next ## milestone heading after the current one.
|
||||
const tail = cleaned.slice(currentMatch.index + currentMatch[0].length);
|
||||
const nextMilestonePattern = /^##\s+([^\n]*(?:v(\d+(?:\.\d+)+)|✅|🚧|🟡|📋)[^\n]*)$/gim;
|
||||
let nextMatch: RegExpExecArray | null;
|
||||
while ((nextMatch = nextMilestonePattern.exec(tail)) !== null) {
|
||||
const heading = nextMatch[1];
|
||||
const versionMatch = heading.match(/v(\d+(?:\.\d+)+)/);
|
||||
if (!versionMatch) continue;
|
||||
const nextVersion = 'v' + versionMatch[1];
|
||||
if (nextVersion === currentVersion) continue;
|
||||
|
||||
// Derive a display name: trim through "vX.Y:" or "vX.Y —" prefix.
|
||||
const nameMatch = heading.match(/v\d+(?:\.\d+)+:?\s*[—–-]?\s*([^\n(]+)/);
|
||||
const name = nameMatch ? nameMatch[1].trim() : heading.trim();
|
||||
|
||||
const sectionStart = (nextMatch.index ?? 0) + nextMatch[0].length;
|
||||
const afterStart = tail.slice(sectionStart);
|
||||
const followingHeader = afterStart.match(/^##\s/m);
|
||||
const sectionEnd = followingHeader && followingHeader.index !== undefined
|
||||
? sectionStart + followingHeader.index
|
||||
: tail.length;
|
||||
const section = tail.slice(sectionStart, sectionEnd);
|
||||
|
||||
return { version: nextVersion, name, section };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user