2482 lines
81 KiB
JavaScript
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);
|
|
}
|