/**
 * auto/phases-pre-dispatch.js — runPreDispatch phase.
 */
import { basename, dirname, join, parse as parsePath } from "node:path";
import { importExtensionModule } from "@singularity-forge/coding-agent";
import {
	clearCurrentPhase,
	setCurrentPhase,
} from "../../shared/sf-phase-state.js";
import { atomicWriteSync } from "../atomic-write.js";
import { resetCompletionNudgeState } from "../auto-completion-nudge.js";
import {
	isAwaitingUserInput,
	USER_DRIVEN_DEEP_UNITS,
} from "../auto-post-unit.js";
import {
	buildLoopRemediationSteps,
	diagnoseExpectedArtifact,
	verifyExpectedArtifact,
} from "../auto-recovery.js";
import {
	formatToolCallSummary,
	resetToolCallCounts,
} from "../auto-tool-tracking.js";
import {
	appendAutonomousSolverCheckpoint,
	assessAutonomousSolverTurn,
	beginAutonomousSolverIteration,
	buildAutonomousSolverMissingCheckpointRepairPrompt,
	buildAutonomousSolverPromptBlock,
	buildAutonomousSolverSteeringPromptBlock,
	classifyAutonomousSolverMissingCheckpointFailure,
	consumePendingAutonomousSolverSteering,
	getConfiguredAutonomousSolverMaxIterations,
	recordAutonomousSolverMissingCheckpointRetry,
} from "../autonomous-solver.js";
import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js";
import { debugLog } from "../debug-logger.js";
import { PROJECT_FILES } from "../detection.js";
import { getErrorMessage } from "../error-utils.js";
import { MergeConflictError } from "../git-service.js";
import { recordLearnedOutcome } from "../learning/runtime.js";
import { sfRoot } from "../paths.js";
import { resolvePersistModelChanges } from "../preferences.js";
import {
	approveProductionMutationWithLlmPolicy,
	ensureProductionMutationApprovalTemplate,
	readProductionMutationApprovalStatus,
} from "../production-mutation-approval.js";
import { pauseAutoForProviderError } from "../provider-error-pause.js";
import {
	buildReasoningAssistPrompt,
	injectReasoningGuidance,
	isReasoningAssistEnabled,
} from "../reasoning-assist.js";
import {
	loadEvidenceFromDisk,
	resetEvidence,
} from "../safety/evidence-collector.js";
import { getDirtyFiles } from "../safety/file-change-validator.js";
import {
	cleanupCheckpoint,
	createCheckpoint,
	rollbackToCheckpoint,
} from "../safety/git-checkpoint.js";
import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js";
import { recordSelfFeedback } from "../self-feedback.js";
import { selectInlineFixCandidates } from "../self-feedback-drain.js";
import {
	_getAdapter,
	checkpointWal,
	getMilestoneSlices,
	getSliceTaskCounts,
	getTask,
	isDbAvailable,
} from "../sf-db.js";
import { getEligibleSlices } from "../slice-parallel-eligibility.js";
import { startSliceParallel } from "../slice-parallel-orchestrator.js";
import { handleProductAudit } from "../tools/product-audit-tool.js";
import { parseUnitId } from "../unit-id.js";
import {
	collectSessionTokenUsage,
	collectWorktreeFingerprint,
	countChangedFiles,
	resetRunawayGuardState,
} from "../uok/auto-runaway-guard.js";
import {
	buildAutonomousUokContext,
	emitAutonomousGate,
} from "../uok/auto-uok-ctx.js";
import { resolveUokFlags } from "../uok/flags.js";
import { emitModelAutoResolvedEvent } from "../uok/model-route-evidence.js";
import {
	ensurePlanV2Graph as ensurePlanningFlowGraph,
	isEmptyPlanV2GraphResult,
	isMissingFinalizedContextResult,
} from "../uok/plan.js";
import { buildUokProgressEvent } from "../uok/progress-event.js";
import {
	evaluatePurposeCoherence,
	PURPOSE_COHERENCE_GATE_ID,
} from "../uok/purpose-coherence.js";
import {
	clearUnitRuntimeRecord,
	writeUnitRuntimeRecord,
} from "../uok/unit-runtime.js";
import {
	_resetLogs,
	drainAndSummarize,
	drainLogs,
	formatForNotification,
	hasAnyIssues,
	logError,
	logWarning,
} from "../workflow-logger.js";
import {
	getRequiredWorkflowToolsForAutoUnit,
	getWorkflowTransportSupportError,
} from "../workflow-tools.js";
import { resolveWorktreeProjectRoot } from "../worktree-root.js";
import { detectStuck } from "./detect-stuck.js";
import {
	FINALIZE_POST_TIMEOUT_MS,
	FINALIZE_PRE_TIMEOUT_MS,
	withTimeout,
} from "./finalize-timeout.js";
import {
	closeoutAndStop,
	generateMilestoneReport,
	maybeFireProductAudit,
	shouldRunPlanningFlowGate,
} from "./phases-helpers.js";
import { runUnit } from "./run-unit.js";
import {
	BUDGET_THRESHOLDS,
	MAX_FINALIZE_TIMEOUTS,
	MAX_RECOVERY_CHARS,
} from "./types.js";

