From abe887de1074a9137c71fb0e48296a0fb11d4085 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 13:09:16 -0500 Subject: [PATCH] fix(gsd): fail-closed stop guard, harden backtrack parsing, fix prompt params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/resources/extensions/gsd/auto/phases.ts | 19 +++++++++++++------ .../extensions/gsd/prompts/rethink.md | 2 +- .../extensions/gsd/triage-resolution.ts | 15 +++++++++++---- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 83fdae0e6..297fe7e4c 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -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 diff --git a/src/resources/extensions/gsd/prompts/rethink.md b/src/resources/extensions/gsd/prompts/rethink.md index a7b136c66..f07a8640a 100644 --- a/src/resources/extensions/gsd/prompts/rethink.md +++ b/src/resources/extensions/gsd/prompts/rethink.md @@ -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. diff --git a/src/resources/extensions/gsd/triage-resolution.ts b/src/resources/extensions/gsd/triage-resolution.ts index 746895e75..256091edf 100644 --- a/src/resources/extensions/gsd/triage-resolution.ts +++ b/src/resources/extensions/gsd/triage-resolution.ts @@ -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");