From f21537d7253db63c7c5b20952887824f7d7d3da4 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Wed, 25 Mar 2026 16:05:06 -0500 Subject: [PATCH] feat(discuss): allow /gsd discuss to target queued milestones Closes #2307 Co-Authored-By: Claude Sonnet 4.6 --- src/resources/extensions/gsd/guided-flow.ts | 98 ++++++- .../tests/discuss-queued-milestones.test.ts | 241 ++++++++++++++++++ 2 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/discuss-queued-milestones.test.ts diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index c5e757052..c529462b8 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -511,9 +511,14 @@ export async function showDiscuss( const state = await deriveState(basePath); - // Guard: no active milestone + // No active milestone — check for pending milestones to discuss instead if (!state.activeMilestone) { - ctx.ui.notify("No active milestone. Run /gsd to create one first.", "warning"); + const pendingMilestones = state.registry.filter(m => m.status === "pending"); + if (pendingMilestones.length === 0) { + ctx.ui.notify("No active milestone. Run /gsd to create one first.", "warning"); + return; + } + await showDiscussQueuedMilestone(ctx, pi, basePath, pendingMilestones); return; } @@ -648,6 +653,17 @@ export async function showDiscuss( }; }); + // Offer access to queued milestones when any exist + const pendingMilestones = state.registry.filter(m => m.status === "pending"); + if (pendingMilestones.length > 0) { + actions.push({ + id: "discuss_queued_milestone", + label: "Discuss a queued milestone", + description: `Refine context for ${pendingMilestones.length} queued milestone(s). Does not affect current execution.`, + recommended: false, + }); + } + const choice = await showNextAction(ctx, { title: "GSD — Discuss a slice", summary: [ @@ -660,6 +676,11 @@ export async function showDiscuss( if (choice === "not_yet") return; + if (choice === "discuss_queued_milestone") { + await showDiscussQueuedMilestone(ctx, pi, basePath, pendingMilestones); + return; + } + const chosen = pendingSlices.find(s => s.id === choice); if (!chosen) return; @@ -689,6 +710,79 @@ export async function showDiscuss( } } +// ─── Queued Milestone Discussion ───────────────────────────────────────────── + +/** + * Show a picker of queued (pending) milestones and dispatch a discuss flow for + * the chosen one. Discussing a queued milestone does NOT activate it — it only + * refines the CONTEXT.md artifact so it is better prepared when auto-mode + * eventually reaches it. + */ +async function showDiscussQueuedMilestone( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, + pendingMilestones: Array<{ id: string; title: string; status: string }>, +): Promise { + const actions = pendingMilestones.map((m, i) => { + const hasContext = !!resolveMilestoneFile(basePath, m.id, "CONTEXT"); + const hasDraft = !hasContext && !!resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); + const contextStatus = hasContext ? "context ✓" : hasDraft ? "draft context" : "no context yet"; + return { + id: m.id, + label: `${m.id}: ${m.title}`, + description: `[queued] · ${contextStatus}`, + recommended: i === 0, + }; + }); + + const choice = await showNextAction(ctx, { + title: "GSD — Discuss a queued milestone", + summary: [ + "Select a queued milestone to discuss.", + "Discussing will update its context file. It will not be activated.", + ], + actions, + notYetMessage: "Run /gsd discuss when ready.", + }); + + if (choice === "not_yet") return; + + const chosen = pendingMilestones.find(m => m.id === choice); + if (!chosen) return; + + await dispatchDiscussForMilestone(ctx, pi, basePath, chosen.id, chosen.title); +} + +/** + * Dispatch the guided-discuss-milestone prompt for a milestone without + * setting pendingAutoStart — so discussing a queued milestone does not + * implicitly activate it when the session ends. + */ +async function dispatchDiscussForMilestone( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + basePath: string, + mid: string, + milestoneTitle: string, +): Promise { + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const draftContent = draftFile ? await loadFile(draftFile) : null; + const discussMilestoneTemplates = inlineTemplate("context", "Context"); + const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; + const basePrompt = loadPrompt("guided-discuss-milestone", { + milestoneId: mid, + milestoneTitle, + inlinedTemplates: discussMilestoneTemplates, + structuredQuestionsAvailable, + commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), + }); + const prompt = draftContent + ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` + : basePrompt; + await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-milestone"); +} + // ─── Smart Entry Point ──────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/tests/discuss-queued-milestones.test.ts b/src/resources/extensions/gsd/tests/discuss-queued-milestones.test.ts new file mode 100644 index 000000000..98c400f95 --- /dev/null +++ b/src/resources/extensions/gsd/tests/discuss-queued-milestones.test.ts @@ -0,0 +1,241 @@ +/** + * discuss-queued-milestones.test.ts — Tests for #2307. + * + * /gsd discuss was previously gated on state.activeMilestone, which prevented + * users from discussing queued (pending) milestones during roadmap grooming. + * + * These tests verify: + * 1. deriveState correctly identifies pending milestones (the set the picker + * will show when no active milestone is present) + * 2. resolveMilestoneFile correctly resolves context artifacts for pending + * milestones so the picker can report their discussion state + * 3. The guided-flow.ts source code no longer hard-exits when no active + * milestone exists but pending milestones are present + * 4. The helper functions for queued discuss exist in the source + */ + +import { describe, test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +import { deriveState } from "../state.ts"; +import { invalidateAllCaches } from "../cache.ts"; +import { resolveMilestoneFile } from "../paths.ts"; + +// ─── Fixture Helpers ────────────────────────────────────────────────────────── + +function createBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-discuss-queued-")); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +function writeMilestoneDir(base: string, mid: string): void { + mkdirSync(join(base, ".gsd", "milestones", mid), { recursive: true }); +} + +function writeContext(base: string, mid: string, content: string): void { + writeMilestoneDir(base, mid); + writeFileSync(join(base, ".gsd", "milestones", mid, `${mid}-CONTEXT.md`), content); +} + +function writeContextDraft(base: string, mid: string, content: string): void { + writeMilestoneDir(base, mid); + writeFileSync(join(base, ".gsd", "milestones", mid, `${mid}-CONTEXT-DRAFT.md`), content); +} + +function writeRoadmap(base: string, mid: string, content: string): void { + writeMilestoneDir(base, mid); + writeFileSync(join(base, ".gsd", "milestones", mid, `${mid}-ROADMAP.md`), content); +} + +function readGuidedFlowSource(): string { + const thisFile = fileURLToPath(import.meta.url); + const thisDir = dirname(thisFile); + return readFileSync(join(thisDir, "..", "guided-flow.ts"), "utf-8"); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("discuss-queued-milestones (#2307)", () => { + + test("1. pending milestones appear in registry when active milestone exists", async () => { + const base = createBase(); + try { + // M001: active — has context + roadmap with a slice + writeContext(base, "M001", "# M001: Active\nContext here."); + writeRoadmap(base, "M001", + "# M001: Active\n\n## Slices\n- [ ] **S01: Do work** `risk:low` `depends:[]`\n > After this: works\n"); + + // M002: pending — context only, no roadmap + writeContext(base, "M002", "# M002: Queued\nFuture work."); + + // M003: pending — draft context only + writeContextDraft(base, "M003", "# M003: Draft\nSeed material."); + + invalidateAllCaches(); + const state = await deriveState(base); + + assert.ok(!!state.activeMilestone, "M001 should be the active milestone"); + assert.strictEqual(state.activeMilestone?.id, "M001"); + + const pendingIds = state.registry + .filter(m => m.status === "pending") + .map(m => m.id); + + assert.ok(pendingIds.includes("M002"), "M002 should be pending"); + assert.ok(pendingIds.includes("M003"), "M003 should be pending"); + } finally { + cleanup(base); + } + }); + + test("2. first context-only milestone is active, subsequent ones are pending", async () => { + const base = createBase(); + try { + // M001: first milestone with context but no roadmap — deriveState marks it active + writeContext(base, "M001", "# M001: First\nContext here."); + // M002: will be pending since M001 is active + writeContext(base, "M002", "# M002: Second\nMore future work."); + + invalidateAllCaches(); + const state = await deriveState(base); + + // deriveState makes the first unfinished milestone "active" even without a roadmap + assert.ok(!!state.activeMilestone, "first milestone should be active"); + assert.strictEqual(state.activeMilestone?.id, "M001", "M001 is the active milestone"); + + const pendingIds = state.registry + .filter(m => m.status === "pending") + .map(m => m.id); + + assert.ok(pendingIds.includes("M002"), + "M002 should be pending — it comes after the active M001"); + } finally { + cleanup(base); + } + }); + + test("3. resolveMilestoneFile finds CONTEXT.md for pending milestone", (t) => { + const base = createBase(); + try { + writeContext(base, "M002", "# M002: Queued\nContent."); + + const contextFile = resolveMilestoneFile(base, "M002", "CONTEXT"); + assert.ok(contextFile !== null, "resolveMilestoneFile should find CONTEXT.md for M002"); + assert.ok(contextFile!.endsWith("M002-CONTEXT.md"), + "resolved path should point to M002-CONTEXT.md"); + } finally { + cleanup(base); + } + }); + + test("4. resolveMilestoneFile finds CONTEXT-DRAFT.md for pending milestone", (t) => { + const base = createBase(); + try { + writeContextDraft(base, "M003", "# M003: Draft\nSeed content."); + + const draftFile = resolveMilestoneFile(base, "M003", "CONTEXT-DRAFT"); + assert.ok(draftFile !== null, "resolveMilestoneFile should find CONTEXT-DRAFT.md for M003"); + assert.ok(draftFile!.endsWith("M003-CONTEXT-DRAFT.md"), + "resolved path should point to M003-CONTEXT-DRAFT.md"); + } finally { + cleanup(base); + } + }); + + test("5. resolveMilestoneFile returns null when pending milestone has no context", (t) => { + const base = createBase(); + try { + writeMilestoneDir(base, "M004"); + + const contextFile = resolveMilestoneFile(base, "M004", "CONTEXT"); + assert.strictEqual(contextFile, null, + "resolveMilestoneFile should return null when no CONTEXT.md exists"); + + const draftFile = resolveMilestoneFile(base, "M004", "CONTEXT-DRAFT"); + assert.strictEqual(draftFile, null, + "resolveMilestoneFile should return null when no CONTEXT-DRAFT.md exists"); + } finally { + cleanup(base); + } + }); + + test("6. guided-flow no longer hard-exits when no active milestone but pending exist", () => { + const source = readGuidedFlowSource(); + + // The old guard was a simple early-exit: + // if (!state.activeMilestone) { + // ctx.ui.notify("No active milestone. Run /gsd to create one first.", "warning"); + // return; + // } + // + // The new guard should check for pending milestones and route instead. + const oldGuardPattern = /if\s*\(!state\.activeMilestone\)\s*\{\s*ctx\.ui\.notify\("No active milestone/; + assert.ok( + !oldGuardPattern.test(source), + "guided-flow must not unconditionally exit when activeMilestone is null", + ); + }); + + test("7. showDiscussQueuedMilestone helper exists in guided-flow", () => { + const source = readGuidedFlowSource(); + assert.ok( + source.includes("showDiscussQueuedMilestone"), + "guided-flow must export showDiscussQueuedMilestone helper", + ); + }); + + test("8. dispatchDiscussForMilestone helper exists in guided-flow", () => { + const source = readGuidedFlowSource(); + assert.ok( + source.includes("dispatchDiscussForMilestone"), + "guided-flow must export dispatchDiscussForMilestone helper", + ); + }); + + test("9. dispatchDiscussForMilestone does not set pendingAutoStart", () => { + const source = readGuidedFlowSource(); + + // Extract the dispatchDiscussForMilestone function body + const fnMatch = source.match( + /async function dispatchDiscussForMilestone\s*\([^)]*\)[^{]*\{([\s\S]*?)\n\}/, + ); + assert.ok(!!fnMatch, "dispatchDiscussForMilestone function body must be present"); + + if (fnMatch) { + assert.ok( + !fnMatch[1].includes("pendingAutoStart"), + "dispatchDiscussForMilestone must NOT set pendingAutoStart — discussing a queued milestone must not activate it", + ); + } + }); + + test("10. slice picker includes queued milestone option when pending milestones exist", () => { + const source = readGuidedFlowSource(); + assert.ok( + source.includes("discuss_queued_milestone"), + "slice picker must include a 'discuss_queued_milestone' action id for queued milestones", + ); + assert.ok( + source.includes("Discuss a queued milestone"), + "slice picker must label the queued milestone action clearly", + ); + }); + + test("11. queued milestone picker labels entries with [queued]", () => { + const source = readGuidedFlowSource(); + assert.ok( + source.includes("[queued]"), + "queued milestone picker must label entries with [queued] to distinguish from active", + ); + }); +});