From bae9e6a67d344b967b7991fed2fb7dfb032ab0e1 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:08:49 +0100 Subject: [PATCH] fix(gsd): extract and honor milestone argument in /gsd auto and /gsd next (#2729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/gsd auto M016` silently discarded the milestone ID and started whichever milestone deriveState() picked as first incomplete. The command handler parsed --verbose, --debug, and --yolo flags but never extracted a milestone target. Root cause: handleAutoCommand() had no milestone-ID extraction step. The `rest` string from parseYoloFlag was only checked for flags, and startAuto() was always called without milestone scoping. Fix: add parseMilestoneTarget() to extract M-prefixed IDs (M001, M001-a3b4c5) from the command string. When a milestone is specified: 1. Validate it exists via findMilestoneIds() — notify on missing 2. Set GSD_MILESTONE_LOCK env var (already honored by state.ts at three derivation points and by auto-post-unit.ts) via a withMilestoneLock() wrapper that cleans up the env var when auto-mode exits, preventing leakage into subsequent commands. Both `/gsd auto ` and `/gsd next ` are supported. Flags (--verbose, --debug) continue to work in any order. Closes #2521 --- .../extensions/gsd/commands/handlers/auto.ts | 79 +++++++++++++++++-- .../gsd/tests/auto-milestone-target.test.ts | 61 ++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/auto-milestone-target.test.ts diff --git a/src/resources/extensions/gsd/commands/handlers/auto.ts b/src/resources/extensions/gsd/commands/handlers/auto.ts index bd9a84cf9..923191cfb 100644 --- a/src/resources/extensions/gsd/commands/handlers/auto.ts +++ b/src/resources/extensions/gsd/commands/handlers/auto.ts @@ -7,6 +7,7 @@ import { enableDebug } from "../../debug-logger.js"; import { getAutoDashboardData, isAutoActive, isAutoPaused, pauseAuto, startAuto, stopAuto, stopAutoRemote } from "../../auto.js"; import { handleRate } from "../../commands-rate.js"; import { guardRemoteSession, projectRoot } from "../context.js"; +import { findMilestoneIds } from "../../milestone-id-utils.js"; /** * Parse --yolo flag and optional file path from the auto command string. @@ -28,6 +29,39 @@ function parseYoloFlag(trimmed: string): { yoloSeedFile: string | null; rest: st return { yoloSeedFile: filePath, rest }; } +/** + * Extract a milestone ID (e.g. M016 or M001-a3b4c5) from the command string. + * Returns the matched ID and the remaining string with the ID removed. + * The milestone ID pattern matches the format used by findMilestoneIds: M\d+ with + * an optional -[a-z0-9]{6} suffix for unique milestone IDs. + */ +export function parseMilestoneTarget(input: string): { milestoneId: string | null; rest: string } { + const match = input.match(/\b(M\d+(?:-[a-z0-9]{6})?)\b/); + if (!match) return { milestoneId: null, rest: input }; + const rest = input.replace(match[0], "").replace(/\s+/g, " ").trim(); + return { milestoneId: match[1], rest }; +} + +/** + * Set GSD_MILESTONE_LOCK to target a specific milestone, then run `fn`. + * Clears the env var when `fn` resolves or rejects, so the lock does not + * leak into subsequent commands in the same process. + */ +async function withMilestoneLock(milestoneId: string, fn: () => Promise): Promise { + const previous = process.env.GSD_MILESTONE_LOCK; + process.env.GSD_MILESTONE_LOCK = milestoneId; + try { + await fn(); + } finally { + // Restore previous value (undefined → delete, else restore). + if (previous === undefined) { + delete process.env.GSD_MILESTONE_LOCK; + } else { + process.env.GSD_MILESTONE_LOCK = previous; + } + } +} + export async function handleAutoCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { if (trimmed === "next" || trimmed.startsWith("next ")) { if (trimmed.includes("--dry-run")) { @@ -35,21 +69,48 @@ export async function handleAutoCommand(trimmed: string, ctx: ExtensionCommandCo await handleDryRun(ctx, projectRoot()); return true; } - const verboseMode = trimmed.includes("--verbose"); - const debugMode = trimmed.includes("--debug"); + const { milestoneId, rest: afterMilestone } = parseMilestoneTarget(trimmed); + const verboseMode = afterMilestone.includes("--verbose"); + const debugMode = afterMilestone.includes("--debug"); if (debugMode) enableDebug(projectRoot()); if (!(await guardRemoteSession(ctx, pi))) return true; - await startAuto(ctx, pi, projectRoot(), verboseMode, { step: true }); + + // Validate the milestone target exists and is not already complete. + if (milestoneId) { + const allIds = findMilestoneIds(projectRoot()); + if (!allIds.includes(milestoneId)) { + ctx.ui.notify(`Milestone ${milestoneId} does not exist. Available: ${allIds.join(", ") || "(none)"}`, "error"); + return true; + } + } + + if (milestoneId) { + await withMilestoneLock(milestoneId, () => + startAuto(ctx, pi, projectRoot(), verboseMode, { step: true }), + ); + } else { + await startAuto(ctx, pi, projectRoot(), verboseMode, { step: true }); + } return true; } if (trimmed === "auto" || trimmed.startsWith("auto ")) { - const { yoloSeedFile, rest } = parseYoloFlag(trimmed); - const verboseMode = rest.includes("--verbose"); - const debugMode = rest.includes("--debug"); + const { yoloSeedFile, rest: afterYolo } = parseYoloFlag(trimmed); + const { milestoneId, rest: afterMilestone } = parseMilestoneTarget(afterYolo); + const verboseMode = afterMilestone.includes("--verbose"); + const debugMode = afterMilestone.includes("--debug"); if (debugMode) enableDebug(projectRoot()); if (!(await guardRemoteSession(ctx, pi))) return true; + // Validate the milestone target exists and is not already complete. + if (milestoneId) { + const allIds = findMilestoneIds(projectRoot()); + if (!allIds.includes(milestoneId)) { + ctx.ui.notify(`Milestone ${milestoneId} does not exist. Available: ${allIds.join(", ") || "(none)"}`, "error"); + return true; + } + } + if (yoloSeedFile) { const resolved = resolve(projectRoot(), yoloSeedFile); if (!existsSync(resolved)) { @@ -66,6 +127,12 @@ export async function handleAutoCommand(trimmed: string, ctx: ExtensionCommandCo // when the LLM says "Milestone X ready." const { showHeadlessMilestoneCreation } = await import("../../guided-flow.js"); await showHeadlessMilestoneCreation(ctx, pi, projectRoot(), seedContent); + } else if (milestoneId) { + // Target a specific milestone — use GSD_MILESTONE_LOCK so state + // derivation only sees this milestone (#2521). + await withMilestoneLock(milestoneId, () => + startAuto(ctx, pi, projectRoot(), verboseMode), + ); } else { await startAuto(ctx, pi, projectRoot(), verboseMode); } diff --git a/src/resources/extensions/gsd/tests/auto-milestone-target.test.ts b/src/resources/extensions/gsd/tests/auto-milestone-target.test.ts new file mode 100644 index 000000000..60faf0a68 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-milestone-target.test.ts @@ -0,0 +1,61 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { parseMilestoneTarget } from "../commands/handlers/auto.js"; + +describe("parseMilestoneTarget", () => { + it("extracts a simple milestone ID", () => { + const result = parseMilestoneTarget("auto M016"); + assert.equal(result.milestoneId, "M016"); + assert.equal(result.rest, "auto"); + }); + + it("extracts a milestone ID with unique suffix", () => { + const result = parseMilestoneTarget("auto M001-a3b4c5 --verbose"); + assert.equal(result.milestoneId, "M001-a3b4c5"); + assert.equal(result.rest, "auto --verbose"); + }); + + it("returns null when no milestone ID is present", () => { + const result = parseMilestoneTarget("auto --verbose"); + assert.equal(result.milestoneId, null); + assert.equal(result.rest, "auto --verbose"); + }); + + it("extracts milestone ID with flags in any order", () => { + const result = parseMilestoneTarget("auto --verbose M003 --debug"); + assert.equal(result.milestoneId, "M003"); + assert.equal(result.rest, "auto --verbose --debug"); + }); + + it("returns null for plain 'auto'", () => { + const result = parseMilestoneTarget("auto"); + assert.equal(result.milestoneId, null); + assert.equal(result.rest, "auto"); + }); + + it("extracts from 'next' command", () => { + const result = parseMilestoneTarget("next M012"); + assert.equal(result.milestoneId, "M012"); + assert.equal(result.rest, "next"); + }); + + it("handles milestone ID at the start of input", () => { + const result = parseMilestoneTarget("M007"); + assert.equal(result.milestoneId, "M007"); + assert.equal(result.rest, ""); + }); + + it("picks the first milestone ID when multiple appear", () => { + // Edge case: user accidentally types two. First one wins. + const result = parseMilestoneTarget("auto M001 M002"); + assert.equal(result.milestoneId, "M001"); + // M002 remains in rest since only the first match is removed + assert.ok(result.rest.includes("M002")); + }); + + it("does not match bare numbers without M prefix", () => { + const result = parseMilestoneTarget("auto 016"); + assert.equal(result.milestoneId, null); + }); +});