diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt index 908f202e..ab63058c 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt @@ -1,9 +1,38 @@ package com.correx.core.events.events import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** A critic's verdict for one review iteration. */ +@Serializable +enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED } + +/** + * The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its + * [verdict] for that iteration. This is the **producing side** of critique calibration: the + * reviewer-loop runtime correlates the sequence of these across iterations into + * [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the + * `:core:critique` projection then folds into per-(model, role) precision. + * + * [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the + * correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash] + * identifies the model that produced the findings (carried through to the outcome event so + * calibration is per-model). + */ +@Serializable +@SerialName("CritiqueFindingsRecorded") +data class CritiqueFindingsRecordedEvent( + val sessionId: SessionId, + val stageId: StageId, + val role: CritiqueRole, + val modelHash: String, + val iteration: Int, + val verdict: CritiqueVerdict, + val findings: List, +) : EventPayload + /** * How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved. * UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive; diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index fc752c38..533032f2 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -15,6 +15,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ChatTurnEvent +import com.correx.core.events.events.CritiqueFindingsRecordedEvent import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.OperatorProfileBoundEvent @@ -142,6 +143,7 @@ val eventModule = SerializersModule { subclass(IdeaDiscardedEvent::class) subclass(IdeaPromotedEvent::class) subclass(CritiqueOutcomeCorrelatedEvent::class) + subclass(CritiqueFindingsRecordedEvent::class) subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointFailedEvent::class) subclass(PossibleContradictionFlaggedEvent::class) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt new file mode 100644 index 00000000..cde4f074 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt @@ -0,0 +1,72 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.CritiqueFinding +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueVerdict +import com.correx.core.events.types.SessionId + +/** + * Decides how each prior critique finding turned out across a review loop — the producer half of + * critique calibration (BACKLOG §B-§6 / pipeline-addenda A3). Pure over the recorded review + * iterations, so it is replay-deterministic and unit-testable; the orchestrator runs it once at + * loop resolution (see completeWorkflow / failWorkflow) and emits the results, which the + * `:core:critique` projection folds into per-(model, role) precision. + * + * Rules, per critic (grouped by role + modelHash) and per finding id: + * - **UPHELD** — the finding was raised in some iteration and is gone by the final review: the + * implementer fixed it between rounds, so the critic was right and the finding was actioned. + * - **DISMISSED** — the finding persists into the final review AND that review APPROVED: the + * reviewer signed off without it being addressed, i.e. treated it as non-blocking. + * - **INCONCLUSIVE** — the finding is still open at a non-approved terminal (loop exhausted): no + * signal either way. + * + * Findings carry a stable [CritiqueFinding.id] so the same logical finding is tracked across + * iterations. + */ +object CritiqueOutcomeCorrelator { + + fun correlate( + sessionId: SessionId, + reviews: List, + ): List = + reviews + .groupBy { it.role to it.modelHash } + .flatMap { (_, group) -> correlateCritic(sessionId, group) } + + private fun correlateCritic( + sessionId: SessionId, + critic: List, + ): List { + val ordered = critic.sortedBy { it.iteration } + val finalReview = ordered.last() + val finalIds = finalReview.findings.mapTo(mutableSetOf()) { it.id } + + // Latest appearance per finding id (later iterations overwrite), used for severity and the + // owning critic's role/modelHash. + val latest = LinkedHashMap>() + for (review in ordered) { + for (finding in review.findings) { + latest[finding.id] = review to finding + } + } + + return latest.map { (id, appearance) -> + val (review, finding) = appearance + val outcome = when { + id !in finalIds -> CritiqueOutcome.UPHELD + finalReview.verdict == CritiqueVerdict.APPROVED -> CritiqueOutcome.DISMISSED + else -> CritiqueOutcome.INCONCLUSIVE + } + CritiqueOutcomeCorrelatedEvent( + sessionId = sessionId, + findingId = id, + role = review.role, + modelHash = review.modelHash, + severity = finding.severity, + outcome = outcome, + ) + } + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index b6407959..6cb8ae37 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -61,11 +61,15 @@ class DefaultSessionOrchestrator( private val compactionService: JournalCompactionService? = null, artifactKindRegistry: ArtifactKindRegistry? = null, repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, + commandRunner: CommandRunner? = null, ) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway { override val tokenizer: Tokenizer? = tokenizer override val cancellations: ConcurrentHashMap = ConcurrentHashMap() + // Deterministic static_check stages (BACKLOG §B-§5) are run here, not via the LLM subagent. + private val staticCheckExecutor = StaticCheckStageExecutor(commandRunner) + override val subagentRunner: SubagentRunner = InSessionSubagentRunner( executeStage = { sid, stg, graph, session, cfg -> executeStage(sid, stg, graph, session, cfg, effectivesFor(cfg)) @@ -375,6 +379,20 @@ class DefaultSessionOrchestrator( } } + // Deterministic stage seam (§B-§5): a `static_check` stage runs a static-analysis tool and + // records its findings instead of invoking the LLM subagent. No retries/clarification apply. + ctx.graph.stages[stageId]?.let { stage -> + if (stage.metadata["stage_type"] == "static_check") { + val event = staticCheckExecutor.execute(ctx.sessionId, stageId, stage) + emit(ctx.sessionId, event) + log.info( + "[Orchestrator] static_check session={} stage={} tool={} findings={}", + ctx.sessionId.value, stageId.value, event.tool, event.findings.size, + ) + return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1)) + } + } + while (true) { if (isCancelled(ctx.sessionId)) { return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId)) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 87731aab..34e938ed 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -30,6 +30,8 @@ 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.ArtifactCreatedEvent +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent import com.correx.core.events.events.StaticFindingsRecordedEvent import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent @@ -1734,6 +1736,7 @@ abstract class SessionOrchestrator( "[Orchestrator] COMPLETED session={} terminalStage={} stages={}", sessionId.value, terminalStageId.value, stageCount, ) + correlateCritiqueOutcomes(sessionId) emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount)) cancellations.remove(sessionId) return WorkflowResult.Completed(sessionId, terminalStageId) @@ -1749,11 +1752,26 @@ abstract class SessionOrchestrator( "[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, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt index 011279ae..7e820e4e 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt @@ -25,18 +25,18 @@ interface CommandRunner { * Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to * stdout — and parsing is lenient: lines that don't match any known format are ignored. * - * TODO(wiring): a real `static_check` stage in role_pipeline.toml (between `implementer` and - * `reviewer`) would invoke this runner and emit the findings as a first-class event: + * Wiring: the deterministic `static_check` stage now exists. [StaticCheckStageExecutor] invokes this + * runner and records a [com.correx.core.events.events.StaticFindingsRecordedEvent]; the orchestrator + * dispatches any stage with `metadata["stage_type"] == "static_check"` to it instead of the LLM + * subagent (see DefaultSessionOrchestrator.enterStage), and role_pipeline.toml carries the stage + * between `implementer` and `reviewer`. Once the event is on the journal, + * [excludeStaticFindingsFromReview] (already live-wired in SessionOrchestrator's context assembly) + * strips the matching entries from the reviewer's context. * - * val findings = StaticAnalysisRunner(commandRunner).analyze("detekt", argv) - * eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId) - * - * Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in - * SessionOrchestrator's context assembly) strips the matching entries from the reviewer's context. - * That stage is intentionally NOT added here: workflow stages are LLM-driven and emit typed - * artifacts, so a deterministic event-emitting stage needs a new stage-type / tool-execution seam - * that doesn't yet exist — adding it now would be broad plumbing. The runner + event + filter are - * complete; only the stage that calls them is deferred. + * The stage is a no-op until a [CommandRunner] is wired into the orchestrator AND the stage sets + * `static_argv`. TODO(activation): wiring a *production* runner means resolving the command to the + * session's workspace (ShellTool-style ProcessBuilder + cwd + timeout) and is live-QA-gated — it is + * deferred so the deterministic seam can land and be unit-verified without a sandbox. */ class StaticAnalysisRunner(private val runner: CommandRunner) { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt new file mode 100644 index 00000000..23719f88 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt @@ -0,0 +1,72 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StaticFindingsRecordedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.transitions.graph.StageConfig + +/** + * Executes a deterministic `static_check` stage (BACKLOG §B-§5). Where an LLM stage runs a + * subagent, this stage shells out to a configured static-analysis tool, parses its console + * output via [StaticAnalysisRunner], and produces a [StaticFindingsRecordedEvent] — the event the + * reviewer-context filter ([excludeStaticFindingsFromReview], already live-wired) consumes so the + * reviewer's attention isn't spent re-finding what the build already reports. + * + * The stage is configured entirely through [StageConfig.metadata] (set from the workflow TOML): + * - `stage_type = "static_check"` — marks the stage deterministic (dispatched in `enterStage`). + * - `static_tool = "detekt"` — the label recorded on the findings (default `"static"`). + * - `static_argv = ""` — the command to run, whitespace-split (simple quotes honoured). + * + * It is a **no-op (empty findings) when no runner is wired or no `static_argv` is configured**, so a + * workflow can carry the stage safely before a tool/command is set up — activation is opt-in. + */ +class StaticCheckStageExecutor(private val commandRunner: CommandRunner?) { + + /** Runs the configured tool (if any) and returns the event to record; never throws on a tool miss. */ + suspend fun execute(sessionId: SessionId, stageId: StageId, stage: StageConfig): StaticFindingsRecordedEvent { + val tool = stage.metadata["static_tool"]?.takeIf { it.isNotBlank() } ?: "static" + val argv = stage.metadata["static_argv"]?.let { tokenizeArgv(it) } ?: emptyList() + val findings = if (commandRunner != null && argv.isNotEmpty()) { + StaticAnalysisRunner(commandRunner).analyze(tool, argv) + } else { + emptyList() + } + return StaticFindingsRecordedEvent( + sessionId = sessionId, + stageId = stageId, + tool = tool, + findings = findings, + ) + } + + companion object { + /** + * Splits a command string into argv on whitespace, honouring simple single- or double-quoted + * runs so a path or flag value may contain spaces. Not a full shell parser (no escapes, no + * variable expansion) — deliberately minimal and deterministic for unit testing. + */ + fun tokenizeArgv(command: String): List { + val tokens = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var inToken = false + fun flush() { + if (inToken) { + tokens.add(current.toString()) + current.clear() + inToken = false + } + } + for (ch in command) { + when { + quote != null -> if (ch == quote) quote = null else current.append(ch) + ch == '\'' || ch == '"' -> { quote = ch; inToken = true } + ch.isWhitespace() -> flush() + else -> { current.append(ch); inToken = true } + } + } + flush() + return tokens + } + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt new file mode 100644 index 00000000..072f2dc6 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt @@ -0,0 +1,131 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.CritiqueFinding +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueRole +import com.correx.core.events.events.CritiqueSeverity +import com.correx.core.events.events.CritiqueVerdict +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CritiqueOutcomeCorrelatorTest { + + private val sessionId = SessionId("s1") + + private fun finding(id: String, severity: CritiqueSeverity = CritiqueSeverity.MAJOR) = CritiqueFinding( + id = id, + role = CritiqueRole.CODE_REVIEWER, + severity = severity, + category = "correctness", + message = "msg-$id", + ) + + private fun review( + iteration: Int, + verdict: CritiqueVerdict, + findings: List, + role: CritiqueRole = CritiqueRole.CODE_REVIEWER, + modelHash: String = "model-A", + ) = CritiqueFindingsRecordedEvent( + sessionId = sessionId, + stageId = StageId("reviewer"), + role = role, + modelHash = modelHash, + iteration = iteration, + verdict = verdict, + findings = findings, + ) + + private fun outcomeFor(events: List, id: String) = + events.single { it.findingId == id }.outcome + + @Test + fun `finding fixed between iterations is UPHELD`() { + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + review(2, CritiqueVerdict.APPROVED, emptyList()), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(1, out.size) + assertEquals(CritiqueOutcome.UPHELD, out.single().outcome) + } + + @Test + fun `finding persisting into an approved final review is DISMISSED`() { + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + review(2, CritiqueVerdict.APPROVED, listOf(finding("f1"))), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1")) + } + + @Test + fun `finding still open at a non-approved terminal is INCONCLUSIVE`() { + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + review(2, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.INCONCLUSIVE, outcomeFor(out, "f1")) + } + + @Test + fun `a single approved review with a finding dismisses it`() { + val reviews = listOf(review(1, CritiqueVerdict.APPROVED, listOf(finding("f1")))) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1")) + } + + @Test + fun `mixed fates in one loop are resolved independently per finding`() { + val reviews = listOf( + // f1 and f2 raised; f1 fixed next round, f2 persists; f3 raised late and persists. + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))), + review(2, CritiqueVerdict.APPROVED, listOf(finding("f2"), finding("f3"))), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(3, out.size) + assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "f1")) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f2")) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f3")) + } + + @Test + fun `findings carry their severity and the producing critic's identity`() { + val reviews = listOf( + review( + 1, CritiqueVerdict.APPROVED, listOf(finding("f1", CritiqueSeverity.BLOCKER)), + role = CritiqueRole.PLAN_CRITIC, modelHash = "model-Z", + ), + ) + val event = CritiqueOutcomeCorrelator.correlate(sessionId, reviews).single() + assertEquals(CritiqueSeverity.BLOCKER, event.severity) + assertEquals(CritiqueRole.PLAN_CRITIC, event.role) + assertEquals("model-Z", event.modelHash) + } + + @Test + fun `each critic (role + model) is calibrated separately`() { + val planCritic = CritiqueRole.PLAN_CRITIC + val reviewer = CritiqueRole.CODE_REVIEWER + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("a")), role = planCritic, modelHash = "m1"), + review(2, CritiqueVerdict.APPROVED, emptyList(), role = planCritic, modelHash = "m1"), + review(1, CritiqueVerdict.APPROVED, listOf(finding("b")), role = reviewer, modelHash = "m2"), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "a")) // plan critic's finding fixed + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "b")) // reviewer's finding approved-through + } + + @Test + fun `no reviews yields no outcomes`() { + assertTrue(CritiqueOutcomeCorrelator.correlate(sessionId, emptyList()).isEmpty()) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt new file mode 100644 index 00000000..f002a610 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt @@ -0,0 +1,80 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StaticFindingSeverity +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.transitions.graph.StageConfig +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StaticCheckStageExecutorTest { + + private val sessionId = SessionId("s1") + private val stageId = StageId("static_check") + + private fun fakeRunner(stdout: String = "", stderr: String = "") = object : CommandRunner { + var lastArgv: List? = null + override suspend fun run(argv: List): CommandOutput { + lastArgv = argv + return CommandOutput(exitCode = 0, stdout = stdout, stderr = stderr) + } + } + + @Test + fun `tokenizeArgv splits on whitespace and honours quotes`() { + assertEquals( + listOf("./gradlew", "detekt", "--console=plain"), + StaticCheckStageExecutor.tokenizeArgv("./gradlew detekt --console=plain"), + ) + assertEquals( + listOf("tool", "--path", "src/main with space/App.kt"), + StaticCheckStageExecutor.tokenizeArgv("""tool --path "src/main with space/App.kt""""), + ) + assertEquals( + listOf("a", "b"), + StaticCheckStageExecutor.tokenizeArgv(" a b "), + ) + assertTrue(StaticCheckStageExecutor.tokenizeArgv("").isEmpty()) + } + + @Test + fun `execute runs the configured tool and records parsed findings`() = runBlocking { + val runner = fakeRunner(stdout = "src/A.kt:10:4: warning: deprecated\nBUILD FAILED") + val stage = StageConfig( + metadata = mapOf( + "stage_type" to "static_check", + "static_tool" to "detekt", + "static_argv" to "./gradlew detekt", + ), + ) + val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage) + + assertEquals("detekt", event.tool) + assertEquals(1, event.findings.size) + assertEquals("src/A.kt", event.findings[0].file) + assertEquals(StaticFindingSeverity.WARNING, event.findings[0].severity) + assertEquals(listOf("./gradlew", "detekt"), runner.lastArgv) + } + + @Test + fun `execute is a no-op when no argv is configured`() = runBlocking { + val runner = fakeRunner(stdout = "src/A.kt:1:1: error: boom") + val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_tool" to "detekt")) + val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage) + + assertEquals("detekt", event.tool) + assertTrue(event.findings.isEmpty(), "no static_argv → tool not run, empty findings") + assertEquals(null, runner.lastArgv, "the runner must not be invoked without a command") + } + + @Test + fun `execute is a no-op with empty findings when no runner is wired`() = runBlocking { + val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_argv" to "./gradlew detekt")) + val event = StaticCheckStageExecutor(null).execute(sessionId, stageId, stage) + + assertEquals("static", event.tool, "default tool label when unspecified") + assertTrue(event.findings.isEmpty()) + } +} diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index 92e5c3cb..e62175a1 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -1,4 +1,4 @@ -# Role pipeline: analyst → architect → planner → implementer ⇄ reviewer +# Role pipeline: analyst → architect → planner → implementer → static_check ⇄ reviewer # # Each stage produces a typed artifact that the next stage `needs`, so work flows forward # without a human relaying notes. The decision journal (pinned into every stage's context) @@ -69,6 +69,19 @@ allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"] token_budget = 32768 max_retries = 3 +# 4b. Deterministic static analysis (NO LLM). Runs a configured tool (compiler/ktlint/detekt) +# over the patch and records its findings as a StaticFindingsRecordedEvent, which the +# reviewer-context filter then strips from the reviewer's context so the LLM reviewer spends +# its attention on semantic review rather than re-finding what the build already reports. +# No-op until `static_argv` is set (and a CommandRunner is wired) — safe to carry unconfigured. +# Activate e.g. with: static_argv = "./gradlew detekt --console=plain" +[[stages]] +id = "static_check" +stage_type = "static_check" +static_tool = "detekt" +token_budget = 0 +max_retries = 0 + # 5. Review the patch against the plan AND the analyst's acceptance criteria. The reviewer # needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow # question) rather than whole files against taste. @@ -103,13 +116,21 @@ to = "implementer" condition_type = "artifact_validated" condition_artifact_id = "impl_plan" +# implementer → static_check (once the patch is validated) → reviewer. The static_check stage +# produces no artifact, so its exit edge is unconditional (always_true). [[transitions]] -id = "implementer-to-reviewer" +id = "implementer-to-static_check" from = "implementer" -to = "reviewer" +to = "static_check" condition_type = "artifact_validated" condition_artifact_id = "patch" +[[transitions]] +id = "static_check-to-reviewer" +from = "static_check" +to = "reviewer" +condition_type = "always_true" + # --- verdict-gated loop exit / re-entry --- [[transitions]] diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 1e94bd16..ece7c8dc 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -47,6 +47,12 @@ private data class StageSection( @param:JsonProperty("requires_approval") val requiresApproval: Boolean = false, @param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false, @param:JsonProperty("ground_references") val groundReferences: Boolean = false, + // Deterministic stage marker (§B-§5): `stage_type = "static_check"` runs a static-analysis + // tool instead of an LLM subagent; `static_tool`/`static_argv` configure it (see + // StaticCheckStageExecutor). Absent for ordinary LLM stages. + @param:JsonProperty("stage_type") val stageType: String? = null, + @param:JsonProperty("static_tool") val staticTool: String? = null, + @param:JsonProperty("static_argv") val staticArgv: String? = null, ) // condition fields flattened into the transition row @@ -93,6 +99,19 @@ class TomlWorkflowLoader( return if (candidate != null && candidate.exists()) candidate.toString() else raw } + // Flattens a stage's TOML flags into the StageConfig.metadata string map the runtime reads + // (prompt paths, approval/inject/ground flags, and the §B-§5 deterministic-stage markers). + private fun stageMetadata(s: StageSection, workflowDir: Path?): Map = buildMap { + s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } + s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } + if (s.requiresApproval) put("requiresApproval", "true") + if (s.injectArtifactKinds) put("injectArtifactKinds", "true") + if (s.groundReferences) put("groundReferences", "true") + s.stageType?.let { put("stage_type", it) } + s.staticTool?.let { put("static_tool", it) } + s.staticArgv?.let { put("static_argv", it) } + } + private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph { val startId = StageId(start) val stageMap = stages.associate { s -> @@ -115,13 +134,7 @@ class TomlWorkflowLoader( maxTokens = s.tokenBudget, ), maxRetries = s.maxRetries, - metadata = buildMap { - s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } - s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } - if (s.requiresApproval) put("requiresApproval", "true") - if (s.injectArtifactKinds) put("injectArtifactKinds", "true") - if (s.groundReferences) put("groundReferences", "true") - }, + metadata = stageMetadata(s, workflowDir), ) } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt index 17eaf553..f0eead46 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt @@ -53,10 +53,21 @@ class RolePipelineWorkflowTest { val graph = TomlWorkflowLoader(registry).load(path!!) assertEquals( - setOf("analyst", "architect", "planner", "implementer", "reviewer"), + setOf("analyst", "architect", "planner", "implementer", "static_check", "reviewer"), graph.stages.keys.map { it.value }.toSet(), ) - assertEquals(6, graph.transitions.size) + assertEquals(7, graph.transitions.size) + + // The deterministic static_check stage sits between implementer and reviewer. + assertEquals("static_check", graph.stages[StageId("static_check")]!!.metadata["stage_type"]) + assertTrue( + graph.transitions.any { it.from == StageId("implementer") && it.to == StageId("static_check") }, + "implementer should hand off to static_check", + ) + assertTrue( + graph.transitions.any { it.from == StageId("static_check") && it.to == StageId("reviewer") }, + "static_check should hand off to reviewer", + ) // Forward chaining via needs. assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan"))) diff --git a/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt b/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt new file mode 100644 index 00000000..51248dd0 --- /dev/null +++ b/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt @@ -0,0 +1,161 @@ +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.DefaultApprovalRepository +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.artifacts.DefaultArtifactReducer +import com.correx.core.events.events.CritiqueFinding +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueRole +import com.correx.core.events.events.CritiqueSeverity +import com.correx.core.events.events.CritiqueVerdict +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceState +import com.correx.core.journal.DecisionJournalProjector +import com.correx.core.journal.DefaultDecisionJournalReducer +import com.correx.core.journal.DefaultDecisionJournalRepository +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository +import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore +import com.correx.testing.fixtures.InferenceFixtures +import com.correx.testing.fixtures.context.ContextFixtures +import com.correx.testing.fixtures.cyclePolicyMissingValidator +import com.correx.testing.fixtures.transitions.TransitionFixtures +import com.correx.testing.kernel.MockSessionEventReplayer +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * End-to-end proof that the reviewer-loop runtime correlates recorded critique findings into + * outcome events at loop resolution (BACKLOG §B-§6), and that those events feed the + * `:core:critique` calibration projection. The LLM-side emission of findings is mocked here by + * seeding [CritiqueFindingsRecordedEvent]s directly (that half is a separate model-gated step). + */ +class CritiqueCalibrationWiringTest { + + private val eventStore = InMemoryEventStore() + private val sessionReplayer = MockSessionEventReplayer() + private val sessionRepository = DefaultSessionRepository(sessionReplayer) + private val orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ) + private val inferenceRepository = InferenceRepository( + object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + }, + ) + private val approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ) + private val decisionJournalRepository = DefaultDecisionJournalRepository( + DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), + ) + private val retryCoordinator = DefaultRetryCoordinator(eventStore) + + private val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = inferenceRepository, + orchestrationRepository = orchestrationRepository, + sessionRepository = sessionRepository, + artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()), + approvalRepository = approvalRepository, + ) + + private val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = OrchestratorEngines( + transitionResolver = TransitionFixtures.simpleResolver(), + contextPackBuilder = ContextFixtures.simpleBuilder(), + inferenceRouter = InferenceFixtures.fixedRouter(), + validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), + approvalEngine = DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + ), + retryCoordinator = retryCoordinator, + artifactStore = NoopArtifactStore(), + decisionJournalRepository = decisionJournalRepository, + ) + + private val defaultConfig = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + + private fun finding(id: String) = CritiqueFinding( + id = id, + role = CritiqueRole.CODE_REVIEWER, + severity = CritiqueSeverity.MAJOR, + category = "correctness", + message = "msg-$id", + ) + + private suspend fun seedReview(sessionId: SessionId, iteration: Int, verdict: CritiqueVerdict, findings: List) { + eventStore.append( + NewEvent( + EventMetadata(EventId("rev-$iteration"), sessionId, Clock.System.now(), 1, null, null), + CritiqueFindingsRecordedEvent( + sessionId = sessionId, + stageId = StageId("reviewer"), + role = CritiqueRole.CODE_REVIEWER, + modelHash = "model-A", + iteration = iteration, + verdict = verdict, + findings = findings, + ), + ), + ) + } + + @Test + fun `recorded findings are correlated at completion and feed the calibration projection`(): Unit = runBlocking { + val sessionId = SessionId("calib-1") + + // Two review rounds: f1 fixed between them (→ UPHELD); f2 persists to an approved final (→ DISMISSED). + seedReview(sessionId, 1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))) + seedReview(sessionId, 2, CritiqueVerdict.APPROVED, listOf(finding("f2"))) + + val stageA = StageId("A") + val done = StageId("done") + val graph = WorkflowGraph( + id = "calib-wiring", + stages = mapOf(stageA to StageConfig(), done to StageConfig()), + transitions = setOf(TransitionEdge(id = TransitionId("t1"), from = stageA, to = done, condition = { true })), + start = stageA, + ) + + orchestrator.run(sessionId, graph, defaultConfig) + + // The orchestrator emits one outcome per recorded finding at completion — the events the + // :core:critique calibration projection consumes (its folding is covered by that module's + // DefaultCriticCalibrationReducerTest). + val outcomes = eventStore.read(sessionId).mapNotNull { it.payload as? CritiqueOutcomeCorrelatedEvent } + assertEquals(2, outcomes.size, "one outcome per recorded finding") + assertEquals(CritiqueOutcome.UPHELD, outcomes.single { it.findingId == "f1" }.outcome) + assertEquals(CritiqueOutcome.DISMISSED, outcomes.single { it.findingId == "f2" }.outcome) + assertTrue(outcomes.all { it.modelHash == "model-A" && it.role == CritiqueRole.CODE_REVIEWER }) + } +} diff --git a/testing/integration/src/test/kotlin/StaticCheckStageTest.kt b/testing/integration/src/test/kotlin/StaticCheckStageTest.kt new file mode 100644 index 00000000..28fb0b90 --- /dev/null +++ b/testing/integration/src/test/kotlin/StaticCheckStageTest.kt @@ -0,0 +1,158 @@ +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.DefaultApprovalRepository +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.artifacts.DefaultArtifactReducer +import com.correx.core.events.events.StaticFindingsRecordedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceState +import com.correx.core.journal.DecisionJournalProjector +import com.correx.core.journal.DefaultDecisionJournalReducer +import com.correx.core.journal.DefaultDecisionJournalRepository +import com.correx.core.kernel.orchestration.CommandOutput +import com.correx.core.kernel.orchestration.CommandRunner +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository +import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore +import com.correx.testing.fixtures.InferenceFixtures +import com.correx.testing.fixtures.context.ContextFixtures +import com.correx.testing.fixtures.cyclePolicyMissingValidator +import com.correx.testing.fixtures.transitions.TransitionFixtures +import com.correx.testing.kernel.MockSessionEventReplayer +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +/** + * End-to-end proof of the deterministic static_check stage seam (BACKLOG §B-§5): a stage marked + * `stage_type = "static_check"` runs the configured tool via an injected [CommandRunner] (no LLM + * subagent), records a [StaticFindingsRecordedEvent] with the parsed findings, and the workflow + * advances on the unconditional exit edge. + */ +class StaticCheckStageTest { + + private val eventStore = InMemoryEventStore() + private val sessionReplayer = MockSessionEventReplayer() + private val sessionRepository = DefaultSessionRepository(sessionReplayer) + private val orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ) + private val inferenceRepository = InferenceRepository( + object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + }, + ) + private val approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ) + private val decisionJournalRepository = DefaultDecisionJournalRepository( + DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), + ) + private val retryCoordinator = DefaultRetryCoordinator(eventStore) + + private val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = inferenceRepository, + orchestrationRepository = orchestrationRepository, + sessionRepository = sessionRepository, + artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()), + approvalRepository = approvalRepository, + ) + + // Canned tool output: one stderr error + one stdout detekt finding (+ noise that must be ignored). + private val fakeRunner = object : CommandRunner { + var lastArgv: List? = null + override suspend fun run(argv: List): CommandOutput { + lastArgv = argv + return CommandOutput( + exitCode = 1, + stdout = "src/Foo.kt:42:9: Magic number found [MagicNumber]\nBUILD FAILED", + stderr = "src/Bar.kt:5:9: error: type mismatch", + ) + } + } + + private val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = OrchestratorEngines( + transitionResolver = TransitionFixtures.simpleResolver(), + contextPackBuilder = ContextFixtures.simpleBuilder(), + inferenceRouter = InferenceFixtures.fixedRouter(), + validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), + approvalEngine = DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + ), + retryCoordinator = retryCoordinator, + artifactStore = NoopArtifactStore(), + decisionJournalRepository = decisionJournalRepository, + commandRunner = fakeRunner, + ) + + private val defaultConfig = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0), + ) + + @Test + fun `static_check stage runs the tool deterministically and records findings`(): Unit = runBlocking { + val sessionId = SessionId("static-check-1") + val staticCheck = StageId("static_check") + val done = StageId("done") + + val graph = WorkflowGraph( + id = "static-check-seam", + stages = mapOf( + staticCheck to StageConfig( + metadata = mapOf( + "stage_type" to "static_check", + "static_tool" to "detekt", + "static_argv" to "./gradlew detekt --console=plain", + ), + ), + done to StageConfig(), + ), + transitions = setOf( + TransitionEdge(id = TransitionId("t1"), from = staticCheck, to = done, condition = { true }), + ), + start = staticCheck, + ) + + orchestrator.run(sessionId, graph, defaultConfig) + + val events = eventStore.read(sessionId) + val recorded = events.mapNotNull { it.payload as? StaticFindingsRecordedEvent } + assertEquals(1, recorded.size, "the static_check stage must record exactly one findings event") + val event = recorded.single() + assertEquals("detekt", event.tool) + assertEquals(2, event.findings.size, "both the stdout detekt finding and the stderr error parse") + assertEquals("MagicNumber", event.findings[0].ruleId) + assertEquals("src/Bar.kt", event.findings[1].file) + assertEquals(listOf("./gradlew", "detekt", "--console=plain"), fakeRunner.lastArgv) + + assertNotNull( + events.find { it.payload is WorkflowCompletedEvent }, + "workflow advances past static_check on the unconditional exit edge and completes", + ) + } +}