fix(gsd): fail-closed stop guard, harden backtrack parsing, fix prompt params

- Stop/backtrack guard now calls pauseAuto before marking captures executed,
  and returns break on any exception to prevent silently dropping user halt intent
- Backtrack target parsing excludes current milestone ID and rejects ambiguous
  multi-target strings instead of guessing first match
- Fixed gsd_skip_slice parameter names in rethink prompt (milestone_id → milestoneId)
This commit is contained in:
Jeremy 2026-04-04 13:09:16 -05:00
parent 4f896cc561
commit abe887de10
3 changed files with 25 additions and 11 deletions

View file

@ -721,6 +721,8 @@ export async function runGuards(
// ── Stop/Backtrack directive guard (#3487) ──
// Check for unexecuted stop or backtrack captures BEFORE dispatching any unit.
// This ensures user "halt" directives are honored immediately.
// IMPORTANT: Fail-closed — any exception during stop handling still breaks the loop
// to ensure user halt intent is never silently dropped.
try {
const { loadStopCaptures, markCaptureExecuted } = await import("../captures.js");
const stopCaptures = loadStopCaptures(s.basePath);
@ -737,12 +739,10 @@ export async function runGuards(
basename(s.originalBasePath || s.basePath),
);
// Mark all stop/backtrack captures as executed so they don't re-fire
for (const cap of stopCaptures) {
markCaptureExecuted(s.basePath, cap.id);
}
// Pause first — ensures auto-mode stops even if later steps fail
await deps.pauseAuto(ctx, pi);
// For backtrack captures, write the backtrack trigger before pausing
// For backtrack captures, write the backtrack trigger after pausing
if (isBacktrack) {
try {
const { executeBacktrack } = await import("../triage-resolution.js");
@ -752,12 +752,19 @@ export async function runGuards(
}
}
await deps.pauseAuto(ctx, pi);
// Mark captures as executed only after successful pause/transition
for (const cap of stopCaptures) {
markCaptureExecuted(s.basePath, cap.id);
}
debugLog("autoLoop", { phase: "exit", reason: isBacktrack ? "user-backtrack" : "user-stop" });
return { action: "break", reason: isBacktrack ? "user-backtrack" : "user-stop" };
}
} catch (e) {
// Fail-closed: if anything in the stop guard throws, break the loop
// rather than silently continuing and dropping user halt intent
debugLog("guards", { phase: "stop-guard-error", error: String(e) });
return { action: "break", reason: "stop-guard-error" };
}
// Budget ceiling guard

View file

@ -48,7 +48,7 @@ Remove the `{ID}-PARKED.md` file from the milestone directory to reactivate it.
### Skip a slice
Mark a slice as skipped so auto-mode advances past it without executing. Use the `gsd_skip_slice` tool:
```
gsd_skip_slice({ milestone_id: "M003", slice_id: "S02", reason: "Descoped — feature moved to M005" })
gsd_skip_slice({ milestoneId: "M003", sliceId: "S02", reason: "Descoped — feature moved to M005" })
```
Skipped slices are treated as closed by the state machine (like "complete" but distinct). Use when a slice is no longer needed or has been superseded. The slice data is preserved for reference.

View file

@ -148,10 +148,17 @@ export function executeBacktrack(
capture: CaptureEntry,
): string | null {
try {
// Extract target milestone from capture text or resolution
const targetMatch = (capture.resolution ?? capture.text)
.match(/\b(M\d{3}(?:-[a-z0-9]{6})?)\b/);
const targetMilestoneId = targetMatch?.[1] ?? null;
// Extract target milestone from capture text or resolution.
// Filter out the current milestone ID to avoid picking it as the backtrack target
// when the text mentions both current and target milestones (e.g. "backtrack from M004 to M003").
const sourceText = capture.resolution ?? capture.text;
const allMatches = [...sourceText.matchAll(/\b(M\d{3}(?:-[a-z0-9]{6})?)\b/g)]
.map(m => m[1])
.filter(id => id !== currentMilestoneId);
// Reject ambiguous multi-target strings — if more than one distinct target remains,
// don't guess; let the user clarify.
const uniqueTargets = [...new Set(allMatches)];
const targetMilestoneId = uniqueTargets.length === 1 ? uniqueTargets[0] : null;
const ts = new Date().toISOString();
const triggerPath = join(gsdRoot(basePath), "BACKTRACK-TRIGGER.md");