From 5dbfc8688223cf6900d090f4a98db273e0b47f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 20 Mar 2026 10:39:46 -0600 Subject: [PATCH] feat: apply model preferences in guided flow for milestone planning (#1614) dispatchWorkflow now resolves per-phase model preferences (e.g., models.planning, models.execution) via resolveModelWithFallbacksForUnit and applies them with pi.setModel before dispatching the workflow message. All 22 call sites pass the appropriate unit type context so planning, research, execution, and completion phases each use the configured model. Closes #1582 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/guided-flow.ts | 148 +++++++++++++++----- 1 file changed, 110 insertions(+), 38 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index e624540d0..c103133be 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -34,6 +34,7 @@ import { showConfirm } from "../shared/mod.js"; import { debugLog } from "./debug-logger.js"; import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js"; import { parkMilestone, discardMilestone } from "./milestone-actions.js"; +import { resolveModelWithFallbacksForUnit } from "./preferences-models.js"; // ─── Re-exports (preserve public API for existing importers) ──────────────── export { @@ -190,8 +191,40 @@ type UIContext = ExtensionContext; /** * Read GSD-WORKFLOW.md and dispatch it to the LLM with a contextual note. * This is the only way the wizard triggers work — everything else is the LLM's job. + * + * When a unitType is provided, resolves the user's model preference for that + * phase (e.g., models.planning → "plan-milestone") and applies it before + * dispatching. This ensures guided-flow dispatches respect the same + * per-phase model preferences that auto-mode uses. */ -function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"): void { +async function dispatchWorkflow( + pi: ExtensionAPI, + note: string, + customType = "gsd-run", + ctx?: ExtensionContext, + unitType?: string, +): Promise { + // Apply model preference for this unit type (if configured) + if (ctx && unitType) { + const modelConfig = resolveModelWithFallbacksForUnit(unitType); + if (modelConfig) { + const availableModels = ctx.modelRegistry.getAvailable(); + const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks]; + + for (const modelId of modelsToTry) { + // Resolve model from available models (same logic as auto-model-selection) + const model = resolveAvailableModel(modelId, availableModels, ctx.model?.provider); + if (!model) continue; + + const ok = await pi.setModel(model, { persist: false }); + if (ok) { + debugLog("guided-flow-model-applied", { unitType, model: `${model.provider}/${model.id}` }); + break; + } + } + } + } + const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md"); const workflow = readFileSync(workflowPath, "utf-8"); @@ -205,6 +238,45 @@ function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run" ); } +/** + * Resolve a model ID string to a model object from available models. + * Handles "provider/model" and bare ID formats. + */ +function resolveAvailableModel( + modelId: string, + availableModels: T[], + currentProvider: string | undefined, +): T | undefined { + const slashIdx = modelId.indexOf("/"); + + if (slashIdx !== -1) { + const maybeProvider = modelId.substring(0, slashIdx); + const id = modelId.substring(slashIdx + 1); + + const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase())); + if (knownProviders.has(maybeProvider.toLowerCase())) { + const match = availableModels.find( + m => m.provider.toLowerCase() === maybeProvider.toLowerCase() + && m.id.toLowerCase() === id.toLowerCase(), + ); + if (match) return match; + } + + // Try matching the full string as a model ID (OpenRouter-style) + const lower = modelId.toLowerCase(); + return availableModels.find( + m => m.id.toLowerCase() === lower + || `${m.provider}/${m.id}`.toLowerCase() === lower, + ); + } + + // Bare ID — prefer current provider, then first available + const exactProviderMatch = availableModels.find( + m => m.id === modelId && m.provider === currentProvider, + ); + return exactProviderMatch ?? availableModels.find(m => m.id === modelId); +} + /** * Build the discuss-and-plan prompt for a new milestone. * Used by all three "new milestone" paths (first ever, no active, all complete). @@ -301,8 +373,8 @@ export async function showHeadlessMilestoneCreation( // Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss) pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; - // Dispatch - dispatchWorkflow(pi, prompt); + // Dispatch — headless milestone creation is a planning activity + await dispatchWorkflow(pi, prompt, "gsd-run", ctx, "plan-milestone"); } @@ -467,21 +539,21 @@ export async function showDiscuss( ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; - dispatchWorkflow(pi, seed, "gsd-discuss"); + await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone"); } else if (choice === "discuss_fresh") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; - dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { + await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), - }), "gsd-discuss"); + }), "gsd-discuss", ctx, "plan-milestone"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath)); + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone"); } return; } @@ -580,7 +652,7 @@ export async function showDiscuss( } const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath, { rediscuss: isRediscuss }); - dispatchWorkflow(pi, prompt, "gsd-discuss"); + await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-slice"); // Wait for the discuss session to finish, then loop back to the picker await ctx.waitForIdle(); @@ -722,10 +794,10 @@ async function handleMilestoneActions( const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - )); + ), "gsd-run", ctx, "plan-milestone"); return true; } @@ -866,10 +938,10 @@ export async function showSmartEntry( if (isFirst) { // First ever — skip wizard, just ask directly pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, basePath - )); + ), "gsd-run", ctx, "plan-milestone"); } else { const choice = await showNextAction(ctx, { title: "GSD — Get Shit Done", @@ -887,10 +959,10 @@ export async function showSmartEntry( if (choice === "new_milestone") { pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - )); + ), "gsd-run", ctx, "plan-milestone"); } } return; @@ -926,10 +998,10 @@ export async function showSmartEntry( const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - )); + ), "gsd-run", ctx, "plan-milestone"); } else if (choice === "status") { const { fireStatusViaCommand } = await import("./commands.js"); await fireStatusViaCommand(ctx); @@ -977,24 +1049,24 @@ export async function showSmartEntry( ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; - dispatchWorkflow(pi, seed, "gsd-discuss"); + await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone"); } else if (choice === "discuss_fresh") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; - dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { + await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), - }), "gsd-discuss"); + }), "gsd-discuss", ctx, "plan-milestone"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - )); + ), "gsd-run", ctx, "plan-milestone"); } return; } @@ -1051,25 +1123,25 @@ export async function showSmartEntry( inlineTemplate("secrets-manifest", "Secrets Manifest"), ].join("\n\n---\n\n"); const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS"); - dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", { + await dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", { milestoneId, milestoneTitle, secretsOutputPath, inlinedTemplates: planMilestoneTemplates, - })); + }), "gsd-run", ctx, "plan-milestone"); } else if (choice === "discuss") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; - dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { + await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), - })); + }), "gsd-run", ctx, "plan-milestone"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; - dispatchWorkflow(pi, buildDiscussPrompt(nextId, + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - )); + ), "gsd-run", ctx, "plan-milestone"); } else if (choice === "discard_milestone") { const confirmed = await showConfirm(ctx, { title: "Discard milestone?", @@ -1181,16 +1253,16 @@ export async function showSmartEntry( inlineTemplate("plan", "Slice Plan"), inlineTemplate("task-plan", "Task Plan"), ].join("\n\n---\n\n"); - dispatchWorkflow(pi, loadPrompt("guided-plan-slice", { + await dispatchWorkflow(pi, loadPrompt("guided-plan-slice", { milestoneId, sliceId, sliceTitle, inlinedTemplates: planSliceTemplates, - })); + }), "gsd-run", ctx, "plan-slice"); } else if (choice === "discuss") { - dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext })); + await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "plan-slice"); } else if (choice === "research") { const researchTemplates = inlineTemplate("research", "Research"); - dispatchWorkflow(pi, loadPrompt("guided-research-slice", { + await dispatchWorkflow(pi, loadPrompt("guided-research-slice", { milestoneId, sliceId, sliceTitle, inlinedTemplates: researchTemplates, - })); + }), "gsd-run", ctx, "research-slice"); } else if (choice === "status") { const { fireStatusViaCommand } = await import("./commands.js"); await fireStatusViaCommand(ctx); @@ -1232,9 +1304,9 @@ export async function showSmartEntry( inlineTemplate("slice-summary", "Slice Summary"), inlineTemplate("uat", "UAT"), ].join("\n\n---\n\n"); - dispatchWorkflow(pi, loadPrompt("guided-complete-slice", { + await dispatchWorkflow(pi, loadPrompt("guided-complete-slice", { workingDirectory: basePath, milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates, - })); + }), "gsd-run", ctx, "complete-slice"); } else if (choice === "status") { const { fireStatusViaCommand } = await import("./commands.js"); await fireStatusViaCommand(ctx); @@ -1297,14 +1369,14 @@ export async function showSmartEntry( if (choice === "execute") { if (hasInterrupted) { - dispatchWorkflow(pi, loadPrompt("guided-resume-task", { + await dispatchWorkflow(pi, loadPrompt("guided-resume-task", { milestoneId, sliceId, - })); + }), "gsd-run", ctx, "execute-task"); } else { const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary"); - dispatchWorkflow(pi, loadPrompt("guided-execute-task", { + await dispatchWorkflow(pi, loadPrompt("guided-execute-task", { milestoneId, sliceId, taskId, taskTitle, inlinedTemplates: executeTaskTemplates, - })); + }), "gsd-run", ctx, "execute-task"); } } else if (choice === "status") { const { fireStatusViaCommand } = await import("./commands.js");