From 84b556908c8d4c2e9f606bfecf2479e17726e152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 18 Mar 2026 17:22:20 -0600 Subject: [PATCH] fix: skip non-artifact UAT dispatch in auto-mode (#1277) * fix(gsd): skip non-artifact UAT dispatch in auto-mode Non-artifact-driven UATs (human-experience, live-runtime, mixed) were dispatched only to write a "surfaced-for-human-review" verdict, which then blocked the verdict gate and killed auto-mode progression. Auto now only dispatches artifact-driven UATs it can actually execute. - checkNeedsRunUat returns null for non-artifact-driven UAT types - Remove pauseAfterDispatch flag (always artifact-driven now) - Strip human-review template path from run-uat prompt - Remove dead pause-after-UAT logic from auto.ts - Add test for non-artifact UAT skip + stale replay guard Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update buildRunUatPrompt call in direct dispatch after signature change Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../extensions/gsd/auto-direct-dispatch.ts | 7 +-- src/resources/extensions/gsd/auto-dispatch.ts | 12 ++-- src/resources/extensions/gsd/auto-prompts.ts | 9 +-- src/resources/extensions/gsd/auto.ts | 8 --- .../extensions/gsd/prompts/run-uat.md | 43 +------------ .../extensions/gsd/tests/run-uat.test.ts | 63 ++++++++++++++++--- 6 files changed, 67 insertions(+), 75 deletions(-) diff --git a/src/resources/extensions/gsd/auto-direct-dispatch.ts b/src/resources/extensions/gsd/auto-direct-dispatch.ts index 1aac353db..2888acb9e 100644 --- a/src/resources/extensions/gsd/auto-direct-dispatch.ts +++ b/src/resources/extensions/gsd/auto-direct-dispatch.ts @@ -182,15 +182,10 @@ export async function dispatchDirectPhase( ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning"); return; } - const uatContent = await loadFile(uatFile); - if (!uatContent) { - ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning"); - return; - } const uatPath = relSliceFile(base, mid, sid, "UAT"); unitType = "run-uat"; unitId = `${mid}/${sid}`; - prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base); + prompt = await buildRunUatPrompt(mid, sid, uatPath, base); break; } diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 6932ddb93..e1c506127 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -11,8 +11,7 @@ import type { GSDState } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; -import type { UatType } from "./files.js"; -import { loadFile, extractUatType, loadActiveOverrides, parseRoadmap } from "./files.js"; +import { loadFile, loadActiveOverrides, parseRoadmap } from "./files.js"; import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relSliceFile, buildMilestoneFileName, @@ -39,7 +38,7 @@ import { // ─── Types ──────────────────────────────────────────────────────────────── export type DispatchAction = - | { action: "dispatch"; unitType: string; unitId: string; prompt: string; pauseAfterDispatch?: boolean } + | { action: "dispatch"; unitType: string; unitId: string; prompt: string } | { action: "stop"; reason: string; level: "info" | "warning" | "error" } | { action: "skip" }; @@ -138,17 +137,14 @@ const DISPATCH_RULES: DispatchRule[] = [ match: async ({ state, mid, basePath, prefs }) => { const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs); if (!needsRunUat) return null; - const { sliceId, uatType } = needsRunUat; - const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; - const uatContent = await loadFile(uatFile); + const { sliceId } = needsRunUat; return { action: "dispatch", unitType: "run-uat", unitId: `${mid}/${sliceId}`, prompt: await buildRunUatPrompt( - mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, + mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), basePath, ), - pauseAfterDispatch: uatType !== "artifact-driven", }; }, }, diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 82b93939c..808394906 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -540,8 +540,11 @@ export async function checkNeedsRunUat( if (resultContent) return null; } - // Classify UAT type; unknown type → treat as human-experience (human review) + // Classify UAT type; skip non-artifact-driven types — auto-mode can only + // execute mechanical checks. Non-artifact UATs are tracked in the dashboard + // but don't block auto-mode progression. const uatType = extractUatType(uatContent) ?? "human-experience"; + if (uatType !== "artifact-driven") return null; return { sliceId: sid, uatType }; } @@ -1111,7 +1114,7 @@ export async function buildReplanSlicePrompt( } export async function buildRunUatPrompt( - mid: string, sliceId: string, uatPath: string, uatContent: string, base: string, + mid: string, sliceId: string, uatPath: string, base: string, ): Promise { const inlined: string[] = []; inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`)); @@ -1129,7 +1132,6 @@ export async function buildRunUatPrompt( const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT")); - const uatType = extractUatType(uatContent) ?? "human-experience"; return loadPrompt("run-uat", { workingDirectory: base, @@ -1137,7 +1139,6 @@ export async function buildRunUatPrompt( sliceId, uatPath, uatResultPath, - uatType, inlinedContext, }); } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 4b3e4e988..e45cd65d4 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1457,7 +1457,6 @@ async function dispatchNextUnit( unitType = dispatchResult.unitType; unitId = dispatchResult.unitId; prompt = dispatchResult.prompt; - let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; // ── Pre-dispatch hooks ── const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath); @@ -1712,13 +1711,6 @@ async function dispatchNextUnit( { triggerTurn: true }, ); - if (pauseAfterUatDispatch) { - ctx.ui.notify( - "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", - "info", - ); - await pauseAuto(ctx, pi); - } } finally { s.dispatching = false; } diff --git a/src/resources/extensions/gsd/prompts/run-uat.md b/src/resources/extensions/gsd/prompts/run-uat.md index f00d2cb4c..3af709b63 100644 --- a/src/resources/extensions/gsd/prompts/run-uat.md +++ b/src/resources/extensions/gsd/prompts/run-uat.md @@ -17,11 +17,8 @@ If a `GSD Skill Preferences` block is present in system context, use it to decid ## UAT Instructions **UAT file:** `{{uatPath}}` -**UAT type:** `{{uatType}}` **Result file to write:** `{{uatResultPath}}` -### If UAT type is `artifact-driven` - You are the test runner. Execute every check defined in `{{uatPath}}` directly: - Run shell commands with `bash` @@ -46,7 +43,7 @@ Write `{{uatResultPath}}` with: ```markdown --- sliceId: {{sliceId}} -uatType: {{uatType}} +uatType: artifact-driven verdict: PASS | FAIL | PARTIAL date: --- @@ -68,44 +65,6 @@ date: ``` -### If UAT type is NOT `artifact-driven` (type is `{{uatType}}`) - -This UAT type requires human execution or live-runtime observation that you cannot perform mechanically. Your role is to surface it clearly for review. - -Write `{{uatResultPath}}` with: - -```markdown ---- -sliceId: {{sliceId}} -uatType: {{uatType}} -verdict: surfaced-for-human-review -date: ---- - -# UAT Result — {{sliceId}} - -## UAT Type - -`{{uatType}}` — requires human execution or live-runtime verification. - -## Status - -Surfaced for human review. Auto-mode will pause after this unit so the UAT can be performed manually. - -## UAT File - -See `{{uatPath}}` for the full UAT specification and acceptance criteria. - -## Instructions for Human Reviewer - -Review `{{uatPath}}`, perform the described UAT steps, then update this file with: -- The actual verdict (PASS / FAIL / PARTIAL) -- Results for each check -- Date completed - -Once updated, run `/gsd auto` to resume auto-mode. -``` - --- **You MUST write `{{uatResultPath}}` before finishing.** diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts index c5acaf4d7..be24b2dfb 100644 --- a/src/resources/extensions/gsd/tests/run-uat.test.ts +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -6,7 +6,8 @@ // (a)–(j) extractUatType classification (17 assertions from T01) // (k) run-uat prompt template loading and content integrity (8 assertions) // (l) dispatch precondition assertions via resolveSliceFile (4 assertions) -// (m) stale replay guard: existing UAT-RESULT never re-dispatches (2 assertions) +// (m) non-artifact UAT skip: human-experience UATs are not dispatched (1 assertion) +// (n) stale replay guard: existing UAT-RESULT never re-dispatches (1 assertion) import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -254,8 +255,8 @@ async function main(): Promise { 'prompt contains artifact-driven execution language (artifact/execute/run)', ); assertTrue( - /surfaced for human review/i.test(promptResult ?? ''), - 'prompt contains "surfaced for human review" text for non-artifact-driven path', + !/surfaced for human review/i.test(promptResult ?? ''), + 'prompt does not contain "surfaced for human review" (non-artifact UATs are skipped, not dispatched)', ); // ─── (l) dispatch precondition assertions via resolveSliceFile ──────────── @@ -310,8 +311,56 @@ async function main(): Promise { } } - // ─── (m) stale replay guard: existing UAT-RESULT never re-dispatches ───── - console.log('\n── (m) stale replay guard'); + // ─── (m) non-artifact UATs are skipped (not dispatched) ───────────────── + console.log('\n── (m) non-artifact UAT skip'); + + { + const base = createFixtureBase(); + try { + const roadmapDir = join(base, '.gsd', 'milestones', 'M001'); + mkdirSync(roadmapDir, { recursive: true }); + writeFileSync( + join(roadmapDir, 'M001-ROADMAP.md'), + [ + '# M001: Test roadmap', + '', + '## Slices', + '', + '- [x] **S01: First slice** `risk:low` `depends:[]`', + '- [ ] **S02: Next slice** `risk:low` `depends:[S01]`', + '', + '## Boundary Map', + '', + ].join('\n'), + ); + + // human-experience UAT — should not dispatch + writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('human-experience')); + + const state = { + activeMilestone: { id: 'M001', title: 'Test roadmap' }, + activeSlice: { id: 'S02', title: 'Next slice' }, + activeTask: null, + phase: 'planning', + recentDecisions: [], + blockers: [], + nextAction: 'Plan S02', + registry: [], + } as const; + + const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); + assertEq( + result, + null, + 'human-experience UAT is skipped — auto-mode only dispatches artifact-driven UATs', + ); + } finally { + cleanup(base); + } + } + + // ─── (n) existing UAT-RESULT never re-dispatches ────────────────────── + console.log('\n── (n) stale replay guard'); { const base = createFixtureBase(); @@ -334,7 +383,7 @@ async function main(): Promise { ); writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven')); - writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: surfaced-for-human-review\n---\n'); + writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '---\nverdict: FAIL\n---\n'); const state = { activeMilestone: { id: 'M001', title: 'Test roadmap' }, @@ -351,7 +400,7 @@ async function main(): Promise { assertEq( result, null, - 'existing UAT-RESULT with non-PASS verdict does not re-dispatch run-uat; verdict gate owns blocking', + 'existing UAT-RESULT with FAIL verdict does not re-dispatch; verdict gate owns blocking', ); } finally { cleanup(base);