feat(workflow): deterministic plan lint (plan-pipeline §5, Slice 1)

First slice of the plan-generation pipeline epic. A pure-Kotlin PlanLinter runs over the
compiled freestyle ExecutionPlan (WorkflowGraph) before it is locked, complementing
ExecutionPlanCompiler (which throws on unreachable/unknown-kind/bad-edge) with the checks
it does NOT make:

- HARD unproduced_need — a stage needs an artifact no stage produces. Real gap: only the
  TOML loader checked this; the freestyle compiler did not, so such plans compiled and
  failed at runtime.
- HARD trap_state — a stage with no path to the terminal (inescapable loop / dead end).
  Uses terminal-reachability, NOT "any cycle", so legitimate verdict-gated review loops pass.
- SOFT stage_count / fan_out / empty_brief / duplicate_brief — scored, never blocking.

FreestyleDriver lints after compile and records PlanLintCompletedEvent (pure function of
the recorded plan → replay-safe, no env observation in v1); a hard failure rejects the plan
(ExecutionPlanRejectedEvent source="lint") before the operator is asked or the plan locks,
so a broken plan never executes. Pays off now for single-plan freestyle; becomes the
per-candidate filter when multi-candidate generation (Slice 2) lands.

Deferred to later slices: file-path grounding + symbol resolution (needs an index),
token-budget/ADR-bypass checks, and a dedicated :core:planning module.
This commit is contained in:
2026-06-15 00:26:22 +04:00
parent 618f6e91c0
commit 68136e77d7
7 changed files with 494 additions and 0 deletions
@@ -0,0 +1,36 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One deterministic plan-lint finding. [code] is a stable machine code (e.g. "unproduced_need",
* "trap_state", "fan_out"); [stageId] is the offending stage, or null for a plan-level finding.
*/
@Serializable
data class PlanLintFinding(
val code: String,
val stageId: String?,
val detail: String,
)
/**
* The deterministic lint of a compiled ExecutionPlan candidate (plan-pipeline-spec §5). Pure: a
* function of the recorded plan artifact alone (no environment observation in v1 — graph-structural
* checks only), so it is recomputable on replay without re-reading anything. Recorded so the operator
* and downstream selection see why a candidate was discarded or down-ranked.
*
* A non-empty [hardFailures] means the candidate is unfit and is rejected (it must not be locked/run);
* [softFindings] are scored, not blocking. [score] is the weighted soft-finding penalty (lower is
* better) used by the future tournament ranking (§9).
*/
@Serializable
@SerialName("PlanLintCompleted")
data class PlanLintCompletedEvent(
val sessionId: SessionId,
val candidateId: String,
val hardFailures: List<PlanLintFinding>,
val softFindings: List<PlanLintFinding>,
val score: Int,
) : EventPayload
@@ -11,6 +11,7 @@ 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.PlanLintCompletedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
@@ -112,6 +113,7 @@ val eventModule = SerializersModule {
subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(StaticAnalysisCompletedEvent::class)
subclass(PlanLintCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class)
@@ -0,0 +1,58 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.PlanLintCompletedEvent
import com.correx.core.events.events.PlanLintFinding
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PlanLintCompletedEventSerializationTest {
private val sample = PlanLintCompletedEvent(
sessionId = SessionId("sess-1"),
candidateId = "freestyle-sess-1",
hardFailures = listOf(
PlanLintFinding(code = "unproduced_need", stageId = "implement", detail = "needs 'design', produced by no stage"),
PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"),
),
softFindings = listOf(
PlanLintFinding(code = "fan_out", stageId = "analyze", detail = "5 outgoing edges"),
),
score = 3,
)
@Test
fun `round-trips as polymorphic EventPayload`() {
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"PlanLintCompleted\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `decodes hand-written PlanLintCompleted JSON`() {
val json = """
{"type":"PlanLintCompleted","sessionId":"s","candidateId":"c1",
"hardFailures":[{"code":"unproduced_need","stageId":"x","detail":"needs y"}],
"softFindings":[],"score":0}
""".trimIndent()
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertTrue(decoded is PlanLintCompletedEvent)
val event = decoded as PlanLintCompletedEvent
assertEquals("unproduced_need", event.hardFailures.single().code)
assertEquals("x", event.hardFailures.single().stageId)
assertTrue(event.softFindings.isEmpty())
}
@Test
fun `plan-level finding allows null stageId`() {
val withNull = sample.copy(
hardFailures = listOf(PlanLintFinding(code = "trap_state", stageId = null, detail = "plan-level")),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), withNull)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(withNull, decoded)
}
}