feat(sdk): add queued_phases to init.manager (closes #2497) (#2514)

* 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:
elfstrob
2026-04-22 07:41:37 -07:00
committed by GitHub
parent 7397f580a5
commit 9b5397a30f
5 changed files with 368 additions and 2 deletions

View File

@@ -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 ─────────────────────────────────────────────────────
/**