/**
 * Surface the open self-feedback queue to the operator at idle-bail time.
 *
 * Why here (sf-mp4rxkwb-l4baga partial): the autonomous loop has historically
 * exited silently on "no-active-milestone" / "milestone-complete" while the
 * self-feedback queue still had unresolved entries. Operators didn't know
 * the queue was waiting, so triage never ran. This helper makes the queue
 * visible at the exact moment the loop is about to bail — the proper fix
 * (triage as a real unit type) is the larger remaining slice of the parent
 * entry.
 *
 * Best-effort: never throws, never blocks. If candidate selection fails or
 * the basePath isn't a forge repo, this is a no-op.
 */
export function surfaceSelfFeedbackQueueOnIdle(ctx, basePath, exitReason) {
	try {
		const candidates = selectInlineFixCandidates(basePath);
		if (!Array.isArray(candidates) || candidates.length === 0) return;
		const n = candidates.length;
		const noun = n === 1 ? "entry" : "entries";
		ctx.ui.notify(
			`Idle (${exitReason}) but ${n} self-feedback ${noun} still open. Run \`sf headless triage --list\` to scan, or \`sf headless triage --run\` to dispatch the canonical triage prompt.`,
			"warning",
		);
	} catch {
		// Best-effort — never block the loop's bail path on a queue probe.
	}
}

// ─── runPreDispatch ───────────────────────────────────────────────────────────
/**
 * Phase 1: Pre-dispatch — resource guard, health gate, state derivation,
 * milestone transition, terminal conditions.
 * Returns break to exit the loop, or next with PreDispatchData on success.
 */
