diff --git a/get-shit-done/workflows/manager.md b/get-shit-done/workflows/manager.md index 8d9de60a4..6d9da93d8 100644 --- a/get-shit-done/workflows/manager.md +++ b/get-shit-done/workflows/manager.md @@ -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 diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index e45c7d104..f0769ffb9 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -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; + expect(data.queued_milestone_version).toBe('v2.1'); + expect(data.queued_milestone_name).toBe('Daily Emails'); + const queued = data.queued_phases as Record[]; + 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; + const queued = data.queued_phases as Record[]; + 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; + const active = (data.phases as Record[]).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; + expect(data.queued_phases).toEqual([]); + expect(data.queued_milestone_version).toBeNull(); + expect(data.queued_milestone_name).toBeNull(); + }); + }); }); diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index 5676e3422..7628b6228 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -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[] = []; + 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).manager as Record> | 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, diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts index dafcabdf0..e001e629d 100644 --- a/sdk/src/query/roadmap.test.ts +++ b/sdk/src/query/roadmap.test.ts @@ -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(); + }); +}); diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index 3c8ec3de9..984ebbb64 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -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 ───────────────────────────────────────────────────── /**