package com.correx.core.kernel.orchestration

import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.approvals.Tier
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.domain.ApprovalEngine
import com.correx.core.approvals.isAtMost
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.ArtifactKindRegistry
import com.correx.core.artifacts.kind.KindContractTable
import com.correx.core.artifacts.kind.KindInference
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.ProcessResultArtifact
import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.ContextPackBuilder
import com.correx.core.context.builder.RequiredContextOverflowException
import com.correx.core.context.model.ContextBucket
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.TokenBudget
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.events.ContractAssertionResult
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
import com.correx.core.events.events.ArtifactRepairFailedEvent
import com.correx.core.events.events.ArtifactRepairResolvedEvent
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.StaticAnalysisFinding
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationQuestions
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.GroundingReference
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.RepoKnowledgeHit
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskSummary
import com.correx.core.toolintent.SessionContextProjection
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.toolintent.WorldProbe
import com.correx.core.toolintent.toAssessedIssues
import com.correx.core.toolintent.toRiskSummary
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.sessions.projections.EgressAllowlistProjection
import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.RiskSummaryId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.PromptRenderer
import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.Tokenizer
import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.ToolDefinition
import com.correx.core.inference.ModelCapability
import com.correx.core.sessions.ApprovalMode
import com.correx.core.inference.ToolFunction
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.risk.RiskAssessor
import com.correx.core.risk.RiskContext
import com.correx.core.risk.toApprovalTier
import com.correx.core.sessions.Session
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision
import com.correx.core.transitions.resolution.TransitionResolver
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
import com.correx.core.validation.artifact.ArtifactFailure
import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.model.ValidationReport
import com.correx.core.validation.model.ValidationSeverity
import com.correx.core.validation.pipeline.ValidationOutcome
import com.correx.core.validation.pipeline.ValidationPipeline
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
import kotlin.coroutines.cancellation.CancellationException

private const val MAX_TOOL_ROUNDS = 10

// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
// artifact before we force the write nudge. A model that keeps calling read tools every round
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
private const val READ_LOOP_NUDGE_THRESHOLD = 3
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
private const val MAX_FEEDBACK_ISSUES = 3
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30
private const val REPO_MAP_FILES_PER_DIR = 8

// A stage prompt must explicitly ask about documentation for .md/docs paths to enter the repo
// layout listing; otherwise docs only reach context through a real retrieval hit.
private val DOCS_REQUEST_REGEX =
    Regex("""\b(docs?|readme|adr|changelog|documentation|markdown)\b|\.md\b""", RegexOption.IGNORE_CASE)

private fun isDocPath(path: String): Boolean =
    path.endsWith(".md", ignoreCase = true) ||
        path.split('/').any { it.equals("docs", ignoreCase = true) || it.equals("doc", ignoreCase = true) }

// Source types whose entries land in the REQUIRED context bucket (see stampBuckets).
private val REQUIRED_SOURCE_TYPES = setOf(
    "systemPrompt",
    "agentPrompt",
    "agentInstructions",
    "claimedTask",
    "schemaInstruction",
    "steeringNote",
    "clarificationAnswer",
    "rejectionFeedback",
    "retryFeedback",
    "neededArtifact",
    "criticFeedback",
)

// HTTP statuses that are transient despite being 4xx (F-002 retry classification).
private const val HTTP_TIMEOUT = 408
private const val HTTP_TOO_MANY_REQUESTS = 429

// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds.
private const val MAX_CLARIFICATION_ROUNDS = 3

// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
private const val CONTRACT_EVIDENCE_CAP = 400
// Gate 3 semantic review: a FAIL blocks only on a correctness finding at/above this confidence, and
// only until this many prior blocks have occurred for the stage — after which findings surface
// without blocking, so a stuck reviewer can never trap a stage. Objective text fed to the reviewer
// is capped to keep the review prompt bounded.
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
private const val REVIEW_BLOCK_RETRY_CAP = 2
private const val REVIEW_OBJECTIVE_CAP = 4_000
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000