export async function runPreDispatch(ic, loopState) {
	const { ctx, pi, s, deps, prefs } = ic;
	const uokFlags = resolveUokFlags(prefs);
	// Schema-v2 run-context for pre-dispatch gates. surface/runControl
	// are pinned by buildAutonomousUokContext (this phase always runs
	// inside the autonomous loop); permissionProfile is derived from
	// session/prefs (YOLO + read-only sessions → "low", otherwise "high").
	const v2Ctx = buildAutonomousUokContext({
		s,
		prefs,
		traceId: `pre-dispatch:${ic.flowId}`,
		unitType: "pre-dispatch",
		unitId: `iter-${ic.iteration}`,
	});
	const runPreDispatchGate = async (input) => {
		if (!uokFlags.gates) return;
		await emitAutonomousGate({
			basePath: s.basePath,
			gateId: input.gateId,
			gateType: input.gateType,
			outcome: input.outcome,
			failureClass: input.failureClass,
			rationale: input.rationale,
			findings: input.findings,
			traceId: `pre-dispatch:${ic.flowId}`,
			turnId: `iter-${ic.iteration}`,
			unitType: "pre-dispatch",
			unitId: `iter-${ic.iteration}`,
			milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined,
			uokContext: v2Ctx,
		});
	};
	// Resource version guard
	const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
	if (staleMsg) {
		await runPreDispatchGate({
			gateId: "resource-version-guard",
			gateType: "policy",
			outcome: "fail",
			failureClass: "policy",
			rationale: "resource version guard blocked dispatch",
			findings: staleMsg,
		});
		await deps.stopAuto(ctx, pi, staleMsg);
		debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
		return { action: "break", reason: "resources-stale" };
	}
	await runPreDispatchGate({
		gateId: "resource-version-guard",
		gateType: "policy",
		outcome: "pass",
		failureClass: "none",
		rationale: "resource version guard passed",
	});
	deps.invalidateAllCaches();
	s.lastPromptCharCount = undefined;
	s.lastBaselineCharCount = undefined;
	// Pre-dispatch health gate
	try {
		const healthGate = await deps.preDispatchHealthGate(s.basePath);
		if (healthGate.fixesApplied.length > 0) {
			ctx.ui.notify(
				`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`,
				"info",
			);
		}
		if (!healthGate.proceed) {
			await runPreDispatchGate({
				gateId: "pre-dispatch-health-gate",
				gateType: "policy",
				outcome: "manual-attention",
				failureClass: "manual-attention",
				rationale: "pre-dispatch health gate blocked dispatch",
				findings: healthGate.reason,
			});
			ctx.ui.notify(
				healthGate.reason ||
					"Pre-dispatch health check failed — run /doctor for details.",
				"error",
			);
			await deps.pauseAuto(ctx, pi);
			debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
			return { action: "break", reason: "health-gate-failed" };
		}
		await runPreDispatchGate({
			gateId: "pre-dispatch-health-gate",
			gateType: "policy",
			outcome: "pass",
			failureClass: "none",
			rationale: "pre-dispatch health gate passed",
			findings:
				healthGate.fixesApplied.length > 0
					? healthGate.fixesApplied.join(", ")
					: "",
		});
	} catch (e) {
		await runPreDispatchGate({
			gateId: "pre-dispatch-health-gate",
			gateType: "policy",
			outcome: "manual-attention",
			failureClass: "manual-attention",
			rationale: "pre-dispatch health gate threw unexpectedly",
			findings: String(e),
		});
		logWarning("engine", "Pre-dispatch health gate threw unexpectedly", {
			error: String(e),
		});
	}
	// Sync project root artifacts into worktree
	if (
		s.originalBasePath &&
		s.basePath !== s.originalBasePath &&
		s.currentMilestoneId
	) {
		deps.syncProjectRootToWorktree(
			s.originalBasePath,
			s.basePath,
			s.currentMilestoneId,
		);
	}
	// Derive state
	let state = await deps.deriveState(s.basePath);
	if (
		uokFlags.planningFlow &&
		isDbAvailable() &&
		shouldRunPlanningFlowGate(state.phase)
	) {
		let compiled = ensurePlanningFlowGraph(s.basePath, state);
		// Empty-graph recovery: stale DB caches can yield 0 nodes right after a
		// task-complete write. Invalidate caches, re-derive state, and retry once.
		if (isEmptyPlanV2GraphResult(compiled)) {
			deps.invalidateAllCaches();
			state = await deps.deriveState(s.basePath);
			compiled = shouldRunPlanningFlowGate(state.phase)
				? ensurePlanningFlowGraph(s.basePath, state)
				: {
						ok: true,
						reason: "empty planning-flow graph recovered by state rederive",
						nodeCount: 0,
					};
		}
		if (!compiled.ok) {
			const reason = compiled.reason ?? "Planning flow compilation failed";
			if (isMissingFinalizedContextResult(compiled)) {
				await runPreDispatchGate({
					gateId: "planning-flow-gate",
					gateType: "policy",
					outcome: "pass",
					failureClass: "none",
					rationale: "plan v2 missing context recovery deferred to dispatch",
					findings: reason,
					milestoneId: state.activeMilestone?.id ?? undefined,
				});
			} else {
				await runPreDispatchGate({
					gateId: "planning-flow-gate",
					gateType: "policy",
					outcome: "manual-attention",
					failureClass: "manual-attention",
					rationale: "planning flow compile gate failed",
					findings: reason,
					milestoneId: state.activeMilestone?.id ?? undefined,
				});
				ctx.ui.notify(
					`Plan gate failed-closed: ${reason}\n\nIf this keeps happening, try: /doctor heal`,
					"error",
				);
				await deps.pauseAuto(ctx, pi);
				return { action: "break", reason: "planning-flow-gate-failed" };
			}
		}
		await runPreDispatchGate({
			gateId: "planning-flow-gate",
			gateType: "policy",
			outcome: "pass",
			failureClass: "none",
			rationale: "planning flow compile gate passed",
			milestoneId: state.activeMilestone?.id ?? undefined,
		});
	}
	// ── purpose-coherence-gate (ADR-0000 restoration) ──────────────────
	// Walks the purpose chain: milestone vision → slice traces-vision →
	// task purpose-trace. Fails (blocks dispatch) only when vision is
	// missing/empty; downgrades to "warn" when the trace columns added
	// by P2/P3 are absent or NULL so legacy DBs still progress.
	if (state.activeMilestone?.id && isDbAvailable()) {
		try {
			const verdict = evaluatePurposeCoherence({
				state,
				db: _getAdapter(),
			});
			await runPreDispatchGate({
				gateId: PURPOSE_COHERENCE_GATE_ID,
				gateType: "policy",
				outcome: verdict.outcome,
				failureClass: verdict.failureClass,
				rationale: verdict.rationale,
				findings: verdict.findings,
				milestoneId: state.activeMilestone?.id ?? undefined,
				sliceId: state.activeSlice?.id ?? undefined,
				taskId: state.activeTask?.id ?? undefined,
			});
			if (verdict.outcome === "fail") {
				ctx.ui.notify(
					`Purpose-coherence gate refused dispatch: ${verdict.rationale}`,
					"error",
				);
				await deps.pauseAuto(ctx, pi);
				debugLog("autoLoop", {
					phase: "exit",
					reason: "purpose-coherence-gate-failed",
				});
				return { action: "break", reason: "purpose-coherence-gate-failed" };
			}
			if (verdict.outcome === "warn") {
				// Surface the downgrade so the operator sees the chain gap, but
				// do not block — this is the documented day-one allowance.
				ctx.ui.notify(
					`Purpose-coherence warn: ${verdict.rationale}`,
					"warning",
				);
			}
		} catch (e) {
			// Gate evaluation must never break the loop. Record manual-attention
			// and continue; the underlying DB issue will surface via other gates.
			await runPreDispatchGate({
				gateId: PURPOSE_COHERENCE_GATE_ID,
				gateType: "policy",
				outcome: "manual-attention",
				failureClass: "manual-attention",
				rationale: "purpose-coherence gate threw unexpectedly",
				findings: String(e),
				milestoneId: state.activeMilestone?.id ?? undefined,
			});
			logWarning("engine", "Purpose-coherence gate threw unexpectedly", {
				error: String(e),
			});
		}
	}
	deps.syncCmuxSidebar(prefs, state);
	let mid = state.activeMilestone?.id;
	let midTitle = state.activeMilestone?.title;
	debugLog("autoLoop", {
		phase: "state-derived",
		iteration: ic.iteration,
		mid,
		statePhase: state.phase,
	});
	// ── Slice-level parallelism gate (#2340) ─────────────────────────────
	// When slice_parallel is enabled, check if multiple slices are eligible
	// for parallel execution. If so, dispatch them in parallel and stop the
	// sequential loop. Workers are spawned via slice-parallel-orchestrator.ts.
	if (
		prefs?.slice_parallel?.enabled &&
		mid &&
		!process.env.SF_PARALLEL_WORKER &&
		isDbAvailable()
	) {
		try {
			const dbSlices = getMilestoneSlices(mid);
			if (dbSlices.length > 0) {
				const doneIds = new Set(
					dbSlices
						.filter((sl) => sl.status === "complete" || sl.status === "done")
						.map((sl) => sl.id),
				);
				const sliceInputs = dbSlices.map((sl) => ({
					id: sl.id,
					done: doneIds.has(sl.id),
					depends: sl.depends ?? [],
				}));
				const eligible = getEligibleSlices(sliceInputs, doneIds);
				if (eligible.length > 1) {
					debugLog("autoLoop", {
						phase: "slice-parallel-dispatch",
						iteration: ic.iteration,
						mid,
						eligibleSlices: eligible.map((e) => e.id),
					});
					ctx.ui.notify(
						`Slice-parallel: dispatching ${eligible.length} eligible slices for ${mid}.`,
						"info",
					);
					const result = await startSliceParallel(s.basePath, mid, eligible, {
						maxWorkers: prefs.slice_parallel.max_workers ?? 2,
						useExecutionGraph: uokFlags.executionGraph,
						shellWrapper: prefs.shell_wrapper,
					});
					if (result.started.length > 0) {
						ctx.ui.notify(
							`Slice-parallel: started ${result.started.length} worker(s): ${result.started.join(", ")}.`,
							"info",
						);
						await deps.stopAuto(
							ctx,
							pi,
							`Slice-parallel dispatched for ${mid}`,
						);
						return { action: "break", reason: "slice-parallel-dispatched" };
					}
					// Fall through to sequential if no workers started
				}
			}
		} catch (err) {
			debugLog("autoLoop", {
				phase: "slice-parallel-check-error",
				error: getErrorMessage(err),
			});
			// Non-fatal — fall through to sequential dispatch
		}
	}
	// ── Milestone transition ────────────────────────────────────────────
	if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "milestone-transition",
			data: { from: s.currentMilestoneId, to: mid },
		});
		ctx.ui.notify(
			`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
			"info",
		);
		deps.sendDesktopNotification(
			"SF",
			`Milestone ${s.currentMilestoneId} complete!`,
			"success",
			"milestone",
			basename(s.originalBasePath || s.basePath),
		);
		deps.logCmuxEvent(
			prefs,
			`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
			"success",
		);
		const vizPrefs = prefs;
		if (vizPrefs?.auto_visualize) {
			ctx.ui.notify("Run /visualize to see progress overview.", "info");
		}
		if (vizPrefs?.auto_report !== false) {
			try {
				await generateMilestoneReport(s, ctx, s.currentMilestoneId);
			} catch (err) {
				ctx.ui.notify(
					`Report generation failed: ${getErrorMessage(err)}`,
					"warning",
				);
			}
		}
		// Reset dispatch counters for new milestone
		s.unitDispatchCount.clear();
		s.unitRecoveryCount.clear();
		s.unitLifetimeDispatches.clear();
		loopState.recentUnits.length = 0;
		loopState.stuckRecoveryAttempts = 0;
		// Worktree lifecycle on milestone transition — merge current, enter next
		try {
			deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
		} catch (mergeErr) {
			if (mergeErr instanceof MergeConflictError) {
				// Real code conflicts — stop the loop instead of retrying forever (#2330)
				ctx.ui.notify(
					`Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /autonomous to resume.`,
					"error",
				);
				await deps.stopAuto(
					ctx,
					pi,
					`Merge conflict on milestone ${s.currentMilestoneId}`,
				);
				return { action: "break", reason: "merge-conflict" };
			}
			// Non-conflict merge errors — stop auto to avoid advancing with unmerged work
			logError("engine", "Milestone merge failed with non-conflict error", {
				milestone: s.currentMilestoneId,
				error: String(mergeErr),
			});
			ctx.ui.notify(
				`Merge failed: ${getErrorMessage(mergeErr)}. Resolve and run /autonomous to resume.`,
				"error",
			);
			await deps.stopAuto(
				ctx,
				pi,
				`Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`,
			);
			return { action: "break", reason: "merge-failed" };
		}
		// Fire product-audit after successful merge (guards against double-fire via s.productAuditMilestoneId)
		await maybeFireProductAudit(s, ctx);
		// PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
		deps.invalidateAllCaches();
		state = await deps.deriveState(s.basePath);
		mid = state.activeMilestone?.id;
		midTitle = state.activeMilestone?.title;
		if (mid) {
			if (deps.getIsolationMode() !== "none") {
				deps.captureIntegrationBranch(s.basePath, mid);
			}
			deps.resolver.enterMilestone(mid, ctx.ui);
		} else {
			// mid is undefined — no milestone to capture integration branch for
		}
		const pendingIds = state.registry
			.filter((m) => m.status !== "complete" && m.status !== "parked")
			.map((m) => m.id);
		deps.pruneQueueOrder(s.basePath, pendingIds);
		// Archive the old completed-units.json instead of wiping it (#2313).
		try {
			const completedKeysPath = join(
				sfRoot(s.basePath),
				"completed-units.json",
			);
			if (existsSync(completedKeysPath) && s.currentMilestoneId) {
				const archivePath = join(
					sfRoot(s.basePath),
					`completed-units-${s.currentMilestoneId}.json`,
				);
				cpSync(completedKeysPath, archivePath);
			}
			atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2));
		} catch (e) {
			logWarning(
				"engine",
				"Failed to archive completed-units on milestone transition",
				{ error: String(e) },
			);
		}
		// Rebuild STATE.md immediately so it reflects the new active milestone.
		// This bypasses the 30-second throttle in the normal rebuild path —
		// milestone transitions are rare and important enough to warrant an
		// immediate write.
		try {
			await deps.rebuildState(s.basePath);
		} catch (e) {
			logWarning(
				"engine",
				"STATE.md rebuild failed after milestone transition",
				{ error: String(e) },
			);
		}
	}
	if (mid) {
		s.currentMilestoneId = mid;
		deps.setActiveMilestoneId(s.basePath, mid);
	}
	// ── Terminal conditions ──────────────────────────────────────────────
	if (!mid) {
		if (s.currentUnit) {
			await deps.closeoutUnit(
				ctx,
				s.basePath,
				s.currentUnit.type,
				s.currentUnit.id,
				s.currentUnit.startedAt,
				deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
			);
		}
		const incomplete = state.registry.filter(
			(m) => m.status !== "complete" && m.status !== "parked",
		);
		if (incomplete.length === 0 && state.registry.length > 0) {
			// All milestones complete — merge milestone branch before stopping
			if (s.currentMilestoneId) {
				try {
					deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
					// Prevent stopAuto from attempting the same merge (#2645)
					s.milestoneMergedInPhases = true;
					// Fire product-audit after successful merge (guards against double-fire via s.productAuditMilestoneId)
					await maybeFireProductAudit(s, ctx);
				} catch (mergeErr) {
					if (mergeErr instanceof MergeConflictError) {
						ctx.ui.notify(
							`Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /autonomous to resume.`,
							"error",
						);
						await deps.stopAuto(
							ctx,
							pi,
							`Merge conflict on milestone ${s.currentMilestoneId}`,
						);
						return { action: "break", reason: "merge-conflict" };
					}
					logError("engine", "Milestone merge failed with non-conflict error", {
						milestone: s.currentMilestoneId,
						error: String(mergeErr),
					});
					ctx.ui.notify(
						`Merge failed: ${getErrorMessage(mergeErr)}. Resolve and run /autonomous to resume.`,
						"error",
					);
					await deps.stopAuto(
						ctx,
						pi,
						`Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`,
					);
					return { action: "break", reason: "merge-failed" };
				}
				// PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
			}
			deps.sendDesktopNotification(
				"SF",
				"All milestones complete!",
				"success",
				"milestone",
				basename(s.originalBasePath || s.basePath),
			);
			deps.logCmuxEvent(prefs, "All milestones complete.", "success");
			await deps.stopAuto(ctx, pi, "All milestones complete");
		} else if (incomplete.length === 0 && state.registry.length === 0) {
			// Empty registry — no milestones visible, likely a path resolution bug
			const diag = `basePath=${s.basePath}, phase=${state.phase}`;
			ctx.ui.notify(
				`No milestones visible in current scope. Possible path resolution issue.\n   Diagnostic: ${diag}`,
				"error",
			);
			await deps.stopAuto(
				ctx,
				pi,
				`No milestones found — check basePath resolution`,
			);
		} else if (state.phase === "blocked") {
			const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
			// Pause instead of hard-stop so the session is resumable with `/autonomous`.
			// Hard-stop here was causing premature termination when slice dependencies
			// were temporarily unresolvable (e.g. after reassessment added new slices).
			await deps.pauseAuto(ctx, pi);
			ctx.ui.notify(
				`${blockerMsg}. Fix and run /autonomous to resume.`,
				"warning",
			);
			deps.sendDesktopNotification(
				"SF",
				blockerMsg,
				"warning",
				"attention",
				basename(s.originalBasePath || s.basePath),
			);
			deps.logCmuxEvent(prefs, blockerMsg, "warning");
		} else {
			const ids = incomplete.map((m) => m.id).join(", ");
			const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
			ctx.ui.notify(
				`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n   Diagnostic: ${diag}`,
				"error",
			);
			await deps.stopAuto(
				ctx,
				pi,
				`No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`,
			);
		}
		surfaceSelfFeedbackQueueOnIdle(ctx, s.basePath, "no-active-milestone");
		debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" });
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "terminal",
			data: { reason: "no-active-milestone" },
		});
		return { action: "break", reason: "no-active-milestone" };
	}
	if (!midTitle) {
		midTitle = mid;
		ctx.ui.notify(
			`Milestone ${mid} has no title in roadmap — using ID as fallback.`,
			"warning",
		);
	}
	// Mid-merge safety check
	const mergeReconcileResult = deps.reconcileMergeState(s.basePath, ctx);
	if (mergeReconcileResult === "blocked") {
		await deps.pauseAuto(ctx, pi);
		debugLog("autoLoop", {
			phase: "exit",
			reason: "merge-reconciliation-blocked",
		});
		return { action: "break", reason: "merge-reconciliation-blocked" };
	}
	if (mergeReconcileResult === "reconciled") {
		deps.invalidateAllCaches();
		state = await deps.deriveState(s.basePath);
		mid = state.activeMilestone?.id;
		midTitle = state.activeMilestone?.title;
	}
	if (!mid || !midTitle) {
		const noMilestoneReason = !mid
			? "No active milestone after merge reconciliation"
			: `Milestone ${mid} has no title after reconciliation`;
		await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
		debugLog("autoLoop", {
			phase: "exit",
			reason: "no-milestone-after-reconciliation",
		});
		return { action: "break", reason: "no-milestone-after-reconciliation" };
	}
	// Terminal: complete
	if (state.phase === "complete") {
		// Milestone merge on complete (before closeout so branch state is clean)
		if (s.currentMilestoneId) {
			try {
				deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
				// Prevent stopAuto from attempting the same merge (#2645)
				s.milestoneMergedInPhases = true;
				// Fire product-audit after successful merge (guards against double-fire via s.productAuditMilestoneId)
				await maybeFireProductAudit(s, ctx);
			} catch (mergeErr) {
				if (mergeErr instanceof MergeConflictError) {
					ctx.ui.notify(
						`Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /autonomous to resume.`,
						"error",
					);
					await deps.stopAuto(
						ctx,
						pi,
						`Merge conflict on milestone ${s.currentMilestoneId}`,
					);
					return { action: "break", reason: "merge-conflict" };
				}
				logError("engine", "Milestone merge failed with non-conflict error", {
					milestone: s.currentMilestoneId,
					error: String(mergeErr),
				});
				ctx.ui.notify(
					`Merge failed: ${getErrorMessage(mergeErr)}. Resolve and run /autonomous to resume.`,
					"error",
				);
				await deps.stopAuto(
					ctx,
					pi,
					`Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`,
				);
				return { action: "break", reason: "merge-failed" };
			}
			// PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
		}
		deps.sendDesktopNotification(
			"SF",
			`Milestone ${mid} complete!`,
			"success",
			"milestone",
			basename(s.originalBasePath || s.basePath),
		);
		deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success");
		await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
		surfaceSelfFeedbackQueueOnIdle(ctx, s.basePath, "milestone-complete");
		debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "terminal",
			data: { reason: "milestone-complete", milestoneId: mid },
		});
		return { action: "break", reason: "milestone-complete" };
	}
	// Terminal: blocked — pause instead of hard-stop so the session is resumable.
	if (state.phase === "blocked") {
		const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
		if (s.currentUnit) {
			await deps.closeoutUnit(
				ctx,
				s.basePath,
				s.currentUnit.type,
				s.currentUnit.id,
				s.currentUnit.startedAt,
				deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
			);
		}
		await deps.pauseAuto(ctx, pi);
		ctx.ui.notify(
			`${blockerMsg}. Fix and run /autonomous to resume.`,
			"warning",
		);
		deps.sendDesktopNotification(
			"SF",
			blockerMsg,
			"warning",
			"attention",
			basename(s.originalBasePath || s.basePath),
		);
		deps.logCmuxEvent(prefs, blockerMsg, "warning");
		debugLog("autoLoop", { phase: "exit", reason: "blocked" });
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "terminal",
			data: { reason: "blocked", blockers: state.blockers },
		});
		return { action: "break", reason: "blocked" };
	}
	return { action: "next", data: { state, mid, midTitle } };
}
