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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-20 10:39:46 -06:00 committed by GitHub
parent dbf24145ab
commit 5dbfc86882

View file

@ -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<void> {
// 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<T extends { id: string; provider: string }>(
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");