feat(discuss): allow /gsd discuss to target queued milestones

Closes #2307

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeremy McSpadden 2026-03-25 16:05:06 -05:00
parent 55c8988900
commit f21537d725
2 changed files with 337 additions and 2 deletions

View file

@ -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<void> {
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<void> {
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 ────────────────────────────────────────────────────────
/**

View file

@ -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",
);
});
});