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

@@ -23,7 +23,7 @@ INIT=$(gsd-sdk query init.manager)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```
Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`, `manager_flags`.
Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`, `manager_flags`, and the optional trio `queued_milestone_version`, `queued_milestone_name`, `queued_phases` (added in SDK fix `2495-2496-2497` — may be absent on older SDK versions, treat missing as empty).
`manager_flags` contains per-step passthrough flags from config:
- `manager_flags.discuss` — appended to `/gsd-discuss-phase` args (e.g. `"--auto --analyze"`)
@@ -103,6 +103,28 @@ Example output:
| 6 | Polish & Final Mail… | 1-5 | · | · | · | · Up next |
```
**Queued section (next milestone preview):**
If `queued_phases` is present and non-empty, render a compact preview of the next milestone's phases directly below the main table. This surfaces upcoming work without cluttering the active-milestone grid. Skip this section entirely when `queued_phases` is empty or missing (e.g. the active milestone is the last one in the roadmap).
Use `queued_milestone_version` and `queued_milestone_name` for the header. Phases render without D/P/E columns since they aren't discussed yet — just number, name (pre-truncated `display_name`), dependencies (`deps_display`), and a fixed `· Queued` status. Phase-name padding should match the active-table column width for visual alignment.
Example:
```
───────────────────────────────────────────────────────────────
◆ Queued — {queued_milestone_version} {queued_milestone_name} ({queued_phases.length} phases)
───────────────────────────────────────────────────────────────
| # | Phase | Deps | Status |
|---|----------------------|------|--------------|
| 31| Email Logs | — | · Queued |
| 32| Today's Sheets | 31 | · Queued |
| 33| Resend Backfill | 31 | · Queued |
| 34| Business Day Audit | 31 | · Queued |
```
Queued phases are NOT eligible for the Continue action menu — they live in a future milestone and must wait for the current milestone to ship. The preview exists purely for situational awareness.
**Recommendations section:**
If `all_complete` is true:
@@ -362,4 +384,5 @@ Display final status with progress bar:
- [ ] Exit shows final status with resume instructions
- [ ] "Other" free-text input parsed for phase number and action
- [ ] Manager loop continues until user exits or milestone completes
- [ ] Queued section renders when `queued_phases` is non-empty; skipped when absent or empty
</success_criteria>

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();
});
});
});

View File

@@ -26,7 +26,12 @@ import { homedir } from 'node:os';
import { loadConfig } from '../config.js';
import { resolveModel } from './config-query.js';
import { planningPaths, normalizePhaseName, phaseTokenMatches, toPosixPath } from './helpers.js';
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
import {
getMilestoneInfo,
extractCurrentMilestone,
extractNextMilestoneSection,
extractPhasesFromSection,
} from './roadmap.js';
import { withProjectRoot } from './init.js';
import type { QueryHandler } from './utils.js';
@@ -543,6 +548,40 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream)
const completedCount = phases.filter(p => p.disk_status === 'complete').length;
// ── Next-milestone surface (issue #2497) ───────────────────────────────
// Populate queued_phases + metadata with the milestone immediately after
// the active one, so the /gsd-manager dashboard can preview what's coming
// next without mixing it into the active phases grid. Empty/null when the
// active milestone is the last one in ROADMAP.
let queuedPhases: Record<string, unknown>[] = [];
let queuedMilestoneVersion: string | null = null;
let queuedMilestoneName: string | null = null;
try {
const next = await extractNextMilestoneSection(rawContent, projectDir);
if (next) {
queuedMilestoneVersion = next.version;
queuedMilestoneName = next.name;
queuedPhases = extractPhasesFromSection(next.section).map(p => {
const MAX_NAME_WIDTH = 20;
const display_name = p.name.length > MAX_NAME_WIDTH
? p.name.slice(0, MAX_NAME_WIDTH - 1) + '…'
: p.name;
const depNums = p.depends_on && !/^none$/i.test(p.depends_on.trim())
? (p.depends_on.match(/\d+(?:\.\d+)*/g) || [])
: [];
return {
number: p.number,
name: p.name,
display_name,
goal: p.goal,
depends_on: p.depends_on,
dep_phases: depNums,
deps_display: depNums.length > 0 ? depNums.join(',') : '—',
};
});
}
} catch { /* queued_phases is a non-critical enhancement */ }
// Read manager flags from config
const managerConfig = (config as Record<string, unknown>).manager as Record<string, Record<string, string>> | undefined;
const sanitizeFlags = (raw: unknown): string => {
@@ -568,6 +607,9 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream)
recommended_actions: recommendedActions,
waiting_signal: waitingSignal,
all_complete: completedCount === phases.length && phases.length > 0,
queued_phases: queuedPhases,
queued_milestone_version: queuedMilestoneVersion,
queued_milestone_name: queuedMilestoneName,
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
roadmap_exists: true,
state_exists: true,

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();
});
});

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