feat(kernel): brief echo-back gate (C-A1)

- BriefEcho model + BriefEchoMismatchEvent (registered + serialization test)
- BriefEchoExtractor: parses the planner's brief_echo block and the original
  brief's referenced-files/symbols/acceptance-criteria dimensions
- BriefEchoComparator: conservative diff — a dropped acceptance criterion or an
  invented file path halts; dropping a file/symbol alone does not
- checkBriefEcho gate chained into the post-stage flow after groundBriefReferences;
  opt-in via stageConfig.metadata["briefEcho"], fails the stage (retryable) on
  divergence with re-grounding feedback
- prompt/workflow activation (planner.md emitting brief_echo; role_pipeline
  metadata) left as live-QA follow-up

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:42:49 +00:00
parent 0e251d6083
commit 1df7af5b85
8 changed files with 388 additions and 1 deletions
@@ -0,0 +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 planner's structured restatement of the brief it was handed: the files it must touch, the
* symbols it must reach, and the acceptance criteria it must satisfy. The harness diffs this echo
* against the original brief before any plan is generated, so a divergence halts at the cheapest
* point in the pipeline. Also reused as the diff input shape for both sides of the comparison.
*/
@Serializable
data class BriefEcho(
val referencedFiles: Set<String> = emptySet(),
val symbols: Set<String> = emptySet(),
val acceptanceCriteria: Set<String> = emptySet(),
)
/**
* A stage echoed the brief but the echo diverged from the original, so the plan is rejected
* before generation ("halt before plan generation"). Recorded so replay explains the halt.
*
* - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief,
* missing from the echo — the planner failed to carry them forward.
* - [inventedFiles]: file paths in the echo that are not in the original brief — hallucinated paths.
*/
@Serializable
@SerialName("BriefEchoMismatch")
data class BriefEchoMismatchEvent(
val sessionId: SessionId,
val stageId: StageId,
val droppedFiles: Set<String>,
val droppedSymbols: Set<String>,
val droppedAcceptanceCriteria: Set<String>,
val inventedFiles: Set<String>,
) : EventPayload
@@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
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.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
@@ -109,6 +110,7 @@ val eventModule = SerializersModule {
subclass(WorkspaceStateObservedEvent::class)
subclass(RepoKnowledgeRetrievedEvent::class)
subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class)
@@ -0,0 +1,28 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.EventPayload
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.assertTrue
class BriefEchoEventSerializationTest {
@Test
fun `BriefEchoMismatchEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = BriefEchoMismatchEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
droppedFiles = setOf("core/kernel/Foo.kt"),
droppedSymbols = setOf("Foo"),
droppedAcceptanceCriteria = setOf("must compile"),
inventedFiles = setOf("core/kernel/Bar.kt"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"BriefEchoMismatch\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}