fix: gate slice progression on UAT verdict, not just file existence (#1241)

Two changes:

1. checkNeedsRunUat now reads the verdict from UAT-RESULT. Only skips
   re-running UAT when verdict is PASS/passed. Non-PASS verdicts (FAIL,
   surfaced-for-human-review) no longer silently advance.

2. Added uat-verdict-gate dispatch rule between run-uat and
   reassess-roadmap. When uat_dispatch is enabled, scans all completed
   slices for non-PASS UAT verdicts and stops auto-mode if found.
   This prevents advancing to the next slice when a UAT failed or
   needs human review.

Fixes #1231
This commit is contained in:
Tom Boucher 2026-03-18 16:00:36 -04:00 committed by GitHub
parent 79ceea257f
commit 1947e8e8e3
2 changed files with 43 additions and 4 deletions

View file

@ -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 }) => {

View file

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