@SuppressWarnings(
    "ForbiddenComment",
    "UnusedParameter",
    "ReturnCount",
    "TooGenericExceptionCaught",
    "TooManyFunctions",
    "NestedBlockDepth",
    "LongMethod",
)
abstract class SessionOrchestrator(
    repositories: OrchestratorRepositories,
    engines: OrchestratorEngines,
    protected val artifactStore: ArtifactStore,
    private val decisionJournalRepository: DefaultDecisionJournalRepository,
    private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(),
    private val artifactKindRegistry: ArtifactKindRegistry? = null,
    private val repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
    private val readyTaskCounter: ReadyTaskCounter? = null,
    private val taskClaimCoordinator: TaskClaimCoordinator? = null,
) {
    private val log = LoggerFactory.getLogger(this::class.java)
    private val eventStore: EventStore = repositories.eventStore
    private val transitionResolver: TransitionResolver = engines.transitionResolver
    private val contextPackBuilder: ContextPackBuilder = engines.contextPackBuilder
    private val inferenceRouter: InferenceRouter = engines.inferenceRouter
    private val validationPipeline: ValidationPipeline = engines.validationPipeline
    private val riskAssessor: RiskAssessor = engines.riskAssessor
    private val promptResolver: PromptResolver = engines.promptResolver
    private val toolExecutor: ToolExecutor? = engines.toolExecutor
    private val toolRegistry: ToolRegistry? = engines.toolRegistry
    private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor
    private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
    private val worldProbe: WorldProbe = engines.worldProbe
    private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
    private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
    private val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
    private val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
    private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
    private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
    internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
    protected open val tokenizer: Tokenizer? = null
    private val approvalEngine: ApprovalEngine = engines.approvalEngine
    private val approvalRepository: DefaultApprovalRepository = repositories.approvalRepository
    internal abstract val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean>

    protected abstract val subagentRunner: SubagentRunner
    internal val pendingApprovals: ConcurrentHashMap<ApprovalRequestId, CompletableDeferred<ApprovalDecision>> =
        ConcurrentHashMap()

    // A stage whose produced artifact carries open questions parks its run on one of these until the
    // operator answers (submitClarification completes it). Separate from pendingApprovals so a
    // clarification round never consumes the failure-retry budget.
    internal val pendingClarifications:
        ConcurrentHashMap<ClarificationRequestId, CompletableDeferred<List<ClarificationAnswer>>> =
        ConcurrentHashMap()

    /** Cache mapping "sessionId:artifactId" to JSON-serialized artifact payload content.
     *  Populated when ProcessResult artifacts are stored on tool failure.
     *  Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
    protected val artifactContentCache: ConcurrentHashMap<String, String> = ConcurrentHashMap()

    /** Deterministic extraction/repair ladder for near-miss LLM artifact text (prose-wrapped JSON,
     *  code fences, trailing commas). Pure — recomputes on replay, records no events. */
    private val artifactExtractionPipeline = ArtifactExtractionPipeline()

    abstract suspend fun run(
        sessionId: SessionId,
        graph: WorkflowGraph,
        config: OrchestrationConfig,
    ): WorkflowResult

    abstract suspend fun cancel(sessionId: SessionId)

    // --- per-run effective registry + policy ---

    internal data class RunEffectives(
        val registry: ToolRegistry?,
        val executor: ToolExecutor?,
        val policy: WorkspacePolicy?,
    )

    internal fun effectivesFor(config: OrchestrationConfig): RunEffectives {
        val wsTools = config.workspace?.let { workspaceToolRegistryProvider?.forWorkspace(it) }
        val registry = wsTools?.registry ?: toolRegistry
        val executor = wsTools?.executor ?: toolExecutor
        val policy = config.workspace
            ?.let { WorkspacePolicy(it.workspaceRoot, it.privilegedLocations) }
            ?: workspacePolicy
        return RunEffectives(registry, executor, policy)
    }

    // --- stage execution ---

    @Suppress("CyclomaticComplexMethod")
    internal suspend fun executeStage(
        sessionId: SessionId,
        stageId: StageId,
        graph: WorkflowGraph,
        session: Session,
        config: OrchestrationConfig,
        effectives: RunEffectives,
    ): StageExecutionResult {
        val stageConfig = requireNotNull(graph.stages[stageId]) {
            "Stage '${stageId.value}' not declared in workflow graph"
        }
        val systemPrompt = stageConfig.metadata["systemPrompt"]
            ?.let { path ->
                runCatching { promptResolver.resolve(path) }
                    .onFailure {
                        log.error(error(stageId, path, it))
                    }
                    .getOrNull()
            }
            ?.takeIf { it.isNotBlank() }
            ?.let { text ->
                listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L0,
                        content = text,
                        sourceType = "systemPrompt",
                        sourceId = stageId.value,
                        tokenEstimate = estimateTokens(text),
                        role = EntryRole.SYSTEM,
                    ),
                )
            } ?: config.defaultSystemPromptPath
            ?.let { path ->
                runCatching { promptResolver.resolve(path) }
                    .onFailure {
                        log.error(error(stageId, path, it))
                    }
                    .getOrNull()
            }
            ?.takeIf { it.isNotBlank() }
            ?.let { text ->
                listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L0,
                        content = text,
                        sourceType = "systemPrompt",
                        sourceId = stageId.value,
                        tokenEstimate = estimateTokens(text),
                        role = EntryRole.SYSTEM,
                    ),
                )
            } ?: emptyList()
        val promptEntries = stageConfig.metadata["promptInline"]
            ?.takeIf { it.isNotBlank() }
            ?.let { text ->
                listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L1,
                        content = text,
                        sourceType = "agentPrompt",
                        sourceId = stageId.value,
                        tokenEstimate = estimateTokens(text),
                        role = EntryRole.USER,
                    ),
                )
            }
            ?: stageConfig.metadata["prompt"]
                ?.let { path ->
                    val resolvedText = runCatching { promptResolver.resolve(path) }
                        .onFailure {
                            log.error(
                                "[SessionOrchestrator] stage=${stageId.value}: " +
                                        "failed to load prompt '$path': ${it.message}",
                            )
                        }
                        .getOrNull()
                    val text = resolvedText?.takeIf { it.isNotBlank() }
                        ?: error(
                            "[SessionOrchestrator] stage=${stageId.value}: " +
                                    "declared prompt '$path' could not be resolved",
                        )
                    listOf(
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L1,
                            content = text,
                            sourceType = "agentPrompt",
                            sourceId = stageId.value,
                            tokenEstimate = estimateTokens(text),
                            role = EntryRole.USER,
                        ),
                    )
                }
            ?: emptyList()

        val llmEmittedSlots = stageConfig.produces.filter { it.kind.llmEmitted }
        require(llmEmittedSlots.size <= 1) {
            "Stage '${stageId.value}' declares ${llmEmittedSlots.size} LLM-emitted artifact kinds; " +
                    "only 0 or 1 is supported"
        }
        val responseFormat = llmEmittedSlots.firstOrNull()
            ?.let { ResponseFormat.Json(it.kind.deriveJsonSchema()) }
            ?: ResponseFormat.Text

        val schemaEntries = buildSchemaEntries(responseFormat, stageId)
        val steeringEntries = buildSteeringNoteEntries(sessionId)
        val clarificationEntries = buildClarificationAnswerEntries(sessionId)

        // Maintain the running transcript in true chronological order. Reading it back
        // from currentContext.layers (a Map grouped by layer) would scramble turn order
        // across rounds; instead we grow our own ordered list and let the builder restamp
        // ordinals from it each round.
        val journalState = decisionJournalRepository.getJournal(sessionId)
        val summaryText = journalState.summaryArtifactId?.let { id ->
            artifactStore.get(id)?.toString(Charsets.UTF_8)
                ?: run {
                    log.warn("Journal summary artifact {} not found in store — rendering without summary", id.value)
                    null
                }
        }
        val journalText = decisionJournalRenderer.render(journalState, summaryText)
        val journalEntries = if (journalText.isBlank()) emptyList() else listOf(
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L0,
                content = journalText,
                sourceType = "decisionJournal",
                sourceId = "decision-journal",
                tokenEstimate = journalText.length / 4,
                role = EntryRole.SYSTEM,
            ),
        )
        val repoMapEntries = buildContextualRepoEntries(sessionId, stageId, stageConfig)
        val sessionEvents = eventStore.read(sessionId)
        val criticIds = criticArtifactIds(sessionEvents, graph, stageId)
        val criticFrom = sessionEvents
            .mapNotNull { it.payload as? RefinementIterationEvent }
            .lastOrNull { it.cycleKey.endsWith("->${stageId.value}") }
            ?.cycleKey?.substringBefore("->")
        val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig, criticIds, criticFrom)
        val profileEntries = session.state.boundProfile?.about
            ?.takeIf { it.isNotBlank() }
            ?.let { about ->
                listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L0,
                        content = "## Operator profile\n$about",
                        sourceType = "operatorProfile",
                        sourceId = "operator-profile",
                        tokenEstimate = about.length / 4,
                        role = EntryRole.SYSTEM,
                    ),
                )
            } ?: emptyList()
        val projectProfileEntries = session.state.boundProjectProfile
            ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
        // AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
        // carries a mandatory "Read Before Editing" walk — "read the whole AGENTS.md chain, do not
        // rely on memory, re-read before editing" — which drives a small model into an endless
        // read-loop that never writes (2026-07-05 root cause). Reincorporating DOX properly (scoped
        // to the stage's subproject, not the repo root; not read-mandating) is deferred.
        val agentInstructionsEntries = emptyList<ContextEntry>()
        val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
            ?.let { listOf(it) } ?: emptyList()
        val vocabularyEntries = artifactKindRegistry
            ?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
            ?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
        // Deterministic claim: a stage flagged claimTask gets the kernel to claim the next ready task
        // (or re-surface the one already claimed) and inject its context bundle as L0 — the loop
        // advances by recorded fact, not by the agent remembering to claim.
        val claimedTaskEntries = if (stageConfig.metadata["claimTask"] == "true") {
            taskClaimCoordinator?.claimNext(sessionId)?.let { bundle ->
                listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L0,
                        content = bundle,
                        sourceType = "claimedTask",
                        sourceId = stageId.value,
                        tokenEstimate = estimateTokens(bundle),
                        role = EntryRole.SYSTEM,
                    ),
                )
            } ?: emptyList()
        } else {
            emptyList()
        }
        var accumulatedEntries = stampBuckets(
            systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
                journalEntries + repoMapEntries + claimedTaskEntries +
                needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
                clarificationEntries + retryFeedbackEntries,
        )
        val contextPack = runCatching {
            contextPackBuilder.build(
                id = ContextPackId(UUID.randomUUID().toString()),
                sessionId = sessionId,
                stageId = stageId,
                entries = accumulatedEntries,
                budget = TokenBudget(limit = stageConfig.tokenBudget),
            )
        }.getOrElse { e ->
            if (e is CancellationException) throw e
            if (e !is RequiredContextOverflowException) throw e
            // Required context doesn't fit — retrying with the same inputs can't succeed.
            log.error("[Orchestrator] stage={}: {}", stageId.value, e.message)
            return StageExecutionResult.Failure(e.message ?: "required context overflow", retryable = false)
        }
        emitContextTruncationIfNeeded(sessionId, stageId, contextPack)

        var currentContext = contextPack
        var inferenceResult = runInference(
            sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
        )
        var toolRounds = 0
        var consecutiveReadOnlyRounds = 0
        // Set when the model produces its artifact via the emit_artifact tool instead of a final
        // JSON message; overrides the post-loop capture of the (then-empty) assistant text.
        var llmArtifactOverride: String? = null

        val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
        fun owesFileWrite(): Boolean = fileWrittenSlots.isNotEmpty() &&
            fileWrittenSlots.any { artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank() }

        // Gate: if the stage declares require_task_decompose, block stage_complete until at least one
        // task_decompose (or task_create) call has completed successfully in this stage. Checked via
        // ToolInvocationRequestedEvent + ToolExecutionCompletedEvent in the session event log.
        // ponytail: scans events on every stage_complete attempt; negligible cost (in-memory list, rare call).
        fun owesTaskDecompose(): Boolean {
            if (stageConfig.metadata["requireTaskDecompose"] != "true") return false
            val events = eventStore.read(sessionId)
            val requestedIds = events
                .mapNotNull { it.payload as? ToolInvocationRequestedEvent }
                .filter { it.stageId == stageId && it.toolName in setOf("task_decompose", "task_create") }
                .map { it.invocationId }
                .toSet()
            if (requestedIds.isEmpty()) return true
            val completedIds = events
                .mapNotNull { it.payload as? ToolExecutionCompletedEvent }
                .filter { it.toolName in setOf("task_decompose", "task_create") && it.invocationId in requestedIds }
                .map { it.invocationId }
                .toSet()
            return completedIds.isEmpty()
        }

        // Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
        // Returns the new result rather than mutating inferenceResult, to preserve smart casts.
        suspend fun pushBack(nudge: String, forceWriteOnly: Boolean = false): InferenceResult {
            accumulatedEntries = accumulatedEntries + ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L2,
                sourceType = "toolResult",
                sourceId = UUID.randomUUID().toString(),
                content = nudge,
                tokenEstimate = estimateTokens(nudge),
                role = EntryRole.TOOL,
            )
            currentContext = contextPackBuilder.build(
                id = ContextPackId(UUID.randomUUID().toString()),
                sessionId = sessionId,
                stageId = stageId,
                entries = accumulatedEntries,
                budget = TokenBudget(limit = stageConfig.tokenBudget),
            )
            emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
            toolRounds++
            return runInference(
                sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
                forceWriteOnly = forceWriteOnly,
            )
        }

        val writeNudge = "ERROR: you described the change but did not apply it. You MUST call the " +
            "file_edit or file_write tool to write the change to the file before finishing — do " +
            "not output the file contents as a message."

        val readLoopNudge = "STOP reading. You have read enough files and this stage has produced " +
            "nothing yet. You MUST now call the file_write tool to create the required file(s) for " +
            "this stage. Do not call file_read or list_dir again before you have written a file."

        while (
            inferenceResult is InferenceResult.Success &&
            toolRounds < MAX_TOOL_ROUNDS &&
            (inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
        ) {
            // Content turn (no tool call) but the stage still owes a file_written artifact: the
            // model emitted the change as prose instead of invoking the write tool. Nudge it to
            // actually write (F-018).
            if (inferenceResult.response.finishReason !is FinishReason.ToolCall) {
                inferenceResult = pushBack(writeNudge)
                continue
            }
            // emit_artifact is a meta-tool: the LLM calls it with the artifact's fields as arguments.
            // Capture those arguments as the artifact content and exit the loop straight to validation
            // (the post-loop capture honours this override instead of the empty assistant text).
            val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
            if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
                llmArtifactOverride = emitCall.function.arguments
                break
            }
            // stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
            // Skip tool dispatch — the loop exits and normal validation/transition follows
            // (emitToolArtifacts + verifyProduces run on the success path after the loop).
            if (inferenceResult.response.toolCalls.any { it.function.name == STAGE_COMPLETE_TOOL }) {
                // Premature-completion guard: don't accept stage_complete while the stage still
                // owes an artifact. For an LLM-emitted slot, nudge to emit the JSON (F-010); for a
                // file_written slot, nudge to invoke the write tool (F-018). Otherwise complete.
                val owedLlm = llmEmittedSlots.firstOrNull()?.takeIf {
                    inferenceResult.response.text.isBlank() &&
                        artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank()
                }
                when {
                    owedLlm != null -> inferenceResult = pushBack(
                        "ERROR: stage_complete is not allowed yet — you have not emitted the " +
                            "required '${owedLlm.name.value}' artifact. Call the emit_artifact tool with " +
                            "the fields filled in (or respond with the artifact as a single JSON message " +
                            "matching the provided schema).",
                    )
                    owesFileWrite() -> inferenceResult = pushBack(writeNudge)
                    owesTaskDecompose() -> inferenceResult = pushBack(
                        "ERROR: stage_complete is not allowed yet — this stage requires a successful " +
                            "task_decompose (or task_create) call before it can complete. Call task_decompose " +
                            "with a parent epic and DEPENDS_ON-linked children representing the full task graph. " +
                            "Do not call stage_complete until task_decompose has returned successfully.",
                    )
                    else -> break
                }
                continue
            }
            val toolEntries = dispatchToolCalls(sessionId, stageId,
                inferenceResult.response.toolCalls, stageConfig, effectives,
                approvalModeFor(session.state.boundProfile?.approvalMode))
            val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
            if (fatalEntry != null) {
                emitProcessResultEvents(sessionId, stageId, stageConfig)
                return StageExecutionResult.Failure(
                    fatalEntry.content.removePrefix("FATAL: "),
                    retryable = false,
                )
            }
            // Recoverable (ERROR:) tool failures are NOT terminal — they flow back into the
            // loop as tool-result context so the model can see the error and adapt (bounded by
            // MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
            accumulatedEntries = accumulatedEntries + toolEntries
            // Read-loop breaker: this round called only read-only tools yet the stage still owes a
            // file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
            // never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
            // an endless read-loop). After READ_LOOP_NUDGE_THRESHOLD such rounds, force the write
            // nudge; a real write resets the counter so multi-file stages are unaffected.
            if (owesFileWrite() &&
                inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
            ) {
                consecutiveReadOnlyRounds++
                if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
                    consecutiveReadOnlyRounds = 0
                    inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
                    continue
                }
            } else {
                consecutiveReadOnlyRounds = 0
            }
            currentContext = contextPackBuilder.build(
                id = ContextPackId(UUID.randomUUID().toString()),
                sessionId = sessionId,
                stageId = stageId,
                entries = accumulatedEntries,
                budget = TokenBudget(limit = stageConfig.tokenBudget),
            )
            emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
            inferenceResult = runInference(
                sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
            )
            toolRounds++
        }
        // Final clean emission. Producing the artifact while tools are in the request is unreliable on
        // some local chat templates (Gemma leaks `<|channel>` markers into the text; other models keep
        // tool-calling until the round cap and never emit). For an LLM-emitted stage that hasn't already
        // produced its artifact via the emit_artifact tool, fire ONE tools-less inference — verified to
        // yield clean JSON deterministically. Skipped when the model already emitted clean final text.
        if (inferenceResult is InferenceResult.Success && llmEmittedSlots.isNotEmpty() && llmArtifactOverride == null) {
            val last = inferenceResult.response
            val needsCleanEmission = last.finishReason is FinishReason.ToolCall ||
                last.text.isBlank() || last.text.contains("<|channel")
            if (needsCleanEmission && !isCancelled(sessionId)) {
                val nudge = "Stop calling tools. Output the required '${llmEmittedSlots.first().name.value}' " +
                    "artifact now as a single JSON object matching the schema — no tool calls, no commentary."
                accumulatedEntries = accumulatedEntries + ContextEntry(
                    id = ContextEntryId(UUID.randomUUID().toString()),
                    layer = ContextLayer.L2,
                    sourceType = "toolResult",
                    sourceId = UUID.randomUUID().toString(),
                    content = nudge,
                    tokenEstimate = estimateTokens(nudge),
                    role = EntryRole.TOOL,
                )
                currentContext = contextPackBuilder.build(
                    id = ContextPackId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    stageId = stageId,
                    entries = accumulatedEntries,
                    budget = TokenBudget(limit = stageConfig.tokenBudget),
                )
                inferenceResult = runInference(
                    sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs,
                    responseFormat, effectives, withTools = false,
                )
            }
        }
        return when (inferenceResult) {
            is InferenceResult.Cancelled -> StageExecutionResult.Failure("CANCELLED", retryable = false)
            is InferenceResult.Failed ->
                StageExecutionResult.Failure(inferenceResult.reason, retryable = isRetryableFailure(inferenceResult.reason))
            is InferenceResult.Success -> {
                if (isCancelled(sessionId)) return StageExecutionResult.Failure("CANCELLED", retryable = false)
                // Capture the LLM-emitted artifact's JSON so transition conditions
                // (e.g. ArtifactFieldEquals on a reviewer's verdict) can read it. An emit_artifact
                // tool call (llmArtifactOverride) supplies the content directly; otherwise the
                // artifact is the final assistant message text.
                val rawArtifactText = llmArtifactOverride ?: inferenceResult.response.text
                val slot = llmEmittedSlots.firstOrNull()
                // Artifact-emission ladder: deterministic repair (prose/fences/trailing commas), then
                // — rarely, gated by the failure classifier — one grammar-constrained LLM-repair rung,
                // before the text is cached/validated. On a hard failure the policy decision
                // short-circuits the stage. Only the nondeterministic rung records events (spec 2026-07-04 §6).
                val artifactText = if (slot != null) {
                    when (val res = artifactExtractionPipeline.run(rawArtifactText, slot.kind.deriveJsonSchema())) {
                        is ArtifactExtractionPipeline.ExtractionResult.Resolved -> {
                            if (res.repaired) {
                                emitArtifactRepairAttempted(sessionId, stageId, slot, ArtifactFailure.FORMATTING, "DETERMINISTIC")
                                emitArtifactRepairResolved(sessionId, stageId, slot, res.canonicalJson.toString())
                            }
                            res.canonicalJson.toString()
                        }
                        is ArtifactExtractionPipeline.ExtractionResult.Unresolved ->
                            when (val ladder = repairArtifact(sessionId, stageId, slot, res, stageConfig, effectives, config.stageTimeoutMs)) {
                                is ArtifactLadderOutcome.Text -> ladder.text
                                is ArtifactLadderOutcome.Reject -> return ladder.failure
                            }
                    }
                } else {
                    rawArtifactText
                }
                val artifactHash = if (artifactText != rawArtifactText || llmArtifactOverride != null) {
                    artifactStore.put(artifactText.toByteArray())
                } else {
                    inferenceResult.responseArtifactId
                }
                slot?.let { slot ->
                    artifactContentCache["${sessionId.value}:${slot.name.value}"] = artifactText
                    artifactHash?.let { hash ->
                        emit(
                            sessionId,
                            ArtifactContentStoredEvent(
                                artifactId = slot.name,
                                contentHash = hash,
                                sessionId = sessionId,
                                stageId = stageId,
                            ),
                        )
                    }
                }
                val validationCtx = ValidationContext(
                    graph = graph,
                    sessionState = session.state,
                    availableTools = effectives.registry?.all()?.map { it.name }?.toSet(),
                    producedArtifactContent = graph.stages.values.flatMap { it.produces }.mapNotNull { slot ->
                        artifactContentCache["${sessionId.value}:${slot.name.value}"]?.let { slot.name.value to it }
                    }.toMap(),
                )
                when (val outcome = mapValidationOutcome(sessionId, stageId, validationCtx)) {
                    is StageExecutionResult.Success -> {
                        emitToolArtifacts(sessionId, stageId, stageConfig)
                        emitLlmArtifacts(sessionId, stageId, stageConfig)
                        runPostStageGates(
                            sessionId, stageId, stageConfig, effectives,
                            session.state.boundProjectProfile?.commands ?: emptyMap(),
                        )
                    }

                    is StageExecutionResult.Failure -> outcome
                }
            }
        }
    }

    private sealed interface ArtifactLadderOutcome {
        data class Text(val text: String) : ArtifactLadderOutcome
        data class Reject(val failure: StageExecutionResult.Failure) : ArtifactLadderOutcome
    }

    /**
     * Handles an [ArtifactExtractionPipeline.ExtractionResult.Unresolved]: attempts one grammar-constrained
     * LLM-repair rung (only for SCHEMA / MISSING_INFO — the classifier gates the expensive rung), then
     * consults the policy [decide] to map a hard failure onto an orchestrator action. Records the §6 events.
     */
    private suspend fun repairArtifact(
        sessionId: SessionId,
        stageId: StageId,
        slot: TypedArtifactSlot,
        unresolved: ArtifactExtractionPipeline.ExtractionResult.Unresolved,
        stageConfig: StageConfig,
        effectives: RunEffectives,
        timeoutMs: Long,
    ): ArtifactLadderOutcome {
        val schema = slot.kind.deriveJsonSchema()
        val eligible = unresolved.classification in setOf(ArtifactFailure.SCHEMA, ArtifactFailure.MISSING_INFO)
        val bestCandidate = unresolved.bestCandidate
        if (eligible && bestCandidate != null && !isCancelled(sessionId)) {
            emitArtifactRepairAttempted(sessionId, stageId, slot, unresolved.classification, "LLM")
            val repaired = runArtifactRepairInference(
                sessionId, stageId, slot, bestCandidate, stageConfig, effectives, timeoutMs,
            )
            val reRun = repaired?.let { artifactExtractionPipeline.run(it, schema) }
            if (reRun is ArtifactExtractionPipeline.ExtractionResult.Resolved) {
                val json = reRun.canonicalJson.toString()
                emitArtifactRepairResolved(sessionId, stageId, slot, json)
                return ArtifactLadderOutcome.Text(json)
            }
        }
        val decision = decide(unresolved.classification, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
        emit(
            sessionId,
            ArtifactRepairFailedEvent(
                artifactId = slot.name,
                classification = unresolved.classification.name,
                decision = decision::class.simpleName ?: "Unknown",
                reason = unresolved.detail,
                sessionId = sessionId,
                stageId = stageId,
            ),
        )
        // ponytail: RetryProducer / RegenerateArtifactOnly / EscalateHuman all fold into a retryable
        // failure — the existing retryStageOrFail loop re-runs the full producer (incl. the tools-less
        // emission nudge) and surfaces exhaustion to the operator. A dedicated regenerate-only /
        // approval-pause mid-artifact path is deferred until one is shown to help.
        return ArtifactLadderOutcome.Reject(
            StageExecutionResult.Failure(
                "artifact repair failed (${unresolved.classification}): ${unresolved.detail}",
                retryable = decision != ArtifactPolicyDecision.Abort,
            ),
        )
    }

    /** One tools-less, grammar-constrained inference asking the model to repair its own malformed artifact. */
    private suspend fun runArtifactRepairInference(
        sessionId: SessionId,
        stageId: StageId,
        slot: TypedArtifactSlot,
        bestCandidate: String,
        stageConfig: StageConfig,
        effectives: RunEffectives,
        timeoutMs: Long,
    ): String? {
        val schema = slot.kind.deriveJsonSchema()
        val schemaJson = Json.encodeToString(JsonSchema.serializer(), schema)
        val prompt = "The previous output for the '${slot.name.value}' artifact was malformed and did not " +
            "match the required schema.\n\nMalformed output:\n$bestCandidate\n\nReturn ONLY a single JSON " +
            "object matching this schema. Do not add fields, prose, or code fences.\nSchema: $schemaJson"
        val entry = ContextEntry(
            id = ContextEntryId(UUID.randomUUID().toString()),
            layer = ContextLayer.L2,
            sourceType = "artifactRepair",
            sourceId = UUID.randomUUID().toString(),
            content = prompt,
            tokenEstimate = estimateTokens(prompt),
            role = EntryRole.USER,
        )
        val ctx = contextPackBuilder.build(
            id = ContextPackId(UUID.randomUUID().toString()),
            sessionId = sessionId,
            stageId = stageId,
            entries = listOf(entry),
            budget = TokenBudget(limit = stageConfig.tokenBudget),
        )
        val result = runInference(
            sessionId, stageId, ctx, stageConfig, timeoutMs, ResponseFormat.Json(schema), effectives, withTools = false,
        )
        return (result as? InferenceResult.Success)?.response?.text
    }

    private suspend fun emitArtifactRepairAttempted(
        sessionId: SessionId,
        stageId: StageId,
        slot: TypedArtifactSlot,
        classification: ArtifactFailure,
        rung: String,
    ) = emit(
        sessionId,
        ArtifactRepairAttemptedEvent(slot.name, classification.name, rung, sessionId, stageId),
    )

    private suspend fun emitArtifactRepairResolved(
        sessionId: SessionId,
        stageId: StageId,
        slot: TypedArtifactSlot,
        json: String,
    ) {
        val hash = artifactStore.put(json.toByteArray())
        emit(sessionId, ArtifactRepairResolvedEvent(slot.name, hash, sessionId, stageId))
    }

    private fun error(
        stageId: StageId,
        path: String,
        throwable: Throwable,
    ): String = "[SessionOrchestrator] stage=${stageId.value}: " +
            "failed to load prompt '$path': ${throwable.message}"

    /**
     * Strip an outer markdown code fence (```` ```json `` / ``` ``` ````) when the whole response
     * is a single fenced block. Conservative: only acts when the trimmed text both starts and ends
     * with a fence, so prose responses that merely contain an inline block are left untouched.
     */
    private fun stripCodeFence(text: String): String {
        val trimmed = text.trim()
        if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
        val withoutClose = trimmed.removeSuffix("```").trimEnd()
        val firstNewline = withoutClose.indexOf('\n')
        if (firstNewline < 0) return text
        // Drop the opening fence line (``` optionally followed by a language tag).
        return withoutClose.substring(firstNewline + 1).trim()
    }

    @Suppress("CyclomaticComplexMethod")
    // Maps the operator profile's free-text approval_mode onto the engine's [ApprovalMode].
    // Unset/unknown falls back to PROMPT so an absent profile keeps the human-in-the-loop
    // default; only an explicit auto/yolo opts a session into unattended approval.
    private fun approvalModeFor(profileMode: String?): ApprovalMode =
        when (profileMode?.trim()?.lowercase()) {
            "deny" -> ApprovalMode.DENY
            "auto" -> ApprovalMode.AUTO
            "yolo" -> ApprovalMode.YOLO
            else -> ApprovalMode.PROMPT
        }

    // On a recoverable tool failure, append the tool's argument schema to the error fed back to the
    // model so it can self-correct its next attempt (it already sees its own malformed call in the
    // assistant entry above) rather than repeating the same mistake. Kept compact to bound context.
    private fun toolArgsHint(tool: Tool?): String =
        tool?.let {
            "\nThe '${it.name}' tool requires arguments matching this JSON schema — re-issue the " +
                "call with corrected arguments: ${it.parametersSchema}"
        }.orEmpty()

    private suspend fun dispatchToolCalls(
        sessionId: SessionId,
        stageId: StageId,
        toolCalls: List<ToolCallRequest>,
        stageConfig: StageConfig,
        effectives: RunEffectives,
        approvalMode: ApprovalMode,
    ): List<ContextEntry> {
        val executor = effectives.executor ?: return emptyList()
        val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
        val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
        return toolCalls.flatMap { toolCall ->
            val invocationId = ToolInvocationId(UUID.randomUUID().toString())
            val parameters = parseToolArguments(toolCall.function.arguments)
            val request = ToolRequest(
                invocationId = invocationId,
                sessionId = sessionId,
                stageId = stageId,
                toolName = toolCall.function.name,
                parameters = parameters,
            )
            val tool = effectives.registry?.resolve(toolCall.function.name)
            val tier = tool?.tier ?: Tier.T2
            emit(
                sessionId,
                ToolInvocationRequestedEvent(
                    invocationId = invocationId,
                    sessionId = sessionId,
                    stageId = stageId,
                    toolName = toolCall.function.name,
                    tier = tier,
                    request = request,
                    capabilities = tool?.requiredCapabilities ?: emptySet(),
                ),
            )
            val deniedByStage = toolCall.function.name != STAGE_COMPLETE_TOOL &&
                toolCall.function.name !in stageConfig.allowedTools
            val deniedByReadOnly = !deniedByStage && isReadOnlyMode(sessionId) &&
                (tool?.requiredCapabilities?.contains(ToolCapability.FILE_WRITE) == true)
            if (deniedByStage || deniedByReadOnly) {
                val denyReason = if (deniedByReadOnly) {
                    "read-before-write: '${toolCall.function.name}' requires reading the file first — use file_read"
                } else {
                    "tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
                }
                emit(
                    sessionId,
                    ToolExecutionRejectedEvent(
                        invocationId = invocationId,
                        sessionId = sessionId,
                        toolName = toolCall.function.name,
                        tier = tier,
                        reason = denyReason,
                    ),
                )
                val sourceId = toolCall.id ?: invocationId.value
                return@flatMap listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L2,
                        sourceType = "assistantToolCall",
                        sourceId = sourceId,
                        content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                        tokenEstimate = estimateTokens(toolCall.function.arguments),
                        role = EntryRole.ASSISTANT,
                    ),
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L2,
                        sourceType = "toolResult",
                        sourceId = sourceId,
                        content = "ERROR: $denyReason",
                        tokenEstimate = estimateTokens(denyReason),
                        role = EntryRole.TOOL,
                    ),
                )
            }
            // Per-task write scope: while a task is claimed, narrow the manifest to its affected
            // paths (recorded on the task) so the implementer can't write outside its unit of work.
            // Falls back to the stage's static manifest when nothing is claimed.
            val effectiveManifest = taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() }
                ?: stageConfig.writeManifest
            val plane2Risk: RiskSummary? = runPlane2Assessment(
                sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
                effectiveManifest,
            )?.let { assessment ->
                if (assessment.recommendedAction == RiskAction.BLOCK) {
                    val rationale = assessment.rationale.joinToString("; ")
                    // On a bad-path block, point the model at the closest real file so it fixes the
                    // path instead of retrying the same wrong guess (deep package paths are easy to
                    // misremember). Only fires when we can name a concrete match from the repo map.
                    val suggestion = if (assessment.rationale.any { it.contains(REFERENCE_EXISTS_CODE) }) {
                        (parameters["path"] as? String)
                            ?.let { suggestClosestPath(sessionId, it) }
                            ?.let { " Did you mean: $it ?" }
                            .orEmpty()
                    } else {
                        ""
                    }
                    emit(
                        sessionId,
                        ToolExecutionRejectedEvent(
                            invocationId = invocationId,
                            sessionId = sessionId,
                            toolName = toolCall.function.name,
                            tier = tier,
                            // Record the specific rule rationale, not a generic string, so the audit
                            // trail (and task history) shows why a call was blocked.
                            reason = rationale.ifBlank { "blocked by tool-call policy" },
                        ),
                    )
                    val sourceId = toolCall.id ?: invocationId.value
                    return@flatMap listOf(
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            sourceType = "assistantToolCall",
                            sourceId = sourceId,
                            content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                            tokenEstimate = estimateTokens(toolCall.function.arguments),
                            role = EntryRole.ASSISTANT,
                        ),
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            sourceType = "toolResult",
                            sourceId = sourceId,
                            content = "BLOCKED: $rationale$suggestion",
                            tokenEstimate = estimateTokens(rationale + suggestion),
                            role = EntryRole.TOOL,
                        ),
                    )
                }
                assessment
            }
            val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
            // A steering note attached to a human approval is captured here and injected after the
            // tool result, so the same-stage loop re-infers with it and the model acts on the note.
            var approvalNote: String? = null
            if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
                // no approval needed
            } else {
                // Grants in effect = this session's own (SESSION/STAGE) unioned with the
                // cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
                // workspace root so PROJECT grants match later sessions on the same repo.
                val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
                val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
                val activeGrants = (sessionGrants + ledgerGrants).toList()
                val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
                val approvalCtx = ApprovalContext(
                    identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
                    mode = approvalMode,
                )
                val requestId = ApprovalRequestId(UUID.randomUUID().toString())
                val toolPreview = computeToolPreview(toolCall.function.name, parameters)
                val domainRequest = DomainApprovalRequest(
                    id = requestId,
                    tier = tier,
                    validationReportId = ValidationReportId(UUID.randomUUID().toString()),
                    riskSummaryId = null,
                    timestamp = Clock.System.now(),
                    toolName = toolCall.function.name,
                    preview = toolPreview ?: toolCall.function.arguments.take(200),
                )
                val engineDecision = approvalEngine.evaluate(
                    domainRequest, approvalCtx, activeGrants, Clock.System.now(),
                )
                if (engineDecision.state == ApprovalStatus.COMPLETED) {
                    emitDecisionResolved(sessionId, domainRequest, engineDecision)
                    if (!engineDecision.isApproved) {
                        val rejectReason = engineDecision.reason ?: "denied"
                        blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
                        emit(sessionId, ToolExecutionRejectedEvent(
                            invocationId = invocationId,
                            sessionId = sessionId,
                            toolName = toolCall.function.name,
                            tier = tier,
                            reason = rejectReason,
                        ))
                        val sourceId = toolCall.id ?: invocationId.value
                        return@flatMap listOf(
                            ContextEntry(
                                id = ContextEntryId(UUID.randomUUID().toString()),
                                layer = ContextLayer.L2,
                                sourceType = "assistantToolCall",
                                sourceId = sourceId,
                                content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                                tokenEstimate = estimateTokens(toolCall.function.arguments),
                                role = EntryRole.ASSISTANT,
                            ),
                            ContextEntry(
                                id = ContextEntryId(UUID.randomUUID().toString()),
                                layer = ContextLayer.L2,
                                sourceType = "toolResult",
                                sourceId = sourceId,
                                content = "ERROR: $rejectReason",
                                tokenEstimate = estimateTokens(rejectReason),
                                role = EntryRole.TOOL,
                            ),
                        )
                    }
                    // grant auto-approved — fall through to execute
                } else {
                    val deferred = CompletableDeferred<ApprovalDecision>()
                    pendingApprovals[requestId] = deferred
                    emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
                    emit(
                        sessionId,
                        ApprovalRequestedEvent(
                            requestId = requestId,
                            tier = tier,
                            validationReportId = domainRequest.validationReportId,
                            riskSummaryId = null,
                            riskSummary = plane2Risk,
                            sessionId = sessionId,
                            stageId = stageId,
                            projectId = null,
                            toolName = toolCall.function.name,
                            preview = toolPreview ?: toolCall.function.arguments.take(200),
                        ),
                    )
                    val userDecision = try {
                        deferred.await()
                    } finally {
                        pendingApprovals.remove(requestId)
                    }
                    emitDecisionResolved(sessionId, domainRequest, userDecision)
                    if (!userDecision.isApproved) {
                        val rejectReason = userDecision.reason ?: "approval denied"
                        blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
                        emit(sessionId, ToolExecutionRejectedEvent(
                            invocationId = invocationId,
                            sessionId = sessionId,
                            toolName = toolCall.function.name,
                            tier = tier,
                            reason = rejectReason,
                        ))
                        emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                        val sourceId = toolCall.id ?: invocationId.value
                        return@flatMap listOf(
                            ContextEntry(
                                id = ContextEntryId(UUID.randomUUID().toString()),
                                layer = ContextLayer.L2,
                                sourceType = "assistantToolCall",
                                sourceId = sourceId,
                                content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                                tokenEstimate = estimateTokens(toolCall.function.arguments),
                                role = EntryRole.ASSISTANT,
                            ),
                            ContextEntry(
                                id = ContextEntryId(UUID.randomUUID().toString()),
                                layer = ContextLayer.L2,
                                sourceType = "toolResult",
                                sourceId = sourceId,
                                content = "ERROR: $rejectReason",
                                tokenEstimate = estimateTokens(rejectReason),
                                role = EntryRole.TOOL,
                            ),
                        )
                    }
                    approvalNote = userDecision.userSteering?.text
                    emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                }
            }
            val result = executor.execute(request)

            // Store ProcessResult artifact for every shell execution outcome
            for (slot in processResultSlots) {
                val processResult = when (result) {
                    is ToolResult.Success -> ProcessResultArtifact(
                        status = "success",
                        command = toolCall.function.arguments,
                        exitCode = result.exitCode,
                        stdout = result.output,
                        stderr = result.metadata["stderr"] ?: "",
                        durationMs = 0,
                    )
                    is ToolResult.Failure -> ProcessResultArtifact(
                        status = "failed",
                        command = toolCall.function.arguments,
                        exitCode = -1,
                        stdout = "",
                        stderr = result.reason,
                        durationMs = 0,
                    )
                }
                val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult)
                val storedHash = artifactStore.put(jsonContent.toByteArray())
                artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent
                emit(
                    sessionId,
                    ArtifactContentStoredEvent(
                        artifactId = slot.name,
                        contentHash = storedHash,
                        sessionId = sessionId,
                        stageId = stageId,
                    ),
                )
            }

            // Record the tool execution outcome as events (invariant #5: every side effect is
            // captured; silent execution is not allowed). For file-mutating tools, materialise a
            // real file_written artifact from the actual on-disk result — so a stage that did NOT
            // write cannot have its file_written slot rubber-stamped as validated (F-015), and the
            // artifact carries the diff as evidence of the change.
            recordToolExecution(
                sessionId, stageId, toolCall, invocationId, tier, result,
                tool as? FileAffectingTool, request, fileWrittenSlots,
            )

            val sourceId = toolCall.id ?: invocationId.value
            val assistantEntry = ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L2,
                sourceType = "assistantToolCall",
                sourceId = sourceId,
                content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                tokenEstimate = estimateTokens(toolCall.function.arguments),
                role = EntryRole.ASSISTANT,
            )
            val resultContent = when (result) {
                is ToolResult.Success ->
                    tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode))
                        ?: result.output
                is ToolResult.Failure -> {
                    if (!result.recoverable) "FATAL: ${result.reason}"
                    else "ERROR: ${result.reason}${toolArgsHint(tool)}"
                }
            }
            val resultEntry = ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L2,
                sourceType = "toolResult",
                sourceId = sourceId,
                content = resultContent,
                tokenEstimate = estimateTokens(resultContent),
                role = EntryRole.TOOL,
            )
            val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
                ContextEntry(
                    id = ContextEntryId(UUID.randomUUID().toString()),
                    layer = ContextLayer.L2,
                    sourceType = "steeringNote",
                    sourceId = sourceId,
                    content = "User steering on the approved '${toolCall.function.name}' call: $it",
                    tokenEstimate = estimateTokens(it),
                    role = EntryRole.USER,
                )
            }
            listOfNotNull(assistantEntry, resultEntry, steeringEntry)
        }
    }

    /**
     * A rejected [SCOPE_PROPOSAL_TOOL] call means the operator denied widening the claimed task's
     * scope — block the task so the loop advances instead of the implementer retrying the same
     * out-of-scope write. Only fires for the scope-proposal tool; other denied tools are unaffected.
     */
    private suspend fun blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) {
        if (toolName == SCOPE_PROPOSAL_TOOL) {
            taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason")
        }
    }

    private suspend fun runPlane2Assessment(
        sessionId: SessionId,
        stageId: StageId,
        invocationId: ToolInvocationId,
        toolName: String,
        request: ToolRequest,
        tool: Tool?,
        effectives: RunEffectives,
        writeManifest: List<String>,
    ): RiskSummary? {
        val assessor = toolCallAssessor ?: return null
        val policy = effectives.policy ?: return null
        // Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents
        // through the projection. Unioned with the static allow-list in NetworkHostRule; empty when
        // nothing has been granted, which never narrows the static allow-list.
        val sessionEgressHosts = resolveSessionEgressHosts(sessionId)
        val assessment = assessor.assess(
            ToolCallAssessmentInput(
                request = request,
                capabilities = tool?.requiredCapabilities ?: emptySet(),
                workspace = policy,
                probe = worldProbe,
                paramRoles = tool?.paramRoles ?: emptyMap(),
                writeManifest = writeManifest,
                sessionEgressHosts = sessionEgressHosts,
                session = resolveSessionContext(sessionId),
            ),
        )
        emit(
            sessionId,
            ToolCallAssessedEvent(
                invocationId = invocationId,
                sessionId = sessionId,
                stageId = stageId,
                toolName = toolName,
                issues = assessment.toAssessedIssues(),
                observations = assessment.observations,
                disposition = assessment.disposition,
                timestampMs = Clock.System.now().toEpochMilliseconds(),
            ),
        )
        return assessment.toRiskSummary()
    }

    /**
     * Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through
     * [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads
     * the log, never live state); empty when no grants have been emitted.
     */
    internal fun resolveSessionEgressHosts(sessionId: SessionId): Set<String> {
        val projection = EgressAllowlistProjection(sessionId)
        return eventStore.read(sessionId)
            .fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
    }

    /**
     * Folds this session's tool events through [SessionContextProjection] into the read-model the
     * anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
     * never live state); empty before anything has happened.
     */
    internal fun resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
        val projection = SessionContextProjection(sessionId)
        return eventStore.read(sessionId)
            .fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
    }

    /**
     * Returns true when the agent must be offered only read-only tools on the next inference turn.
     * Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it.
     * Derived purely from the event log — no separate flag stored.
     */
    private fun isReadOnlyMode(sessionId: SessionId): Boolean {
        var blocked = false
        val pendingReadIds = mutableSetOf<String>()
        for (event in eventStore.read(sessionId)) {
            when (val p = event.payload) {
                is ToolInvocationRequestedEvent ->
                    if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
                        pendingReadIds += p.invocationId.value
                is ToolCallAssessedEvent ->
                    if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
                        when {
                            p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
                                blocked = true
                                pendingReadIds.clear()
                            }
                            // A read BLOCKED because its target doesn't exist (REFERENCE_EXISTS) can never
                            // produce a completion, so it can't be what lifts read-only mode. Treat the
                            // attempt as satisfying read-before-write: the file is absent, so there is
                            // nothing to read before creating it. Without this, a stage that writes a NEW
                            // file deadlocks — writes are filtered out and every read of the not-yet-created
                            // target is blocked, burning MAX_TOOL_ROUNDS with no artifact.
                            p.issues.any { it.code == REFERENCE_EXISTS_CODE } -> {
                                blocked = false
                                pendingReadIds.clear()
                            }
                        }
                    }
                is ToolExecutionCompletedEvent ->
                    if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
                        blocked = false
                        pendingReadIds.clear()
                    }
                else -> {}
            }
        }
        return blocked
    }

    private suspend fun buildSchemaEntries(
        responseFormat: ResponseFormat,
        stageId: StageId,
    ): List<ContextEntry> {
        if (responseFormat !is ResponseFormat.Json) return emptyList()
        val compactSchema = Json.encodeToString(JsonSchema.serializer(), responseFormat.schema)
        val instruction = "Respond with a single JSON object matching this schema. " +
                "Do not include markdown, code fences, or commentary outside the JSON. " +
                "Schema: $compactSchema"
        return listOf(
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L0,
                content = instruction,
                sourceType = "schemaInstruction",
                sourceId = stageId.value,
                tokenEstimate = estimateTokens(instruction),
                role = EntryRole.SYSTEM,
            ),
        )
    }

    private suspend fun buildSteeringNoteEntries(sessionId: SessionId): List<ContextEntry> {
        val events = eventStore.read(sessionId)
        var locked = false
        return events.mapNotNull { event ->
            when (val p = event.payload) {
                is ExecutionPlanLockedEvent -> { locked = true; null }
                is SteeringNoteAddedEvent -> ContextEntry(
                    id = ContextEntryId(UUID.randomUUID().toString()),
                    layer = if (locked) ContextLayer.L0 else ContextLayer.L2,
                    content = if (locked) "PRIORITY DIRECTIVE — overrides the locked plan: ${p.content}"
                              else p.content,
                    sourceType = "steeringNote",
                    sourceId = p.stageId?.value ?: sessionId.value,
                    tokenEstimate = estimateTokens(p.content),
                    role = EntryRole.SYSTEM,
                )
                is ApprovalDecisionResolvedEvent -> {
                    val steering = p.userSteering
                    when {
                        steering != null -> ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            content = steering.text,
                            sourceType = "steeringNote",
                            sourceId = steering.sessionId.value,
                            tokenEstimate = estimateTokens(steering.text),
                            role = EntryRole.USER,
                        )
                        // A bare rejection (no note) still feeds back so the model does not
                        // re-propose the identical call on the retryable re-run (anti-loop).
                        p.outcome == ApprovalOutcome.REJECTED -> {
                            val feedback = "A previous tool call was rejected by the user. " +
                                "Do not repeat the same call; choose a different approach."
                            ContextEntry(
                                id = ContextEntryId(UUID.randomUUID().toString()),
                                layer = ContextLayer.L2,
                                content = feedback,
                                sourceType = "rejectionFeedback",
                                sourceId = sessionId.value,
                                tokenEstimate = estimateTokens(feedback),
                                role = EntryRole.SYSTEM,
                            )
                        }
                        else -> null
                    }
                }
                else -> null
            }
        }
    }

    /**
     * Injects the operator's answers to a stage's open questions as an L2 USER entry, so the stage
     * sees its own questions resolved on the clarification re-run. Correlates each answer's
     * questionId back to the prompt recorded on the [ClarificationRequestedEvent].
     */
    private suspend fun buildClarificationAnswerEntries(sessionId: SessionId): List<ContextEntry> {
        val events = eventStore.read(sessionId)
        val answered = events.mapNotNull { it.payload as? ClarificationAnsweredEvent }
        if (answered.isEmpty()) return emptyList()
        val prompts = events
            .mapNotNull { it.payload as? ClarificationRequestedEvent }
            .flatMap { it.questions }
            .associate { it.id to it.prompt }
        val rendered = answered.flatMap { it.answers }.joinToString("\n") { a ->
            "Q: ${prompts[a.questionId] ?: a.questionId}\nA: ${a.value}"
        }
        val content = "The operator answered your open questions. Treat these answers as authoritative " +
            "and do not ask them again:\n$rendered"
        return listOf(
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L2,
                content = content,
                sourceType = "clarificationAnswer",
                sourceId = sessionId.value,
                tokenEstimate = estimateTokens(content),
                role = EntryRole.USER,
            ),
        )
    }

    /**
     * If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
     * run: record the questions (invariant #9 — observed at parse time), await the operator's
     * answers, record them, and return true so the caller re-runs the stage with the answers in
     * context. Bounded by [MAX_CLARIFICATION_ROUNDS] so a stage that keeps re-asking eventually
     * proceeds. Returns false when there are no questions or the round budget is spent.
     */
    internal suspend fun requestClarificationIfNeeded(
        sessionId: SessionId,
        stageId: StageId,
        graph: WorkflowGraph,
    ): Boolean {
        val slot = graph.stages[stageId]?.produces?.firstOrNull { it.kind.llmEmitted } ?: return false
        val content = artifactContentCache["${sessionId.value}:${slot.name.value}"] ?: return false
        val questions = ClarificationQuestions.parse(content)
        if (questions.isEmpty()) return false

        val priorRounds = eventStore.read(sessionId)
            .count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
        if (priorRounds >= MAX_CLARIFICATION_ROUNDS) return false

        val requestId = ClarificationRequestId(UUID.randomUUID().toString())
        val deferred = CompletableDeferred<List<ClarificationAnswer>>()
        pendingClarifications[requestId] = deferred
        emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "CLARIFICATION_PENDING"))
        emit(sessionId, ClarificationRequestedEvent(requestId, sessionId, stageId, questions))
        return try {
            val answers = deferred.await()
            emit(sessionId, ClarificationAnsweredEvent(requestId, sessionId, stageId, answers))
            emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
            true
        } finally {
            pendingClarifications.remove(requestId)
        }
    }

    /**
     * Renders the recorded repo map as a structural "what exists" listing — directories with
     * their files, alphabetical, no recency ranking and no symbol excerpts. Relevance is the
     * retrieval layer's job (buildRelevantFilesEntry); the map only tells the model what it can
     * `file_read`. Markdown/docs paths are gated: they only appear when the stage prompt
     * explicitly asks about documentation, so churny kernel docs can't bury real source
     * (2026-07-05 context-poisoning root cause). Reads the latest [RepoMapComputedEvent] from
     * the log (replay-safe — never re-scans the FS).
     */
    private suspend fun buildRepoMapEntries(sessionId: SessionId, stagePrompt: String = ""): List<ContextEntry> {
        val map = eventStore.read(sessionId)
            .mapNotNull { it.payload as? RepoMapComputedEvent }
            .lastOrNull() ?: return emptyList()
        val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
        val paths = map.entries.map { it.path }.distinct()
            .filter { docsWanted || !isDocPath(it) }
            .sorted()
        if (paths.isEmpty()) return emptyList()
        val byDir = paths.groupBy { it.substringBeforeLast('/', missingDelimiterValue = ".") }
        val content = buildString {
            appendLine("## Repo layout (what exists — use file_read for content)")
            byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
                val names = files.map { it.substringAfterLast('/') }
                val shown = names.take(REPO_MAP_FILES_PER_DIR)
                val more = names.size - shown.size
                val suffix = if (more > 0) ", …(+$more more)" else ""
                appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
            }
        }.trimEnd()
        return listOf(
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L3,
                content = content,
                sourceType = "repoMap",
                sourceId = "repo-map",
                tokenEstimate = estimateTokens(content),
                role = EntryRole.SYSTEM,
            ),
        )
    }

    /**
     * Best real path in the repo map that shares [guessed]'s filename, ranked by shared trailing
     * path segments — so a wrong package prefix (e.g. `com/correx/server/...` for the real
     * `com/correx/apps/server/...`) still resolves. Null when nothing shares the filename, so we
     * only ever suggest a concrete, existing file. Reads the recorded map (replay-safe).
     */
    private fun suggestClosestPath(sessionId: SessionId, guessed: String): String? {
        val map = eventStore.read(sessionId)
            .mapNotNull { it.payload as? RepoMapComputedEvent }
            .lastOrNull() ?: return null
        return closestPathByFilename(guessed, map.entries.map { it.path })
    }

    private suspend fun buildContextualRepoEntries(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ): List<ContextEntry> {
        val stagePrompt = (stageConfig.metadata["promptInline"] ?: stageConfig.metadata["prompt"] ?: stageId.value)
            .trim().ifBlank { stageId.value }
        val recorded = eventStore.read(sessionId)
            .mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
            .lastOrNull { it.stageId == stageId }
        if (recorded != null) return repoEntriesOrMapFloor(sessionId, recorded.hits, stagePrompt)

        val retriever = repoKnowledgeRetriever ?: return buildRepoMapEntries(sessionId, stagePrompt)
        val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, REPO_MAP_INJECT_TOP_K) }
            .getOrElse { e ->
                if (e is CancellationException) throw e
                log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
                emptyList()
            }
        // Record the raw retrieval for audit (a degenerate all-zero result is itself the signal that
        // the embedder is noop / the index is empty), but only useful hits ever reach the agent.
        if (hits.isNotEmpty()) emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, stagePrompt, hits))
        return repoEntriesOrMapFloor(sessionId, hits, stagePrompt)
    }

    /**
     * Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
     * them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
     * useful survives, fall back to the deterministic repo map so the stage still has real grounding.
     *
     * Markdown/docs hits are gated exactly like the repo-map floor (buildRepoMapEntries): with the
     * embedder live, semantic similarity happily ranks churny kernel docs (epics, ADRs, QA plans)
     * above real source for a code task, re-poisoning the context the floor's gate was added to fix
     * (2026-07-05 root cause). They only survive when the stage prompt explicitly asks about docs.
     */
    private suspend fun repoEntriesOrMapFloor(
        sessionId: SessionId,
        hits: List<RepoKnowledgeHit>,
        stagePrompt: String,
    ): List<ContextEntry> {
        val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
        val useful = hits.filter { it.score > 0f && (docsWanted || !isDocPath(it.path)) }
        return if (useful.isEmpty()) {
            buildRepoMapEntries(sessionId, stagePrompt)
        } else {
            listOf(buildRelevantFilesEntry(useful))
        }
    }

    /**
     * Stamps the required/optional budget bucket by sourceType. REQUIRED = the stage cannot run
     * correctly without it (task, constraints, loaded inputs, failure feedback) — never trimmed,
     * build fails fast if it alone exceeds the budget. Everything else (repo map, retrieval,
     * journal, profiles, vocabulary, transcript) is OPTIONAL and shares the remainder under
     * per-type ceilings.
     */
    private fun stampBuckets(entries: List<ContextEntry>): List<ContextEntry> = entries.map {
        if (it.sourceType in REQUIRED_SOURCE_TYPES) it.copy(bucket = ContextBucket.REQUIRED) else it
    }

    private suspend fun buildNeedsArtifactEntries(
        sessionId: SessionId,
        stageConfig: StageConfig,
        criticIds: Set<ArtifactId> = emptySet(),
        criticFromStage: String? = null,
    ): List<ContextEntry> {
        return stageConfig.needs.mapNotNull { needed ->
            val cached = artifactContentCache["${sessionId.value}:${needed.value}"]
                ?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
            val content = if (needed in criticIds) {
                "## Critic feedback from stage '${criticFromStage ?: "reviewer"}': ${needed.value}\n" +
                    "This is reviewer feedback on your previous attempt. " +
                    "Address every finding before completing the stage.\n$cached"
            } else {
                // file_written handoff: hand the next stage the whole producing stage's change set
                // as a manifest (known files), not one file's content. The cache value only holds
                // the LAST write of the stage, so rendering it inline both misleads (5 of 6 files
                // invisible) and wastes budget — content stays lazy behind file_read.
                val manifest = parseFileWrittenArtifact(cached)
                    ?.let { fileWrittenManifest(sessionId, needed) }
                "## Input artifact: ${needed.value}\n${manifest ?: cached}"
            }
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L1,
                content = content,
                sourceType = if (needed in criticIds) "criticFeedback" else "neededArtifact",
                sourceId = needed.value,
                tokenEstimate = estimateTokens(content),
                role = EntryRole.USER,
            )
        }
    }

    private fun parseFileWrittenArtifact(json: String): FileWrittenArtifact? =
        runCatching { Json.decodeFromString(FileWrittenArtifact.serializer(), json) }.getOrNull()

    /**
     * Projects the full change set of the stage(s) that produced a file_written artifact from
     * recorded events: ArtifactContentStoredEvent → producing stageIds, ToolInvocationRequested →
     * that stage's invocations, FileWrittenEvent → the paths actually written. Pure projection
     * over existing events — no new artifact kind, no producer change, replay-safe. Null when no
     * writes are on record (caller falls back to the cached single-file JSON).
     */
    private fun fileWrittenManifest(sessionId: SessionId, needed: ArtifactId): String? {
        val events = eventStore.read(sessionId)
        val stageIds = events.mapNotNull { it.payload as? ArtifactContentStoredEvent }
            .filter { it.artifactId == needed }
            .map { it.stageId }
            .toSet()
        if (stageIds.isEmpty()) return null
        val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
            .filter { it.stageId in stageIds }
            .map { it.invocationId }
            .toSet()
        val paths = events.mapNotNull { it.payload as? FileWrittenEvent }
            .filter { it.invocationId in invocationIds && it.postImageHash != null }
            .map { it.path }
            .distinct()
        if (paths.isEmpty()) return null
        return buildString {
            appendLine("Files written by the producing stage (use file_read to load any content you need):")
            paths.forEach { appendLine("- $it") }
        }.trimEnd()
    }

    private suspend fun emitToolArtifacts(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ) {
        stageConfig.produces
            .filter { !it.kind.llmEmitted }
            .forEach { slot ->
                // A file_written slot must be backed by real content materialised from an actual
                // successful write (see recordToolExecution). If none happened, do NOT fabricate a
                // validated artifact (F-015) — leave the slot unproduced so verifyProduces fails the
                // stage and it retries, rather than reporting a green run that changed nothing.
                if (slot.kind.id == "file_written" &&
                    artifactContentCache["${sessionId.value}:${slot.name.value}"] == null
                ) {
                    return@forEach
                }
                emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
                emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
                emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
            }
    }

    private suspend fun recordToolExecution(
        sessionId: SessionId,
        stageId: StageId,
        toolCall: ToolCallRequest,
        invocationId: ToolInvocationId,
        tier: Tier,
        result: ToolResult,
        fileTool: FileAffectingTool?,
        request: ToolRequest,
        fileWrittenSlots: List<TypedArtifactSlot>,
    ) {
        // Invariant #5: every tool side effect is captured. This is the single authoritative
        // ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
        // FileWrittenEvent (with pre/post-image hashes for reversibility) and ToolExecutionStarted are
        // emitted once, by SandboxedToolExecutor — NOT here — so completions aren't duplicated in the log.
        when (result) {
            is ToolResult.Failure -> emit(
                sessionId,
                ToolExecutionFailedEvent(invocationId, sessionId, toolCall.function.name, result.reason),
            )

            is ToolResult.Success -> {
                val diff = result.metadata["diff"]?.takeIf { it.isNotBlank() }
                val affected = fileTool?.affectedPaths(request).orEmpty()
                emit(
                    sessionId,
                    ToolExecutionCompletedEvent(
                        invocationId = invocationId,
                        sessionId = sessionId,
                        toolName = toolCall.function.name,
                        receipt = ToolReceipt(
                            invocationId = invocationId,
                            toolName = toolCall.function.name,
                            exitCode = result.exitCode,
                            outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
                            // Carry the tool's structured metadata (e.g. file_read's contentHash) so
                            // session projections can read it.
                            structuredOutput = result.metadata,
                            affectedEntities = affected.map { it.toString() },
                            durationMs = 0,
                            tier = tier,
                            timestamp = Clock.System.now(),
                            diff = diff,
                        ),
                    ),
                )
                if (affected.isNotEmpty()) {
                    materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
                }
            }
        }
    }

    /**
     * Materialise a [FileWrittenArtifact] from the actual on-disk file after a successful mutation,
     * store it as the stage's file_written slot content, and record [ArtifactContentStoredEvent].
     * This ties the file_written artifact to a real write so validation can no longer be faked.
     */
    private suspend fun materializeFileWritten(
        sessionId: SessionId,
        stageId: StageId,
        request: ToolRequest,
        affected: Set<java.nio.file.Path>,
        fileWrittenSlots: List<TypedArtifactSlot>,
        diff: String?,
    ) {
        if (fileWrittenSlots.isEmpty()) return
        val relPath = request.parameters["path"] as? String ?: return
        val onDisk = affected.firstOrNull() ?: Paths.get(relPath)
        val bytes = runCatching { Files.readAllBytes(onDisk) }.getOrNull() ?: return
        val contentHash = artifactStore.put(bytes)
        val artifact = FileWrittenArtifact(
            path = relPath,
            contentHash = contentHash.value,
            size = bytes.size.toLong(),
            mode = "0644",
            diff = diff,
        )
        val json = Json.encodeToString(FileWrittenArtifact.serializer(), artifact)
        val storedHash = artifactStore.put(json.toByteArray())
        fileWrittenSlots.forEach { slot ->
            artifactContentCache["${sessionId.value}:${slot.name.value}"] = json
            emit(
                sessionId,
                ArtifactContentStoredEvent(
                    artifactId = slot.name,
                    contentHash = storedHash,
                    sessionId = sessionId,
                    stageId = stageId,
                ),
            )
        }
    }

    private suspend fun emitLlmArtifacts(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ) {
        stageConfig.produces
            .filter { it.kind.llmEmitted }
            .forEach { slot ->
                // Same rule as the file_written gate (F-015): no stored content means the
                // model never produced this artifact — do not fabricate a validated one.
                val cached = artifactContentCache["${sessionId.value}:${slot.name.value}"]
                if (cached.isNullOrBlank()) {
                    log.warn(
                        "[Orchestrator] stage={} slot={}: no content stored — skipping artifact emission",
                        stageId.value,
                        slot.name.value,
                    )
                    return@forEach
                }
                emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
                emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
                emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
            }
    }

    private fun verifyProduces(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ): StageExecutionResult {
        if (stageConfig.produces.isEmpty()) {
            if (stageConfig.allowedTools.isEmpty()) return StageExecutionResult.Success(emptyList())
            val ranTool = eventStore.read(sessionId).any {
                (it.payload as? ToolInvocationRequestedEvent)?.stageId == stageId
            }
            if (ranTool) return StageExecutionResult.Success(emptyList())
            log.error(
                "[Orchestrator] stage declared no artifacts and ran no tools " +
                        "session=${sessionId.value} stage=${stageId.value}",
            )
            return StageExecutionResult.Failure(
                "stage ${stageId.value} declared no artifacts and ran no tools",
                retryable = true,
            )
        }
        val producedIds = stageConfig.produces.map { it.name }.toSet()
        val present = eventStore.read(sessionId)
            .mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId }
            .toSet()
        val missing = producedIds - present
        if (missing.isEmpty()) return StageExecutionResult.Success(emptyList())
        val missingIds = missing.joinToString(", ") { it.value }
        log.error(
            "[Orchestrator] stage missing produced artifacts " +
                    "session=${sessionId.value} stage=${stageId.value} missing=$missingIds",
        )
        return StageExecutionResult.Failure(
            "stage ${stageId.value} did not produce declared artifacts: $missingIds",
            retryable = true,
        )
    }

    /**
     * Stage-level plan checkpointing (BACKLOG §C-A2): make plan adherence observable and replayable
     * by emitting a first-class checkpoint event per stage, reconciling produced-vs-expected against
     * the confirmed (locked) plan. Only runs on plan-driven (phase-2) sessions — i.e. a log that
     * holds an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. The halt itself is owned
     * by [verifyProduces]; this method only EMITS — a [StageCheckpointFailedEvent] accompanies that
     * halt and surfaces *why* the plan diverged.
     */
    private suspend fun emitStageCheckpoint(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ) {
        if (eventStore.read(sessionId).none { it.payload is ExecutionPlanLockedEvent }) return
        val expected = stageConfig.produces.map { it.name.value }.toSet()
        if (expected.isEmpty()) return
        val produced = eventStore.read(sessionId)
            .mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId?.value }
            .toSet()
        val cp = StageCheckpointReconciler.reconcile(expected, produced)
        if (cp.passed) {
            emit(sessionId, StageCheckpointPassedEvent(sessionId, stageId, expected, produced))
        } else {
            emit(sessionId, StageCheckpointFailedEvent(sessionId, stageId, expected, produced, cp.missing))
        }
    }

    /**
     * Entity grounding (role-reliability §3): for a stage that opted in (`ground_references`),
     * check every workspace-relative file path its brief named against the filesystem. Existence
     * is a nondeterministic observation, so the result is recorded as a [BriefGroundingCheckedEvent]
     * (invariant #9) — replay reads it back, never re-probes. A hallucinated path fails the stage
     * (retryable), feeding the missing paths back so the model re-grounds on the next attempt.
     */
    private suspend fun groundBriefReferences(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
        effectives: RunEffectives,
    ): StageExecutionResult {
        if (stageConfig.metadata["groundReferences"] != "true") return StageExecutionResult.Success(emptyList())
        val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
        val artifact = stageConfig.produces
            .filter { it.kind.llmEmitted }
            .firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] }
            ?: return StageExecutionResult.Success(emptyList())

        val references = BriefReferenceExtractor.fileReferences(artifact)
        if (references.isEmpty()) return StageExecutionResult.Success(emptyList())

        val results = references.map { ref ->
            GroundingReference(ref, worldProbe.exists(workspaceRoot.resolve(ref)))
        }
        emit(sessionId, BriefGroundingCheckedEvent(sessionId, stageId, stageId.value, results))

        val missing = results.filterNot { it.grounded }.map { it.reference }
        return if (missing.isEmpty()) {
            StageExecutionResult.Success(emptyList())
        } else {
            log.warn(
                "[Orchestrator] brief references nonexistent paths session={} stage={} missing={}",
                sessionId.value, stageId.value, missing.joinToString(", "),
            )
            StageExecutionResult.Failure(
                "stage ${stageId.value} references paths that do not exist in the workspace: " +
                    "${missing.joinToString(", ")}. Reference only files you have read.",
                retryable = true,
            )
        }
    }

    /**
     * Brief echo gate (plan-pipeline-addenda §A1): for a stage that opted in (`brief_echo`),
     * diff the model's restatement artifact against the original brief. Both are already in the
     * artifact content cache (recorded via ArtifactCreatedEvent), so the diff is a pure function
     * of recorded data — deterministically recomputable on replay without re-probing any
     * environment (invariants #8, #9). Emits [BriefEchoMismatchEvent] on mismatch as a domain
     * signal, then fails the stage (retryable) so the pipeline cannot reach plan generation on a
     * misread brief.
     *
     * Brief-source selection among `needs`: prefers the artifact named "analysis"; if absent,
     * falls back to the single needs artifact present. When there are multiple needs and none is
     * named "analysis", the gate skips (ambiguous brief) and returns Success.
     */
    private suspend fun checkBriefEcho(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ): StageExecutionResult {
        if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList())

        val echoSlot = stageConfig.produces.firstOrNull { it.kind.llmEmitted }
            ?: return StageExecutionResult.Success(emptyList())
        val echo = artifactContentCache["${sessionId.value}:${echoSlot.name.value}"]
            ?: return StageExecutionResult.Success(emptyList())

        val brief = when {
            stageConfig.needs.any { it.value == "analysis" } ->
                artifactContentCache["${sessionId.value}:analysis"]
            stageConfig.needs.size == 1 ->
                artifactContentCache["${sessionId.value}:${stageConfig.needs.single().value}"]
            else -> null // ambiguous — skip
        } ?: return StageExecutionResult.Success(emptyList())

        val div = BriefEchoDiff.compare(brief, echo)

        return if (div.isMismatch) {
            emit(
                sessionId,
                BriefEchoMismatchEvent(
                    sessionId = sessionId,
                    stageId = stageId,
                    uncoveredRequirements = div.uncoveredRequirements,
                    hallucinatedFiles = div.hallucinatedFiles,
                    hallucinatedSymbols = div.hallucinatedSymbols,
                ),
            )
            log.warn(
                "[Orchestrator] brief echo mismatch session={} stage={} uncovered={} hallucinatedFiles={}",
                sessionId.value, stageId.value,
                div.uncoveredRequirements.joinToString(", "),
                div.hallucinatedFiles.joinToString(", "),
            )
            StageExecutionResult.Failure(
                "stage ${stageId.value} restated the brief unfaithfully — " +
                    "dropped requirements: [${div.uncoveredRequirements.joinToString("; ")}]; " +
                    "files not in the brief: [${div.hallucinatedFiles.joinToString(", ")}]. " +
                    "Restate the brief exactly; do not drop requirements or invent files.",
                retryable = true,
            )
        } else {
            StageExecutionResult.Success(emptyList())
        }
    }

    /**
     * Post-validation stage gates, run in order; the first failure short-circuits and is returned.
     * Cheap deterministic checks run first (produces presence, brief grounding, brief echo), then
     * the heavier process-spawning static-analysis step last, so it only runs once a stage is
     * otherwise sound.
     */
    private suspend fun runPostStageGates(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
        effectives: RunEffectives,
        profileCommands: Map<String, String>,
    ): StageExecutionResult {
        val produced = verifyProduces(sessionId, stageId, stageConfig)
        if (produced is StageExecutionResult.Failure) return produced
        // Stage-level plan checkpoint (C-A2): once a stage has produced its declared artifacts,
        // record the checkpoint before the heavier brief/static gates run.
        emitStageCheckpoint(sessionId, stageId, stageConfig)
        val gates: List<suspend () -> StageExecutionResult> = listOf(
            { groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
            { checkBriefEcho(sessionId, stageId, stageConfig) },
            { runContractGate(sessionId, stageId, stageConfig, effectives) },
            { runPlanCompileGate(sessionId, stageId, stageConfig) },
            { runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
            { runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
            { runReviewGate(sessionId, stageId, stageConfig, effectives) },
        )
        for (gate in gates) {
            val result = gate()
            if (result is StageExecutionResult.Failure) return result
        }
        return StageExecutionResult.Success(emptyList())
    }

    /**
     * Gate 2 — contract (design 2026-07-06-staged-verification-and-review.md §1). For a stage that
     * produces file_written artifacts, project the files it actually wrote (Option B: runtime
     * manifest), infer each file's kind from its path, derive the [KindContractTable] assertions
     * (universal floor for unknown kinds), and evaluate the FS/TEXT ones deterministically. Every
     * verdict is recorded in a [ContractGateEvaluatedEvent] (invariant #9), so replay reads it back
     * and never re-inspects the filesystem.
     *
     * A failed assertion fails the stage retryably with the failing assertion ids + evidence fed
     * back verbatim — failing-test-name granular hand-back, so the implementer fixes exactly the
     * empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
     * skipped here; the static-analysis command gate is their authoritative check.
     */
    private suspend fun runContractGate(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
        effectives: RunEffectives,
    ): StageExecutionResult {
        val evaluator = contractAssertionEvaluator ?: return StageExecutionResult.Success(emptyList())
        val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
        if (stageConfig.produces.none { it.kind.id == "file_written" }) return StageExecutionResult.Success(emptyList())

        // Option A ∪ B: the files the stage actually wrote (runtime manifest) unioned with the
        // concrete files the plan declared it would produce (expectedFiles). A declared file that
        // was never written survives here and fails file_exists — that is the missing-file catch.
        val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
        if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())

        val assertions = paths.flatMap { path ->
            KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
        }
        val results = assertions.map { a ->
            val v = contractAssertionEvaluator.evaluate(workspaceRoot, a)
            ContractAssertionResult(
                assertionId = a.id,
                target = a.target,
                layer = a.layer.name,
                evaluator = a.evaluator.name,
                passed = v.passed,
                evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
            )
        }
        emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))

        val failures = results.filterNot { it.passed }
        if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())

        log.warn(
            "[Orchestrator] contract gate failed session={} stage={} assertions={}",
            sessionId.value, stageId.value,
            failures.joinToString(", ") { "${it.assertionId}(${it.target})" },
        )
        val detail = failures.joinToString("\n") { "- ${it.assertionId} on ${it.target}: ${it.evidence}" }
        return StageExecutionResult.Failure(
            "stage ${stageId.value} did not satisfy its file contract. Fix these before review:\n$detail",
            retryable = true,
        )
    }

    /**
     * Plan-compile gate: for a stage that produces an `execution_plan` artifact (the freestyle
     * architect), compile its produced plan JSON as a post-stage gate. A compile failure fails the
     * stage retryably with the compiler error fed back, so the existing retry-feedback loop hands it
     * to the architect and the model self-corrects — rather than the post-planning compiler rejecting
     * a broken plan with no re-plan path (which parked the session in ACTIVE forever). The verdict is
     * recorded in a [PlanCompileCheckedEvent] (invariant #9) so replay reads it back and never
     * re-compiles.
     */
    private suspend fun runPlanCompileGate(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ): StageExecutionResult {
        val check = planCompilationCheck ?: return StageExecutionResult.Success(emptyList())
        val planSlot = stageConfig.produces.firstOrNull { it.kind.id == "execution_plan" }
            ?: return StageExecutionResult.Success(emptyList())
        val json = artifactContentCache["${sessionId.value}:${planSlot.name.value}"]
            ?: return StageExecutionResult.Success(emptyList())

        val error = check.check(json, "freestyle-${sessionId.value}")
        emit(sessionId, PlanCompileCheckedEvent(sessionId, stageId, passed = error == null, error = error))
        if (error == null) return StageExecutionResult.Success(emptyList())

        log.warn(
            "[Orchestrator] plan-compile gate failed session={} stage={}: {}",
            sessionId.value, stageId.value, error,
        )
        return StageExecutionResult.Failure(
            "stage ${stageId.value} produced an execution_plan that does not compile. " +
                "Fix the plan before it can be locked:\n$error",
            retryable = true,
        )
    }

    /**
     * The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
     * ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
     * under them. Pure projection, replay-safe.
     */
    private fun stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
        val events = eventStore.read(sessionId)
        val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
            .filter { it.stageId == stageId }
            .map { it.invocationId }
            .toSet()
        return events.mapNotNull { it.payload as? FileWrittenEvent }
            .filter { it.invocationId in invocationIds && it.postImageHash != null }
            .map { it.path }
            .distinct()
    }

    /**
     * Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
     * commands, run them (compiler / detekt / formatters) against its just-produced output in the
     * workspace root. Every result is recorded in a [StaticAnalysisCompletedEvent] — an environment
     * observation captured at run time (invariant #9), so replay reads it back and never re-runs.
     *
     * A non-clean command fails the stage retryably with its output fed back verbatim (§2: compiler/
     * test output is ground truth, not LLM noise), so the producing stage fixes the issue and only
     * static-clean output ever reaches the downstream LLM reviewer — its context thereby excludes
     * anything static tools already catch, with no parsing or context plumbing.
     */
    private suspend fun runStaticAnalysis(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
        effectives: RunEffectives,
    ): StageExecutionResult {
        if (stageConfig.staticAnalysis.isEmpty()) return StageExecutionResult.Success(emptyList())
        val runner = staticAnalysisRunner
        val workspaceRoot = effectives.policy?.workspaceRoot
        if (runner == null || workspaceRoot == null) {
            log.warn(
                "[Orchestrator] stage declares static_analysis but no {} is wired — skipping " +
                    "session={} stage={}",
                if (runner == null) "runner" else "workspace root",
                sessionId.value, stageId.value,
            )
            return StageExecutionResult.Success(emptyList())
        }

        val results = stageConfig.staticAnalysis.map { command ->
            val run = runner.run(workspaceRoot, command)
            StaticAnalysisFinding(
                command = command,
                exitCode = run.exitCode,
                summary = run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP),
            ) to run.output
        }
        emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, results.map { it.first }))

        val failures = results.filterNot { it.first.clean }
        if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())

        log.warn(
            "[Orchestrator] static analysis failed session={} stage={} commands={}",
            sessionId.value, stageId.value,
            failures.joinToString(", ") { it.first.command },
        )
        val detail = failures.joinToString("\n\n") { (finding, output) ->
            "$ ${finding.command}  (exit ${finding.exitCode})\n${output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP)}"
        }
        return StageExecutionResult.Failure(
            "stage ${stageId.value} did not pass static analysis. Fix these before review:\n\n$detail",
            retryable = true,
        )
    }

    /**
     * Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
     * (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
     * command and runs it as a deterministic build gate. NONE runs nothing (early stages where the
     * project isn't yet runnable). A profile missing the alias skips with a warning — the vocabulary
     * degrades safely rather than failing when the operator hasn't configured commands. A non-clean
     * run fails the stage retryably with the build output fed back (recorded as a
     * [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
     */
    private suspend fun runExecutionGate(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
        effectives: RunEffectives,
        profileCommands: Map<String, String>,
    ): StageExecutionResult {
        val alias = stageConfig.buildExpectation.commandAlias ?: return StageExecutionResult.Success(emptyList())
        val runner = staticAnalysisRunner
        val workspaceRoot = effectives.policy?.workspaceRoot
        if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
        val command = profileCommands[alias]
        if (command.isNullOrBlank()) {
            log.warn(
                "[Orchestrator] stage {} declares build_expectation={} but project profile has no '{}' " +
                    "command — skipping execution gate",
                stageId.value, stageConfig.buildExpectation, alias,
            )
            return StageExecutionResult.Success(emptyList())
        }

        val run = runner.run(workspaceRoot, command)
        val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
        emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
        if (finding.clean) return StageExecutionResult.Success(emptyList())

        log.warn(
            "[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
            sessionId.value, stageId.value, stageConfig.buildExpectation, command,
        )
        return StageExecutionResult.Failure(
            "stage ${stageId.value} did not pass its ${stageConfig.buildExpectation} build gate. " +
                "Fix these before proceeding:\n\n$ $command  (exit ${run.exitCode})\n" +
                run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
            retryable = true,
        )
    }

    /**
     * Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
     * (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
     * (the FileWritten manifest — never blind context) and reviews them against the stage objective,
     * returning PR-comment-style findings. Runs last, so its input is static-clean (the deterministic
     * funnel already passed) and its findings exclude anything the cheap gates catch.
     *
     * The reviewer is advisory: every finding is recorded in a [ReviewFindingsRaisedEvent] and
     * surfaces to the operator regardless of verdict. A [ReviewVerdict.FAIL] blocks the stage
     * retryably ONLY when it carries a high-confidence *correctness* finding and the review-block
     * budget ([REVIEW_BLOCK_RETRY_CAP]) is not yet spent — so a stuck reviewer can never trap the
     * stage; after the budget, findings surface without blocking. Suboptimal/opinion findings never
     * block. Recorded as an environment observation (invariant #9); replay never re-runs the reviewer.
     */
    private suspend fun runReviewGate(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
        effectives: RunEffectives,
    ): StageExecutionResult {
        if (!stageConfig.semanticReview) return StageExecutionResult.Success(emptyList())
        val reviewer = semanticReviewer ?: return StageExecutionResult.Success(emptyList())
        val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
        val files = stageWrittenPaths(sessionId, stageId)
        if (files.isEmpty()) return StageExecutionResult.Success(emptyList())

        val objective = stageConfig.needs
            .mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
            .joinToString("\n\n")
            .ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
            .take(REVIEW_OBJECTIVE_CAP)

        val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)

        // A FAIL blocks only on a high-confidence correctness finding, and only while the review-block
        // budget remains — count prior review blocks for this stage from the log (replay-safe).
        val priorBlocks = eventStore.read(sessionId)
            .mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
            .count { it.stageId == stageId && it.blocked }
        val blockingFinding = outcome.findings.firstOrNull {
            it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE
        }
        val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
            blockingFinding != null &&
            priorBlocks < REVIEW_BLOCK_RETRY_CAP

        emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))

        if (!shouldBlock) {
            if (outcome.findings.isNotEmpty()) {
                log.info(
                    "[Orchestrator] semantic review advisory session={} stage={} verdict={} findings={}",
                    sessionId.value, stageId.value, outcome.verdict, outcome.findings.size,
                )
            }
            return StageExecutionResult.Success(emptyList())
        }

        log.warn(
            "[Orchestrator] semantic review blocked session={} stage={} finding={}",
            sessionId.value, stageId.value, blockingFinding?.target,
        )
        val detail = outcome.findings
            .filter { it.correctness }
            .joinToString("\n") { "- [${it.severity}] ${it.target}: ${it.message}" +
                (it.suggestedFix?.let { fix -> "\n  fix: $fix" } ?: "") }
        return StageExecutionResult.Failure(
            "stage ${stageId.value} failed semantic review — fix these correctness issues:\n$detail",
            retryable = true,
        )
    }

    private suspend fun emitProcessResultEvents(
        sessionId: SessionId,
        stageId: StageId,
        stageConfig: StageConfig,
    ) {
        // Emit artifact lifecycle events for process_result slots on failure branches.
        // Content was already stored in CAS + cache by dispatchToolCalls for every outcome.
        stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot ->
            emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
            emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
            emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
        }
    }

    internal open suspend fun mapValidationOutcome(
        sessionId: SessionId,
        stageId: StageId,
        context: ValidationContext,
    ): StageExecutionResult = when (val outcome = validationPipeline.validate(context)) {
        is ValidationOutcome.Passed -> StageExecutionResult.Success(emptyList())
        is ValidationOutcome.Rejected ->
            StageExecutionResult.Failure(summarizeRejection(outcome.report), retryable = outcome.retryable)

        is ValidationOutcome.NeedsApproval -> handleApproval(sessionId, stageId, outcome)
    }

    // emit_artifact gives the model a structured channel to produce its required artifact: it calls
    // the tool with the fields filled in (parameter schema = the artifact's own schema) instead of
    // emitting free-form JSON as a final message. Tool-calling models handle this far more reliably,
    // and it sidesteps llama.cpp's grammar+tools incompatibility (the artifact can't be grammar-
    // constrained while tools are present). Empty when the stage has no LLM-emitted slot.
    private fun emitArtifactTool(stageConfig: StageConfig): List<ToolDefinition> {
        val slot = stageConfig.produces.firstOrNull { it.kind.llmEmitted } ?: return emptyList()
        val schema = Json.encodeToJsonElement(JsonSchema.serializer(), slot.kind.deriveJsonSchema()).jsonObject
        return listOf(
            ToolDefinition(
                function = ToolFunction(
                    name = EMIT_ARTIFACT_TOOL,
                    description = "Emit the required '${slot.name.value}' artifact by filling in the fields " +
                        "below. Prefer this over writing the JSON as a plain message.",
                    parameters = schema,
                ),
            ),
        )
    }

    // Collapse a validation report into a concise, actionable reason. This string becomes the
    // RetryAttemptedEvent.failureReason that the retry-feedback entry feeds back to the model, so a
    // weak model is told *what* was wrong (e.g. "required property 'summary' missing") instead of a
    // bare "validation failed" it can't act on. Capped to keep the feedback prompt small.
    private fun summarizeRejection(report: ValidationReport): String {
        val errors = report.sections.flatMap { section ->
            section.issues
                .filter { it.severity == ValidationSeverity.ERROR }
                .map { it.message }
        }.distinct()
        return if (errors.isEmpty()) {
            "validation failed"
        } else {
            "validation failed: " + errors.take(MAX_FEEDBACK_ISSUES).joinToString("; ")
        }
    }

    @Suppress("LongParameterList")
    internal open suspend fun runInference(
        sessionId: SessionId,
        stageId: StageId,
        contextPack: ContextPack,
        stageConfig: StageConfig,
        timeoutMs: Long,
        responseFormat: ResponseFormat = ResponseFormat.Text,
        effectives: RunEffectives = RunEffectives(toolRegistry, toolExecutor, workspacePolicy),
        // When false, send NO tools. Some local chat templates (e.g. llama.cpp's Gemma) switch to a
        // tool-calling/reasoning-channel format when tools are present and leak `<|channel>` markers
        // into the artifact text; a tools-less call yields clean JSON deterministically (and lets the
        // JSON grammar constraint re-apply, since grammar+tools is rejected by llama.cpp).
        withTools: Boolean = true,
        // When true, offer ONLY write tools (+ stage_complete / emit_artifact) — read tools are
        // stripped. Used to break a read-loop: a model that owes a file_written artifact but keeps
        // calling read tools ignores a textual nudge (its output all goes to reasoning_content), so
        // we structurally remove the option to read and leave writing as the only move.
        forceWriteOnly: Boolean = false,
    ): InferenceResult {
        // A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
        // declared capabilities — the capability-aware strategy then ranks eligible providers by their
        // ToolCalling score and routes the stage to the best tool-caller.
        val requiredCapabilities = stageConfig.requiredCapabilities +
            if (withTools && stageConfig.allowedTools.isNotEmpty()) setOf(ModelCapability.ToolCalling) else emptySet()
        val provider = inferenceRouter.route(stageId, requiredCapabilities, stageConfig.modelId)
        log.debug(
            "[Orchestrator] inference session={} stage={} provider={} timeoutMs={}",
            sessionId.value, stageId.value, provider.id.value, timeoutMs,
        )
        val requestId = InferenceRequestId(UUID.randomUUID().toString())
        val request = InferenceRequest(
            requestId = requestId,
            sessionId = sessionId,
            stageId = stageId,
            contextPack = contextPack,
            generationConfig = stageConfig.generationConfig,
            responseFormat = responseFormat,
            tools = if (!withTools) {
                emptyList()
            } else {
                stageConfig.allowedTools
                    .mapNotNull { effectives.registry?.resolve(it) }
                    .filter { tool ->
                        // ponytail: filter write tools while read-before-write block is active; restored once a read completes
                        !isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
                    }
                    .filter { tool ->
                        // Read-loop break: keep only write tools (drop file_read/list_dir/shell) so
                        // the model cannot keep reading and must write or complete.
                        !forceWriteOnly || ToolCapability.FILE_WRITE in tool.requiredCapabilities
                    }
                    .map { tool ->
                        ToolDefinition(
                            function = ToolFunction(
                                name = tool.name,
                                description = tool.description,
                                parameters = tool.parametersSchema,
                            ),
                        )
                    } + ToolDefinition(
                        function = ToolFunction(
                            name = STAGE_COMPLETE_TOOL,
                            description = "Call this tool when the stage's goal is fully met and no further tool calls are needed. " +
                                    "The orchestrator will proceed to the next stage.",
                            parameters = JsonObject(emptyMap()),
                        ),
                    ) + emitArtifactTool(stageConfig)
            },
        )

        val renderedMessages = PromptRenderer.render(contextPack)
        val promptArtifactId = artifactStore.put(Json.encodeToString(renderedMessages).toByteArray())
        emit(sessionId, InferenceStartedEvent(requestId, sessionId, stageId, provider.id, promptArtifactId))

        if (isCancelled(sessionId)) return InferenceResult.Cancelled

        return try {
            val rawResponse = withTimeout(timeoutMs) { provider.infer(request) }
            // Models frequently wrap a JSON artifact in a ```json … ``` markdown fence despite
            // being told not to. The fence makes the artifact unparseable → validation fails.
            // Strip an outer fence wrapper so the stored artifact, its CAS hash, the content
            // cache, and validation all see the same clean payload.
            val response = rawResponse.copy(text = stripCodeFence(rawResponse.text))
            log.debug(
                "[Orchestrator] inference done session={} stage={} tokens={} latencyMs={}",
                sessionId.value, stageId.value, response.tokensUsed, response.latencyMs,
            )

            if (isCancelled(sessionId)) return InferenceResult.Cancelled

            val responseArtifactId = artifactStore.put(response.text.toByteArray())
            emit(
                sessionId,
                InferenceCompletedEvent(
                    requestId = requestId,
                    sessionId = sessionId,
                    stageId = stageId,
                    providerId = provider.id,
                    tokensUsed = response.tokensUsed,
                    latencyMs = response.latencyMs,
                    responseArtifactId = responseArtifactId,
                ),
            )

            InferenceResult.Success(response, responseArtifactId)
        } catch (e: TimeoutCancellationException) {
            emit(
                sessionId,
                InferenceTimeoutEvent(
                    requestId = requestId,
                    sessionId = sessionId,
                    stageId = stageId,
                    providerId = provider.id,
                    timeoutMs = timeoutMs,
                ),
            )
            InferenceResult.Failed("TIMEOUT: ${e.message}")
        } catch (e: CancellationException) {
            throw e // never swallow
        } catch (e: Exception) {
            emit(
                sessionId,
                InferenceFailedEvent(
                    requestId = requestId,
                    sessionId = sessionId,
                    stageId = stageId,
                    providerId = provider.id,
                    reason = e.message ?: "unknown",
                ),
            )
            InferenceResult.Failed(e.message ?: "inference failed")
        }
    }

    // A provider 4xx client error (e.g. llama.cpp 400 "invalid_request") is a deterministic
    // request-construction failure — retrying re-sends the identical bad request and exhausts the
    // budget for nothing (F-002). Treat 4xx as terminal, except 408 (timeout) and 429 (rate-limit)
    // which are transient. Everything else (network/5xx/unknown) stays retryable.
    private fun isRetryableFailure(reason: String): Boolean {
        val status = Regex("""returned\s+(\d{3})""").find(reason)?.groupValues?.get(1)?.toIntOrNull()
            ?: return true
        return status !in 400..499 || status == HTTP_TIMEOUT || status == HTTP_TOO_MANY_REQUESTS
    }

    // --- transition helpers ---

    internal fun resolveTransition(
        graph: WorkflowGraph,
        sessionId: SessionId,
        currentStageId: StageId,
        artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
        artifactContent: Map<ArtifactId, String> = emptyMap(),
    ): TransitionDecision {
        val ctx = EvaluationContext(
            sessionId = sessionId,
            currentStage = currentStageId,
            artifacts = artifacts,
            artifactContent = artifactContent,
            readyTaskCount = readyTaskCounter?.count(sessionId) ?: 0,
        )
        return transitionResolver.resolve(graph, ctx)
    }

    internal fun isTerminal(graph: WorkflowGraph, stageId: StageId): Boolean =
        graph.transitions.none { it.from == stageId }

    internal suspend fun advanceStage(
        sessionId: SessionId,
        fromStageId: StageId,
        decision: TransitionDecision.Move,
    ): StageId {
        emit(
            sessionId,
            TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId, decision.redirectId),
        )
        return decision.to
    }

    // --- terminal state helpers ---

    internal suspend fun emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) {
        emit(
            sessionId,
            WorkflowStartedEvent(
                sessionId = sessionId,
                workflowId = graph.id,
                startStageId = graph.start,
                retryPolicy = config.retryPolicy,
            ),
        )
    }

    internal suspend fun completeWorkflow(
        sessionId: SessionId,
        terminalStageId: StageId,
        stageCount: Int,
        workflowId: String,
    ): WorkflowResult.Completed {
        log.debug(
            "[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
            sessionId.value, terminalStageId.value, stageCount,
        )
        correlateCritiqueOutcomes(sessionId)
        emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
        cancellations.remove(sessionId)
        return WorkflowResult.Completed(sessionId, terminalStageId)
    }

    internal suspend fun failWorkflow(
        sessionId: SessionId,
        stageId: StageId,
        reason: String,
        retryExhausted: Boolean,
    ): WorkflowResult.Failed {
        log.warn(
            "[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
            sessionId.value, stageId.value, reason, retryExhausted,
        )
        correlateCritiqueOutcomes(sessionId)
        emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
        cancellations.remove(sessionId)
        return WorkflowResult.Failed(sessionId, reason, retryExhausted)
    }

    /**
     * At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that
     * feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were
     * recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent
     * — it skips when outcomes already exist — so it is safe on every terminal path.
     */
    private suspend fun correlateCritiqueOutcomes(sessionId: SessionId) {
        val events = eventStore.read(sessionId)
        if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return
        val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent }
        if (reviews.isEmpty()) return
        CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) }
    }

    internal suspend fun handleCancellation(
        sessionId: SessionId,
        stageId: StageId,
    ): WorkflowResult.Cancelled {
        log.warn("[Orchestrator] CANCELLED session={} stage={}", sessionId.value, stageId.value)
        emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
        cancellations.remove(sessionId)
        return WorkflowResult.Cancelled(sessionId)
    }

    // --- cancellation ---

    internal fun isCancelled(sessionId: SessionId): Boolean =
        cancellations[sessionId]?.get() == true

    // --- event emission ---

    // Surface context-budget overflow as an event on the workflow path. The builder already
    // enforces the budget (drops oldest/compressible entries, pinning steering + event history);
    // this records that it happened so replay and operators can see it, mirroring the router path.
    private suspend fun emitContextTruncationIfNeeded(
        sessionId: SessionId,
        stageId: StageId,
        contextPack: ContextPack,
    ) {
        val meta = contextPack.compressionMetadata
        if (meta.entriesDropped <= 0) return
        emit(
            sessionId,
            ContextTruncatedEvent(
                sessionId = sessionId,
                turnId = stageId.value,
                entriesDropped = meta.entriesDropped,
                truncatedLayers = meta.truncatedLayers.map { it.name },
                timestampMs = Clock.System.now().toEpochMilliseconds(),
            ),
        )
    }

    internal suspend fun emit(sessionId: SessionId, payload: EventPayload) {
        log.debug("[session {}] emitting event, payload: {}", sessionId.value.take(7), payload)
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = payload,
            ),
        )
    }

    // --- token estimation ---

    protected open suspend fun estimateTokens(content: String): Int {
        val t = tokenizer
        if (t != null) {
            // A zero count for non-blank content means the tokenizer endpoint is lying
            // (e.g. a malformed request silently answered with an empty token list) —
            // trust the heuristic instead, or every budget check goes blind.
            return runCatching { t.countTokens(content) }
                .getOrElse { fallbackTokenEstimate(content) }
                .takeIf { it > 0 || content.isBlank() }
                ?: fallbackTokenEstimate(content)
        }
        return fallbackTokenEstimate(content)
    }

    private fun fallbackTokenEstimate(content: String): Int {
        return (content.length / 4).coerceAtLeast(1)
    }

    // --- stage-entry approval gate ---

    /**
     * Emits [OrchestrationPausedEvent] + [ApprovalRequestedEvent] for a stage flagged
     * `requiresApproval`, awaits the user decision, and records it.
     * Returns `true` if the run should proceed into the stage, `false` if the approval was rejected.
     */
    internal suspend fun requestStageApproval(
        sessionId: SessionId,
        stageId: StageId,
        preview: String,
    ): Boolean {
        val requestId = ApprovalRequestId(UUID.randomUUID().toString())
        val validationReportId = ValidationReportId(UUID.randomUUID().toString())
        val deferred = CompletableDeferred<ApprovalDecision>()
        pendingApprovals[requestId] = deferred

        emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
        emit(
            sessionId,
            ApprovalRequestedEvent(
                requestId = requestId,
                tier = Tier.T2,
                validationReportId = validationReportId,
                riskSummaryId = null,
                riskSummary = null,
                sessionId = sessionId,
                stageId = stageId,
                projectId = null,
                preview = preview,
            ),
        )

        return try {
            val decision = deferred.await()
            emitDecisionResolved(
                sessionId,
                DomainApprovalRequest(
                    id = requestId,
                    tier = Tier.T2,
                    validationReportId = validationReportId,
                    riskSummaryId = null,
                    riskSummary = null,
                    timestamp = Clock.System.now(),
                ),
                decision,
            )
            if (decision.isApproved) {
                emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                true
            } else {
                emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                false
            }
        } finally {
            pendingApprovals.remove(requestId)
        }
    }

    /** Public seam for out-of-band gates (e.g. freestyle plan review) that reuse the stage-approval flow. */
    suspend fun requestPlanApproval(sessionId: SessionId, preview: String): Boolean =
        requestStageApproval(sessionId, StageId("plan_review"), preview)

    // --- private functions ---

    private suspend fun handleApproval(
        sessionId: SessionId,
        stageId: StageId,
        outcome: ValidationOutcome.NeedsApproval,
    ): StageExecutionResult {
        log.debug("[Orchestrator] approval required session={} stage={}", sessionId.value, stageId.value)
        val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
        val deferred = CompletableDeferred<ApprovalDecision>()
        pendingApprovals[domainRequest.id] = deferred

        emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
        emit(
            sessionId,
            ApprovalRequestedEvent(
                requestId = domainRequest.id,
                tier = domainRequest.tier,
                validationReportId = domainRequest.validationReportId,
                riskSummaryId = domainRequest.riskSummaryId,
                riskSummary = domainRequest.riskSummary,
                sessionId = sessionId,
                stageId = stageId,
                projectId = null,
            ),
        )

        return try {
            log.debug(
                "[Orchestrator] awaiting approval session={} requestId={}",
                sessionId.value, domainRequest.id.value,
            )
            val decision = deferred.await()
            log.debug(
                "[Orchestrator] approval resolved session={} approved={}",
                sessionId.value, decision.isApproved,
            )
            emitDecisionResolved(sessionId, domainRequest, decision)
            if (decision.isApproved) {
                emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                StageExecutionResult.Success(emptyList())
            } else {
                emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                StageExecutionResult.Failure(decision.reason ?: "approval rejected", retryable = false)
            }
        } finally {
            pendingApprovals.remove(domainRequest.id)
        }
    }

    private suspend fun buildApprovalRequest(
        sessionId: SessionId,
        stageId: StageId,
        outcome: ValidationOutcome.NeedsApproval,
    ): DomainApprovalRequest {
        val state = orchestrationRepository.getState(sessionId)
        val inferenceState = inferenceRepository.getInferenceState(sessionId)
        val riskSummary = riskAssessor.assess(
            RiskContext(
                validationReport = outcome.request.validationReport,
                orchestrationState = state,
                inferenceState = inferenceState,
            ),
        )
        val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString())
        // RiskAssessedEvent records the indexed level+action summary; ApprovalRequestedEvent carries the full
        // authoritative RiskSummary (signals + rationale). Both derive from the same assessment value so
        // they cannot diverge.
        emit(
            sessionId,
            RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction),
        )
        return DomainApprovalRequest(
            id = ApprovalRequestId(UUID.randomUUID().toString()),
            tier = riskSummary.level.toApprovalTier(),
            validationReportId = ValidationReportId(UUID.randomUUID().toString()),
            riskSummaryId = riskSummaryId,
            riskSummary = riskSummary,
            timestamp = Clock.System.now(),
        )
    }

    private suspend fun emitDecisionResolved(
        sessionId: SessionId,
        domainRequest: DomainApprovalRequest,
        decision: ApprovalDecision,
    ) {
        emit(
            sessionId,
            ApprovalDecisionResolvedEvent(
                decisionId = ApprovalDecisionId(UUID.randomUUID().toString()),
                requestId = domainRequest.id,
                outcome = decision.outcome ?: ApprovalOutcome.REJECTED,
                status = ApprovalStatus.COMPLETED,
                tier = domainRequest.tier,
                resolutionTimestamp = Clock.System.now(),
                reason = decision.reason,
                userSteering = decision.userSteering,
            ),
        )
    }
}

/**
 * Compute a unified-diff preview for file-affecting tool calls.
 * For [file_write] with operation "write": reads the existing file (if any) and diffs it
 * against the proposed content from the LLM arguments.
 * For all other tools: returns null (caller falls back to raw JSON args).
 *
 * This function performs blocking I/O (file reads) and must be called from a suspend context
 * that will dispatch it on [Dispatchers.IO].
 */
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
    if (toolName == "shell") return shellCommandPreview(parameters)
    if (toolName == "task_decompose") return renderDecomposePreview(parameters)
    if (toolName != "file_write") return null
    val path = parameters["path"] as? String ?: return null
    // file_write no longer carries an `operation` param (delete was split into file_delete), so the
    // preview is unconditional: path + content is always a write. The old `operation == "write"` gate
    // silently bailed to the raw-JSON args fallback for every file_write.
    val proposedContent = parameters["content"] as? String ?: return null

    val existingContent = withContext(Dispatchers.IO) {
        runCatching {
            val filePath = java.nio.file.Paths.get(path)
            if (java.nio.file.Files.exists(filePath)) {
                java.nio.file.Files.readString(filePath)
            } else null
        }.getOrNull()
    }

    return buildDiffString(path, existingContent, proposedContent)
}

/**
 * Render a shell tool's argv as a full, shell-quoted command line for the approval
 * card (tui-requirements §5 / §3 "full untruncated command"). Returning the rendered
 * line — rather than the raw `arguments.take(200)` fallback — keeps the command both
 * untruncated and human-readable; the TUI's deterministic parser re-tokenizes it.
 */
private fun shellCommandPreview(parameters: Map<String, Any>): String? {
    val argv = when (val raw = parameters["argv"]) {
        is List<*> -> raw.filterIsInstance<String>()
        is String -> runCatching { Json.decodeFromString<List<String>>(raw) }.getOrElse { emptyList() }
        else -> emptyList()
    }
    if (argv.isEmpty()) return null
    return argv.joinToString(" ") { shellQuoteToken(it) }
}

/** Single-quote a token when it carries whitespace or shell metacharacters. */
private fun shellQuoteToken(token: String): String {
    if (token.isEmpty()) return "''"
    val needsQuote = token.any { it.isWhitespace() || it in "\"'\\|&;<>(){}\$`*?[]~#!" }
    return if (needsQuote) "'" + token.replace("'", "'\\''") + "'" else token
}

/**
 * Build a unified-diff-style string for a full file replacement.
 * When the file doesn't exist yet (new file), only `+` lines are shown.
 * Uses a simple full-file diff: all old lines as `-`, all new lines as `+`.
 */
private fun buildDiffString(path: String, existingContent: String?, proposedContent: String): String {
    val existingLines = existingContent?.lines() ?: emptyList()
    val proposedLines = proposedContent.lines()
    if (existingLines == proposedLines) return "  (no change)"

    return buildString {
        appendLine("--- a/$path")
        appendLine("+++ b/$path")
        appendLine("@@ -1,${existingLines.size.coerceAtLeast(1)} +1,${proposedLines.size.coerceAtLeast(1)} @@")
        existingLines.forEach { appendLine("-$it") }
        proposedLines.forEach { appendLine("+$it") }
    }.trimEnd()
}

/**
 * The candidate in [paths] that shares [guessed]'s filename and the most trailing path segments,
 * or null when nothing shares the filename. Lets a wrong package/dir prefix
 * (`com/correx/server/routes/SessionRoutes.kt`) still resolve to the real file
 * (`.../com/correx/apps/server/routes/SessionRoutes.kt`) without ever inventing a non-existent path.
 */
internal fun closestPathByFilename(guessed: String, paths: List<String>): String? {
    val guessedName = guessed.substringAfterLast('/')
    if (guessedName.isBlank()) return null
    val guessedSegs = guessed.split('/').filter { it.isNotBlank() }
    return paths.asSequence()
        .filter { it.substringAfterLast('/') == guessedName }
        .maxByOrNull { candidate ->
            val segs = candidate.split('/').filter { it.isNotBlank() }
            var shared = 0
            var i = segs.size - 1
            var j = guessedSegs.size - 1
            while (i >= 0 && j >= 0 && segs[i] == guessedSegs[j]) {
                shared++; i--; j--
            }
            shared
        }
}

internal sealed interface InferenceResult {
    data class Success(val response: InferenceResponse, val responseArtifactId: ArtifactId? = null) : InferenceResult
    data class Failed(val reason: String) : InferenceResult
    data object Cancelled : InferenceResult
}

/**
 * Flattens an LLM tool call's JSON arguments into a [ToolRequest.parameters] map. A JSON array of
 * primitives becomes a `List<String>` so list-param accessors read it — otherwise it arrived as a
 * `.toString()` string and silently read empty, dropping e.g. a task's `affected_paths` (which in
 * turn left the write-scope and cite-before-claim gates with nothing to enforce). A primitive
 * becomes its content string; objects and arrays-of-objects keep their JSON text for tools that
 * re-parse them (e.g. task_decompose). Malformed JSON yields an empty map.
 */
internal fun parseToolArguments(arguments: String): Map<String, Any> = runCatching {
    val obj = Json.parseToJsonElement(arguments) as? JsonObject ?: return@runCatching emptyMap()
    obj.entries.associate { (k, v) ->
        k to when {
            v is JsonPrimitive -> v.content
            v is JsonArray && v.all { it is JsonPrimitive } -> v.map { (it as JsonPrimitive).content }
            else -> v.toString()
        } as Any
    }
}.getOrElse { emptyMap() }

/**
 * Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered
 * child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic
 * fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are
 * re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments.
 */
internal fun renderDecomposePreview(parameters: Map<String, Any>): String? {
    val project = parameters["project"] as? String ?: return null
    val tasks = (parameters["tasks"] as? String)
        ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null
    if (tasks.isEmpty()) return null
    val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content }
    val refToIndex = tasks.mapIndexedNotNull { i, e ->
        ((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i }
    }.toMap()
    val parentTitle = (parameters["parent"] as? String)
        ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject }
        ?.let { (it["title"] as? JsonPrimitive)?.content }

    return buildString {
        append("Decompose '").append(project).append("' into:")
        parentTitle?.let { append("\n  epic: ").append(it) }
        tasks.forEachIndexed { i, e ->
            val o = e as? JsonObject
            append("\n  ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)")
            val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList())
                .map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d }
            if (afters.isNotEmpty()) append("  (after: ").append(afters.joinToString(", ")).append(")")
        }
    }
}
