/**
 * Autonomous mode bootstrap — fresh-start initialization path.
 *
 * Git/state bootstrap, crash lock detection, debug init, worktree recovery,
 * guided flow gate, session init, worktree lifecycle, DB lifecycle,
 * preflight validation.
 *
 * Extracted from startAuto() in auto.ts. The resume path (s.paused)
 * remains in auto.ts — this module handles only the fresh-start path.
 */
import {
	existsSync,
	mkdirSync,
	readdirSync,
	rmSync,
	statSync,
	unlinkSync,
} from "node:fs";
import { join, sep as pathSep } from "node:path";
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
import { ensureAgenticDocsScaffold } from "./agentic-docs-scaffold.js";
import {
	cleanStaleRuntimeUnits,
	getAutoWorktreePath,
	readResourceVersion,
} from "./auto-worktree.js";
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
import { invalidateAllCaches } from "./cache.js";
import { ensureSiftIndexWarmup } from "./code-intelligence.js";
import { clearLock, writeLock } from "./crash-recovery.js";
import {
	debugLog,
	enableDebug,
	getDebugLogPath,
	isDebugEnabled,
} from "./debug-logger.js";
import {
	resetProactiveHealing,
	setLevelChangeCallback,
} from "./doctor-proactive.js";
import { getErrorMessage } from "./error-utils.js";
import { getManifestStatus, loadFile } from "./files.js";
import { GitService } from "./git-service.js";
import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
import { initMetrics } from "./metrics.js";
import { initMetricsCentral } from "./metrics-central.js";
import {
	migrateToExternalState,
	recoverFailedMigration,
} from "./migrate-external.js";
import {
	nativeAddAll,
	nativeBranchDelete,
	nativeBranchList,
	nativeBranchListMerged,
	nativeCheckoutBranch,
	nativeCommit,
	nativeCommitCountBetween,
	nativeDetectMainBranch,
	nativeGetCurrentBranch,
	nativeInit,
	nativeIsRepo,
	nativeWorktreeRemove,
} from "./native-git-bridge.js";
import { initNotificationStore } from "./notification-store.js";
import { resolveMilestoneFile, sfRoot } from "./paths.js";
import { resetHookState, restoreHookState } from "./post-unit-hooks.js";
import {
	getIsolationMode,
	loadEffectiveSFPreferences,
	resolvePersistModelChanges,
	resolveSkillDiscoveryMode,
} from "./preferences.js";
import {
	isCustomProvider,
	resolveDefaultSessionModel,
	resolveDynamicRoutingConfig,
} from "./preferences-models.js";
import {
	ensureSfSymlink,
	isInheritedRepo,
	validateProjectId,
} from "./repo-identity.js";
import { initRoutingHistory } from "./routing-history.js";
import {
	acquireSessionLock,
	isSessionPidAlive,
	releaseSessionLock,
	terminateExistingSession,
	updateSessionLock,
} from "./session-lock.js";
import { getSessionModelOverride } from "./session-model-override.js";
import {
	expireStaleMemories,
	getMilestone,
	isDbAvailable,
	openDatabase,
} from "./sf-db.js";
import { snapshotSkills } from "./skill-discovery.js";
import { deriveState, isGhostMilestone } from "./state.js";
import { isClosedStatus } from "./status-guards.js";
import {
	reconcileDurableCompleteUnitRuntimeRecords,
	reconcileStaleCompleteSliceRecords,
} from "./uok/unit-runtime.js";
import { safeSetWidget } from "./widget-safe.js";
import { logError, logWarning } from "./workflow-logger.js";
import {
	captureIntegrationBranch,
	detectWorktreeName,
	setActiveMilestoneId,
} from "./worktree.js";
import {
	worktreePath as getWorktreeDir,
	isInsideWorktreesDir,
} from "./worktree-manager.js";
import { emitWorktreeOrphaned } from "./worktree-telemetry.js";

/**
 * Bootstrap a fresh autonomous mode session. Handles everything from git init
 * through secrets collection, returning when ready for the first
 * dispatchNextUnit call.
 *
 * Returns false if the bootstrap aborted (e.g., guided flow returned,
 * concurrent session detected). Returns true when ready to dispatch.
 */
// Guard constant for consecutive bootstrap attempts that found phase === "complete".
// Counter moved to AutoSession.consecutiveCompleteBootstraps so s.reset() clears it.
const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2;
/**
 * Decide which survivor-branch recovery action bootstrapAutoSession must
 * run for the current (hasSurvivorBranch, phase) combination. Pure function,
 * extracted for testability.
 */
export function decideSurvivorAction(hasSurvivorBranch, phase) {
	if (!hasSurvivorBranch) return "none";
	if (phase === "needs-discussion") return "discuss";
	if (phase === "complete") return "finalize";
	return "none";
}
export async function openProjectDbIfPresent(basePath) {
	const sfDbPath = resolveProjectRootDbPath(basePath);
	if (!existsSync(sfDbPath) || isDbAvailable()) return;
	try {
		openDatabase(sfDbPath);
	} catch (err) {
		logWarning(
			"engine",
			`sf-db: failed to open existing database: ${getErrorMessage(err)}`,
		);
	}
}
/**
 * Audit for orphaned milestone branches at bootstrap.
 *
 * After a milestone completes, the teardown step (merge branch → main,
 * delete branch, remove worktree) runs as a post-completion engine step.
 * If the session ends between completion and teardown, the branch and
 * worktree are orphaned — the DB says "complete" so autonomous mode won't
 * re-enter the milestone, and the teardown is never retried.
 *
 * This audit runs on every fresh bootstrap to catch that gap:
 * 1. Lists all local `milestone/*` branches.
 * 2. For each, checks if the milestone's DB status is "complete".
 * 3. If the branch is already merged into main → deletes the branch
 *    and cleans up any orphaned worktree directory (safe, no data loss).
 * 4. If the branch is NOT merged → preserves it and warns the user
 *    so they can merge manually (data safety first).
 *
 * Returns a summary of actions taken for the caller to surface via notify.
 */
