/** * Direct phase dispatch — handles manual /sf dispatch commands. * Resolves phase name → unit type + prompt, creates a session, and sends the message. */ import { pauseAuto } from "./auto.js"; import { buildCompleteMilestonePrompt, buildCompleteSlicePrompt, buildExecuteTaskPrompt, buildPlanMilestonePrompt, buildPlanSlicePrompt, buildReassessRoadmapPrompt, buildReplanSlicePrompt, buildResearchMilestonePrompt, buildResearchSlicePrompt, buildRunUatPrompt, } from "./auto-prompts.js"; import { scopeActiveToolsForUnitType } from "./constants.js"; import { loadFile } from "./files.js"; import { relSliceFile, resolveSliceFile } from "./paths.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; import { getMilestoneSlices, isDbAvailable } from "./sf-db.js"; import { deriveState } from "./state.js"; import { getRequiredWorkflowToolsForAutoUnit, getWorkflowTransportSupportError, } from "./workflow-tools.js"; export async function dispatchDirectPhase(ctx, pi, phase, base) { const state = await deriveState(base); const mid = state.activeMilestone?.id; const midTitle = state.activeMilestone?.title ?? ""; if (!mid) { ctx.ui.notify("Cannot dispatch: no active milestone.", "warning"); return; } const normalized = phase.toLowerCase(); let unitType; let unitId; let prompt; switch (normalized) { case "research": case "research-milestone": case "research-slice": { const isSlice = normalized === "research-slice" || (normalized === "research" && state.phase !== "pre-planning"); if (isSlice) { const sid = state.activeSlice?.id; const sTitle = state.activeSlice?.title ?? ""; if (!sid) { ctx.ui.notify( "Cannot dispatch research-slice: no active slice.", "warning", ); return; } // When require_slice_discussion is enabled, pause auto-mode before // each new slice so the user can discuss requirements first (#789). const sliceContextFile = resolveSliceFile(base, mid, sid, "CONTEXT"); const requireDiscussion = loadEffectiveSFPreferences()?.preferences?.phases ?.require_slice_discussion; if (requireDiscussion && !sliceContextFile) { ctx.ui.notify( `Slice ${sid} requires discussion before planning. Run /sf discuss to discuss this slice, then /sf autonomous to resume.`, "info", ); await pauseAuto(ctx, pi); return; } unitType = "research-slice"; unitId = `${mid}/${sid}`; prompt = await buildResearchSlicePrompt( mid, midTitle, sid, sTitle, base, ); } else { unitType = "research-milestone"; unitId = mid; prompt = await buildResearchMilestonePrompt(mid, midTitle, base); } break; } case "plan": case "plan-milestone": case "plan-slice": { const isSlice = normalized === "plan-slice" || (normalized === "plan" && state.phase !== "pre-planning"); if (isSlice) { const sid = state.activeSlice?.id; const sTitle = state.activeSlice?.title ?? ""; if (!sid) { ctx.ui.notify( "Cannot dispatch plan-slice: no active slice.", "warning", ); return; } unitType = "plan-slice"; unitId = `${mid}/${sid}`; prompt = await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, base); } else { unitType = "plan-milestone"; unitId = mid; prompt = await buildPlanMilestonePrompt(mid, midTitle, base); } break; } case "execute": case "execute-task": { const sid = state.activeSlice?.id; const sTitle = state.activeSlice?.title ?? ""; const tid = state.activeTask?.id; const tTitle = state.activeTask?.title ?? ""; if (!sid) { ctx.ui.notify( "Cannot dispatch execute-task: no active slice.", "warning", ); return; } if (!tid) { ctx.ui.notify( "Cannot dispatch execute-task: no active task.", "warning", ); return; } unitType = "execute-task"; unitId = `${mid}/${sid}/${tid}`; prompt = await buildExecuteTaskPrompt( mid, sid, sTitle, tid, tTitle, base, ); break; } case "complete": case "complete-slice": case "complete-milestone": { const isSlice = normalized === "complete-slice" || (normalized === "complete" && state.phase === "summarizing"); if (isSlice) { const sid = state.activeSlice?.id; const sTitle = state.activeSlice?.title ?? ""; if (!sid) { ctx.ui.notify( "Cannot dispatch complete-slice: no active slice.", "warning", ); return; } unitType = "complete-slice"; unitId = `${mid}/${sid}`; prompt = await buildCompleteSlicePrompt( mid, midTitle, sid, sTitle, base, ); } else { unitType = "complete-milestone"; unitId = mid; prompt = await buildCompleteMilestonePrompt(mid, midTitle, base); } break; } case "reassess": case "reassess-roadmap": { let completedSliceIds = []; if (isDbAvailable()) { completedSliceIds = getMilestoneSlices(mid) .filter((s) => s.status === "complete") .map((s) => s.id); } else { ctx.ui.notify( "Cannot dispatch reassess-roadmap: database unavailable.", "warning", ); return; } if (completedSliceIds.length === 0) { ctx.ui.notify( "Cannot dispatch reassess-roadmap: no completed slices.", "warning", ); return; } const completedSliceId = completedSliceIds[completedSliceIds.length - 1]; unitType = "reassess-roadmap"; unitId = `${mid}/${completedSliceId}`; prompt = await buildReassessRoadmapPrompt( mid, midTitle, completedSliceId, base, ); break; } case "uat": case "run-uat": { // UAT targets the most recently completed slice, not the active (next // incomplete) slice. After slice completion, state.activeSlice advances // to the next incomplete slice, so we find the last done slice from the // DB instead (#1693). let uatCompletedSliceIds = []; if (isDbAvailable()) { uatCompletedSliceIds = getMilestoneSlices(mid) .filter((s) => s.status === "complete") .map((s) => s.id); } else { ctx.ui.notify( "Cannot dispatch run-uat: database unavailable.", "warning", ); return; } if (uatCompletedSliceIds.length === 0) { ctx.ui.notify( "Cannot dispatch run-uat: no completed slices.", "warning", ); return; } const sid = uatCompletedSliceIds[uatCompletedSliceIds.length - 1]; const uatFile = resolveSliceFile(base, mid, sid, "UAT"); if (!uatFile) { ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning"); return; } const uatContent = await loadFile(uatFile); if (!uatContent) { ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning"); return; } const uatPath = relSliceFile(base, mid, sid, "UAT"); unitType = "run-uat"; unitId = `${mid}/${sid}`; prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base); break; } case "replan": case "replan-slice": { const sid = state.activeSlice?.id; const sTitle = state.activeSlice?.title ?? ""; if (!sid) { ctx.ui.notify( "Cannot dispatch replan-slice: no active slice.", "warning", ); return; } unitType = "replan-slice"; unitId = `${mid}/${sid}`; prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base); break; } default: ctx.ui.notify( `Unknown phase "${phase}". Valid phases: research, plan, execute, complete, reassess, uat, replan.`, "warning", ); return; } const compatibilityError = getWorkflowTransportSupportError( ctx.model?.provider, getRequiredWorkflowToolsForAutoUnit(unitType), { projectRoot: base, surface: "direct phase dispatch", unitType, authMode: ctx.model?.provider ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) : undefined, baseUrl: ctx.model?.baseUrl, }, ); if (compatibilityError) { ctx.ui.notify(compatibilityError, "error"); return; } ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info"); const result = await ctx.newSession(); if (result.cancelled) { ctx.ui.notify("Session creation cancelled.", "warning"); return; } let savedTools = null; if ( typeof pi.getActiveTools === "function" && typeof pi.setActiveTools === "function" ) { const currentTools = pi.getActiveTools(); const scopedTools = scopeActiveToolsForUnitType(unitType, currentTools); if (scopedTools.length !== currentTools.length) { savedTools = currentTools; pi.setActiveTools(scopedTools); } } try { await pi.sendMessage( { customType: "sf-dispatch", content: prompt, display: false }, { triggerTurn: true }, ); } finally { if (savedTools) pi.setActiveTools(savedTools); } }