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:
parent
55c8988900
commit
f21537d725
2 changed files with 337 additions and 2 deletions
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue