diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts new file mode 100644 index 000000000..8d6b9341e --- /dev/null +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -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; +} + +// ─── 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 { + 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); +} diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 962e7a9ab..ca9543edf 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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) { diff --git a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts index 13650a257..fc76aee5a 100644 --- a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts +++ b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts @@ -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