export function auditOrphanedMilestoneBranches(basePath, isolationMode) {
	const recovered = [];
	const warnings = [];
	// Skip in none mode — no milestone branches are created
	if (isolationMode === "none") return { recovered, warnings };
	// Skip if DB not available — can't determine completion status
	if (!isDbAvailable()) return { recovered, warnings };
	let milestoneBranches;
	try {
		milestoneBranches = nativeBranchList(basePath, "milestone/*");
	} catch {
		// git branch list failed — skip audit
		return { recovered, warnings };
	}
	if (milestoneBranches.length === 0) return { recovered, warnings };
	// Detect main branch for merge-check
	let mainBranch;
	try {
		mainBranch = nativeDetectMainBranch(basePath);
	} catch {
		mainBranch = "main";
	}
	// Get branches already merged into main
	let mergedBranches;
	try {
		mergedBranches = new Set(
			nativeBranchListMerged(basePath, mainBranch, "milestone/*"),
		);
	} catch {
		mergedBranches = new Set();
	}
	for (const branch of milestoneBranches) {
		const milestoneId = branch.replace(/^milestone\//, "");
		const milestone = getMilestone(milestoneId);
		if (!milestone) continue;
		// #4762 — in-progress milestone branch with unmerged commits ahead of
		// main. This is the pre-completion orphan case: autonomous mode exited without
		// completing the milestone (pause, stop, crash, merge error, blocker) and
		// work is stranded on the branch or in the worktree. Data safety first:
		// we never delete or touch; we just surface a warning so the user knows
		// where to look.
		//
		// Gate on isClosedStatus so we only warn about genuinely open milestones.
		// Parked/other closed statuses go through the legacy complete/unmerged
		// path below where appropriate.
		if (!isClosedStatus(milestone.status)) {
			const isMergedForInProgress = mergedBranches.has(branch);
			if (isMergedForInProgress) continue; // nothing to recover
			let commitsAhead = 0;
			try {
				commitsAhead = nativeCommitCountBetween(basePath, mainBranch, branch);
			} catch {
				// Rev-walk failure — skip rather than noise
				continue;
			}
			if (commitsAhead === 0) continue;
			const wtDir = getWorktreeDir(basePath, milestoneId);
			const wtDirExists = existsSync(wtDir);
			const wtSuffix = wtDirExists
				? ` Worktree directory at .sf/worktrees/${milestoneId}/ holds the live work.`
				: "";
			warnings.push(
				`Branch ${branch} has ${commitsAhead} commit(s) ahead of ${mainBranch} for in-progress milestone ${milestoneId}.` +
					wtSuffix +
					` Run \`/autonomous\` to resume, or merge manually if abandoning.`,
			);
			// #4764 telemetry
			try {
				emitWorktreeOrphaned(basePath, milestoneId, {
					reason: "in-progress-unmerged",
					commitsAhead,
					worktreeDirExists: wtDirExists,
				});
			} catch (err) {
				logWarning(
					"engine",
					`worktree-orphaned telemetry failed for ${milestoneId}: ${getErrorMessage(err)}`,
				);
			}
			continue;
		}
		// Only the "complete" status participates in the merged/unmerged cleanup
		// paths below — other closed statuses (parked, etc.) are intentionally
		// left alone.
		if (milestone.status !== "complete") continue;
		const isMerged = mergedBranches.has(branch);
		if (isMerged) {
			// Branch is merged — safe to delete branch and clean up worktree dir
			try {
				nativeBranchDelete(basePath, branch, true);
				recovered.push(
					`Deleted merged branch ${branch} for completed milestone ${milestoneId}.`,
				);
			} catch (err) {
				warnings.push(
					`Failed to delete merged branch ${branch}: ${getErrorMessage(err)}`,
				);
			}
			// Clean up orphaned worktree directory if it exists
			const wtDir = getWorktreeDir(basePath, milestoneId);
			if (existsSync(wtDir)) {
				// Try git worktree remove first (handles registered worktrees)
				try {
					nativeWorktreeRemove(basePath, wtDir, true);
				} catch (e) {
					// Not a registered worktree — expected for orphaned dirs
					logWarning(
						"engine",
						`worktree remove failed (expected for orphaned dirs): ${getErrorMessage(e)}`,
					);
				}
				// If the directory still exists after git worktree remove (either it
				// wasn't registered or the remove was a noop), fall back to direct
				// filesystem removal — but only inside .sf/worktrees/ for safety (#2365).
				if (existsSync(wtDir)) {
					if (isInsideWorktreesDir(basePath, wtDir)) {
						try {
							rmSync(wtDir, { recursive: true, force: true });
							recovered.push(
								`Removed orphaned worktree directory for ${milestoneId}.`,
							);
						} catch (err2) {
							warnings.push(
								`Failed to remove worktree directory for ${milestoneId}: ${getErrorMessage(err2)}`,
							);
						}
					} else {
						warnings.push(
							`Orphaned worktree directory for ${milestoneId} is outside .sf/worktrees/ — skipping removal for safety.`,
						);
					}
				} else {
					recovered.push(
						`Removed orphaned worktree directory for ${milestoneId}.`,
					);
				}
			}
		} else {
			// Branch is NOT merged — preserve for safety, warn the user
			warnings.push(
				`Branch ${branch} exists for completed milestone ${milestoneId} but is NOT merged into ${mainBranch}. ` +
					`This may contain unmerged work. Merge manually or run \`/doctor fix\` to resolve.`,
			);
			// #4764 telemetry
			try {
				emitWorktreeOrphaned(basePath, milestoneId, {
					reason: "complete-unmerged",
					worktreeDirExists: existsSync(getWorktreeDir(basePath, milestoneId)),
				});
			} catch (err) {
				logWarning(
					"engine",
					`worktree-orphaned telemetry failed for ${milestoneId}: ${getErrorMessage(err)}`,
				);
			}
		}
	}
	return { recovered, warnings };
}
export async function bootstrapAutoSession(
	s,
	ctx,
	pi,
	base,
	verboseMode,
	requestedStepMode,
	deps,
	interrupted,
) {
	const {
		shouldUseWorktreeIsolation,
		registerSigtermHandler,
		lockBase,
		buildResolver,
	} = deps;
	// Defensive: ensure notification store is initialized before any bootstrap
	// notify calls. session_start also initializes it, but this closes the gap
	// in case the bootstrap path runs before the lifecycle hook fires.
	initNotificationStore(base);
	let lockResult = acquireSessionLock(base, {
		sessionId: ctx.sessionManager?.getSessionId?.(),
		sessionFile: ctx.sessionManager?.getSessionFile?.(),
	});
	// Lock busy on a *live* peer: instead of just refusing to start, ask the
	// operator whether to terminate the existing session and take over. Two
	// non-interactive escape hatches keep CI/headless usage predictable:
	//   - SF_KILL_EXISTING=1 (or =true / =yes) — auto-confirm the kill
	//   - SF_KILL_EXISTING=0 (or =false / =no) — auto-decline (current behavior)
	//   - SF_HEADLESS=1 with no SF_KILL_EXISTING — auto-decline (safe default
	//     for batch contexts where a hung interactive prompt would deadlock)
	if (!lockResult.acquired && lockResult.existingPid) {
		const existingPid = Number(lockResult.existingPid);
		if (isSessionPidAlive(existingPid)) {
			const envKill = String(process.env.SF_KILL_EXISTING ?? "")
				.trim()
				.toLowerCase();
			const headless =
				process.env.SF_HEADLESS === "1" ||
				String(process.env.SF_HEADLESS ?? "").toLowerCase() === "true";
			let confirmed;
			if (envKill === "1" || envKill === "true" || envKill === "yes") {
				confirmed = true;
			} else if (envKill === "0" || envKill === "false" || envKill === "no") {
				confirmed = false;
			} else if (headless) {
				// Headless without an explicit opt-in: refuse to kill silently.
				confirmed = false;
			} else if (typeof ctx.ui?.confirm === "function") {
				confirmed = await ctx.ui.confirm(
					"Stop running SF session?",
					`Another SF autonomous session (PID ${existingPid}) is already running on this project. Stop it and start a fresh session?`,
				);
			} else {
				confirmed = false;
			}
			if (confirmed) {
				ctx.ui.notify(
					`Stopping existing SF session (PID ${existingPid})…`,
					"info",
				);
				let result;
				try {
					result = await terminateExistingSession(existingPid);
				} catch (err) {
					ctx.ui.notify(
						`Failed to stop existing SF session (PID ${existingPid}): ${err?.message ?? err}. Stop it manually with \`kill ${existingPid}\`.`,
						"error",
					);
					return false;
				}
				if (!result.terminated) {
					ctx.ui.notify(
						`Unable to stop existing SF session (PID ${existingPid}). It may belong to another user or be unresponsive. Stop it manually with \`kill -9 ${existingPid}\`.`,
						"error",
					);
					return false;
				}
				ctx.ui.notify(
					result.escalated
						? `Existing SF session (PID ${existingPid}) did not exit on SIGTERM; SIGKILL applied.`
						: `Existing SF session (PID ${existingPid}) stopped.`,
					result.escalated ? "warning" : "info",
				);
				lockResult = acquireSessionLock(base, {
					sessionId: ctx.sessionManager?.getSessionId?.(),
					sessionFile: ctx.sessionManager?.getSessionFile?.(),
				});
			}
		}
	}
	if (!lockResult.acquired) {
		const reason = lockResult.reason;
		ctx.ui.notify(reason, "error");
		return false;
	}
	// Session-start janitor: prune per-flow trace files older than the longest
	// analyzer window (30d). Best-effort, never blocks startup, errors swallowed
	// in pruneStaleTraces. Keeps `.sf/traces/` from growing without bound.
	try {
		const { pruneStaleTraces } = await import("./uok/trace-writer.js");
		pruneStaleTraces(base);
	} catch {
		// trace janitor must never break autonomous startup
	}
	function releaseLockAndReturn() {
		releaseSessionLock(base);
		clearLock(base);
		return false;
	}
	// Capture the user's session model before guided-flow dispatch can apply a
	// phase-specific planning model for a discuss turn (#2829).
	//
	// Precedence:
	// 1) Explicit session override via /model (this session)
	// 2) SF model preferences from preferences.yaml (validated against live auth)
	// 3) Current session model from settings/session restore (if provider ready)
	//
	// This preserves #3517 defaults while honoring explicit runtime model
	// selection for subsequent /runs in the same session.
	//
	// Exception (#4122): when the session provider is a custom provider declared
	// in ~/.sf/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.),
	// preferences.yaml is skipped entirely. preferences.yaml cannot reference custom
	// providers, so honoring it would silently reroute autonomous mode to a built-in
	// provider the user is not logged into and surface as "Not logged in · Please
	// run /login" before pausing and resetting to claude-code/claude-sonnet-4-6.
	const manualSessionOverride = getSessionModelOverride(
		ctx.sessionManager.getSessionId(),
	);
	const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider);
	const preferredModel = sessionProviderIsCustom
		? null
		: resolveDefaultSessionModel(ctx.model?.provider);
	// Validate the preferred model against the live registry + provider auth so
	// an unconfigured preferences.yaml entry (no API key / OAuth) can't become the
	// start-model snapshot. Without this, every subsequent unit would try to
	// fall back to an unusable model.
	let validatedPreferredModel;
	if (preferredModel) {
		const { resolveModelId } = await import("./auto-model-selection.js");
		const available = ctx.modelRegistry.getAvailable();
		const match = resolveModelId(
			`${preferredModel.provider}/${preferredModel.id}`,
			available,
			ctx.model?.provider,
		);
		if (match) {
			validatedPreferredModel = { provider: match.provider, id: match.id };
		} else {
			ctx.ui.notify(
				`Preferred model ${preferredModel.provider}/${preferredModel.id} from preferences.yaml is not configured; falling back to session default.`,
				"warning",
			);
		}
	}
	const sessionModelReady =
		ctx.model && ctx.modelRegistry.isProviderRequestReady(ctx.model.provider);
	const startModelSnapshot =
		manualSessionOverride ??
		validatedPreferredModel ??
		(sessionModelReady && ctx.model
			? { provider: ctx.model.provider, id: ctx.model.id }
			: null);
	try {
		// Validate SF_PROJECT_ID early so the user gets immediate feedback
		const customProjectId = process.env.SF_PROJECT_ID;
		if (customProjectId && !validateProjectId(customProjectId)) {
			ctx.ui.notify(
				`SF_PROJECT_ID must contain only alphanumeric characters, hyphens, and underscores. Got: "${customProjectId}"`,
				"error",
			);
			return releaseLockAndReturn();
		}
		// Ensure git repo exists *locally* at base.
		// nativeIsRepo() uses `git rev-parse` which traverses up to parent dirs,
		// so a parent repo can make it return true even when base has no .git of
		// its own. Check for a local .git instead (defense-in-depth for the case
		// where isInheritedRepo() returns a false negative, e.g. stale .sf at
		// the parent git root). See #2393 and related issue.
		const hasLocalGit = existsSync(join(base, ".git"));
		if (!hasLocalGit || isInheritedRepo(base)) {
			const mainBranch =
				loadEffectiveSFPreferences()?.preferences?.git?.main_branch || "main";
			nativeInit(base, mainBranch);
		}
		// Migrate legacy in-project .sf/ to external state directory.
		// Migration MUST run before ensureGitignore to avoid adding ".sf" to
		// .gitignore when .sf/ is git-tracked (data-loss bug #1364).
		recoverFailedMigration(base);
		const migration = migrateToExternalState(base);
		if (migration.error) {
			ctx.ui.notify(
				`External state migration warning: ${migration.error}`,
				"warning",
			);
		}
		// Ensure symlink exists (handles fresh projects and post-migration)
		ensureSfSymlink(base);
		// Ensure .gitignore has baseline patterns.
		// ensureGitignore checks for git-tracked .sf/ files and skips the
		// ".sf" pattern if the project intentionally tracks .sf/ in git.
		const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git;
		const manageGitignore = gitPrefs?.manage_gitignore;
		ensureGitignore(base, { manageGitignore });
		ensureAgenticDocsScaffold(base);
		ensureSiftIndexWarmup(
			base,
			loadEffectiveSFPreferences()?.preferences?.codebase,
		);
		if (manageGitignore !== false) untrackRuntimeFiles(base);
		// Bootstrap milestones/ if it doesn't exist.
		// Check milestones/ directly — ensureSfSymlink above already created .sf/,
		// so checking .sf/ existence would be dead code (#2942).
		const sfDir = join(base, ".sf");
		const milestonesPath = join(sfDir, "milestones");
		if (!existsSync(milestonesPath)) {
			mkdirSync(milestonesPath, { recursive: true });
			try {
				nativeAddAll(base);
				nativeCommit(base, "chore: init sf");
			} catch (err) {
				/* nothing to commit */
				logWarning("engine", `mkdir failed: ${getErrorMessage(err)}`);
			}
		}
		// Initialize GitService
		s.gitService = new GitService(
			s.basePath,
			loadEffectiveSFPreferences()?.preferences?.git ?? {},
		);
		// ── Debug mode ──
		if (!isDebugEnabled() && process.env.SF_DEBUG === "1") {
			enableDebug(base);
		}
		if (isDebugEnabled()) {
			const { isNativeParserAvailable } = await import(
				"./native-parser-bridge.js"
			);
			debugLog("debug-start", {
				platform: process.platform,
				arch: process.arch,
				node: process.version,
				model: ctx.model?.id ?? "unknown",
				provider: ctx.model?.provider ?? "unknown",
				nativeParser: isNativeParserAvailable(),
				cwd: base,
			});
			ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info");
		}
		if (interrupted.classification !== "recoverable") {
			s.pendingCrashRecovery = null;
		}
		// Invalidate caches before initial state derivation
		invalidateAllCaches();
		// Clean stale runtime unit files for completed milestones (#887)
		cleanStaleRuntimeUnits(
			sfRoot(base),
			(mid) => !!resolveMilestoneFile(base, mid, "SUMMARY"),
		);
		// Reconcile stale complete-slice runtime records where the slice
		// completed successfully on retry but a prior cancelled/failed record
		// persists. Prevents flow-audit false positives (#sf-moqv5o7h-vaabu6).
		try {
			const reconciled = reconcileStaleCompleteSliceRecords(base);
			if (reconciled.cleared > 0) {
				debugLog("bootstrap", {
					phase: "stale-slice-runtime-reconciled",
					cleared: reconciled.cleared,
					units: reconciled.details,
				});
			}
		} catch (err) {
			// Non-fatal — defensive cleanup, never block bootstrap
			logWarning(
				"bootstrap",
				`stale slice runtime reconciliation failed: ${getErrorMessage(err)}`,
			);
		}
		// Open the project-root DB before deriveState so DB-backed state
		// derivation (queue-order, task status) works on a cold start (#2841).
		await openProjectDbIfPresent(base);
		try {
			const reconciled = await reconcileDurableCompleteUnitRuntimeRecords(base);
			if (reconciled.cleared > 0) {
				debugLog("bootstrap", {
					phase: "durable-complete-runtime-reconciled",
					cleared: reconciled.cleared,
					units: reconciled.details,
				});
			}
		} catch (err) {
			// Non-fatal — defensive cleanup, never block bootstrap
			logWarning(
				"bootstrap",
				`durable complete runtime reconciliation failed: ${getErrorMessage(err)}`,
			);
		}
		// ── Orphaned milestone branch audit ──
		// Catches completed milestones whose teardown (merge + branch delete)
		// was lost due to session ending between completion and teardown.
		// Must run after DB open and before worktree entry.
		try {
			const auditResult = auditOrphanedMilestoneBranches(
				base,
				getIsolationMode(),
			);
			for (const msg of auditResult.recovered) {
				ctx.ui.notify(`Orphan audit: ${msg}`, "info");
			}
			for (const msg of auditResult.warnings) {
				ctx.ui.notify(`Orphan audit: ${msg}`, "warning");
			}
			if (auditResult.recovered.length > 0) {
				debugLog("orphan-audit", {
					recovered: auditResult.recovered,
					warnings: auditResult.warnings,
				});
			}
		} catch (err) {
			// Non-fatal — the audit is defensive, never block bootstrap
			logWarning(
				"bootstrap",
				`orphaned milestone branch audit failed: ${getErrorMessage(err)}`,
			);
		}
		let state = await deriveState(base);
		// Stale worktree state recovery (#654)
		if (
			state.activeMilestone &&
			shouldUseWorktreeIsolation() &&
			!detectWorktreeName(base)
		) {
			const wtPath = getAutoWorktreePath(base, state.activeMilestone.id);
			if (wtPath) {
				state = await deriveState(wtPath);
			}
		}
		// Milestone branch recovery (#601, #2358)
		// Detect survivor milestone branches in both pre-planning and complete phases.
		// In phase=complete, the milestone artifacts exist but finalization (merge,
		// worktree cleanup) was never run — the survivor branch must be merged.
		let hasSurvivorBranch = false;
		if (
			state.activeMilestone &&
			(state.phase === "pre-planning" || state.phase === "complete") &&
			shouldUseWorktreeIsolation() &&
			!detectWorktreeName(base) &&
			!base.includes(`${pathSep}.sf${pathSep}worktrees${pathSep}`)
		) {
			const milestoneBranch = `milestone/${state.activeMilestone.id}`;
			const { nativeBranchExists } = await import("./native-git-bridge.js");
			hasSurvivorBranch = nativeBranchExists(base, milestoneBranch);
			if (hasSurvivorBranch) {
				ctx.ui.notify(
					`Found prior session branch ${milestoneBranch}. Resuming.`,
					"info",
				);
			}
		}
		// Survivor branch exists but milestone still needs discussion (#1726):
		// The worktree/branch was created but the milestone only has CONTEXT-DRAFT.md.
		// Route to the interactive discussion handler instead of falling through to
		// autonomous mode, which would immediately stop with "needs discussion".
		if (decideSurvivorAction(hasSurvivorBranch, state.phase) === "discuss") {
			const { showWorkflowEntry } = await import("./guided-flow.js");
			await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
			invalidateAllCaches();
			const postState = await deriveState(base);
			if (postState.activeMilestone && postState.phase !== "needs-discussion") {
				state = postState;
				// Discussion succeeded — clear survivor flag so normal flow continues
				hasSurvivorBranch = false;
			} else {
				ctx.ui.notify(
					"Discussion completed but milestone draft was not promoted. Run /next to try again.",
					"warning",
				);
				return releaseLockAndReturn();
			}
		}
		// Survivor branch exists and milestone is complete (#2358):
		// The milestone artifacts were written but finalization (merge, worktree
		// cleanup) never ran. Run mergeAndExit to finalize, then re-derive state
		// so the normal "all milestones complete" or "next milestone" path runs.
		if (decideSurvivorAction(hasSurvivorBranch, state.phase) === "finalize") {
			const mid = state.activeMilestone.id;
			ctx.ui.notify(
				`Milestone ${mid} is complete but branch/worktree was not finalized. Running merge now.`,
				"info",
			);
			const resolver = buildResolver();
			resolver.mergeAndExit(mid, {
				notify: ctx.ui.notify.bind(ctx.ui),
			});
			invalidateAllCaches();
			state = await deriveState(base);
			// Clear survivor flag — finalization is done
			hasSurvivorBranch = false;
		}
		if (!hasSurvivorBranch) {
			// No active work — start a new milestone via discuss flow
			if (!state.activeMilestone || state.phase === "complete") {
				// Guard against recursive dialog loop (#1348):
				// If we've entered this branch multiple times in quick succession,
				// the discuss workflow isn't producing a milestone. Break the cycle.
				s.consecutiveCompleteBootstraps++;
				if (
					s.consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS
				) {
					s.consecutiveCompleteBootstraps = 0;
					ctx.ui.notify(
						"All milestones are complete and the discussion didn't produce a new one. " +
							"Run /next to start a new milestone manually.",
						"warning",
					);
					return releaseLockAndReturn();
				}
				// Autonomous mode: map the codebase and create milestones
				// without waiting for user answers. Uses discuss-headless prompt.
				ctx.ui.notify(
					"No milestones found. Bootstrapping from repo docs and source inventory.",
					"info",
				);
				const { buildAutoBootstrapContext } = await import(
					"./auto-bootstrap-context.js"
				);
				const {
					bootstrapNewMilestone,
					dispatchNewMilestoneDiscuss,
					injectTodoContext,
				} = await import("./guided-flow.js");
				const bootstrapContext = buildAutoBootstrapContext(base);
				const nextId = bootstrapNewMilestone(base);
				await dispatchNewMilestoneDiscuss(ctx, pi, base, nextId, {
					autonomousBootstrap: true,
					preamble: injectTodoContext(base, bootstrapContext),
				});
				invalidateAllCaches();
				let postState = await deriveState(base);
				if (!postState.activeMilestone) {
					ctx.ui.notify(
						`Headless bootstrap for ${nextId} returned without artifacts. Starting roadmap planning repair session.`,
						"warning",
					);
					await dispatchNewMilestoneDiscuss(ctx, pi, base, nextId, {
						autonomousBootstrap: true,
						preamble: injectTodoContext(
							base,
							[
								`This is an autonomous roadmap bootstrap repair for ${nextId}.`,
								"The previous bootstrap turn ended without writing CONTEXT, CONTEXT-DRAFT, or ROADMAP artifacts.",
								"Use the repo-doc/source bootstrap context below as the source of truth.",
								bootstrapContext,
								"Start the roadmap planning session now: build project knowledge, run the planning meeting, and persist artifacts.",
								"Do not stop after reflection. At minimum write CONTEXT-DRAFT with evidence and open questions.",
								"If confidence is high enough, write CONTEXT and call plan_milestone so autonomous mode can continue.",
							].join("\n"),
						),
					});
					invalidateAllCaches();
					postState = await deriveState(base);
				}
				if (
					postState.activeMilestone &&
					postState.phase !== "complete" &&
					postState.phase !== "pre-planning"
				) {
					s.consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete"
					state = postState;
				} else if (
					postState.activeMilestone &&
					postState.phase === "pre-planning"
				) {
					const contextFile = resolveMilestoneFile(
						base,
						postState.activeMilestone.id,
						"CONTEXT",
					);
					const hasContext = !!(contextFile && (await loadFile(contextFile)));
					if (hasContext) {
						state = postState;
					} else {
						const repairId = postState.activeMilestone.id;
						ctx.ui.notify(
							`Headless bootstrap created ${repairId} without context. Starting roadmap planning repair session.`,
							"warning",
						);
						await dispatchNewMilestoneDiscuss(ctx, pi, base, repairId, {
							autonomousBootstrap: true,
							preamble: injectTodoContext(
								base,
								[
									`This is an autonomous roadmap bootstrap repair for existing milestone ${repairId}.`,
									"The previous bootstrap created a milestone shell but did not write CONTEXT.md, CONTEXT-DRAFT.md, or ROADMAP.md.",
									"Use the repo-doc/source bootstrap context below as the source of truth.",
									bootstrapContext,
									"Reuse this milestone ID. Do not create a new milestone for the same bootstrap work.",
									"Run the roadmap planning session now and persist CONTEXT or CONTEXT-DRAFT at minimum.",
									"If confidence is high enough, write CONTEXT and call plan_milestone so autonomous mode can continue.",
								].join("\n"),
							),
						});
						invalidateAllCaches();
						postState = await deriveState(base);
						if (
							postState.activeMilestone &&
							postState.phase !== "complete" &&
							postState.phase !== "pre-planning"
						) {
							s.consecutiveCompleteBootstraps = 0;
							state = postState;
						} else if (
							postState.activeMilestone &&
							postState.phase === "pre-planning"
						) {
							const repairedContextFile = resolveMilestoneFile(
								base,
								postState.activeMilestone.id,
								"CONTEXT",
							);
							const repairedHasContext = !!(
								repairedContextFile && (await loadFile(repairedContextFile))
							);
							if (repairedHasContext) {
								state = postState;
							} else {
								ctx.ui.notify(
									"Headless bootstrap repair completed but milestone context is still missing.",
									"warning",
								);
								return releaseLockAndReturn();
							}
						} else {
							ctx.ui.notify(
								"Headless bootstrap repair completed but no milestone artifacts were written. Auto cannot continue without a context or draft.",
								"warning",
							);
							return releaseLockAndReturn();
						}
					}
				} else {
					if (isGhostMilestone(base, nextId)) {
						rmSync(join(sfRoot(base), "milestones", nextId), {
							recursive: true,
							force: true,
						});
						invalidateAllCaches();
					}
					ctx.ui.notify(
						"Headless bootstrap repair completed but no milestone artifacts were written. Auto cannot continue without a context or draft.",
						"warning",
					);
					return releaseLockAndReturn();
				}
			}
			// Active milestone exists but has no roadmap
			if (state.phase === "pre-planning") {
				const mid = state.activeMilestone.id;
				const contextFile = resolveMilestoneFile(base, mid, "CONTEXT");
				const hasContext = !!(contextFile && (await loadFile(contextFile)));
				if (!hasContext) {
					ctx.ui.notify(
						`Milestone ${mid} has no context. Bootstrapping from repo docs and source inventory.`,
						"info",
					);
					const { buildAutoBootstrapContext } = await import(
						"./auto-bootstrap-context.js"
					);
					const { dispatchNewMilestoneDiscuss, injectTodoContext } =
						await import("./guided-flow.js");
					const bootstrapContext = buildAutoBootstrapContext(base);
					await dispatchNewMilestoneDiscuss(ctx, pi, base, mid, {
						autonomousBootstrap: true,
						preamble: injectTodoContext(
							base,
							[
								`This is an autonomous roadmap bootstrap repair for existing milestone ${mid}.`,
								"The milestone exists but has no CONTEXT.md yet.",
								"Use the repo-doc/source bootstrap context below as the source of truth.",
								bootstrapContext,
								"Reuse this milestone ID. Do not create a new milestone for the same bootstrap work.",
								"Build project knowledge, run the planning meeting, and persist CONTEXT or CONTEXT-DRAFT.",
							].join("\n"),
						),
					});
					invalidateAllCaches();
					const postState = await deriveState(base);
					if (postState.activeMilestone && postState.phase !== "pre-planning") {
						state = postState;
					} else if (
						postState.activeMilestone &&
						postState.phase === "pre-planning"
					) {
						const repairedContextFile = resolveMilestoneFile(
							base,
							postState.activeMilestone.id,
							"CONTEXT",
						);
						const repairedHasContext = !!(
							repairedContextFile && (await loadFile(repairedContextFile))
						);
						if (repairedHasContext) {
							state = postState;
						} else {
							ctx.ui.notify(
								"Discussion completed but milestone context is still missing. Run /next to try again.",
								"warning",
							);
							return releaseLockAndReturn();
						}
					} else {
						ctx.ui.notify(
							"Discussion completed but milestone context is still missing. Run /next to try again.",
							"warning",
						);
						return releaseLockAndReturn();
					}
				}
			}
			// Active milestone has CONTEXT-DRAFT but no full context — needs discussion
			if (state.phase === "needs-discussion") {
				const { showWorkflowEntry } = await import("./guided-flow.js");
				await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
				invalidateAllCaches();
				const postState = await deriveState(base);
				if (
					postState.activeMilestone &&
					postState.phase !== "needs-discussion"
				) {
					state = postState;
				} else {
					ctx.ui.notify(
						"Discussion completed but milestone draft was not promoted. Run /next to try again.",
						"warning",
					);
					return releaseLockAndReturn();
				}
			}
		}
		// Unreachable safety check
		if (!state.activeMilestone) {
			const { showWorkflowEntry } = await import("./guided-flow.js");
			await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
			return releaseLockAndReturn();
		}
		// Successfully resolved an active milestone — reset the re-entry guard
		s.consecutiveCompleteBootstraps = 0;
		// ── Initialize session state ──
		// Notify shared phase state so subagent conflict checks can fire
		const { activateSF: activateSFPhaseState } = await import(
			"../shared/sf-phase-state.js"
		);
		activateSFPhaseState();
		s.active = true;
		s.stepMode = requestedStepMode;
		s.verbose = verboseMode;
		s.cmdCtx = ctx;
		s.basePath = base;
		s.unitDispatchCount.clear();
		s.unitRecoveryCount.clear();
		s.lastBudgetAlertLevel = 0;
		s.unitLifetimeDispatches.clear();
		resetHookState();
		restoreHookState(base);
		resetProactiveHealing();
		// Notify user on health level transitions (green→yellow→red and back)
		setLevelChangeCallback((_from, to, summary) => {
			const level =
				to === "red" ? "error" : to === "yellow" ? "warning" : "info";
			ctx.ui.notify(summary, level);
		});
		s.autoStartTime = Date.now();
		s.resourceVersionOnStart = readResourceVersion();
		s.pendingQuickTasks = [];
		s.currentUnit = null;
		s.currentMilestoneId = state.activeMilestone?.id ?? null;
		s.originalModelId = ctx.model?.id ?? null;
		s.originalModelProvider = ctx.model?.provider ?? null;
		// Register SIGTERM handler
		registerSigtermHandler(base);
		// Capture integration branch
		if (s.currentMilestoneId) {
			if (getIsolationMode() !== "none") {
				captureIntegrationBranch(base, s.currentMilestoneId);
			}
			setActiveMilestoneId(base, s.currentMilestoneId);
		}
		// Guard against stale milestone branch when isolation:none (#3613).
		// A prior session with isolation:branch/worktree may have left HEAD on
		// milestone/<MID>. Auto-checkout back to the integration branch.
		if (getIsolationMode() === "none" && nativeIsRepo(base)) {
			try {
				const currentBranch = nativeGetCurrentBranch(base);
				if (currentBranch.startsWith("milestone/")) {
					const integrationBranch = nativeDetectMainBranch(base);
					nativeCheckoutBranch(base, integrationBranch);
					logWarning(
						"bootstrap",
						`Returned to "${integrationBranch}" — HEAD was on stale milestone branch "${currentBranch}" (isolation: none does not use milestone branches).`,
					);
				}
			} catch (err) {
				logWarning(
					"bootstrap",
					`Could not auto-checkout from stale milestone branch: ${getErrorMessage(err)}`,
				);
			}
		}
		// ── Auto-worktree setup ──
		s.originalBasePath = base;
		const isUnderSfWorktrees = (p) => {
			// Direct layout: /.sf/worktrees/
			const marker = `${pathSep}.sf${pathSep}worktrees${pathSep}`;
			if (p.includes(marker)) return true;
			const worktreesSuffix = `${pathSep}.sf${pathSep}worktrees`;
			if (p.endsWith(worktreesSuffix)) return true;
			// Symlink-resolved layout: /.sf/projects/<hash>/worktrees/
			const symlinkRe = new RegExp(
				`\\${pathSep}\\.sf\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees(?:\\${pathSep}|$)`,
			);
			return symlinkRe.test(p);
		};
		if (
			s.currentMilestoneId &&
			shouldUseWorktreeIsolation() &&
			!detectWorktreeName(base) &&
			!isUnderSfWorktrees(base)
		) {
			buildResolver().enterMilestone(s.currentMilestoneId, {
				notify: ctx.ui.notify.bind(ctx.ui),
			});
			if (s.basePath !== base) {
				// Successfully entered worktree — re-register SIGTERM handler at original base
				registerSigtermHandler(s.originalBasePath);
			}
		}
		// ── DB lifecycle ──
		const sfDbPath = resolveProjectRootDbPath(s.basePath);
		const sfDirPath = join(s.basePath, ".sf");
		if (existsSync(sfDirPath) && !existsSync(sfDbPath)) {
			const hasDecisions = existsSync(join(sfDirPath, "DECISIONS.md"));
			const hasRequirements = existsSync(join(sfDirPath, "REQUIREMENTS.md"));
			const hasMilestones = existsSync(join(sfDirPath, "milestones"));
			try {
				const { openDatabase: openDb } = await import("./sf-db.js");
				openDb(sfDbPath);
				if (hasDecisions || hasRequirements || hasMilestones) {
					const { migrateFromMarkdown } = await import("./md-importer.js");
					migrateFromMarkdown(s.basePath);
				}
			} catch (err) {
				logError("engine", `auto-migration failed: ${err.message}`);
			}
		}
		if (existsSync(sfDbPath) && !isDbAvailable()) {
			try {
				const { openDatabase: openDb } = await import("./sf-db.js");
				openDb(sfDbPath);
			} catch (err) {
				logError("engine", `failed to open existing database: ${err.message}`);
			}
		}
		// Gate: abort bootstrap if the DB file exists but the provider is
		// still unavailable after both open attempts above. Without this,
		// autonomous mode starts but every complete_task / complete_slice
		// call returns "db_unavailable", triggering artifact-retry which
		// re-dispatches the same task — producing an infinite loop (#2419).
		if (existsSync(sfDbPath) && !isDbAvailable()) {
			ctx.ui.notify(
				"SQLite database exists but failed to open. Autonomous mode cannot proceed without a working database provider. " +
					"Check for corrupt sf.db or missing native SQLite bindings.",
				"error",
			);
			return releaseLockAndReturn();
		}
		// Initialize metrics
		initMetrics(s.basePath);
		// Initialize centralized metrics collector (Prometheus + SQLite)
		try {
			const { getDatabase } = await import("./sf-db.js");
			const db = getDatabase();
			initMetricsCentral(s.basePath, {
				sessionId: s.currentTraceId ?? `session-${Date.now()}`,
				dbAdapter: db,
				flushIntervalMs: 60_000,
			});
		} catch (err) {
			logWarning("metrics-central", `Init failed: ${err.message}`);
		}
		// Initialize routing history
		initRoutingHistory(s.basePath);
		// Expire stale memories to prevent poisoning future sessions.
		// Never-accessed memories expire after 28 days; all memories after 90 days.
		if (isDbAvailable()) {
			try {
				const expired = expireStaleMemories();
				if (expired > 0) {
					logWarning(
						"engine",
						`Expired ${expired} stale ${expired === 1 ? "memory" : "memories"} (TTL exceeded)`,
					);
				}
			} catch {
				// Non-fatal — TTL expiry failure must not block autonomous start
			}
		}
		// Restore the model that was active when auto bootstrap began (#650, #2829).
		if (startModelSnapshot) {
			s.autoModeStartModel = {
				provider: startModelSnapshot.provider,
				id: startModelSnapshot.id,
			};
		}
		s.manualSessionModelOverride = manualSessionOverride ?? null;
		// Apply worker model override from parallel orchestrator (#worker-model).
		// SF_WORKER_MODEL is injected by the coordinator when parallel.worker_model
		// is configured, so parallel milestone workers use a cheaper model than the
		// coordinator session (e.g. Haiku for execution, Sonnet for planning).
		const workerModelOverride = process.env.SF_WORKER_MODEL;
		if (workerModelOverride && process.env.SF_PARALLEL_WORKER === "1") {
			const availableModels = ctx.modelRegistry.getAvailable();
			const { resolveModelId } = await import("./auto-model-selection.js");
			const overrideModel = resolveModelId(
				workerModelOverride,
				availableModels,
				ctx.model?.provider,
			);
			if (overrideModel) {
				const ok = await pi.setModel(overrideModel, {
					persist: resolvePersistModelChanges(),
				});
				if (ok) {
					// Update start model so all subsequent units use this as the baseline
					s.autoModeStartModel = {
						provider: overrideModel.provider,
						id: overrideModel.id,
					};
					ctx.ui.notify(
						`Worker model override: ${overrideModel.provider}/${overrideModel.id}`,
						"info",
					);
				}
			}
		}
		// Snapshot installed skills
		if (resolveSkillDiscoveryMode() !== "off") {
			snapshotSkills();
		}
		ctx.ui.setStatus("sf-auto", s.stepMode ? "next" : "auto");
		// sf-tui footer already handles auto mode via isAutoActive() → renderAutoFooter;
		// no need to override it here.
		// Hide sf-health during AUTO — sf-progress is the single source of truth
		// for last-commit / cost / health signal while auto is running.
		safeSetWidget(ctx, "sf-health", undefined);
		const modeLabel = s.stepMode ? "Assisted mode" : "Autonomous mode";
		const pendingCount = (state.registry ?? []).filter(
			(m) => m.status !== "complete" && m.status !== "parked",
		).length;
		const scopeMsg =
			pendingCount > 1
				? `Will loop through ${pendingCount} milestones.`
				: "Will loop until milestone complete.";
		ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
		// Show dynamic routing status so users know upfront if models will be
		// downgraded for simple tasks (#3962).
		// Use the same effective logic as selectAndApplyModel: check flat-rate
		// provider suppression and resolve the actual ceiling model.
		const routingConfig = resolveDynamicRoutingConfig();
		const startModelLabel = s.autoModeStartModel
			? `${s.autoModeStartModel.provider}/${s.autoModeStartModel.id}`
			: ctx.model
				? `${ctx.model.provider}/${ctx.model.id}`
				: "default";
		// Flat-rate providers (e.g. GitHub Copilot, claude-code, user-declared
		// subscription proxies, externalCli CLIs) suppress routing at dispatch
		// time (#3453) — reflect that in the banner.  Thread the same
		// FlatRateContext used by selectAndApplyModel so user-declared
		// flat-rate providers and externalCli auto-detection are respected.
		const { isFlatRateProvider, buildFlatRateContext } = await import(
			"./auto-model-selection.js"
		);
		const bannerPrefs = loadEffectiveSFPreferences()?.preferences;
		const effectiveProvider =
			s.autoModeStartModel?.provider ?? ctx.model?.provider;
		const effectivelyEnabled =
			routingConfig.enabled &&
			!(
				effectiveProvider &&
				isFlatRateProvider(
					effectiveProvider,
					buildFlatRateContext(effectiveProvider, ctx, bannerPrefs),
				)
			);
		// The actual ceiling may come from tier_models.heavy, not the start model.
		const effectiveCeiling =
			routingConfig.enabled && routingConfig.tier_models?.heavy
				? routingConfig.tier_models.heavy
				: startModelLabel;
		if (effectivelyEnabled) {
			ctx.ui.notify(
				`Dynamic routing: enabled — simple tasks may use cheaper models (ceiling: ${effectiveCeiling})`,
				"info",
			);
		} else {
			ctx.ui.notify(
				`Dynamic routing: disabled — all tasks will use ${startModelLabel}`,
				"info",
			);
		}
		updateSessionLock(
			lockBase(),
			"starting",
			s.currentMilestoneId ?? "unknown",
		);
		writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown");
		// Secrets collection gate
		const mid = state.activeMilestone.id;
		try {
			const manifestStatus = await getManifestStatus(
				base,
				mid,
				s.originalBasePath || base,
			);
			if (manifestStatus && manifestStatus.pending.length > 0) {
				const result = await collectSecretsFromManifest(base, mid, ctx);
				if (
					result &&
					result.applied &&
					result.skipped &&
					result.existingSkipped
				) {
					ctx.ui.notify(
						`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
						"info",
					);
				} else {
					ctx.ui.notify("Secrets collection skipped.", "info");
				}
			}
		} catch (err) {
			ctx.ui.notify(
				`Secrets collection error: ${getErrorMessage(err)}. Continuing with next task.`,
				"warning",
			);
		}
		// Self-heal: remove stale .git/index.lock
		try {
			const gitLockFile = join(base, ".git", "index.lock");
			if (existsSync(gitLockFile)) {
				const lockAge = Date.now() - statSync(gitLockFile).mtimeMs;
				if (lockAge > 60_000) {
					unlinkSync(gitLockFile);
					ctx.ui.notify(
						"Removed stale .git/index.lock from prior crash.",
						"info",
					);
				}
			}
		} catch (e) {
			debugLog("git-lock-cleanup-failed", {
				error: getErrorMessage(e),
			});
		}
		// Pre-flight: validate milestone queue
		try {
			const msDir = join(base, ".sf", "milestones");
			if (existsSync(msDir)) {
				const milestoneIds = readdirSync(msDir, { withFileTypes: true })
					.filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name))
					.map((d) => d.name.match(/^(M\d{3})/)?.[1] ?? d.name);
				if (milestoneIds.length > 1) {
					const issues = [];
					for (const id of milestoneIds) {
						// Skip completed/parked milestones — a leftover CONTEXT-DRAFT.md
						// on a finished milestone is harmless residue, not an actionable warning.
						if (isDbAvailable()) {
							const ms = getMilestone(id);
							if (ms?.status === "complete" || ms?.status === "parked")
								continue;
						}
						const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT");
						if (draft)
							issues.push(
								`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`,
							);
					}
					if (issues.length > 0) {
						ctx.ui.notify(
							`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map((i) => `  ⚠ ${i}`).join("\n")}`,
							"warning",
						);
					} else {
						ctx.ui.notify(
							`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`,
							"info",
						);
					}
				}
			}
		} catch (err) {
			/* non-fatal */
			logWarning(
				"engine",
				`preflight validation failed: ${getErrorMessage(err)}`,
			);
		}
		return true;
	} catch (err) {
		releaseSessionLock(base);
		clearLock(base);
		throw err;
	}
}
