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

@@ -229,4 +229,92 @@ describe('initManager', () => {
expect(typeof flags.plan).toBe('string');
expect(typeof flags.execute).toBe('string');
});
// ── queued_phases (#2497) ─────────────────────────────────────────────
describe('queued_phases (#2497)', () => {
const MULTI_MILESTONE = [
'# Roadmap',
'',
'## Milestone v1.0: Old — ✅ SHIPPED 2026-01-01',
'',
'Shipped.',
'',
'## Milestone v2.0.5: Current',
'',
'### Phase 35: Audit',
'**Goal**: Audit schemas.',
'**Depends on**: None',
'',
'## 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');
beforeEach(async () => {
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), MULTI_MILESTONE);
await writeFile(join(tmpDir, '.planning', 'STATE.md'), [
'---',
'milestone: v2.0.5',
'milestone_name: Current',
'---',
].join('\n'));
});
it('surfaces the next milestone in queued_phases with metadata', async () => {
const result = await initManager([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.queued_milestone_version).toBe('v2.1');
expect(data.queued_milestone_name).toBe('Daily Emails');
const queued = data.queued_phases as Record<string, unknown>[];
expect(queued.map(p => p.number)).toEqual(['31', '32']);
// Only the NEXT milestone's phases appear — not v2.2's Phase 99.
expect(queued.find(p => p.number === '99')).toBeUndefined();
});
it('queued_phases entries carry name, deps_display, and display_name', async () => {
const result = await initManager([], tmpDir);
const data = result.data as Record<string, unknown>;
const queued = data.queued_phases as Record<string, unknown>[];
const p32 = queued.find(p => p.number === '32');
expect(p32).toBeDefined();
expect(p32!.name).toBe('Sending');
expect(p32!.deps_display).toBe('31');
expect(typeof p32!.display_name).toBe('string');
});
it('does NOT mix queued phases into the active phases list', async () => {
const result = await initManager([], tmpDir);
const data = result.data as Record<string, unknown>;
const active = (data.phases as Record<string, unknown>[]).map(p => p.number);
// Active milestone is v2.0.5 → only Phase 35 belongs here.
expect(active).toContain('35');
expect(active).not.toContain('31');
expect(active).not.toContain('32');
});
it('returns empty queued_phases and null metadata when active is last milestone', async () => {
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [
'## Milestone v2.0.5: Only Milestone',
'',
'### Phase 35: Audit',
'**Goal**: Final.',
].join('\n'));
const result = await initManager([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.queued_phases).toEqual([]);
expect(data.queued_milestone_version).toBeNull();
expect(data.queued_milestone_name).toBeNull();
});
});
});