fix(gsd): extract and honor milestone argument in /gsd auto and /gsd next (#2729)

`/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 <milestone>` and `/gsd next <milestone>` are
supported. Flags (--verbose, --debug) continue to work in any order.

Closes #2521
This commit is contained in:
mastertyko 2026-03-26 23:08:49 +01:00 committed by GitHub
parent 41dda26b9a
commit bae9e6a67d
2 changed files with 134 additions and 6 deletions

View file

@ -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<void>): Promise<void> {
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<boolean> {
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);
}

View file

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