From c622eec0e19703fc7d5494cda239c29a6ec58b82 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 27 Mar 2026 12:36:42 -0500 Subject: [PATCH] fix(model-routing): use honest unitTypes for discuss dispatches and map all auto-dispatch phases Discuss dispatches in guided-flow.ts were aliased to "plan-milestone"/"plan-slice" unitTypes, causing the planning model preference to silently override the user's active model. This was discovered when a user configured Codex as their model but got switched to Opus during discuss phases because models.planning was set. Changes: - Add "discuss" and "validation" keys to GSDModelConfig/GSDModelConfigV2 - Map discuss-milestone/discuss-slice to models.discuss (falls back to planning) - Map reassess-roadmap/rewrite-docs/gate-evaluate/validate-milestone to models.validation (falls back to planning) - Map reactive-execute to models.execution, complete-milestone to models.completion - Fix 15 dispatchWorkflow calls in guided-flow.ts to use honest unitTypes - Add discuss-slice to LIFECYCLE_ONLY_UNITS, artifact paths, metrics phase classification, complexity tiers, and all dashboard/overlay label functions Closes #2865 --- .../extensions/gsd/auto-artifact-paths.ts | 6 + .../extensions/gsd/auto-dashboard.ts | 7 +- .../extensions/gsd/auto-post-unit.ts | 2 +- .../extensions/gsd/complexity-classifier.ts | 1 + .../extensions/gsd/dashboard-overlay.ts | 2 + .../gsd/docs/preferences-reference.md | 6 +- src/resources/extensions/gsd/guided-flow.ts | 32 +-- src/resources/extensions/gsd/metrics.ts | 7 +- .../extensions/gsd/preferences-models.ts | 12 ++ .../extensions/gsd/preferences-types.ts | 4 + .../gsd/tests/model-unittype-mapping.test.ts | 192 ++++++++++++++++++ 11 files changed, 248 insertions(+), 23 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/model-unittype-mapping.test.ts diff --git a/src/resources/extensions/gsd/auto-artifact-paths.ts b/src/resources/extensions/gsd/auto-artifact-paths.ts index c04e774ec..df8b52ad2 100644 --- a/src/resources/extensions/gsd/auto-artifact-paths.ts +++ b/src/resources/extensions/gsd/auto-artifact-paths.ts @@ -30,6 +30,10 @@ export function resolveExpectedArtifactPath( const dir = resolveMilestonePath(base, mid); return dir ? join(dir, buildMilestoneFileName(mid, "CONTEXT")) : null; } + case "discuss-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "CONTEXT")) : null; + } case "research-milestone": { const dir = resolveMilestonePath(base, mid); return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null; @@ -98,6 +102,8 @@ export function diagnoseExpectedArtifact( switch (unitType) { case "discuss-milestone": return `${relMilestoneFile(base, mid, "CONTEXT")} (milestone context from discussion)`; + case "discuss-slice": + return `${relSliceFile(base, mid, sid!, "CONTEXT")} (slice context from discussion)`; case "research-milestone": return `${relMilestoneFile(base, mid, "RESEARCH")} (milestone research)`; case "plan-milestone": diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 19d2433f4..98a6ff052 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -77,7 +77,8 @@ export interface AutoDashboardData { export function unitVerb(unitType: string): string { if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`; switch (unitType) { - case "discuss-milestone": return "discussing"; + case "discuss-milestone": + case "discuss-slice": return "discussing"; case "research-milestone": case "research-slice": return "researching"; case "plan-milestone": @@ -96,7 +97,8 @@ export function unitVerb(unitType: string): string { export function unitPhaseLabel(unitType: string): string { if (unitType.startsWith("hook/")) return "HOOK"; switch (unitType) { - case "discuss-milestone": return "DISCUSS"; + case "discuss-milestone": + case "discuss-slice": return "DISCUSS"; case "research-milestone": return "RESEARCH"; case "research-slice": return "RESEARCH"; case "plan-milestone": return "PLAN"; @@ -123,6 +125,7 @@ function peekNext(unitType: string, state: GSDState): string { if (unitType.startsWith("hook/")) return `continue ${sid}`; switch (unitType) { case "discuss-milestone": return "research or plan milestone"; + case "discuss-slice": return "plan slice"; case "research-milestone": return "plan milestone roadmap"; case "plan-milestone": return "plan or execute first slice"; case "research-slice": return `plan ${sid}`; diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 00a0e9097..3083a20fa 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -74,7 +74,7 @@ function enqueueSidecar( * Auto-commit is skipped for these — their state files are picked up by the * next actual task commit via `smartStage()`. */ const LIFECYCLE_ONLY_UNITS = new Set([ - "research-milestone", "discuss-milestone", "plan-milestone", + "research-milestone", "discuss-milestone", "discuss-slice", "plan-milestone", "validate-milestone", "research-slice", "plan-slice", "replan-slice", "complete-slice", "run-uat", "reassess-roadmap", "rewrite-docs", diff --git a/src/resources/extensions/gsd/complexity-classifier.ts b/src/resources/extensions/gsd/complexity-classifier.ts index 6e117cccd..73e505958 100644 --- a/src/resources/extensions/gsd/complexity-classifier.ts +++ b/src/resources/extensions/gsd/complexity-classifier.ts @@ -37,6 +37,7 @@ const UNIT_TYPE_TIERS: Record = { // Tier 2 — Standard: research, routine planning, discussion "discuss-milestone": "standard", + "discuss-slice": "standard", "research-milestone": "standard", "research-slice": "standard", "plan-milestone": "standard", diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index 26926cf97..37bd547fb 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -29,6 +29,8 @@ import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-envi function unitLabel(type: string): string { switch (type) { + case "discuss-milestone": + case "discuss-slice": return "Discuss"; case "research-milestone": return "Research"; case "plan-milestone": return "Plan"; case "research-slice": return "Research"; diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index eb016e0c0..8f110ce37 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -102,12 +102,14 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `custom_instructions`: extra durable instructions related to skill use. For operational project knowledge (recurring rules, gotchas, patterns), use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically and agents can append to it during execution. -- `models`: per-stage model selection for auto-mode. Keys: `research`, `planning`, `execution`, `execution_simple`, `completion`, `subagent`. Values can be: +- `models`: per-stage model selection (applies to both auto-mode and guided-flow dispatches). Keys: `research`, `planning`, `discuss`, `execution`, `execution_simple`, `completion`, `validation`, `subagent`. Values can be: - Simple string: `"claude-sonnet-4-6"` — single model, no fallbacks - Provider-qualified string: `"bedrock/claude-sonnet-4-6"` — targets a specific provider when the same model ID exists across multiple providers - Object with fallbacks: `{ model: "claude-opus-4-6", fallbacks: ["glm-5", "minimax-m2.5"] }` — tries fallbacks in order if primary fails - Object with provider: `{ model: "claude-opus-4-6", provider: "bedrock" }` — explicit provider targeting in object format - - Omit a key to use whatever model is currently active. Fallbacks are tried when model switching fails (provider unavailable, rate limited, etc.). + - Omit a key to use whatever model is currently active (except `discuss` and `validation` which fall back to `planning` when unset). Fallbacks are tried when model switching fails (provider unavailable, rate limited, etc.). + - `discuss` — used for milestone/slice discussion (interactive context gathering). Falls back to `planning` if unset. + - `validation` — used for gate evaluation, roadmap reassessment, milestone validation, and doc rewrites. Falls back to `planning` if unset. - `skill_staleness_days`: number — skills unused for this many days get deprioritized during discovery. Set to `0` to disable staleness tracking. Default: `60`. diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 2cda89d72..68e64ad0a 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -211,7 +211,7 @@ type UIContext = ExtensionContext; * 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 + * phase (e.g., models.planning → "plan-milestone", models.discuss → "discuss-milestone") and applies it before * dispatching. This ensures guided-flow dispatches respect the same * per-phase model preferences that auto-mode uses. */ @@ -573,7 +573,7 @@ export async function showDiscuss( ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; - await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone"); + await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "discuss-milestone"); } else if (choice === "discuss_fresh") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; @@ -581,13 +581,13 @@ export async function showDiscuss( await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), - }), "gsd-discuss", ctx, "plan-milestone"); + }), "gsd-discuss", ctx, "discuss-milestone"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false }; - await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone"); + await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "discuss-milestone"); } return; } @@ -717,7 +717,7 @@ export async function showDiscuss( } const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath, { rediscuss: isRediscuss }); - await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-slice"); + await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-slice"); // Wait for the discuss session to finish, then loop back to the picker await ctx.waitForIdle(); @@ -795,7 +795,7 @@ async function dispatchDiscussForMilestone( const prompt = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; - await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-milestone"); + await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-milestone"); } // ─── Smart Entry Point ──────────────────────────────────────────────────────── @@ -935,7 +935,7 @@ async function handleMilestoneActions( await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - ), "gsd-run", ctx, "plan-milestone"); + ), "gsd-run", ctx, "discuss-milestone"); return true; } @@ -1086,7 +1086,7 @@ export async function showSmartEntry( 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"); + ), "gsd-run", ctx, "discuss-milestone"); } else { const choice = await showNextAction(ctx, { title: "GSD — Get Shit Done", @@ -1107,7 +1107,7 @@ export async function showSmartEntry( await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - ), "gsd-run", ctx, "plan-milestone"); + ), "gsd-run", ctx, "discuss-milestone"); } } return; @@ -1146,7 +1146,7 @@ export async function showSmartEntry( await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - ), "gsd-run", ctx, "plan-milestone"); + ), "gsd-run", ctx, "discuss-milestone"); } else if (choice === "status") { const { fireStatusViaCommand } = await import("./commands.js"); await fireStatusViaCommand(ctx); @@ -1194,7 +1194,7 @@ export async function showSmartEntry( ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; - await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone"); + await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "discuss-milestone"); } else if (choice === "discuss_fresh") { const discussMilestoneTemplates = inlineTemplate("context", "Context"); const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; @@ -1202,7 +1202,7 @@ export async function showSmartEntry( await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), - }), "gsd-discuss", ctx, "plan-milestone"); + }), "gsd-discuss", ctx, "discuss-milestone"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; @@ -1211,7 +1211,7 @@ export async function showSmartEntry( await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - ), "gsd-run", ctx, "plan-milestone"); + ), "gsd-run", ctx, "discuss-milestone"); } return; } @@ -1286,7 +1286,7 @@ export async function showSmartEntry( 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"); + }), "gsd-run", ctx, "discuss-milestone"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; @@ -1295,7 +1295,7 @@ export async function showSmartEntry( await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath - ), "gsd-run", ctx, "plan-milestone"); + ), "gsd-run", ctx, "discuss-milestone"); } else if (choice === "discard_milestone") { const confirmed = await showConfirm(ctx, { title: "Discard milestone?", @@ -1421,7 +1421,7 @@ export async function showSmartEntry( }), }), "gsd-run", ctx, "plan-slice"); } else if (choice === "discuss") { - await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "plan-slice"); + await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "discuss-slice"); } else if (choice === "research") { const researchTemplates = inlineTemplate("research", "Research"); await dispatchWorkflow(pi, loadPrompt("guided-research-slice", { diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index ba86c7ab6..1467499e4 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -75,13 +75,16 @@ export interface MetricsLedger { // ─── Phase classification ───────────────────────────────────────────────────── -export type MetricsPhase = "research" | "planning" | "execution" | "completion" | "reassessment"; +export type MetricsPhase = "research" | "discussion" | "planning" | "execution" | "completion" | "reassessment"; export function classifyUnitPhase(unitType: string): MetricsPhase { switch (unitType) { case "research-milestone": case "research-slice": return "research"; + case "discuss-milestone": + case "discuss-slice": + return "discussion"; case "plan-milestone": case "plan-slice": return "planning"; @@ -299,7 +302,7 @@ export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] { agg.duration += u.finishedAt - u.startedAt; } // Return in a stable order - const order: MetricsPhase[] = ["research", "planning", "execution", "completion", "reassessment"]; + const order: MetricsPhase[] = ["research", "discussion", "planning", "execution", "completion", "reassessment"]; return order.map(p => map.get(p)).filter((a): a is PhaseAggregate => !!a); } diff --git a/src/resources/extensions/gsd/preferences-models.ts b/src/resources/extensions/gsd/preferences-models.ts index 7134b4a3d..f5a488672 100644 --- a/src/resources/extensions/gsd/preferences-models.ts +++ b/src/resources/extensions/gsd/preferences-models.ts @@ -56,16 +56,28 @@ export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedMode case "replan-slice": phaseConfig = m.planning; break; + case "discuss-milestone": + case "discuss-slice": + phaseConfig = m.discuss ?? m.planning; + break; case "execute-task": + case "reactive-execute": phaseConfig = m.execution; break; case "execute-task-simple": phaseConfig = m.execution_simple ?? m.execution; break; case "complete-slice": + case "complete-milestone": case "run-uat": phaseConfig = m.completion; break; + case "reassess-roadmap": + case "rewrite-docs": + case "gate-evaluate": + case "validate-milestone": + phaseConfig = m.validation ?? m.planning; + break; default: // Subagent unit types (e.g., "subagent", "subagent/scout") if (unitType === "subagent" || unitType.startsWith("subagent/")) { diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 5659b5f85..663c58376 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -134,9 +134,11 @@ export interface GSDPhaseModelConfig { export interface GSDModelConfig { research?: string; planning?: string; + discuss?: string; execution?: string; execution_simple?: string; completion?: string; + validation?: string; subagent?: string; } @@ -147,9 +149,11 @@ export interface GSDModelConfig { export interface GSDModelConfigV2 { research?: string | GSDPhaseModelConfig; planning?: string | GSDPhaseModelConfig; + discuss?: string | GSDPhaseModelConfig; execution?: string | GSDPhaseModelConfig; execution_simple?: string | GSDPhaseModelConfig; completion?: string | GSDPhaseModelConfig; + validation?: string | GSDPhaseModelConfig; subagent?: string | GSDPhaseModelConfig; } diff --git a/src/resources/extensions/gsd/tests/model-unittype-mapping.test.ts b/src/resources/extensions/gsd/tests/model-unittype-mapping.test.ts new file mode 100644 index 000000000..c23d1f4b2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/model-unittype-mapping.test.ts @@ -0,0 +1,192 @@ +/** + * Model UnitType Mapping — regression tests for #2865. + * + * Verifies that all auto-dispatch unitTypes have corresponding entries in: + * - resolveModelWithFallbacksForUnit (preferences-models.ts) + * - classifyUnitPhase (metrics.ts) + * - LIFECYCLE_ONLY_UNITS (auto-post-unit.ts) + * - unitVerb / unitPhaseLabel (auto-dashboard.ts) + * - resolveExpectedArtifactPath (auto-artifact-paths.ts) + * + * Uses source-level checks to avoid import resolution issues in dev. + * + * Copyright (c) 2026 Jeremy McSpadden + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gsdDir = join(__dirname, ".."); + +function readSrc(file: string): string { + return readFileSync(join(gsdDir, file), "utf-8"); +} + +const preferencesSrc = readSrc("preferences-models.ts"); +const metricsSrc = readSrc("metrics.ts"); +const postUnitSrc = readSrc("auto-post-unit.ts"); +const dashboardSrc = readSrc("auto-dashboard.ts"); +const artifactSrc = readSrc("auto-artifact-paths.ts"); +const guidedFlowSrc = readSrc("guided-flow.ts"); +const autoDispatchSrc = readSrc("auto-dispatch.ts"); + +// Derive unitTypes directly from auto-dispatch.ts source so the test +// automatically tracks dispatch rule changes (Copilot review feedback). +const AUTO_DISPATCH_UNIT_TYPES = (() => { + const unitTypeRegex = /unitType:\s*["']([^"']+)["']/g; + const unitTypes = new Set(); + let match: RegExpExecArray | null; + while ((match = unitTypeRegex.exec(autoDispatchSrc)) !== null) { + unitTypes.add(match[1]); + } + return Array.from(unitTypes); +})(); + +// Additionally include unitTypes used by guided-flow but not auto-dispatch +// (e.g., discuss-slice is dispatched by guided-flow but not auto-dispatch). +const ALL_KNOWN_UNIT_TYPES = [ + ...new Set([...AUTO_DISPATCH_UNIT_TYPES, "discuss-slice"]), +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// #2865: discuss dispatches must NOT alias to plan unitTypes +// ═══════════════════════════════════════════════════════════════════════════ + +test("#2865: no dispatchWorkflow with gsd-discuss customType uses plan-milestone", () => { + // Match dispatchWorkflow calls where "gsd-discuss" appears before "plan-milestone" + // in the same call (the 5 args are on consecutive lines). + const blocks = guidedFlowSrc.split(/dispatchWorkflow\(/); + for (const block of blocks) { + const callEnd = block.indexOf(");"); + if (callEnd === -1) continue; + const call = block.slice(0, callEnd); + if (call.includes('"gsd-discuss"') && call.includes('"plan-milestone"')) { + assert.fail(`Discuss dispatch should not use plan-milestone: ...dispatchWorkflow(${call.slice(0, 120).trim()}...`); + } + } +}); + +test("#2865: no dispatchWorkflow with gsd-discuss customType uses plan-slice", () => { + const blocks = guidedFlowSrc.split(/dispatchWorkflow\(/); + for (const block of blocks) { + const callEnd = block.indexOf(");"); + if (callEnd === -1) continue; + const call = block.slice(0, callEnd); + if (call.includes('"gsd-discuss"') && call.includes('"plan-slice"')) { + assert.fail(`Discuss slice dispatch should not use plan-slice: ...dispatchWorkflow(${call.slice(0, 120).trim()}...`); + } + } +}); + +test("#2865: no buildDiscussPrompt call dispatches with plan-milestone", () => { + const blocks = guidedFlowSrc.split(/dispatchWorkflow\(/); + for (const block of blocks) { + const callEnd = block.indexOf(");"); + if (callEnd === -1) continue; + const call = block.slice(0, callEnd); + if (call.includes("buildDiscussPrompt") && call.includes('"plan-milestone"')) { + assert.fail(`buildDiscussPrompt dispatch should not use plan-milestone`); + } + } +}); + +test("#2865: no buildDiscussSlicePrompt call dispatches with plan-slice", () => { + const blocks = guidedFlowSrc.split(/dispatchWorkflow\(/); + for (const block of blocks) { + const callEnd = block.indexOf(");"); + if (callEnd === -1) continue; + const call = block.slice(0, callEnd); + if (call.includes("buildDiscussSlicePrompt") && call.includes('"plan-slice"')) { + assert.fail(`buildDiscussSlicePrompt dispatch should not use plan-slice`); + } + } +}); + +test("#2865: no guided-discuss-milestone loadPrompt dispatches with plan-milestone", () => { + const blocks = guidedFlowSrc.split(/dispatchWorkflow\(/); + for (const block of blocks) { + const callEnd = block.indexOf(");"); + if (callEnd === -1) continue; + const call = block.slice(0, callEnd); + if (call.includes("guided-discuss-milestone") && call.includes('"plan-milestone"')) { + assert.fail(`guided-discuss-milestone dispatch should not use plan-milestone`); + } + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// preferences-models.ts: resolveModelWithFallbacksForUnit coverage +// ═══════════════════════════════════════════════════════════════════════════ + +test("resolveModelWithFallbacksForUnit handles discuss-milestone", () => { + assert.ok(preferencesSrc.includes('"discuss-milestone"'), "missing discuss-milestone case"); +}); + +test("resolveModelWithFallbacksForUnit handles discuss-slice", () => { + assert.ok(preferencesSrc.includes('"discuss-slice"'), "missing discuss-slice case"); +}); + +test("discuss unitTypes fall back to planning when models.discuss is unset", () => { + assert.ok( + preferencesSrc.includes("m.discuss ?? m.planning"), + "discuss should fall back to m.planning", + ); +}); + +test("validation unitTypes fall back to planning when models.validation is unset", () => { + assert.ok( + preferencesSrc.includes("m.validation ?? m.planning"), + "validation should fall back to m.planning", + ); +}); + +test("all auto-dispatch unitTypes have preference mapping or subagent handling", () => { + const unmapped: string[] = []; + for (const ut of ALL_KNOWN_UNIT_TYPES) { + if (!preferencesSrc.includes(`"${ut}"`)) { + unmapped.push(ut); + } + } + assert.deepEqual(unmapped, [], `Unmapped unitTypes in preferences-models.ts: ${unmapped.join(", ")}`); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// metrics.ts: classifyUnitPhase coverage +// ═══════════════════════════════════════════════════════════════════════════ + +test("classifyUnitPhase includes discussion phase", () => { + assert.ok(metricsSrc.includes('"discussion"'), "MetricsPhase should include discussion"); +}); + +test("classifyUnitPhase maps discuss-milestone and discuss-slice", () => { + assert.ok(metricsSrc.includes('"discuss-milestone"'), "missing discuss-milestone in metrics"); + assert.ok(metricsSrc.includes('"discuss-slice"'), "missing discuss-slice in metrics"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// auto-post-unit.ts: LIFECYCLE_ONLY_UNITS +// ═══════════════════════════════════════════════════════════════════════════ + +test("LIFECYCLE_ONLY_UNITS includes discuss-slice", () => { + assert.ok(postUnitSrc.includes('"discuss-slice"'), "discuss-slice should be lifecycle-only"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// auto-dashboard.ts: display label coverage +// ═══════════════════════════════════════════════════════════════════════════ + +test("unitVerb handles discuss-slice", () => { + assert.ok(dashboardSrc.includes('"discuss-slice"'), "missing discuss-slice in dashboard"); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// auto-artifact-paths.ts: artifact resolution +// ═══════════════════════════════════════════════════════════════════════════ + +test("resolveExpectedArtifactPath handles discuss-slice", () => { + assert.ok(artifactSrc.includes('"discuss-slice"'), "missing discuss-slice in artifact paths"); +});