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

@@ -16,6 +16,8 @@ import {
roadmapGetPhase,
getMilestoneInfo,
extractCurrentMilestone,
extractNextMilestoneSection,
extractPhasesFromSection,
stripShippedMilestones,
} from './roadmap.js';
@@ -474,3 +476,99 @@ describe('roadmapAnalyze', () => {
expect((data1.phases as unknown[]).length).toBe((data2.phases as unknown[]).length);
});
});
// ─── extractPhasesFromSection + extractNextMilestoneSection (#2497) ──────
describe('extractPhasesFromSection', () => {
it('parses phase number, name, goal, and depends_on from a milestone section', () => {
const section = [
'',
'### Phase 31: Email Schema',
'**Goal**: Set up Prisma models.',
'**Depends on**: None',
'',
'### Phase 32: Today\'s Sheets',
'**Goal**: Port the GAS sender.',
'**Depends on**: Phase 31',
'',
].join('\n');
const phases = extractPhasesFromSection(section);
expect(phases).toEqual([
{ number: '31', name: 'Email Schema', goal: 'Set up Prisma models.', depends_on: 'None' },
{ number: '32', name: "Today's Sheets", goal: 'Port the GAS sender.', depends_on: 'Phase 31' },
]);
});
it('returns empty array when section has no phase headings', () => {
expect(extractPhasesFromSection('no phases here\njust prose.')).toEqual([]);
});
});
describe('extractNextMilestoneSection', () => {
const MULTI = [
'# Roadmap',
'',
'## Milestone v1.0: Old — ✅ SHIPPED 2026-01-01',
'',
'Shipped stuff.',
'',
'## Milestone v2.0.5: Current Milestone',
'',
'### Phase 35: Audit',
'**Goal**: Audit schemas.',
'',
'## Milestone v2.1: Daily Emails',
'',
'### Phase 31: Schema',
'**Goal**: Build schema.',
'**Depends on**: None',
'',
'### Phase 32: Sending',
'**Goal**: Send emails.',
'**Depends on**: Phase 31',
'',
'## Milestone v2.2: Later',
'',
'### Phase 99: Future',
'**Goal**: Later work.',
].join('\n');
it('returns the milestone immediately after the active one (STATE-driven)', async () => {
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), MULTI);
await writeFile(
join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.0.5\nmilestone_name: Current Milestone\n---\n',
);
const next = await extractNextMilestoneSection(MULTI, tmpDir);
expect(next).not.toBeNull();
expect(next!.version).toBe('v2.1');
expect(next!.name).toBe('Daily Emails');
// Phases parse correctly from the returned section — only v2.1 phases,
// not v2.2's Phase 99.
const phases = extractPhasesFromSection(next!.section).map(p => p.number);
expect(phases).toEqual(['31', '32']);
});
it('returns null when the active milestone is the last one in ROADMAP', async () => {
const roadmap = [
'# Roadmap',
'',
'## Milestone v2.0.5: Last One',
'',
'### Phase 35: Final',
'**Goal**: Final work.',
].join('\n');
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
await writeFile(
join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.0.5\n---\n',
);
const next = await extractNextMilestoneSection(roadmap, tmpDir);
expect(next).toBeNull();
});
it('returns null when no current milestone can be resolved', async () => {
const next = await extractNextMilestoneSection('# Roadmap\nno milestones\n', tmpDir);
expect(next).toBeNull();
});
});