refactor(auto): extract dispatch table from if-else chain (#521) (#539)

Replace the 130-line if-else chain in dispatchNextUnit with a
declarative DispatchRule[] table in auto-dispatch.ts.

Each rule maps a GSD state to the unit type, unit ID, and prompt
builder. Rules are evaluated in order; first match wins. The table
is inspectable, testable per-rule, and extensible without modifying
orchestration code.

- auto-dispatch.ts: 258 lines, 12 named rules
- auto.ts dispatch section: 130 lines → 20 lines
- Updated auto-draft-pause test to verify rules in new location
- 123/123 tests pass, zero TypeScript errors

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-15 17:10:27 -06:00 committed by GitHub
parent 06f4bdc7f4
commit 4afcc81382
3 changed files with 291 additions and 163 deletions

View file

@ -0,0 +1,258 @@
/**
* Auto-mode Dispatch Table declarative phase unit mapping.
*
* Each rule maps a GSD state to the unit type, unit ID, and prompt builder
* that should be dispatched. Rules are evaluated in order; the first match wins.
*
* This replaces the 130-line if-else chain in dispatchNextUnit with a
* data structure that is inspectable, testable per-rule, and extensible
* without modifying orchestration code.
*/
import type { GSDState } from "./types.js";
import type { GSDPreferences } from "./preferences.js";
import type { UatType } from "./files.js";
import { loadFile, extractUatType } from "./files.js";
import {
resolveMilestoneFile, resolveSliceFile,
relSliceFile,
} from "./paths.js";
import {
buildResearchMilestonePrompt,
buildPlanMilestonePrompt,
buildResearchSlicePrompt,
buildPlanSlicePrompt,
buildExecuteTaskPrompt,
buildCompleteSlicePrompt,
buildCompleteMilestonePrompt,
buildReplanSlicePrompt,
buildRunUatPrompt,
buildReassessRoadmapPrompt,
checkNeedsReassessment,
checkNeedsRunUat,
} from "./auto-prompts.js";
// ─── Types ────────────────────────────────────────────────────────────────
export type DispatchAction =
| { action: "dispatch"; unitType: string; unitId: string; prompt: string; pauseAfterDispatch?: boolean }
| { action: "stop"; reason: string; level: "info" | "warning" | "error" }
| { action: "skip" };
export interface DispatchContext {
basePath: string;
mid: string;
midTitle: string;
state: GSDState;
prefs: GSDPreferences | undefined;
}
interface DispatchRule {
/** Human-readable name for debugging and test identification */
name: string;
/** Return a DispatchAction if this rule matches, null to fall through */
match: (ctx: DispatchContext) => Promise<DispatchAction | null>;
}
// ─── Rules ────────────────────────────────────────────────────────────────
const DISPATCH_RULES: DispatchRule[] = [
{
name: "summarizing → complete-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "summarizing") return null;
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
return {
action: "dispatch",
unitType: "complete-slice",
unitId: `${mid}/${sid}`,
prompt: await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, basePath),
};
},
},
{
name: "run-uat (post-completion)",
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);
return {
action: "dispatch",
unitType: "run-uat",
unitId: `${mid}/${sliceId}`,
prompt: await buildRunUatPrompt(
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
),
pauseAfterDispatch: uatType !== "artifact-driven",
};
},
},
{
name: "reassess-roadmap (post-completion)",
match: async ({ state, mid, midTitle, basePath }) => {
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
if (!needsReassess) return null;
return {
action: "dispatch",
unitType: "reassess-roadmap",
unitId: `${mid}/${needsReassess.sliceId}`,
prompt: await buildReassessRoadmapPrompt(mid, midTitle, needsReassess.sliceId, basePath),
};
},
},
{
name: "needs-discussion → stop",
match: async ({ state, mid, midTitle }) => {
if (state.phase !== "needs-discussion") return null;
return {
action: "stop",
reason: `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
level: "warning",
};
},
},
{
name: "pre-planning (no context) → stop",
match: async ({ state, mid, basePath }) => {
if (state.phase !== "pre-planning") return null;
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
const hasContext = !!(contextFile && await loadFile(contextFile));
if (hasContext) return null; // fall through to next rule
return {
action: "stop",
reason: "No context or roadmap yet. Run /gsd to discuss first.",
level: "warning",
};
},
},
{
name: "pre-planning (no research) → research-milestone",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "pre-planning") return null;
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
if (researchFile) return null; // has research, fall through
return {
action: "dispatch",
unitType: "research-milestone",
unitId: mid,
prompt: await buildResearchMilestonePrompt(mid, midTitle, basePath),
};
},
},
{
name: "pre-planning (has research) → plan-milestone",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "pre-planning") return null;
return {
action: "dispatch",
unitType: "plan-milestone",
unitId: mid,
prompt: await buildPlanMilestonePrompt(mid, midTitle, basePath),
};
},
},
{
name: "planning (no research, not S01) → research-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "planning") return null;
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
if (researchFile) return null; // has research, fall through
// Skip slice research for S01 when milestone research already exists —
// the milestone research already covers the same ground for the first slice.
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice
return {
action: "dispatch",
unitType: "research-slice",
unitId: `${mid}/${sid}`,
prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath),
};
},
},
{
name: "planning → plan-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "planning") return null;
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
return {
action: "dispatch",
unitType: "plan-slice",
unitId: `${mid}/${sid}`,
prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
};
},
},
{
name: "replanning-slice → replan-slice",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "replanning-slice") return null;
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
return {
action: "dispatch",
unitType: "replan-slice",
unitId: `${mid}/${sid}`,
prompt: await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
};
},
},
{
name: "executing → execute-task",
match: async ({ state, mid, basePath }) => {
if (state.phase !== "executing" || !state.activeTask) return null;
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const tid = state.activeTask.id;
const tTitle = state.activeTask.title;
return {
action: "dispatch",
unitType: "execute-task",
unitId: `${mid}/${sid}/${tid}`,
prompt: await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath),
};
},
},
{
name: "completing-milestone → complete-milestone",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "completing-milestone") return null;
return {
action: "dispatch",
unitType: "complete-milestone",
unitId: mid,
prompt: await buildCompleteMilestonePrompt(mid, midTitle, basePath),
};
},
},
];
// ─── Resolver ─────────────────────────────────────────────────────────────
/**
* Evaluate dispatch rules in order. Returns the first matching action,
* or a "stop" action if no rule matches (unhandled phase).
*/
export async function resolveDispatch(ctx: DispatchContext): Promise<DispatchAction> {
for (const rule of DISPATCH_RULES) {
const result = await rule.match(ctx);
if (result) return result;
}
// No rule matched — unhandled phase
return {
action: "stop",
reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`,
level: "info",
};
}
/** Exposed for testing — returns the rule names in evaluation order. */
export function getDispatchRuleNames(): string[] {
return DISPATCH_RULES.map(r => r.name);
}

View file

@ -107,20 +107,7 @@ import {
buildLoopRemediationSteps,
reconcileMergeState,
} from "./auto-recovery.js";
import {
buildResearchMilestonePrompt,
buildPlanMilestonePrompt,
buildResearchSlicePrompt,
buildPlanSlicePrompt,
buildExecuteTaskPrompt,
buildCompleteSlicePrompt,
buildCompleteMilestonePrompt,
buildReplanSlicePrompt,
buildRunUatPrompt,
buildReassessRoadmapPrompt,
checkNeedsReassessment,
checkNeedsRunUat,
} from "./auto-prompts.js";
import { resolveDispatch } from "./auto-dispatch.js";
import {
type AutoDashboardData,
updateProgressWidget as _updateProgressWidget,
@ -1347,144 +1334,27 @@ async function dispatchNextUnit(
await runSecretsGate();
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
// Flag: for human/mixed UAT, pause auto-mode after the prompt is sent so the user
// can perform the UAT manually. On next resume, result file will exist → skip.
let pauseAfterUatDispatch = false;
// ── Dispatch table: resolve phase → unit type + prompt ──
const dispatchResult = await resolveDispatch({
basePath, mid, midTitle: midTitle!, state, prefs,
});
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
// If the current phase is "summarizing", complete-slice is responsible for
// complete-slice must run before reassessment.
if (state.phase === "summarizing") {
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
unitType = "complete-slice";
unitId = `${mid}/${sid}`;
prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
} else {
// ── Adaptive Replanning: check if last completed slice needs reassessment ──
// Computed here (after summarizing guard) so complete-slice always runs first.
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
if (needsRunUat) {
const { sliceId, uatType } = needsRunUat;
unitType = "run-uat";
unitId = `${mid}/${sliceId}`;
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
const uatContent = await loadFile(uatFile);
prompt = await buildRunUatPrompt(
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
);
// For non-artifact-driven UAT types, pause after the prompt is dispatched.
// The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
// then auto-mode pauses for human execution. On resume, result file exists → skip.
if (uatType !== "artifact-driven") {
pauseAfterUatDispatch = true;
}
} else if (needsReassess) {
unitType = "reassess-roadmap";
unitId = `${mid}/${needsReassess.sliceId}`;
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
} else if (state.phase === "needs-discussion") {
// Draft milestone — pause auto-mode and notify user.
// This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion
// where the user chose "Needs own discussion". Auto-mode cannot proceed because
// the draft is seed material, not a finalized context — planning requires a
// dedicated discussion first.
await stopAuto(ctx, pi);
ctx.ui.notify(
`${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
"warning",
);
return;
} else if (state.phase === "pre-planning") {
// Need roadmap — check if context exists
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
const hasContext = !!(contextFile && await loadFile(contextFile));
if (!hasContext) {
await stopAuto(ctx, pi);
ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
return;
}
// Research before roadmap if no research exists
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
const hasResearch = !!researchFile;
if (!hasResearch) {
unitType = "research-milestone";
unitId = mid;
prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
} else {
unitType = "plan-milestone";
unitId = mid;
prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
}
} else if (state.phase === "planning") {
// Slice needs planning — but research first if no research exists
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
const hasResearch = !!researchFile;
if (!hasResearch) {
// Skip slice research for S01 when milestone research already exists —
// the milestone research already covers the same ground for the first slice.
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
const hasMilestoneResearch = !!milestoneResearchFile;
if (hasMilestoneResearch && sid === "S01") {
unitType = "plan-slice";
unitId = `${mid}/${sid}`;
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
} else {
unitType = "research-slice";
unitId = `${mid}/${sid}`;
prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
}
} else {
unitType = "plan-slice";
unitId = `${mid}/${sid}`;
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
}
} else if (state.phase === "replanning-slice") {
// Blocker discovered — replan the slice before continuing
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
unitType = "replan-slice";
unitId = `${mid}/${sid}`;
prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
} else if (state.phase === "executing" && state.activeTask) {
// Execute next task
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const tid = state.activeTask.id;
const tTitle = state.activeTask.title;
unitType = "execute-task";
unitId = `${mid}/${sid}/${tid}`;
prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
} else if (state.phase === "completing-milestone") {
// All slices done — complete the milestone
unitType = "complete-milestone";
unitId = mid;
prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
} else {
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
ctx.ui.notify(`Unhandled phase "${state.phase}" — run /gsd doctor to diagnose.`, "info");
return;
if (dispatchResult.action === "stop") {
if (currentUnit) {
const modelId = ctx.model?.id ?? "unknown";
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
}
await stopAuto(ctx, pi);
ctx.ui.notify(dispatchResult.reason, dispatchResult.level);
return;
}
unitType = dispatchResult.unitType;
unitId = dispatchResult.unitId;
prompt = dispatchResult.prompt;
let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
// ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ──
const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath);
if (preDispatchResult.firedHooks.length > 0) {

View file

@ -74,14 +74,8 @@ assert(
`executing label should include task ID, got: "${exResult.label}"`,
);
// ─── Static verification: needs-discussion in dispatchNextUnit ──────────────
// ─── Static verification: needs-discussion in dispatch table ──────────────
const autoSource = readFileSync(
join(import.meta.dirname, "..", "auto.ts"),
"utf-8",
);
// describeNextUnit was extracted to auto-dashboard.ts — check there for the case
const dashboardSource = readFileSync(
join(import.meta.dirname, "..", "auto-dashboard.ts"),
"utf-8",
@ -91,16 +85,22 @@ const dashboardSource = readFileSync(
const hasDescribeCase = dashboardSource.includes('case "needs-discussion"');
assert(hasDescribeCase, "auto-dashboard.ts describeNextUnit should have 'needs-discussion' case");
// Check dispatchNextUnit has the branch
const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"');
assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch");
// Dispatch logic moved to auto-dispatch.ts — verify the rule exists there
const dispatchSource = readFileSync(
join(import.meta.dirname, "..", "auto-dispatch.ts"),
"utf-8",
);
// Check the dispatch branch calls stopAuto
const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"');
const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600);
// Check dispatch table has a needs-discussion rule
const hasDispatchRule = dispatchSource.includes('"needs-discussion"');
assert(hasDispatchRule, "auto-dispatch.ts should have 'needs-discussion' rule");
// Check the rule returns a stop action
const ruleIdx = dispatchSource.indexOf('"needs-discussion"');
const nextChunk = dispatchSource.slice(ruleIdx, ruleIdx + 600);
assert(
nextChunk.includes("stopAuto"),
"needs-discussion dispatch branch should call stopAuto",
nextChunk.includes('"stop"') || nextChunk.includes("action: \"stop\""),
"needs-discussion dispatch rule should return stop action",
);
// Check notification includes /gsd guidance