singularity-forge/src/resources/extensions/sf/auto-direct-dispatch.js

318 lines
8.6 KiB
JavaScript
Raw Normal View History

/**
* 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";
2026-05-05 14:31:16 +02:00
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";
2026-05-06 22:55:35 +02:00
import { relSliceFile, resolveSliceFile } from "./paths.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import { getMilestoneSlices, isDbAvailable } from "./sf-db.js";
import { deriveState } from "./state.js";
2026-05-05 14:31:16 +02:00
import {
getRequiredWorkflowToolsForAutoUnit,
getWorkflowTransportSupportError,
} from "./workflow-tools.js";
export async function dispatchDirectPhase(ctx, pi, phase, base) {
2026-05-05 14:31:16 +02:00
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);
2026-05-06 22:55:35 +02:00
} else {
ctx.ui.notify(
"Cannot dispatch reassess-roadmap: database unavailable.",
"warning",
);
return;
2026-05-05 14:31:16 +02:00
}
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
2026-05-06 22:55:35 +02:00
// DB instead (#1693).
2026-05-05 14:31:16 +02:00
let uatCompletedSliceIds = [];
if (isDbAvailable()) {
uatCompletedSliceIds = getMilestoneSlices(mid)
.filter((s) => s.status === "complete")
.map((s) => s.id);
2026-05-06 22:55:35 +02:00
} else {
ctx.ui.notify(
"Cannot dispatch run-uat: database unavailable.",
"warning",
);
return;
2026-05-05 14:31:16 +02:00
}
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);
}
}