feat(kernel): static-first reviewer gate (role-reliability §5)

Run operator-configured static-analysis commands (compiler/detekt/formatters) as a
deterministic harness step on the producing stage, before the LLM reviewer. A stage
declares `static_analysis = [commands]`; after it produces its artifact, each command
runs in the workspace root and the results are recorded in a StaticAnalysisCompletedEvent
(invariant #9 env observation — recorded live, folded on replay, never re-run). A
non-clean command fails the stage retryably with its output fed back verbatim (§2:
compiler/test output is ground truth), so the implementer fixes it and only static-clean
code ever reaches the reviewer. This makes §5's "reviewer context excludes static findings"
mechanical — the findings are resolved upstream, so the reviewer can't waste inference on
what detekt catches for free; no output-parsing, no context plumbing.

- StaticAnalysisCompletedEvent + StaticAnalysisFinding (core:events) + registration.
- StaticAnalysisRunner seam + ProcessStaticAnalysisRunner (whitespace-split argv, stderr
  merged, timeout→nonzero exit) in core:kernel; wired (nullable) into OrchestratorEngines
  and the server. Null runner / no workspace root → logged no-op, never blocks the run.
- StageConfig.staticAnalysis + `static_analysis` TOML field; runStaticAnalysis folded into
  a runPostStageGates sequence (produces → grounding → echo → static analysis).
- Documented `static_analysis` on the role_pipeline implementer stage (commented; commands
  are workspace-specific). The reviewer prompt half shipped in 3467826.
This commit is contained in:
2026-06-14 23:51:49 +04:00
parent 0d8df4a130
commit 365eb8a279
13 changed files with 354 additions and 9 deletions
@@ -34,6 +34,7 @@ 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.ProcessStaticAnalysisRunner
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.kernel.orchestration.WorkspaceTools
@@ -277,6 +278,7 @@ fun main() {
toolCallAssessor = toolCallAssessor,
workspacePolicy = workspacePolicy,
workspaceToolRegistryProvider = wsToolRegistryProvider,
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
)
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
val defaultOrchestrationConfig = OrchestrationConfig(
@@ -0,0 +1,40 @@
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
/** The outcome of one operator-configured static-analysis command run against a stage's output. */
@Serializable
data class StaticAnalysisFinding(
val command: String,
val exitCode: Int,
/**
* Truncated tail of the command's combined stdout+stderr. The full output is fed back to the
* model verbatim at runtime but is not retained in the log — the tail carries the error summary
* and keeps the event bounded.
*/
val summary: String,
) {
val clean: Boolean get() = exitCode == 0
}
/**
* Environment observation (invariant #9): the static-analysis commands (compiler / detekt /
* formatters) a stage ran against its just-produced output, each with its exit code and a truncated
* output tail, captured at the moment the stage completed. Replay reads these recorded facts and
* never re-runs the commands.
*
* Realizes role-reliability §5 "static-first ordering": deterministic tools run before the LLM
* reviewer. A non-clean finding fails the producing stage retryably (the output is fed back so the
* implementer fixes it), so only static-clean output ever reaches the reviewer — its context thereby
* excludes anything static tools already catch, with no parsing or context plumbing.
*/
@Serializable
@SerialName("StaticAnalysisCompleted")
data class StaticAnalysisCompletedEvent(
val sessionId: SessionId,
val stageId: StageId,
val findings: List<StaticAnalysisFinding>,
) : EventPayload
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
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.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
@@ -110,6 +111,7 @@ val eventModule = SerializersModule {
subclass(RepoKnowledgeRetrievedEvent::class)
subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(StaticAnalysisCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class)
@@ -0,0 +1,51 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.StaticAnalysisFinding
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class StaticAnalysisCompletedEventSerializationTest {
private val sample = StaticAnalysisCompletedEvent(
sessionId = SessionId("sess-1"),
stageId = StageId("implementer"),
findings = listOf(
StaticAnalysisFinding(command = "./gradlew compileKotlin", exitCode = 0, summary = "BUILD SUCCESSFUL"),
StaticAnalysisFinding(command = "./gradlew detekt", exitCode = 1, summary = "MagicNumber at Foo.kt:42"),
),
)
@Test
fun `round-trips as polymorphic EventPayload`() {
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StaticAnalysisCompleted\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `decodes hand-written StaticAnalysisCompleted JSON`() {
val json = """
{"type":"StaticAnalysisCompleted","sessionId":"s","stageId":"implementer",
"findings":[{"command":"./gradlew detekt","exitCode":2,"summary":"3 violations"}]}
""".trimIndent()
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertTrue(decoded is StaticAnalysisCompletedEvent)
val finding = (decoded as StaticAnalysisCompletedEvent).findings.single()
assertEquals("./gradlew detekt", finding.command)
assertEquals(2, finding.exitCode)
assertFalse(finding.clean)
}
@Test
fun `clean is derived from exit code zero`() {
assertTrue(StaticAnalysisFinding("c", 0, "").clean)
assertFalse(StaticAnalysisFinding("c", 1, "").clean)
}
}
@@ -28,4 +28,7 @@ data class OrchestratorEngines(
val workspacePolicy: WorkspacePolicy? = null,
val worldProbe: WorldProbe = FileSystemWorldProbe(),
val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = null,
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
val staticAnalysisRunner: StaticAnalysisRunner? = null,
)
@@ -0,0 +1,55 @@
package com.correx.core.kernel.orchestration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.nio.file.Path
/**
* Process-spawning [StaticAnalysisRunner]: splits each operator-configured command on whitespace
* into argv (no shell), runs it in the workspace root with stderr merged into stdout, and returns
* its exit code with the combined output. A spawn failure or timeout is reported as a non-zero exit
* so the gate treats a hung or unrunnable check as a failure rather than silently passing.
*
* Commands come from the operator's workflow config (trusted, like prompt files), not from the LLM,
* so they bypass the tool-approval pipeline by design. Mirrors the streaming/timeout shape of
* ShellTool to avoid pipe-buffer deadlock on large output.
*/
class ProcessStaticAnalysisRunner(
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
) : StaticAnalysisRunner {
override suspend fun run(workingDir: Path, command: String): StaticAnalysisRunResult =
withContext(Dispatchers.IO) {
val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() }
if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command")
val process = runCatching {
ProcessBuilder(argv).directory(workingDir.toFile()).redirectErrorStream(true).start()
}.getOrElse {
return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$command': ${it.message}")
}
runCatching {
withTimeout(timeoutMs) {
val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
val exit = process.waitFor()
StaticAnalysisRunResult(exit, outputDeferred.await())
}
}.getOrElse {
process.destroyForcibly()
val reason = if (it is TimeoutCancellationException) {
"static analysis '$command' timed out after ${timeoutMs}ms"
} else {
it.message ?: "error running '$command'"
}
StaticAnalysisRunResult(EXIT_NOT_RUN, reason)
}
}
private companion object {
const val DEFAULT_TIMEOUT_MS = 300_000L
const val EXIT_NOT_RUN = -1
val WHITESPACE = Regex("\\s+")
}
}
@@ -30,6 +30,8 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
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
@@ -151,6 +153,11 @@ 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 STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@SuppressWarnings(
"ForbiddenComment",
"UnusedParameter",
@@ -182,6 +189,7 @@ abstract class SessionOrchestrator(
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 workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
@@ -549,15 +557,7 @@ abstract class SessionOrchestrator(
is StageExecutionResult.Success -> {
emitToolArtifacts(sessionId, stageId, stageConfig)
emitLlmArtifacts(sessionId, stageId, stageConfig)
when (val produced = verifyProduces(sessionId, stageId, stageConfig)) {
is StageExecutionResult.Success ->
when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) {
is StageExecutionResult.Success ->
checkBriefEcho(sessionId, stageId, stageConfig)
is StageExecutionResult.Failure -> grounded
}
is StageExecutionResult.Failure -> produced
}
runPostStageGates(sessionId, stageId, stageConfig, effectives)
}
is StageExecutionResult.Failure -> outcome
@@ -1468,6 +1468,88 @@ abstract class SessionOrchestrator(
}
}
/**
* 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,
): StageExecutionResult {
val gates: List<suspend () -> StageExecutionResult> = listOf(
{ verifyProduces(sessionId, stageId, stageConfig) },
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
)
for (gate in gates) {
val result = gate()
if (result is StageExecutionResult.Failure) return result
}
return StageExecutionResult.Success(emptyList())
}
/**
* 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,
)
}
private suspend fun emitProcessResultEvents(
sessionId: SessionId,
stageId: StageId,
@@ -0,0 +1,22 @@
package com.correx.core.kernel.orchestration
import java.nio.file.Path
/** Result of running one static-analysis command: its exit code and combined stdout+stderr. */
data class StaticAnalysisRunResult(
val exitCode: Int,
val output: String,
)
/**
* Seam for running operator-configured static-analysis commands (compiler / detekt / formatters)
* as a deterministic harness step (role-reliability §5 static-first ordering). Implemented by a
* process-spawning adapter in infrastructure and injected, so the deterministic core never spawns
* processes itself and the gate stays unit-testable with a fake.
*
* The orchestrator records each result as a [com.correx.core.events.events.StaticAnalysisCompletedEvent]
* (invariant #9 environment observation) — replay reads the recorded facts and never re-runs the command.
*/
fun interface StaticAnalysisRunner {
suspend fun run(workingDir: Path, command: String): StaticAnalysisRunResult
}
@@ -0,0 +1,54 @@
package com.correx.core.kernel.orchestration
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Path
import kotlin.io.path.createTempDirectory
import kotlin.io.path.writeText
class ProcessStaticAnalysisRunnerTest {
private val runner = ProcessStaticAnalysisRunner()
private fun script(dir: Path, name: String, body: String): String {
val file = dir.resolve(name)
file.writeText("#!/bin/sh\n$body\n")
file.toFile().setExecutable(true)
return "./$name"
}
@Test
fun `clean command reports exit zero and captures output`() = runBlocking {
val dir = createTempDirectory("sa-ok")
val cmd = script(dir, "ok.sh", "echo all-good")
val result = runner.run(dir, cmd)
assertEquals(0, result.exitCode)
assertTrue(result.output.contains("all-good"), "output: ${result.output}")
}
@Test
fun `failing command reports nonzero exit and captures stderr`() = runBlocking {
val dir = createTempDirectory("sa-fail")
// stderr is merged into the captured output (redirectErrorStream).
val cmd = script(dir, "bad.sh", "echo boom 1>&2\nexit 2")
val result = runner.run(dir, cmd)
assertEquals(2, result.exitCode)
assertTrue(result.output.contains("boom"), "output: ${result.output}")
}
@Test
fun `unrunnable command is a failure, not a silent pass`() = runBlocking {
val dir = createTempDirectory("sa-missing")
val result = runner.run(dir, "./does-not-exist-xyz")
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
}
@Test
fun `blank command is a failure`() = runBlocking {
val dir = createTempDirectory("sa-blank")
val result = runner.run(dir, " ")
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
}
}
@@ -23,5 +23,10 @@ data class StageConfig(
// the declared set is scope creep — the dominant local-implementer failure — and is
// blocked by the plane-2 ManifestContainmentRule.
val writeManifest: List<String> = emptyList(),
// Operator-configured static-analysis commands (compiler/detekt/formatters) run as a
// deterministic harness step after this stage produces its output (role-reliability §5).
// A non-clean command fails the stage retryably with its output fed back, so only
// static-clean output reaches the downstream reviewer. Empty = no static-first step.
val staticAnalysis: List<String> = emptyList(),
val metadata: Map<String, String> = emptyMap(),
)
+8
View File
@@ -75,12 +75,20 @@ max_retries = 2
# stage may write — a FILE_WRITE outside it is blocked as scope creep. Left open
# here because the targets are task-specific; a task-scoped workflow would set e.g.
# writes = ["core/sessions/**", "testing/sessions/**"]
# Optional: `static_analysis` runs deterministic tools (compiler/detekt/formatters)
# against the patch BEFORE the reviewer (role-reliability §5). A non-clean command
# fails this stage retryably with its output fed back verbatim, so only static-clean
# code reaches the reviewer — the reviewer never spends inference on what tools catch
# for free. Commands are workspace-specific, so left commented; a Kotlin/Gradle repo
# would set e.g.
# static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"]
[[stages]]
id = "implementer"
prompt = "prompts/implementer.md"
needs = ["impl_plan"]
produces = [{ name = "patch", kind = "file_written" }]
allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"]
# static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"]
token_budget = 32768
max_retries = 3
@@ -42,6 +42,7 @@ private data class StageSection(
val needs: List<String> = emptyList(),
@param:JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
val writes: List<String> = emptyList(),
@param:JsonProperty("static_analysis") val staticAnalysis: List<String> = emptyList(),
@param:JsonProperty("token_budget") val tokenBudget: Int = 4096,
@param:JsonProperty("max_retries") val maxRetries: Int = 3,
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
@@ -106,6 +107,7 @@ class TomlWorkflowLoader(
needs = s.needs.map { ArtifactId(it) }.toSet(),
allowedTools = s.allowedTools.toSet(),
writeManifest = s.writes,
staticAnalysis = s.staticAnalysis,
tokenBudget = s.tokenBudget,
// Propagate the declared token budget to the inference completion cap.
// Without this the StageConfig default (maxTokens=2048) is used, truncating
@@ -156,6 +156,25 @@ class TomlWorkflowLoaderTest {
assertEquals(emptyList(), collectStage.writeManifest)
}
@Test
fun `static_analysis maps to the stage static-analysis commands`() {
val toml = validToml.replace(
"allowed_tools = [\"ShellTool\"]",
"allowed_tools = [\"ShellTool\"]\nstatic_analysis = [\"./gradlew compileKotlin\", \"./gradlew detekt\"]",
)
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(toml) }
val graph = loader.load(path)
val collectStage = graph.stages[graph.start]!!
assertEquals(listOf("./gradlew compileKotlin", "./gradlew detekt"), collectStage.staticAnalysis)
}
@Test
fun `static_analysis absent means no commands`() {
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) }
val graph = loader.load(path)
assertEquals(emptyList(), graph.stages[graph.start]!!.staticAnalysis)
}
@Test
fun `ground_references true maps to metadata key`() {
val toml = validToml.replace(