317 lines
8.6 KiB
JavaScript
317 lines
8.6 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|