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:
parent
41dda26b9a
commit
bae9e6a67d
2 changed files with 134 additions and 6 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue