package com.correx.core.kernel.orchestration

import com.correx.core.context.builder.RequiredContextOverflowException
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.FinishReason
import com.correx.core.inference.ResponseFormat
import com.correx.core.sessions.Session
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
import com.correx.core.validation.artifact.ArtifactFailure
import com.correx.core.validation.model.ValidationContext
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
import kotlin.coroutines.cancellation.CancellationException

@Suppress("CyclomaticComplexMethod")
internal suspend fun SessionOrchestrator.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()
    // ponytail: env toggle so the prompt-audition harness (scripts/prompt-audition.sh, task #169) can
    // A/B the curated guidance block against a trimmed baseline without a rebuild. CORREX_STAGE_GUIDANCE=off
    // drops it; anything else (default) keeps it. Promote to a real config knob only if this outlives the audit.
    val operatingGuidance = if (System.getenv("CORREX_STAGE_GUIDANCE") == "off") emptyList() else listOf(
        ContextEntry(
            id = ContextEntryId(UUID.randomUUID().toString()),
            layer = ContextLayer.L0,
            content = CURATED_STAGE_OPERATING_GUIDANCE,
            sourceType = "operatingGuidance",
            sourceId = "curated-v1",
            tokenEstimate = estimateTokens(CURATED_STAGE_OPERATING_GUIDANCE),
            role = EntryRole.SYSTEM,
        ),
    )
    // Capability accretion (design 2026-07-12-acr-concept-compiler.md): deterministically inject the
    // validated failure→fix concepts this stage could act on, so a known resolution is delivered
    // BEFORE the stage re-derives it. Primary channel; L3 remains the instance-recall fallback.
    val promotedConcepts = promotedConceptEntries(stageConfig)
    // Positive-pattern accretion (design 2026-07-12 §"broaden beyond failure→fix"): show the architect a
    // prior successful plan shape for a similar intent so it plans from a validated decomposition, not zero.
    val successfulPlanShapes = successfulPlanShapeEntries(sessionId, stageConfig)
    // Known-good workspace invariant (design 2026-07-15 seam 2): tell the stage whether the last
    // green build is still valid for the current workspace state, or stale and needing re-verification.
    val verifiedBaseline = verifiedBaselineEntries(sessionId)
    // A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
    // SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
    // recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
    // "make the smallest change, don't rewrite from scratch" plus the gate evidence. See
    // mandateSuppressedByTicket.
    val promptEntries = if (mandateSuppressedByTicket(sessionId, stageId, stageConfig)) {
        emptyList()
    } else {
        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 intentEntries = buildIntentEntry(sessionId)
    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()),
            // #290: the decision journal is L3/USER reference (OPTIONAL, ceiling-capped), not policy —
            // keep it out of the leading system block that must hold policy/schema/constraints only.
            layer = ContextLayer.L3,
            content = journalText,
            sourceType = "decisionJournal",
            sourceId = "decision-journal",
            tokenEstimate = estimateTokens(journalText),
            role = EntryRole.USER,
        ),
    )
    val sessionEvents = eventStore.read(sessionId)
    // #290 (acceptance #5): on a gate-repair retry the model must patch the named failure against
    // the recorded images, not go re-exploring — so omit the L3 retrieval bundle (repo map, docs
    // catalog, semantic hits) on repair turns. Detected by a pending retry mandate for this stage.
    val isRepairRetry = buildRetryFeedbackEntry(sessionEvents, stageId) != null
    val repoMapEntries = if (isRepairRetry) {
        emptyList()
    } else {
        buildContextualRepoEntries(sessionId, stageId, stageConfig)
    }
    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 rejectionEntries = buildRejectionFeedbackEntry(sessionEvents, stageId)
        ?.let { listOf(it) } ?: emptyList()
    val groundingFeedbackEntries = buildGroundingFeedbackEntry(sessionEvents, stageId)
        ?.let { listOf(it) } ?: emptyList()
    val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId)
        ?.let { listOf(it) } ?: emptyList()
    // Store 2 soft-confidence lifecycle (below hard-promotion threshold): reactive hint/steer-away
    // matched to THIS retry's own classKey, see unconfirmedFixEntries.
    val unconfirmedFixHints = unconfirmedFixEntries(sessionEvents, stageId)
    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),
                    // #312: the claim advances as the run progresses — mutable ⇒ USER (stays L0).
                    role = EntryRole.USER,
                ),
            )
        } ?: emptyList()
    } else {
        emptyList()
    }
    // Remaining-delta checklist (stage-termination design 2026-07-11 §1a): the stage's currently
    // unmet file-contract assertions as a forward-looking, shrinking TODO. Injected pinned
    // (neverDrop) so it survives the budget/dedup passes that otherwise wipe the model's memory of
    // progress; refreshed inside the tool loop each time a write lands (§1b, cache-until-write).
    var remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
    val remainingDeltaEntries = remainingDeltaResults
        ?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
        ?.let { listOf(it) } ?: emptyList()
    var accumulatedEntries = stampBuckets(
        systemPrompt + operatingGuidance + promotedConcepts + successfulPlanShapes + verifiedBaseline +
            intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
            journalEntries + repoMapEntries + claimedTaskEntries +
            needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
            rejectionEntries + clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries +
            recoveryTicketEntries + unconfirmedFixHints + remainingDeltaEntries,
    )
    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)
    emitContextAssembled(sessionId, stageId, contextPack)

    var currentContext = contextPack
    var inferenceResult = runInference(
        sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
    )
    var toolRounds = 0
    var consecutiveReadOnlyRounds = 0
    var consecutiveRejectedRounds = 0
    // Fingerprints ("tool:arguments") of every read-only call made during the current
    // no-write streak. A round only counts against the read-loop breaker if it repeats calls
    // already seen — a stage reading N distinct files before writing is legitimate context
    // gathering, not a loop, and shouldn't trip the same counter as re-reading the same file.
    val seenReadFingerprints = mutableSetOf<String>()
    // 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)
        emitContextAssembled(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 < tuning.maxToolRounds &&
        (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.
        // Validate those arguments INLINE, like any other tool call: a valid artifact captures and
        // exits the loop; an invalid one is handed straight back into the SAME conversation as a
        // tool-result error, so the model fixes it with its full context (and CoT) intact. Breaking
        // the loop on a bad artifact would instead drop into a cold repair/retry that re-explores
        // from scratch and tends to re-make the identical schema mistake (2026-07-18).
        val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
        if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
            val emitSlot = llmEmittedSlots.first()
            when (
                val res = artifactExtractionPipeline.run(emitCall.function.arguments, emitSlot.kind.deriveJsonSchema())
            ) {
                is ArtifactExtractionPipeline.ExtractionResult.Resolved -> {
                    llmArtifactOverride = res.canonicalJson.toString()
                    break
                }
                is ArtifactExtractionPipeline.ExtractionResult.Unresolved -> {
                    inferenceResult = pushBack(
                        "ERROR: your emit_artifact call for '${emitSlot.name.value}' did not match the " +
                            "schema: ${res.detail}. Call emit_artifact again with only that corrected. Every " +
                            "field must sit at the level the schema defines — do not nest a top-level field " +
                            "(e.g. ready, questions) inside another object.",
                    )
                    continue
                }
            }
        }
        // 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),
            inferenceResult.response.reasoning)
        repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason ->
            return StageExecutionResult.Failure(reason, retryable = true, gate = WORKSPACE_PRECONDITION_GATE)
        }
        repeatedToolFailureLoop(sessionId, stageId, tuning.stageFailureLoopLimit)?.let { reason ->
            return StageExecutionResult.Failure(reason, retryable = true, gate = STAGE_LOOP_BREAK_GATE)
        }
        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 }
        ) {
            val roundFingerprints = inferenceResult.response.toolCalls
                .map { "${it.function.name}:${it.function.arguments}" }
            val madeProgress = roundFingerprints.any { it !in seenReadFingerprints }
            seenReadFingerprints += roundFingerprints
            if (madeProgress) {
                consecutiveReadOnlyRounds = 0
            } else {
                consecutiveReadOnlyRounds++
                if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
                    consecutiveReadOnlyRounds = 0
                    inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
                    continue
                }
            }
        } else {
            consecutiveReadOnlyRounds = 0
            seenReadFingerprints.clear()
        }
        // Rejection-loop breaker (stage-type agnostic): every tool result this round was a
        // rejection (plane-2 BLOCKED or a stage/policy ERROR) and nothing succeeded. A stage that
        // owes no file_written artifact (discovery, analyst) never trips the read-loop breaker, so
        // without this it re-issues the same rejected call until MAX_TOOL_ROUNDS. After the
        // threshold, tell it to stop retrying and produce its output; any success resets.
        val toolResults = toolEntries.filter { it.role == EntryRole.TOOL }
        val allRejected = toolResults.isNotEmpty() &&
            toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
        if (allRejected) {
            consecutiveRejectedRounds++
            if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
                consecutiveRejectedRounds = 0
                inferenceResult = pushBack(
                    "STOP. Your last tool calls were all rejected and retrying the same paths will " +
                        "keep failing. Do not repeat them. Proceed with the information you already " +
                        "have: produce this stage's required output now (call stage_complete, or emit " +
                        "the required artifact). If a path you wanted does not exist yet, that is a " +
                        "fact to record — do not keep probing for it.",
                )
                continue
            }
        } else {
            consecutiveRejectedRounds = 0
        }
        // Refresh the remaining-delta checklist only when a write actually landed this round
        // (cache-until-write, §1b): reads can never change the contract verdict, so re-evaluating
        // on them would just burn filesystem work and re-emit identical events. On a successful
        // write, recompute contract-minus-reality and swap the pinned entry so the model watches
        // the list shrink toward the empty=done signal it previously lacked.
        val wroteThisRound = inferenceResult.response.toolCalls.any { it.function.name in WRITE_TOOL_NAMES } &&
            toolEntries.any {
                it.role == EntryRole.TOOL &&
                    !it.content.startsWith("ERROR:") && !it.content.startsWith("BLOCKED:")
            }
        if (wroteThisRound) {
            remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
            val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
            accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } +
                listOfNotNull(refreshed)
        }
        currentContext = contextPackBuilder.build(
            id = ContextPackId(UUID.randomUUID().toString()),
            sessionId = sessionId,
            stageId = stageId,
            entries = accumulatedEntries,
            budget = TokenBudget(limit = stageConfig.tokenBudget),
        )
        emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
        emitContextAssembled(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),
            )
            emitContextAssembled(sessionId, stageId, currentContext)
            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
            }
        }
    }
}
