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) <noreply@anthropic.com>

* fix: update buildRunUatPrompt call in direct dispatch after signature change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-18 17:22:20 -06:00 committed by GitHub
parent a488de99bb
commit 84b556908c
6 changed files with 67 additions and 75 deletions

View file

@ -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;
}

View file

@ -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",
};
},
},

View file

@ -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<string> {
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,
});
}

View file

@ -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;
}

View file

@ -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: <ISO 8601 timestamp>
---
@ -68,44 +65,6 @@ date: <ISO 8601 timestamp>
<any additional context, errors encountered, or follow-up items>
```
### 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: <ISO 8601 timestamp>
---
# 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.**

View file

@ -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<void> {
'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<void> {
}
}
// ─── (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<void> {
);
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<void> {
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);