singularity-forge/src/resources/extensions/sf/guided-flow.js

2482 lines
81 KiB
JavaScript

/**
* SF Guided Flow — Workflow Entry Wizard
*
* Primary entrypoints: `showWorkflowEntry()` and the legacy `showSmartEntry()`
* export. Reads state from disk, shows a contextual wizard via
* `showNextAction()`, and dispatches through SF-WORKFLOW.md.
* No execution state, no hooks, no tools — the LLM does the rest.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { showConfirm, showNextAction } from "../shared/tui.js";
import { ensureAgenticDocsScaffold } from "./agentic-docs-scaffold.js";
import { resolveExpectedArtifactPath, startAutoDetached } from "./auto.js";
import { selectAndApplyModel } from "./auto-model-selection.js";
import { buildSkillActivationBlock } from "./auto-prompts.js";
import { invalidateAllCaches } from "./cache.js";
import { ensureSiftIndexWarmup } from "./code-intelligence.js";
import { scopeActiveToolsForUnitType } from "./constants.js";
import { clearLock } from "./crash-recovery.js";
import { debugLog } from "./debug-logger.js";
import { detectProjectState } from "./detection.js";
import { loadFile, saveFile } from "./files.js";
import {
ensureGitignore,
ensurePreferences,
untrackRuntimeFiles,
} from "./gitignore.js";
import { offerMigration, showProjectInit } from "./init-wizard.js";
import {
assessInterruptedSession,
formatInterruptedSessionRunningMessage,
formatInterruptedSessionSummary,
} from "./interrupted-session.js";
import { discardMilestone, parkMilestone } from "./milestone-actions.js";
import {
clearReservedMilestoneIds,
findMilestoneIds,
getReservedMilestoneIds,
nextMilestoneId,
reserveMilestoneId,
} from "./milestone-ids.js";
import { nativeInit, nativeIsRepo } from "./native-git-bridge.js";
import {
milestonesDir,
relMilestoneFile,
relSfRootFile,
relSliceFile,
resolveMilestoneFile,
resolveSfRootFile,
resolveSliceFile,
resolveSlicePath,
sfRoot,
} from "./paths.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import {
formatCodebaseBrief,
formatPriorContextBrief,
runPreparation,
} from "./preparation.js";
import { inlineTemplate, loadPrompt } from "./prompt-loader.js";
import { isInheritedRepo } from "./repo-identity.js";
import { parseRoadmapSlices } from "./roadmap-slices.js";
import {
isSessionLockProcessAlive,
readSessionLockData,
} from "./session-lock.js";
import { getMilestoneSlices, isDbAvailable } from "./sf-db.js";
import { deriveState } from "./state.js";
import { resolveUokFlags } from "./uok/flags.js";
import { UokGateRunner } from "./uok/gate-runner.js";
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan.js";
import {
clearRunawayRecoveredRuntimeRecords,
clearUnitRuntimeRecord,
listUnitRuntimeRecords,
} from "./uok/unit-runtime.js";
import { validateDirectory } from "./validate-directory.js";
import {
getRequiredWorkflowToolsForGuidedUnit,
getWorkflowTransportSupportError,
supportsStructuredQuestions,
} from "./workflow-tools.js";
export {
buildExistingMilestonesContext,
handleQueueReorder,
showQueue,
showQueueAdd,
} from "./guided-flow-queue.js";
// ─── Re-exports (preserve public API for existing importers) ────────────────
export {
claimReservedId,
clearReservedMilestoneIds,
extractMilestoneSeq,
findMilestoneIds,
generateMilestoneSuffix,
getReservedMilestoneIds,
MILESTONE_ID_RE,
maxMilestoneNum,
milestoneIdSort,
nextMilestoneId,
parseMilestoneId,
reserveMilestoneId,
} from "./milestone-ids.js";
import { logWarning } from "./workflow-logger.js";
// ─── Todo/Spec File Detection ────────────────────────────────────────────────
const TODO_FILE_NAMES = ["todo.md", "TODO.md", "SPEC.md", "spec.md"];
/**
* If a todo/spec file exists at the project root, read and delete it, then
* append its contents to `preamble` so any discuss or bootstrap prompt treats
* it as the primary specification. Returns the (possibly enriched) preamble.
*
* Called identically in autonomous mode bootstrap and interactive discuss — one flow.
*/
export function injectTodoContext(basePath, preamble) {
for (const fname of TODO_FILE_NAMES) {
const fpath = join(basePath, fname);
if (!existsSync(fpath)) continue;
try {
const content = readFileSync(fpath, "utf-8").slice(0, 8000);
try {
unlinkSync(fpath);
} catch {
/* non-fatal */
}
return `${preamble}\n\n### ${fname} (user-provided specification)\n\n${content}`;
} catch {
/* non-fatal — fall through */
}
}
return preamble;
}
// ─── ID Generation with Reservation ─────────────────────────────────────────
/**
* Generate the next milestone ID, accounting for reserved IDs, and reserve it.
* Ensures any preview ID shown in the UI matches what `new_milestone_id`
* will later return.
*/
function nextMilestoneIdReserved(existingIds, uniqueEnabled) {
const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])];
const id = nextMilestoneId(allIds, uniqueEnabled);
reserveMilestoneId(id);
return id;
}
function needsPlanningFlowGate(state) {
return (
state.phase === "executing" ||
state.phase === "summarizing" ||
state.phase === "validating-milestone" ||
state.phase === "completing-milestone"
);
}
async function runPlanningFlowGate(ctx, basePath, state) {
const prefs = loadEffectiveSFPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
if (!uokFlags.planningFlow || !needsPlanningFlowGate(state)) return true;
const compiled = ensurePlanningFlowGraph(basePath, state);
const milestoneId = state.activeMilestone?.id ?? undefined;
const traceId = `guided-flow:${milestoneId ?? "unknown"}`;
const turnId = `guided-${Date.now()}`;
const persistGate = async (
outcome,
failureClass,
rationale,
findings = "",
) => {
if (!uokFlags.gates) return;
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "planning-flow-gate",
type: "policy",
execute: async () => ({ outcome, failureClass, rationale, findings }),
});
await gateRunner.run("planning-flow-gate", {
basePath,
traceId,
turnId,
milestoneId,
unitType: "pre-dispatch",
unitId: "guided-flow",
});
};
if (!compiled.ok) {
const reason = compiled.reason ?? "planning-flow compilation failed";
await persistGate(
"manual-attention",
"manual-attention",
"planning flow compile gate failed",
reason,
);
ctx.ui.notify(
`Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`,
"error",
);
return false;
}
await persistGate("pass", "none", "planning flow compile gate passed");
return true;
}
// ─── Commit Instruction Helpers ──────────────────────────────────────────────
/** Build commit instruction for planning prompts. .sf/ is managed externally and always gitignored. */
function buildDocsCommitInstruction(_message) {
return "Do not commit planning artifacts — .sf/ is managed externally.";
}
const pendingAutoStartMap = new Map();
/**
* Backward-compat bridge: returns a mutable reference to the entry matching
* basePath, or the sole entry when only one session exists.
* Internal use only — external code should use the Map directly.
*/
function _getPendingAutoStart(basePath) {
if (basePath) return pendingAutoStartMap.get(basePath) ?? null;
if (pendingAutoStartMap.size === 1)
return pendingAutoStartMap.values().next().value;
return null;
}
/**
* Store pending auto-start state for a project.
* Exported for testing (#2985).
*/
export function setPendingAutoStart(basePath, entry) {
pendingAutoStartMap.set(basePath, {
createdAt: Date.now(),
...entry,
});
}
/**
* Clear pending auto-start state.
* If basePath is given, clears only that project. Otherwise clears all.
* Exported for testing (#2985).
*/
export function clearPendingAutoStart(basePath) {
if (basePath) {
pendingAutoStartMap.delete(basePath);
} else {
pendingAutoStartMap.clear();
}
}
/**
* Returns the milestoneId being discussed for the given project.
* When basePath is omitted and only one session is active, returns that
* session's milestoneId for backward compatibility. Returns null when
* multiple sessions exist and basePath is not specified (#2985 Bug 4).
*/
export function getDiscussionMilestoneId(basePath) {
if (basePath) {
return pendingAutoStartMap.get(basePath)?.milestoneId ?? null;
}
// Backward compat: return the sole entry's milestoneId, or null if ambiguous
if (pendingAutoStartMap.size === 1) {
return pendingAutoStartMap.values().next().value.milestoneId;
}
return null;
}
/** Called from agent_end to check if autonomous mode should start after discuss */
export function checkAutoStartAfterDiscuss() {
const entry = _getPendingAutoStart();
if (!entry) return false;
const { ctx, pi, basePath, milestoneId, step } = entry;
// Gate 1: Primary milestone must have CONTEXT.md or ROADMAP.md
// The "discuss" path creates CONTEXT.md; the "plan" path creates ROADMAP.md.
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
if (!contextFile && !roadmapFile) return false; // neither artifact yet — keep waiting
// Gate 2: STATE.md must exist — written as the last step in the discuss
// output phase. This prevents auto-start from firing during Phase 3
// (sequential readiness gates for remaining milestones) in multi-milestone
// discussions, where M001-CONTEXT.md exists but M002/M003 haven't been
// processed yet.
const stateFile = resolveSfRootFile(basePath, "STATE");
if (!stateFile) return false; // discussion not finalized yet
// Gate 3: Multi-milestone completeness warning
// Parse PROJECT.md for milestone sequence, warn if any are missing context.
// Don't block — milestones can be intentionally queued without context.
const projectFile = resolveSfRootFile(basePath, "PROJECT");
let projectIds = [];
if (projectFile) {
try {
const projectContent = readFileSync(projectFile, "utf-8");
projectIds = parseMilestoneSequenceFromProject(projectContent);
if (projectIds.length > 1) {
const missing = projectIds.filter((id) => {
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
const hasDraft = !!resolveMilestoneFile(
basePath,
id,
"CONTEXT-DRAFT",
);
const hasDir = existsSync(join(sfRoot(basePath), "milestones", id));
return !hasContext && !hasDraft && !hasDir;
});
if (missing.length > 0) {
ctx.ui.notify(
`Multi-milestone validation: ${missing.join(", ")} not found in filesystem. ` +
`Discussion may not have completed all readiness gates.`,
"warning",
);
}
}
} catch (e) {
logWarning("guided", `PROJECT.md parsing failed: ${e.message}`);
}
}
// Gate 4: Discussion manifest process verification (multi-milestone only)
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
// When it exists, validate it before auto-starting. Project history alone is
// not a reliable signal for the current discussion mode.
const manifestPath = join(sfRoot(basePath), "DISCUSSION-MANIFEST.json");
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
const total = typeof manifest.total === "number" ? manifest.total : 0;
const completed =
typeof manifest.gates_completed === "number"
? manifest.gates_completed
: 0;
if (total > 1 && completed < total) {
// Discussion not complete — block auto-start until all gates are done
return false;
}
// Cross-check manifest milestones against PROJECT.md if available
if (projectIds.length > 0) {
const manifestIds = Object.keys(manifest.milestones ?? {});
const untracked = projectIds.filter((id) => !manifestIds.includes(id));
if (untracked.length > 0) {
ctx.ui.notify(
`Discussion manifest missing gates for: ${untracked.join(", ")}`,
"warning",
);
}
}
} catch (e) {
logWarning(
"guided",
`discussion manifest verification failed: ${e.message}`,
);
}
}
// Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
// CONTEXT.md, delete the draft — it's been consumed by the discussion.
try {
const draftFile = resolveMilestoneFile(
basePath,
milestoneId,
"CONTEXT-DRAFT",
);
if (draftFile) unlinkSync(draftFile);
} catch (e) {
logWarning("guided", `CONTEXT-DRAFT.md unlink failed: ${e.message}`);
}
// Cleanup: remove discussion manifest after auto-start (only needed during discussion)
try {
unlinkSync(manifestPath);
} catch (e) {
logWarning("guided", `manifest unlink failed: ${e.message}`);
}
pendingAutoStartMap.delete(basePath);
ctx.ui.notify(`Milestone ${milestoneId} ready.`, "info", {
kind: "approval_request",
blocking: true,
source: "workflow",
dedupe_key: `milestone-ready:${milestoneId}`,
});
startAutoDetached(ctx, pi, basePath, false, { step });
return true;
}
/**
* Extract milestone IDs from PROJECT.md milestone sequence table.
* Looks for rows like "| M001 | Name | Status |" and extracts the ID column.
*/
function parseMilestoneSequenceFromProject(content) {
const ids = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^\|\s*(M\d{3}[A-Z0-9-]*)\s*\|/);
if (match) ids.push(match[1]);
}
return ids;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Read SF-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", models.discuss → "discuss-milestone") and applies it before
* dispatching. This ensures guided-flow dispatches respect the same
* per-phase model preferences that autonomous mode uses.
*/
async function dispatchWorkflow(
pi,
note,
customType = "sf-run",
ctx,
unitType,
) {
// Route through the dynamic routing pipeline (complexity classification,
// tier downgrade, fallback chains) — same path as autonomous mode dispatches (#2958).
if (ctx && unitType) {
const prefs = loadEffectiveSFPreferences()?.preferences;
const result = await selectAndApplyModel(
ctx,
pi,
unitType,
/* unitId */ "",
/* basePath */ process.cwd(),
prefs,
/* verbose */ false,
/* autoModeStartModel */ null,
/* retryContext */ undefined,
/* isAutoMode */ false,
);
if (result.appliedModel) {
debugLog("guided-flow-model-applied", {
unitType,
model: `${result.appliedModel.provider}/${result.appliedModel.id}`,
routing: result.routing,
});
}
const compatibilityError = getWorkflowTransportSupportError(
result.appliedModel?.provider ?? ctx.model?.provider,
getRequiredWorkflowToolsForGuidedUnit(unitType),
{
projectRoot: process.cwd(),
surface: "guided flow",
unitType,
authMode: result.appliedModel?.provider
? ctx.modelRegistry.getProviderAuthMode(result.appliedModel.provider)
: ctx.model?.provider
? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider)
: undefined,
baseUrl: result.appliedModel?.baseUrl ?? ctx.model?.baseUrl,
},
);
if (compatibilityError) {
ctx.ui.notify(compatibilityError, "error");
return;
}
}
// Scope tools for bounded workflow phases (#2949, research containment).
// Providers with grammar-based constrained decoding (xAI/Grok) return
// "Grammar is too complex" when the combined tool schema is too large.
// Some phases also only need a small subset of SF tools; strip
// out-of-phase planning/execution/completion tools to keep turns bounded.
let savedTools = null;
if (unitType) {
const currentTools = pi.getActiveTools();
const scopedTools = scopeActiveToolsForUnitType(unitType, currentTools);
if (scopedTools.length !== currentTools.length) {
savedTools = currentTools;
pi.setActiveTools(scopedTools);
debugLog("unit-tool-scoping", {
unitType,
before: currentTools.length,
after: scopedTools.length,
removed: currentTools.length - scopedTools.length,
});
}
}
const workflowPath =
process.env.SF_WORKFLOW_PATH ??
join(process.env.HOME ?? "~", ".sf", "agent", "SF-WORKFLOW.md");
const workflow = readFileSync(workflowPath, "utf-8");
try {
await pi.sendMessage(
{
customType,
content: `Read the following SF workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${note}`,
display: false,
},
{ triggerTurn: true },
);
} finally {
// Restore full tool set after the scoped turn has been handed to the agent.
// The LLM turn has captured the scoped set by then; restoring prevents the
// narrowed tools from leaking into subsequent dispatches (#3628).
if (savedTools) {
pi.setActiveTools(savedTools);
}
}
}
function getStructuredQuestionsAvailability(pi, ctx) {
if (!ctx) return "false";
const provider = ctx.model?.provider;
const authMode = provider
? ctx.modelRegistry.getProviderAuthMode(provider)
: undefined;
return supportsStructuredQuestions(pi.getActiveTools(), {
authMode,
baseUrl: ctx.model?.baseUrl,
})
? "true"
: "false";
}
/**
* Resolve a model ID string to a model object from available models.
* Handles "provider/model" and bare ID formats.
*/
function _resolveAvailableModel(modelId, availableModels, currentProvider) {
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).
*/
function buildDiscussPrompt(
nextId,
preamble,
_basePath,
pi,
ctx,
preparationContext,
) {
const milestoneRel = `.sf/milestones/${nextId}`;
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
const inlinedTemplates = [
inlineTemplate("project", "Project"),
inlineTemplate("requirements", "Requirements"),
inlineTemplate("context", "Context"),
inlineTemplate("roadmap", "Roadmap"),
inlineTemplate("decisions", "Decisions"),
].join("\n\n---\n\n");
return loadPrompt("discuss", {
milestoneId: nextId,
preamble,
preparationContext: preparationContext ?? "",
structuredQuestionsAvailable,
contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
inlinedTemplates,
commitInstruction: buildDocsCommitInstruction(
`docs(${nextId}): context, requirements, and roadmap`,
),
multiMilestoneCommitInstruction: buildDocsCommitInstruction(
"docs: project plan — N milestones",
),
});
}
/**
* Build the discuss prompt for headless milestone creation.
* Uses the discuss-headless prompt template with seed context injected.
*/
function buildHeadlessDiscussPrompt(nextId, seedContext, _basePath) {
const milestoneRel = `.sf/milestones/${nextId}`;
const inlinedTemplates = [
inlineTemplate("project", "Project"),
inlineTemplate("requirements", "Requirements"),
inlineTemplate("context", "Context"),
inlineTemplate("roadmap", "Roadmap"),
inlineTemplate("decisions", "Decisions"),
].join("\n\n---\n\n");
return loadPrompt("discuss-headless", {
milestoneId: nextId,
seedContext,
contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
inlinedTemplates,
commitInstruction: buildDocsCommitInstruction(
`docs(${nextId}): context, requirements, and roadmap`,
),
multiMilestoneCommitInstruction: buildDocsCommitInstruction(
"docs: project plan — N milestones",
),
});
}
/**
* Run preparation phase if enabled, then build the discuss prompt.
* Preparation analyzes the codebase and prior context, injecting the results
* as supplementary context into the standard discuss template. The discuss
* template drives the conversation (asks "What's the vision?" first), while
* the preparation briefs give the agent grounding in the existing codebase.
*
* @param ctx - Extension command context with UI for progress notifications
* @param nextId - The milestone ID being discussed
* @param preamble - Preamble text for the discuss prompt
* @param basePath - Root directory of the project
* @returns The discuss prompt string
*/
async function prepareAndBuildDiscussPrompt(
ctx,
pi,
nextId,
preamble,
basePath,
) {
const prefs = loadEffectiveSFPreferences()?.preferences ?? {};
// Run preparation if enabled (default: true) — results are injected as
// supplementary context into the standard discuss prompt, NOT as a
// replacement template. The discuss prompt always leads with "What's the
// vision?" so the user defines the scope, not the codebase analysis.
let preparationContext = "";
if (prefs.discuss_preparation !== false) {
try {
const prepResult = await runPreparation(basePath, ctx.ui, {
discuss_preparation: prefs.discuss_preparation,
discuss_web_research: prefs.discuss_web_research,
discuss_depth: prefs.discuss_depth,
});
if (prepResult.enabled) {
const codebaseBrief =
prepResult.codebaseBrief || formatCodebaseBrief(prepResult.codebase);
const priorContextBrief =
prepResult.priorContextBrief ||
formatPriorContextBrief(prepResult.priorContext);
const parts = [];
if (codebaseBrief) parts.push(`### Codebase Brief\n\n${codebaseBrief}`);
if (priorContextBrief)
parts.push(`### Prior Context Brief\n\n${priorContextBrief}`);
if (parts.length > 0) {
preparationContext = `\n\n## Preparation Context\n\nThe system analyzed the codebase before this discussion. Use these findings as background context — they describe what already exists, NOT what the user wants to build. Always ask the user what they want to build first.\n\n${parts.join("\n\n")}`;
}
}
} catch (err) {
logWarning(
"guided",
`preparation failed, proceeding without context: ${err.message}`,
);
}
}
return buildDiscussPrompt(
nextId,
preamble,
basePath,
pi,
ctx,
preparationContext,
);
}
/**
* Bootstrap a .sf/ project from scratch for headless use.
* Ensures git repo, .sf/ structure, gitignore, and preferences all exist.
*/
export function bootstrapProject(basePath) {
if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) {
const mainBranch =
loadEffectiveSFPreferences()?.preferences?.git?.main_branch || "main";
nativeInit(basePath, mainBranch);
}
const root = sfRoot(basePath);
mkdirSync(join(root, "milestones"), { recursive: true });
mkdirSync(join(root, "runtime"), { recursive: true });
ensureGitignore(basePath);
ensurePreferences(basePath);
ensureAgenticDocsScaffold(basePath);
ensureSiftIndexWarmup(
basePath,
loadEffectiveSFPreferences()?.preferences?.codebase,
);
untrackRuntimeFiles(basePath);
}
/**
* Machine-surface milestone creation from a seed specification document.
* Bootstraps the project if needed, generates the next milestone ID,
* and dispatches the machine-surface discuss prompt. Run-control policy may
* ask only the final depth-verification gate before promoting draft knowledge.
*/
export async function showHeadlessMilestoneCreation(
ctx,
pi,
basePath,
seedContext,
) {
// Clear stale reservations from previous cancelled sessions (#2488)
clearReservedMilestoneIds();
// Ensure .sf/ is bootstrapped
bootstrapProject(basePath);
// Generate next milestone ID
const existingIds = findMilestoneIds(basePath);
const prefs = loadEffectiveSFPreferences();
const nextId = nextMilestoneIdReserved(
existingIds,
prefs?.preferences?.unique_milestone_ids ?? false,
);
// Create milestone directory
const milestoneDir = join(sfRoot(basePath), "milestones", nextId, "slices");
mkdirSync(milestoneDir, { recursive: true });
// Build and dispatch the headless discuss prompt.
const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath);
// Set pending auto start (autonomous mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss)
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: nextId,
createdAt: Date.now(),
});
// Dispatch as discuss-milestone. The LLM writes PROJECT.md, REQUIREMENTS.md,
// and CONTEXT.md, then calls plan_milestone — this is semantically the
// discuss path, just non-interactive. Using "plan-milestone" here caused
// model/tool routing to skip discuss-flow tool scoping and
// `checkAutoStartAfterDiscuss` guardrails that rely on the
// "discuss-"-prefixed unitType.
await dispatchWorkflow(pi, prompt, "sf-run", ctx, "discuss-milestone");
}
/**
* Single discuss-dispatch entry point for new milestones.
* autonomousBootstrap=true → headless prompt, rootFiles seed, discuss-milestone workflow
* autonomousBootstrap=false → discuss prompt with preparation and operator review
*/
export async function dispatchNewMilestoneDiscuss(
ctx,
pi,
basePath,
nextId,
options,
) {
if (options.autonomousBootstrap) {
const seedParts = [options.preamble, ""];
const rootFiles = [
"README.md",
"README.rst",
"package.json",
"go.mod",
"Cargo.toml",
"pyproject.toml",
];
for (const fname of rootFiles) {
try {
const fpath = join(basePath, fname);
if (existsSync(fpath)) {
const content = readFileSync(fpath, "utf-8").slice(0, 4000);
seedParts.push(`### ${fname}\n\n${content}`);
}
} catch {
/* non-fatal */
}
}
seedParts.push(
"",
[
"Autonomously analyze this codebase to plan what needs to be built or improved.",
"",
"Investigation approach:",
"1. Scout the codebase deeply — use rg, find, ast-grep, and file reads to understand structure, patterns, and tech stack",
"2. Run existing tests (go test, cargo test, npm test, etc.) to measure current quality",
"3. Web search for industry best practices for this type of software — testing strategies, architecture patterns, operational requirements",
"4. Research any libraries, frameworks, or external services involved — get current API docs and constraints",
"5. Update .sf/CODEBASE.md with verified project knowledge: stack signals, critical paths, file descriptions, verification commands, and skill needs",
"6. Identify gaps: missing tests, incomplete features, error handling, observability, security, documentation",
"",
"Goal: define milestones that represent the highest-value work to make this software production-ready, well-tested, and complete.",
"Use all available models and research tools. Treat your findings as the specification.",
].join("\n"),
);
const prompt = buildHeadlessDiscussPrompt(
nextId,
seedParts.join("\n"),
basePath,
);
// Do NOT set pendingAutoStartMap — caller (bootstrapAutoSession) manages the loop
await dispatchWorkflow(pi, prompt, "sf-run", ctx, "discuss-milestone");
} else {
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: nextId,
step: options.step,
createdAt: Date.now(),
});
const prompt = await prepareAndBuildDiscussPrompt(
ctx,
pi,
nextId,
options.preamble,
basePath,
);
await dispatchWorkflow(pi, prompt, "sf-run", ctx, "discuss-milestone");
}
}
/**
* Bootstrap a new milestone: ensure .sf/ structure exists, clear stale
* reservations, generate + reserve the next milestone ID, and create its
* slice directory. Returns the reserved ID.
*
* Call this before `dispatchNewMilestoneDiscuss` when starting from auto-start.
*/
export function bootstrapNewMilestone(basePath) {
clearReservedMilestoneIds();
bootstrapProject(basePath);
const existingIds = findMilestoneIds(basePath);
const prefs = loadEffectiveSFPreferences();
const nextId = nextMilestoneIdReserved(
existingIds,
prefs?.preferences?.unique_milestone_ids ?? false,
);
mkdirSync(join(sfRoot(basePath), "milestones", nextId, "slices"), {
recursive: true,
});
return nextId;
}
// ─── Discuss Flow ─────────────────────────────────────────────────────────────
/**
* Build a rich inlined-context prompt for discussing a specific slice.
* Preloads roadmap, milestone context, research, decisions, and completed
* slice summaries so the agent can ask grounded UX/behaviour questions
* without wasting a turn reading files.
*/
async function buildDiscussSlicePrompt(mid, sid, sTitle, base, options) {
const inlined = [];
// Roadmap — always included so the agent sees surrounding slices
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
if (roadmapContent) {
inlined.push(
`### Milestone Roadmap\nSource: \`${roadmapRel}\`\n\n${roadmapContent.trim()}`,
);
}
// Milestone context — understanding the full milestone intent
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
const contextContent = contextPath ? await loadFile(contextPath) : null;
if (contextContent) {
inlined.push(
`### Milestone Context\nSource: \`${contextRel}\`\n\n${contextContent.trim()}`,
);
}
// Milestone research — technical grounding
const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
const researchRel = relMilestoneFile(base, mid, "RESEARCH");
const researchContent = researchPath ? await loadFile(researchPath) : null;
if (researchContent) {
inlined.push(
`### Milestone Research\nSource: \`${researchRel}\`\n\n${researchContent.trim()}`,
);
}
// Decisions — architectural context that constrains this slice
const decisionsPath = resolveSfRootFile(base, "DECISIONS");
if (existsSync(decisionsPath)) {
const decisionsContent = await loadFile(decisionsPath);
if (decisionsContent) {
inlined.push(
`### Decisions Register\nSource: \`${relSfRootFile("DECISIONS")}\`\n\n${decisionsContent.trim()}`,
);
}
}
// Completed slice summaries — what was already built that this slice builds on
// Ensure DB is open so getMilestoneSlices returns real data (#2560).
{
const { ensureDbOpen } = await import("./bootstrap/dynamic-tools.js");
await ensureDbOpen();
let normSlices = [];
if (isDbAvailable()) {
normSlices = getMilestoneSlices(mid).map((s) => ({
id: s.id,
done: s.status === "complete",
}));
}
for (const s of normSlices) {
if (!s.done || s.id === sid) continue;
const summaryPath = resolveSliceFile(base, mid, s.id, "SUMMARY");
const summaryRel = relSliceFile(base, mid, s.id, "SUMMARY");
const summaryContent = summaryPath ? await loadFile(summaryPath) : null;
if (summaryContent) {
inlined.push(
`### ${s.id} Summary (completed)\nSource: \`${summaryRel}\`\n\n${summaryContent.trim()}`,
);
}
}
}
const inlinedContext =
inlined.length > 0
? `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`
: `## Inlined Context\n\n_(no context files found yet — go in blind and ask broad questions)_`;
const sliceDirPath = `.sf/milestones/${mid}/slices/${sid}`;
const sliceContextPath = `${sliceDirPath}/${sid}-CONTEXT.md`;
// When re-discussing, inject a preamble so the agent treats this as an update interview
const rediscussPreamble = options?.rediscuss
? `\n\n## Re-discuss Mode\n\nThis slice already has an existing context file (\`${sliceContextPath}\`) from a prior discussion. The user has chosen to re-discuss it. Read the existing context file, interview for any updates, changes, or new decisions, and rewrite the file with merged findings. Do NOT skip the interview — the user explicitly asked to revisit this slice.\n`
: "";
const inlinedTemplates = inlineTemplate("slice-context", "Slice Context");
return loadPrompt("guided-discuss-slice", {
milestoneId: mid,
sliceId: sid,
sliceTitle: sTitle,
inlinedContext: inlinedContext + rediscussPreamble,
sliceDirPath,
contextPath: sliceContextPath,
projectRoot: base,
inlinedTemplates,
structuredQuestionsAvailable:
options?.structuredQuestionsAvailable ?? "false",
commitInstruction: buildDocsCommitInstruction(
`docs(${mid}/${sid}): slice context from discuss`,
),
});
}
/**
* /discuss — show a picker of non-done slices and run a slice interview.
* Loops back to the picker after each discussion so the user can chain
* multiple slice interviews in one session.
*/
export async function showDiscuss(ctx, pi, basePath) {
// Guard: no .sf/ project
if (!existsSync(sfRoot(basePath))) {
ctx.ui.notify(
"No SF project found. Run /next to start one first.",
"warning",
);
return;
}
// Invalidate caches to pick up artifacts written by a just-completed discuss/plan
invalidateAllCaches();
const state = await deriveState(basePath);
// Rebuild STATE.md from derived state before any dispatch (#3475).
// Without this, guided prompts read a stale STATE.md cache and the
// agent bootstraps from the wrong milestone.
try {
const { buildStateMarkdown } = await import("./doctor.js");
await saveFile(
resolveSfRootFile(basePath, "STATE"),
buildStateMarkdown(state),
);
} catch (err) {
logWarning("guided", `STATE.md rebuild failed: ${err.message}`);
}
// No active milestone (or corrupted milestone with undefined id) —
// check for pending milestones to discuss instead
if (!state.activeMilestone?.id) {
const pendingMilestones = state.registry.filter(
(m) => m.status === "pending",
);
if (pendingMilestones.length === 0) {
ctx.ui.notify(
"No active milestone. Run /next to create one first.",
"warning",
);
return;
}
await showDiscussQueuedMilestone(ctx, pi, basePath, pendingMilestones);
return;
}
const mid = state.activeMilestone.id;
const milestoneTitle = state.activeMilestone.title;
// Special case: milestone is in needs-discussion phase (has CONTEXT-DRAFT.md but no roadmap yet).
// Route to the draft discussion flow instead of erroring — the discussion IS how the roadmap gets created.
if (state.phase === "needs-discussion") {
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
const draftContent = draftFile ? await loadFile(draftFile) : null;
const choice = await showNextAction(ctx, {
title: `SF — ${mid}: ${milestoneTitle}`,
summary: [
"This milestone has a draft context from a prior discussion.",
"It needs a dedicated discussion before auto-planning can begin.",
],
actions: [
{
id: "discuss_draft",
label: "Discuss from draft",
description:
"Continue where the prior discussion left off — seed material is loaded automatically.",
recommended: true,
},
{
id: "discuss_fresh",
label: "Start fresh discussion",
description:
"Discard the draft and start a new discussion from scratch.",
},
{
id: "skip_milestone",
label: "Skip — create new milestone",
description: "Leave this milestone as-is and start something new.",
},
],
notYetMessage: "Run /discuss when ready to discuss this milestone.",
});
if (choice === "discuss_draft") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId: mid,
milestoneTitle,
inlinedTemplates: discussMilestoneTemplates,
structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(
`docs(${mid}): milestone context from discuss`,
),
fastPathInstruction: "",
});
const seed = draftContent
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
: basePrompt;
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: mid,
step: false,
createdAt: Date.now(),
});
await dispatchWorkflow(pi, seed, "sf-discuss", ctx, "discuss-milestone");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: mid,
step: false,
createdAt: Date.now(),
});
await dispatchWorkflow(
pi,
loadPrompt("guided-discuss-milestone", {
milestoneId: mid,
milestoneTitle,
inlinedTemplates: discussMilestoneTemplates,
structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(
`docs(${mid}): milestone context from discuss`,
),
fastPathInstruction: "",
}),
"sf-discuss",
ctx,
"discuss-milestone",
);
} else if (choice === "skip_milestone") {
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds =
!!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: nextId,
step: false,
createdAt: Date.now(),
});
await dispatchWorkflow(
pi,
await prepareAndBuildDiscussPrompt(
ctx,
pi,
nextId,
`New milestone ${nextId}.`,
basePath,
),
"sf-run",
ctx,
"discuss-milestone",
);
}
return;
}
// Ensure DB is open before querying slices (#2560).
// showDiscuss() is a command handler — unlike tool handlers, it has no
// automatic ensureDbOpen() call. Without this, isDbAvailable() returns
// false on cold-start sessions and normSlices falls to [] → false
// "All slices complete" exit.
const { ensureDbOpen } = await import("./bootstrap/dynamic-tools.js");
await ensureDbOpen();
// Guard: no roadmap yet (unless DB has slices)
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent && !isDbAvailable()) {
ctx.ui.notify(
"No roadmap yet for this milestone. Run /next to plan first.",
"warning",
);
return;
}
let normSlices;
if (isDbAvailable()) {
normSlices = getMilestoneSlices(mid).map((s) => ({
id: s.id,
done: s.status === "complete",
title: s.title,
}));
} else {
normSlices = [];
}
// DB is open but returned zero slices despite a roadmap existing —
// the DB may be empty due to WAL loss or truncation (see #2815, #2892).
// Fall back to roadmap parsing to prevent false "all complete" exit.
if (normSlices.length === 0 && roadmapContent) {
normSlices = parseRoadmapSlices(roadmapContent).map((s) => ({
id: s.id,
done: s.done,
title: s.title,
}));
}
const pendingSlices = normSlices.filter((s) => !s.done);
if (pendingSlices.length === 0) {
// All slices complete — but queued milestones may still need discussion (#3150)
const pendingMilestones = state.registry.filter(
(m) => m.status === "pending",
);
if (pendingMilestones.length > 0) {
await showDiscussQueuedMilestone(ctx, pi, basePath, pendingMilestones);
return;
}
ctx.ui.notify("All slices are complete — nothing to discuss.", "info");
return;
}
// Loop: show picker, dispatch discuss, repeat until "not_yet"
while (true) {
// Invalidate caches so we pick up CONTEXT files written by the just-completed discussion
invalidateAllCaches();
// Build discussion-state map: which slices have CONTEXT files already?
const discussedMap = new Map();
for (const s of pendingSlices) {
const contextFile = resolveSliceFile(basePath, mid, s.id, "CONTEXT");
discussedMap.set(s.id, !!contextFile);
}
// If all pending slices are discussed, check for queued milestones before exiting (#3150)
const allDiscussed = pendingSlices.every((s) => discussedMap.get(s.id));
if (allDiscussed) {
const pendingMilestones = state.registry.filter(
(m) => m.status === "pending",
);
if (pendingMilestones.length > 0) {
await showDiscussQueuedMilestone(ctx, pi, basePath, pendingMilestones);
return;
}
const lockData = readSessionLockData(basePath);
const remoteAutoRunning =
lockData &&
lockData.pid !== process.pid &&
isSessionLockProcessAlive(lockData);
const nextStep = remoteAutoRunning
? "Autonomous mode is already running — use /status to check progress."
: "Run /next to start planning.";
ctx.ui.notify(
`All ${pendingSlices.length} slices discussed. ${nextStep}`,
"info",
);
return;
}
// Find the first undiscussed slice to recommend
const firstUndiscussedId = pendingSlices.find(
(s) => !discussedMap.get(s.id),
)?.id;
const actions = pendingSlices.map((s) => {
const discussed = discussedMap.get(s.id) ?? false;
const statusParts = [];
if (state.activeSlice?.id === s.id) statusParts.push("active");
else statusParts.push("upcoming");
statusParts.push(discussed ? "discussed ✓" : "not discussed");
return {
id: s.id,
label: `${s.id}: ${s.title}`,
description: statusParts.join(" · "),
recommended: s.id === firstUndiscussedId,
};
});
// Offer access to queued milestones when any exist
const pendingMilestones = state.registry.filter(
(m) => m.status === "pending",
);
if (pendingMilestones.length > 0) {
actions.push({
id: "discuss_queued_milestone",
label: "Discuss a queued milestone",
description: `Refine context for ${pendingMilestones.length} queued milestone(s). Does not affect current execution.`,
recommended: false,
});
}
const choice = await showNextAction(ctx, {
title: "SF — Discuss a slice",
summary: [
`${mid}: ${milestoneTitle}`,
"Pick a slice to interview. Context file will be written when done.",
],
actions,
notYetMessage: "Run /discuss when ready.",
});
if (choice === "not_yet") return;
if (choice === "discuss_queued_milestone") {
await showDiscussQueuedMilestone(ctx, pi, basePath, pendingMilestones);
return;
}
const chosen = pendingSlices.find((s) => s.id === choice);
if (!chosen) return;
// If the slice already has a CONTEXT file, confirm re-discuss intent
const isRediscuss = discussedMap.get(chosen.id) ?? false;
if (isRediscuss) {
const confirm = await showNextAction(ctx, {
title: `Re-discuss ${chosen.id}?`,
summary: [
`${chosen.id} already has a context file from a prior discussion.`,
"Re-discussing will interview for updates and rewrite the context file.",
],
actions: [
{
id: "rediscuss",
label: "Re-discuss to update context",
description: "Interview for changes and rewrite",
recommended: true,
},
{
id: "cancel",
label: "Cancel",
description: "Go back to slice picker",
},
],
});
if (confirm !== "rediscuss") continue;
}
const sqAvail = getStructuredQuestionsAvailability(pi, ctx);
const prompt = await buildDiscussSlicePrompt(
mid,
chosen.id,
chosen.title,
basePath,
{ rediscuss: isRediscuss, structuredQuestionsAvailable: sqAvail },
);
await dispatchWorkflow(pi, prompt, "sf-discuss", ctx, "discuss-slice");
// Wait for the discuss session to finish, then loop back to the picker
await ctx.waitForIdle();
invalidateAllCaches();
}
}
// ─── Queued Milestone Discussion ─────────────────────────────────────────────
/**
* Show a picker of queued (pending) milestones and dispatch a discuss flow for
* the chosen one. Discussing a queued milestone does NOT activate it — it only
* refines the CONTEXT.md artifact so it is better prepared when autonomous mode
* eventually reaches it.
*/
async function showDiscussQueuedMilestone(
ctx,
pi,
basePath,
pendingMilestones,
) {
const actions = pendingMilestones.map((m, i) => {
const hasContext = !!resolveMilestoneFile(basePath, m.id, "CONTEXT");
const hasDraft =
!hasContext && !!resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT");
const contextStatus = hasContext
? "context ✓"
: hasDraft
? "draft context"
: "no context yet";
return {
id: m.id,
label: `${m.id}: ${m.title}`,
description: `[queued] · ${contextStatus}`,
recommended: i === 0,
};
});
const choice = await showNextAction(ctx, {
title: "SF — Discuss a queued milestone",
summary: [
"Select a queued milestone to discuss.",
"Discussing will update its context file. It will not be activated.",
],
actions,
notYetMessage: "Run /discuss when ready.",
});
if (choice === "not_yet") return;
const chosen = pendingMilestones.find((m) => m.id === choice);
if (!chosen) return;
const hasDraft = !!resolveMilestoneFile(basePath, chosen.id, "CONTEXT-DRAFT");
let fastPath = hasDraft;
if (!hasDraft) {
const mode = await showNextAction(ctx, {
title: `Discuss ${chosen.id}`,
summary: [
"Choose how to start the discussion.",
"Fast path skips generic scouting — use it when you already know the scope.",
],
actions: [
{
id: "full",
label: "Full discussion",
description:
"Scout the codebase, ask open-ended questions, explore deeply",
recommended: true,
},
{
id: "fast",
label: "I have the scope — fast path",
description:
"Treat your first message as authoritative seed context; skip scouting",
},
],
notYetMessage: "Run /discuss when ready.",
});
if (mode === "not_yet") return;
fastPath = mode === "fast";
}
await dispatchDiscussForMilestone(
ctx,
pi,
basePath,
chosen.id,
chosen.title,
{ fastPath },
);
}
/**
* Dispatch the guided-discuss-milestone prompt for a milestone without
* setting pendingAutoStart — so discussing a queued milestone does not
* implicitly activate it when the session ends.
*/
async function dispatchDiscussForMilestone(
ctx,
pi,
basePath,
mid,
milestoneTitle,
opts = {},
) {
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
const draftContent = draftFile ? await loadFile(draftFile) : null;
const hasSeed = !!(draftContent || opts.fastPath);
const fastPathInstruction = hasSeed
? [
"> **Fast path active — scope provided.**",
"> Do NOT perform a generic codebase scouting pass.",
"> Do at most 2 targeted reads to check for obvious conflicts with existing work.",
"> Treat the seed context or the operator's first message as authoritative.",
"> Move directly to the depth summary and write step.",
"> Ask only questions where the answer would materially change scope.",
].join("\n")
: "";
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId: mid,
milestoneTitle,
inlinedTemplates: discussMilestoneTemplates,
structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(
`docs(${mid}): milestone context from discuss`,
),
fastPathInstruction,
});
const prompt = draftContent
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
: basePrompt;
await dispatchWorkflow(pi, prompt, "sf-discuss", ctx, "discuss-milestone");
}
// ─── Workflow Entry Point ─────────────────────────────────────────────────────
/**
* The workflow entry wizard. Reads state, shows contextual options, and dispatches into the workflow doc.
*/
/**
* Self-heal: scan runtime records and clear stale ones left behind when
* autonomous mode crashed mid-unit. Recover `runaway-recovered` snapshots so
* `decideUnitRuntimeDispatch` stops blocking autonomous and guided resumes.
* Clearing at entry matches a fresh session boundary and avoids indefinite
* `runaway-recovery-reset-required` wedges.
*/
function selfHealRuntimeRecords(basePath, ctx) {
try {
let cleared = clearRunawayRecoveredRuntimeRecords(basePath);
const records = listUnitRuntimeRecords(basePath);
for (const record of records) {
const { unitType, unitId, phase } = record;
// Clear records whose expected artifact already exists (completed but not cleaned up)
const artifactPath = resolveExpectedArtifactPath(
unitType,
unitId,
basePath,
);
if (artifactPath && existsSync(artifactPath)) {
clearUnitRuntimeRecord(basePath, unitType, unitId);
cleared++;
continue;
}
// Clear records stuck in dispatched or timeout phase (process died mid-unit)
if (phase === "dispatched" || phase === "timeout") {
clearUnitRuntimeRecord(basePath, unitType, unitId);
cleared++;
}
}
if (cleared > 0) {
ctx.ui.notify(
`Self-heal: cleared ${cleared} stale runtime record(s) from a previous session.`,
"info",
);
}
return { cleared };
} catch (e) {
logWarning(
"guided",
`self-heal stale runtime records failed: ${e.message}`,
);
return { cleared: 0 };
}
}
// ─── Milestone Actions Submenu ──────────────────────────────────────────────
/**
* Shows a submenu with Park / Discard / Skip / Back options for the active milestone.
* Returns true if an action was taken (caller should re-enter showSmartEntry or
* dispatch a new workflow). Returns false if the user chose "Back".
*/
async function handleMilestoneActions(
ctx,
pi,
basePath,
milestoneId,
milestoneTitle,
options,
) {
const stepMode = options?.step;
const choice = await showNextAction(ctx, {
title: `Milestone Actions — ${milestoneId}`,
summary: [`${milestoneId}: ${milestoneTitle}`],
actions: [
{
id: "park",
label: "Park milestone",
description: "Pause this milestone — it stays on disk but is skipped.",
},
{
id: "discard",
label: "Discard milestone",
description: "Permanently delete this milestone and all its contents.",
},
{
id: "skip",
label: "Skip — create new milestone",
description: "Leave this milestone and start a fresh one.",
},
{
id: "back",
label: "Back",
description: "Return to the previous menu.",
},
],
notYetMessage: "Run /next when ready.",
});
if (choice === "park") {
const reason = await showNextAction(ctx, {
title: `Park ${milestoneId}`,
summary: ["Why is this milestone being parked?"],
actions: [
{
id: "priority_shift",
label: "Priority shift",
description: "Other work is more important right now.",
},
{
id: "blocked_external",
label: "Blocked externally",
description: "Waiting on an external dependency or decision.",
},
{
id: "needs_rethink",
label: "Needs rethinking",
description: "The approach needs to be reconsidered.",
},
],
notYetMessage: "Run /next when ready.",
});
// User pressed "Not yet" / Escape — cancel the park operation
if (!reason || reason === "not_yet") return false;
const reasonText =
reason === "priority_shift"
? "Priority shift — other work is more important"
: reason === "blocked_external"
? "Blocked externally — waiting on external dependency"
: reason === "needs_rethink"
? "Needs rethinking — approach needs reconsideration"
: "Parked by user";
const success = parkMilestone(basePath, milestoneId, reasonText);
if (success) {
ctx.ui.notify(
`Parked ${milestoneId}. Run /unpark ${milestoneId} to reactivate.`,
"info",
);
} else {
ctx.ui.notify(
`Could not park ${milestoneId} — milestone not found or already parked.`,
"warning",
);
}
return true;
}
if (choice === "discard") {
const confirmed = await showConfirm(ctx, {
title: "Discard milestone?",
message: `This will permanently delete ${milestoneId} and all its contents (roadmap, plans, task summaries).`,
confirmLabel: "Discard",
declineLabel: "Cancel",
});
if (confirmed) {
discardMilestone(basePath, milestoneId);
ctx.ui.notify(`Discarded ${milestoneId}.`, "info");
return true;
}
return false;
}
if (choice === "skip") {
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds =
!!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: nextId,
step: stepMode,
createdAt: Date.now(),
});
await dispatchWorkflow(
pi,
await prepareAndBuildDiscussPrompt(
ctx,
pi,
nextId,
`New milestone ${nextId}.`,
basePath,
),
"sf-run",
ctx,
"discuss-milestone",
);
return true;
}
// "back" or null
return false;
}
export async function showWorkflowEntry(ctx, pi, basePath, options) {
const stepMode = options?.step;
// ── Clear stale milestone ID reservations from previous cancelled sessions ──
// Reservations only need to survive within a single /interaction.
// Without this, each cancelled session permanently bumps the next ID. (#2488)
clearReservedMilestoneIds();
// ── Directory safety check — refuse to operate in system/home dirs ───
const dirCheck = validateDirectory(basePath);
if (dirCheck.severity === "blocked") {
ctx.ui.notify(dirCheck.reason, "error");
return;
}
if (dirCheck.severity === "warning") {
const proceed = await showConfirm(ctx, {
title: "SF — Unusual Directory",
message: dirCheck.reason,
confirmLabel: "Continue anyway",
declineLabel: "Cancel",
});
if (!proceed) return;
}
// ── Detection preamble — run before any bootstrap ────────────────────
// Check bootstrap completeness, not just .sf/ directory existence.
// A zombie .sf/ state (symlink exists but missing PREFERENCES.md and
// milestones/) must trigger the init wizard, not skip it (#2942).
const sfPath = sfRoot(basePath);
const hasBootstrapArtifacts =
existsSync(sfPath) &&
(existsSync(join(sfPath, "PREFERENCES.md")) ||
existsSync(join(sfPath, "milestones")));
if (!hasBootstrapArtifacts) {
const detection = detectProjectState(basePath);
// v1 .planning/ detected — offer migration before anything else
if (detection.state === "v1-planning" && detection.v1) {
const migrationChoice = await offerMigration(ctx, detection.v1);
if (migrationChoice === "cancel") return;
if (migrationChoice === "migrate") {
const { handleMigrate } = await import("./migrate/command.js");
await handleMigrate("", ctx, pi);
return;
}
// "fresh" — fall through to init wizard
}
// No .sf/ or zombie .sf/ — run the project init wizard
const result = await showProjectInit(ctx, pi, basePath, detection);
if (!result.completed) return; // User cancelled
// Init wizard bootstrapped .sf/ — fall through to the normal flow below
// which will detect "no milestones" and start the discuss prompt
}
// ── Ensure git repo exists — SF needs it for worktree isolation ──────
// Also handle inherited repos: if basePath is a subdirectory of another
// git repo that has no .sf, create a fresh repo to prevent cross-project
// state leaks (#1639).
if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) {
const mainBranch =
loadEffectiveSFPreferences()?.preferences?.git?.main_branch || "main";
nativeInit(basePath, mainBranch);
}
// ── Ensure .gitignore has baseline patterns ──────────────────────────
ensureGitignore(basePath);
untrackRuntimeFiles(basePath);
// ── Self-heal stale runtime records from crashed autonomous mode sessions ──
selfHealRuntimeRecords(basePath, ctx);
const interrupted = await assessInterruptedSession(basePath);
if (interrupted.classification === "running") {
ctx.ui.notify(formatInterruptedSessionRunningMessage(interrupted), "error");
return;
}
if (interrupted.classification === "stale") {
clearLock(basePath);
if (interrupted.pausedSession) {
try {
unlinkSync(join(sfRoot(basePath), "runtime", "paused-session.json"));
} catch (e) {
logWarning("guided", `stale pause file cleanup failed: ${e.message}`, {
file: "guided-flow.ts",
});
}
}
} else if (interrupted.classification === "recoverable") {
if (interrupted.lock) clearLock(basePath);
const resumeLabel = interrupted.pausedSession?.stepMode
? "Resume with /next"
: "Resume with /autonomous";
const resume = await showNextAction(ctx, {
title: "SF — Interrupted Session Detected",
summary: formatInterruptedSessionSummary(interrupted),
actions: [
{
id: "resume",
label: resumeLabel,
description: "Pick up where it left off",
recommended: true,
},
{
id: "continue",
label: "Continue manually",
description: "Open the wizard as normal",
},
],
});
if (resume === "resume") {
startAutoDetached(ctx, pi, basePath, false, {
interrupted,
step: interrupted.pausedSession?.stepMode ?? false,
});
return;
}
}
// Always derive from the project root — the assessment may have derived
// state from a worktree path that was cleaned up in the stale branch above.
const state = await deriveState(basePath);
// Rebuild STATE.md from derived state before any dispatch (#3475).
try {
const { buildStateMarkdown } = await import("./doctor.js");
await saveFile(
resolveSfRootFile(basePath, "STATE"),
buildStateMarkdown(state),
);
} catch (err) {
logWarning("guided", `STATE.md rebuild failed: ${err.message}`);
}
if (!(await runPlanningFlowGate(ctx, basePath, state))) return;
if (!state.activeMilestone?.id) {
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
// Both /and /autonomous reach this branch when no milestone exists yet.
// Without this guard, every subsequent /call overwrites the pending auto-start
// and fires another dispatchWorkflow, resetting the conversation mid-interview.
if (pendingAutoStartMap.has(basePath)) {
// #3274: If /clear interrupted the discussion, the pending entry is stale.
// Detect staleness: no manifest, no CONTEXT.md, AND entry is older than
// 30s (avoids race between .set() and LLM writing first artifact).
const entry = pendingAutoStartMap.get(basePath);
const ageMs = Date.now() - (entry.createdAt || 0);
const manifestExists = existsSync(
join(sfRoot(basePath), "DISCUSSION-MANIFEST.json"),
);
const milestoneHasContext = existsSync(
join(
sfRoot(basePath),
"milestones",
entry.milestoneId,
`${entry.milestoneId}-CONTEXT.md`,
),
);
if (!manifestExists && !milestoneHasContext && ageMs > 30_000) {
// Stale entry from an interrupted discussion — clear and continue
pendingAutoStartMap.delete(basePath);
} else {
ctx.ui.notify(
"Discussion already in progress — answer the question above to continue.",
"info",
);
return;
}
}
const milestoneIds = findMilestoneIds(basePath);
// Sanity check (#456): if findMilestoneIds returns [] but the milestones
// directory has contents, something went wrong (permissions, stale worktree
// cwd, etc). Warn instead of silently starting a new-project flow.
if (milestoneIds.length === 0) {
const mDir = milestonesDir(basePath);
if (existsSync(mDir)) {
try {
const entries = readdirSync(mDir);
if (entries.length > 0) {
ctx.ui.notify(
`Milestone directory has ${entries.length} entries but none were recognized as milestones. ` +
`This may indicate a corrupted state or wrong working directory. Run \`/doctor\` to diagnose.`,
"warning",
);
return;
}
} catch (e) {
logWarning("guided", `directory read failed: ${e.message}`);
}
}
}
const uniqueMilestoneIds =
!!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
const isFirst = milestoneIds.length === 0;
if (isFirst) {
await dispatchNewMilestoneDiscuss(ctx, pi, basePath, nextId, {
autonomousBootstrap: false,
preamble: injectTodoContext(
basePath,
`New project, milestone ${nextId}. Do NOT read or explore .sf/ — it's empty scaffolding.`,
),
step: stepMode,
});
} else {
const choice = await showNextAction(ctx, {
title: "SF — Singularity Forge",
summary: ["No active milestone."],
actions: [
{
id: "new_milestone",
label: "Create next milestone",
description: "Define what to build next.",
recommended: true,
},
],
notYetMessage: "Run /next when ready.",
});
if (choice === "new_milestone") {
await dispatchNewMilestoneDiscuss(ctx, pi, basePath, nextId, {
autonomousBootstrap: false,
preamble: injectTodoContext(basePath, `New milestone ${nextId}.`),
step: stepMode,
});
}
}
return;
}
const milestoneId = state.activeMilestone.id;
const milestoneTitle = state.activeMilestone.title;
// ── All milestones complete → New milestone ──────────────────────────
if (state.phase === "complete") {
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId}: ${milestoneTitle}`,
summary: ["All milestones complete."],
actions: [
{
id: "new_milestone",
label: "Start new milestone",
description: "Define and plan the next milestone.",
recommended: true,
},
{
id: "status",
label: "View status",
description: "Review what was built.",
},
],
notYetMessage: "Run /next when ready.",
});
if (choice === "new_milestone") {
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds =
!!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
await dispatchNewMilestoneDiscuss(ctx, pi, basePath, nextId, {
autonomousBootstrap: false,
preamble: injectTodoContext(basePath, `New milestone ${nextId}.`),
step: stepMode,
});
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);
}
return;
}
// ── Draft milestone — needs discussion before planning ────────────────
if (state.phase === "needs-discussion") {
const draftFile = resolveMilestoneFile(
basePath,
milestoneId,
"CONTEXT-DRAFT",
);
const draftContent = draftFile ? await loadFile(draftFile) : null;
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId}: ${milestoneTitle}`,
summary: [
"This milestone has a draft context from a prior discussion.",
"It needs a dedicated discussion before auto-planning can begin.",
],
actions: [
{
id: "discuss_draft",
label: "Discuss from draft",
description:
"Continue where the prior discussion left off — seed material is loaded automatically.",
recommended: true,
},
{
id: "discuss_fresh",
label: "Start fresh discussion",
description:
"Discard the draft and start a new discussion from scratch.",
},
{
id: "skip_milestone",
label: "Skip — create new milestone",
description: "Leave this milestone as-is and start something new.",
},
],
notYetMessage: "Run /next when ready to discuss this milestone.",
});
if (choice === "discuss_draft") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId,
milestoneTitle,
inlinedTemplates: discussMilestoneTemplates,
structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(
`docs(${milestoneId}): milestone context from discuss`,
),
fastPathInstruction: "",
});
const seed = draftContent
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
: basePrompt;
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId,
step: stepMode,
createdAt: Date.now(),
});
await dispatchWorkflow(pi, seed, "sf-discuss", ctx, "discuss-milestone");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId,
step: stepMode,
createdAt: Date.now(),
});
await dispatchWorkflow(
pi,
loadPrompt("guided-discuss-milestone", {
milestoneId,
milestoneTitle,
inlinedTemplates: discussMilestoneTemplates,
structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(
`docs(${milestoneId}): milestone context from discuss`,
),
fastPathInstruction: "",
}),
"sf-discuss",
ctx,
"discuss-milestone",
);
} else if (choice === "skip_milestone") {
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds =
!!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: nextId,
step: stepMode,
createdAt: Date.now(),
});
await dispatchWorkflow(
pi,
await prepareAndBuildDiscussPrompt(
ctx,
pi,
nextId,
`New milestone ${nextId}.`,
basePath,
),
"sf-run",
ctx,
"discuss-milestone",
);
}
return;
}
// ── No active slice ──────────────────────────────────────────────────
if (!state.activeSlice) {
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
const hasRoadmap = !!(roadmapFile && (await loadFile(roadmapFile)));
// A roadmap file with zero parseable slices (placeholder text) should be
// treated the same as no roadmap — offer "Create roadmap" instead of "Go auto"
// which would immediately get stuck in blocked state (#3441).
let roadmapHasSlices = false;
if (hasRoadmap) {
const roadmapContent = await loadFile(roadmapFile);
if (roadmapContent) {
const parsed = parseRoadmapSlices(roadmapContent);
roadmapHasSlices = parsed.length > 0;
}
}
if (!hasRoadmap || !roadmapHasSlices) {
// No roadmap → discuss or plan
const contextFile = resolveMilestoneFile(
basePath,
milestoneId,
"CONTEXT",
);
const hasContext = !!(contextFile && (await loadFile(contextFile)));
const actions = [
{
id: "plan",
label: "Create roadmap",
description: hasContext
? "Context captured. Decompose into slices with a boundary map."
: "Decompose the milestone into slices with a boundary map.",
recommended: true,
},
...(!hasContext
? [
{
id: "discuss",
label: "Discuss first",
description: "Capture decisions on gray areas before planning.",
},
]
: []),
{
id: "skip_milestone",
label: "Skip — create new milestone",
description: "Leave this milestone on disk and start a fresh one.",
},
{
id: "discard_milestone",
label: "Discard this milestone",
description: "Delete the milestone directory and start over.",
},
];
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId}: ${milestoneTitle}`,
summary: [
hasContext
? "Context captured. Ready to create roadmap."
: "New milestone — no roadmap yet.",
],
actions,
notYetMessage: "Run /next when ready.",
});
if (choice === "plan") {
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId,
step: stepMode,
createdAt: Date.now(),
});
const planMilestoneTemplates = [
inlineTemplate("roadmap", "Roadmap"),
inlineTemplate("plan", "Slice Plan"),
inlineTemplate("task-plan", "Task Plan"),
inlineTemplate("secrets-manifest", "Secrets Manifest"),
].join("\n\n---\n\n");
const secretsOutputPath = relMilestoneFile(
basePath,
milestoneId,
"SECRETS",
);
await dispatchWorkflow(
pi,
loadPrompt("guided-plan-milestone", {
milestoneId,
milestoneTitle,
secretsOutputPath,
inlinedTemplates: planMilestoneTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
milestoneTitle,
extraContext: [planMilestoneTemplates],
}),
}),
"sf-run",
ctx,
"plan-milestone",
);
} else if (choice === "discuss") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(
pi,
ctx,
);
await dispatchWorkflow(
pi,
loadPrompt("guided-discuss-milestone", {
milestoneId,
milestoneTitle,
inlinedTemplates: discussMilestoneTemplates,
structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(
`docs(${milestoneId}): milestone context from discuss`,
),
fastPathInstruction: "",
}),
"sf-run",
ctx,
"discuss-milestone",
);
} else if (choice === "skip_milestone") {
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds =
!!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(
milestoneIds,
uniqueMilestoneIds,
);
pendingAutoStartMap.set(basePath, {
ctx,
pi,
basePath,
milestoneId: nextId,
step: stepMode,
createdAt: Date.now(),
});
await dispatchWorkflow(
pi,
await prepareAndBuildDiscussPrompt(
ctx,
pi,
nextId,
`New milestone ${nextId}.`,
basePath,
),
"sf-run",
ctx,
"discuss-milestone",
);
} else if (choice === "discard_milestone") {
const confirmed = await showConfirm(ctx, {
title: "Discard milestone?",
message: `This will permanently delete ${milestoneId} and all its contents.`,
confirmLabel: "Discard",
declineLabel: "Cancel",
});
if (confirmed) {
discardMilestone(basePath, milestoneId);
return showWorkflowEntry(ctx, pi, basePath, options);
}
}
} else {
// Roadmap exists — either blocked or ready for auto
const actions = [
{
id: "auto",
label: "Go auto",
description:
"Execute everything automatically until milestone complete.",
recommended: true,
},
{
id: "status",
label: "View status",
description: "See milestone progress and blockers.",
},
{
id: "milestone_actions",
label: "Milestone actions",
description: "Park, discard, or skip this milestone.",
},
];
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId}: ${milestoneTitle}`,
summary: ["Roadmap exists. Ready to execute."],
actions,
notYetMessage: "Run /status for details.",
});
if (choice === "auto") {
startAutoDetached(ctx, pi, basePath, false);
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(
ctx,
pi,
basePath,
milestoneId,
milestoneTitle,
options,
);
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
}
}
return;
}
const sliceId = state.activeSlice.id;
const sliceTitle = state.activeSlice.title;
// ── Slice needs planning ─────────────────────────────────────────────
if (state.phase === "planning") {
const contextFile = resolveSliceFile(
basePath,
milestoneId,
sliceId,
"CONTEXT",
);
const researchFile = resolveSliceFile(
basePath,
milestoneId,
sliceId,
"RESEARCH",
);
const hasContext = !!(contextFile && (await loadFile(contextFile)));
const hasResearch = !!(researchFile && (await loadFile(researchFile)));
const actions = [
{
id: "plan",
label: `Plan ${sliceId}`,
description: `Decompose "${sliceTitle}" into tasks with must-haves.`,
recommended: true,
},
...(!hasContext
? [
{
id: "discuss",
label: `Discuss ${sliceId} first`,
description: "Capture context and decisions for this slice.",
},
]
: []),
...(!hasResearch
? [
{
id: "research",
label: `Research ${sliceId} first`,
description: "Scout codebase and relevant docs.",
},
]
: []),
{
id: "status",
label: "View status",
description: "See milestone progress.",
},
{
id: "milestone_actions",
label: "Milestone actions",
description: "Park, discard, or skip this milestone.",
},
];
const summaryParts = [];
if (hasContext) summaryParts.push("context ✓");
if (hasResearch) summaryParts.push("research ✓");
const summaryLine =
summaryParts.length > 0
? `${sliceId}: ${sliceTitle} (${summaryParts.join(", ")})`
: `${sliceId}: ${sliceTitle} — ready for planning.`;
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId} / ${sliceId}: ${sliceTitle}`,
summary: [summaryLine],
actions,
notYetMessage: "Run /next when ready.",
});
if (choice === "plan") {
const planSliceTemplates = [
inlineTemplate("plan", "Slice Plan"),
inlineTemplate("task-plan", "Task Plan"),
].join("\n\n---\n\n");
await dispatchWorkflow(
pi,
loadPrompt("guided-plan-slice", {
milestoneId,
sliceId,
sliceTitle,
inlinedTemplates: planSliceTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
sliceTitle,
extraContext: [planSliceTemplates],
}),
}),
"sf-run",
ctx,
"plan-slice",
);
} else if (choice === "discuss") {
const sqAvail = getStructuredQuestionsAvailability(pi, ctx);
await dispatchWorkflow(
pi,
await buildDiscussSlicePrompt(
milestoneId,
sliceId,
sliceTitle,
basePath,
{ rediscuss: hasContext, structuredQuestionsAvailable: sqAvail },
),
"sf-run",
ctx,
"discuss-slice",
);
} else if (choice === "research") {
const researchTemplates = inlineTemplate("research", "Research");
await dispatchWorkflow(
pi,
loadPrompt("guided-research-slice", {
milestoneId,
sliceId,
sliceTitle,
inlinedTemplates: researchTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
sliceTitle,
extraContext: [researchTemplates],
}),
}),
"sf-run",
ctx,
"research-slice",
);
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(
ctx,
pi,
basePath,
milestoneId,
milestoneTitle,
options,
);
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
}
return;
}
// ── All tasks done → Complete slice ──────────────────────────────────
if (state.phase === "summarizing") {
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId} / ${sliceId}: ${sliceTitle}`,
summary: ["All tasks complete. Ready for slice summary."],
actions: [
{
id: "complete",
label: `Complete ${sliceId}`,
description:
"Write slice summary, UAT, mark done, and squash-merge to main.",
recommended: true,
},
{
id: "status",
label: "View status",
description: "Review tasks before completing.",
},
{
id: "milestone_actions",
label: "Milestone actions",
description: "Park, discard, or skip this milestone.",
},
],
notYetMessage: "Run /next when ready.",
});
if (choice === "complete") {
const completeSliceTemplates = [
inlineTemplate("slice-summary", "Slice Summary"),
inlineTemplate("uat", "UAT"),
].join("\n\n---\n\n");
await dispatchWorkflow(
pi,
loadPrompt("guided-complete-slice", {
workingDirectory: basePath,
milestoneId,
sliceId,
sliceTitle,
inlinedTemplates: completeSliceTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
sliceTitle,
extraContext: [completeSliceTemplates],
}),
}),
"sf-run",
ctx,
"complete-slice",
);
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(
ctx,
pi,
basePath,
milestoneId,
milestoneTitle,
options,
);
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
}
return;
}
// ── Active task → Execute ────────────────────────────────────────────
if (state.activeTask) {
const taskId = state.activeTask.id;
const taskTitle = state.activeTask.title;
const continueFile = resolveSliceFile(
basePath,
milestoneId,
sliceId,
"CONTINUE",
);
const sDir = resolveSlicePath(basePath, milestoneId, sliceId);
const hasInterrupted =
!!(continueFile && (await loadFile(continueFile))) ||
!!(sDir && (await loadFile(join(sDir, "continue.md"))));
const choice = await showNextAction(ctx, {
title: `SF — ${milestoneId} / ${sliceId}: ${sliceTitle}`,
summary: [
hasInterrupted
? `Resuming: ${taskId}${taskTitle}`
: `Next: ${taskId}${taskTitle}`,
],
actions: [
{
id: "execute",
label: hasInterrupted ? `Resume ${taskId}` : `Execute ${taskId}`,
description: hasInterrupted
? "Continue from where you left off."
: `Start working on "${taskTitle}".`,
recommended: true,
},
{
id: "auto",
label: "Go auto",
description: "Execute this and all remaining tasks automatically.",
},
{
id: "status",
label: "View status",
description: "See slice progress before starting.",
},
{
id: "milestone_actions",
label: "Milestone actions",
description: "Park, discard, or skip this milestone.",
},
],
notYetMessage: "Run /next when ready.",
});
if (choice === "auto") {
startAutoDetached(ctx, pi, basePath, false);
return;
}
if (choice === "execute") {
if (hasInterrupted) {
await dispatchWorkflow(
pi,
loadPrompt("guided-resume-task", {
milestoneId,
sliceId,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
taskId,
taskTitle,
}),
}),
"sf-run",
ctx,
"execute-task",
);
} else {
const executeTaskTemplates = inlineTemplate(
"task-summary",
"Task Summary",
);
await dispatchWorkflow(
pi,
loadPrompt("guided-execute-task", {
milestoneId,
sliceId,
taskId,
taskTitle,
inlinedTemplates: executeTaskTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
taskId,
taskTitle,
extraContext: [executeTaskTemplates],
}),
}),
"sf-run",
ctx,
"execute-task",
);
}
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(
ctx,
pi,
basePath,
milestoneId,
milestoneTitle,
options,
);
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
}
return;
}
// ── Fallback: show status ────────────────────────────────────────────
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);
}