diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index f7e50c2f2..85a79340c 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -12,7 +12,7 @@ import type { GSDState } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; import type { UatType } from "./files.js"; -import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; +import { loadFile, extractUatType, loadActiveOverrides, parseRoadmap } from "./files.js"; import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relSliceFile, buildMilestoneFileName, @@ -123,6 +123,35 @@ const DISPATCH_RULES: DispatchRule[] = [ }; }, }, + { + name: "uat-verdict-gate (non-PASS blocks progression)", + match: async ({ mid, basePath, prefs }) => { + // Only applies when UAT dispatch is enabled + if (!prefs?.uat_dispatch) return null; + + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return null; + + const roadmap = parseRoadmap(roadmapContent); + for (const slice of roadmap.slices.filter(s => s.done)) { + const resultFile = resolveSliceFile(basePath, mid, slice.id, "UAT-RESULT"); + if (!resultFile) continue; + const content = await loadFile(resultFile); + if (!content) continue; + const verdictMatch = content.match(/verdict:\s*([\w-]+)/i); + const verdict = verdictMatch?.[1]?.toLowerCase(); + if (verdict && verdict !== "pass" && verdict !== "passed") { + return { + action: "stop" as const, + reason: `UAT verdict for ${slice.id} is "${verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /gsd auto after fixing.`, + level: "warning" as const, + }; + } + } + return null; + }, + }, { name: "reassess-roadmap (post-completion)", match: async ({ state, mid, midTitle, basePath, prefs }) => { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 6125e9470..67f963a0a 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -530,11 +530,21 @@ export async function checkNeedsRunUat( const uatContent = await loadFile(uatFile); if (!uatContent) return null; - // If UAT result already exists, skip (idempotent) + // If UAT result already exists with a PASS verdict, skip (idempotent). + // Non-PASS verdicts (FAIL, surfaced-for-human-review) should block slice + // progression — return the slice for re-evaluation (#1231). const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); if (uatResultFile) { - const hasResult = !!(await loadFile(uatResultFile)); - if (hasResult) return null; + const resultContent = await loadFile(uatResultFile); + if (resultContent) { + const verdictMatch = resultContent.match(/verdict:\s*([\w-]+)/i); + const verdict = verdictMatch?.[1]?.toLowerCase(); + if (verdict === "pass" || verdict === "passed") return null; // PASS — skip + // Non-PASS verdict exists — don't re-run UAT, but don't advance either. + // Return null here since the UAT already ran; the dispatch table's + // complete-slice rule should check the verdict before advancing. + // For now, returning the slice signals it still needs attention. + } } // Classify UAT type; unknown type → treat as human-experience (human review)