merge: integrate feat/backlog-burndown into master

master had advanced 16 commits past feat/backlog-burndown's base, and the two
branches independently built four of the same features. Resolved 26 conflicts.

Overlap features — kept master's implementation (more complete / production-wired /
more robust), dropped the feature branch's parallel constellation:
- llama-server health probe: kept master's event-store-backed tps probe; dropped the
  branch's LlamaLivenessClient (liveness-only, throughput unwired).
- event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe.
- brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording);
  dropped the branch's exact-set-diff BriefEchoComparator/Extractor.
- static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner,
  wired); dropped the branch's structured-finding static_check stage (no-op seam).
  Its structured-findings model is filed as a follow-up in BACKLOG.

Feature-branch net-new work brought in and kept (master had none):
- native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer,
  dependency graph + gates, decompose, REST/CLI, TUI task board)
- critique-outcome producer (role-rel §6 — master had deferred it)
- stage-level plan checkpointing (C-A2, folded into runPostStageGates)
- CLAUDE.md/AGENTS.md L0 standing context
- cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser)

Verified: full Gradle compile (all modules + tests) green; tests pass for core:events,
core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go
go build + go test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
@@ -14,6 +14,7 @@ import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.StageId
import com.correx.core.sessions.BoundAgentInstructions
import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.WorkflowGraph
import java.util.UUID
@@ -108,3 +109,17 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
role = EntryRole.SYSTEM,
)
}
// CLAUDE.md / AGENTS.md injected as L0 standing context (feat/backlog-burndown).
fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry {
val content = instructions.content
return ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = content,
sourceType = "agentInstructions",
sourceId = "agent-instructions",
tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM,
)
}
@@ -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<CritiqueFindingsRecordedEvent>,
): List<CritiqueOutcomeCorrelatedEvent> =
reviews
.groupBy { it.role to it.modelHash }
.flatMap { (_, group) -> correlateCritic(sessionId, group) }
private fun correlateCritic(
sessionId: SessionId,
critic: List<CritiqueFindingsRecordedEvent>,
): List<CritiqueOutcomeCorrelatedEvent> {
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<String, Pair<CritiqueFindingsRecordedEvent, CritiqueFinding>>()
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,
)
}
}
}
@@ -2,6 +2,8 @@ package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.approvals.Tier
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.domain.ApprovalEngine
@@ -28,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.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
@@ -55,6 +59,8 @@ import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
@@ -64,6 +70,7 @@ import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskSummary
import com.correx.core.toolintent.SessionContextProjection
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy
@@ -75,6 +82,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.sessions.projections.EgressAllowlistProjection
import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId
@@ -129,7 +137,9 @@ import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
@@ -403,13 +413,16 @@ abstract class SessionOrchestrator(
} ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val agentInstructionsEntries = session.state.boundAgentInstructions
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
var accumulatedEntries =
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries
val contextPack = contextPackBuilder.build(
@@ -601,13 +614,7 @@ abstract class SessionOrchestrator(
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
return toolCalls.flatMap { toolCall ->
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
val parameters = runCatching {
val element = Json.parseToJsonElement(toolCall.function.arguments)
val jsonObject = element as? kotlinx.serialization.json.JsonObject ?: return@runCatching emptyMap()
jsonObject.entries.associate { (k, v) ->
k to ((v as? kotlinx.serialization.json.JsonPrimitive)?.content ?: v.toString()) as Any
}
}.getOrElse { emptyMap() }
val parameters = parseToolArguments(toolCall.function.arguments)
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
@@ -626,6 +633,7 @@ abstract class SessionOrchestrator(
toolName = toolCall.function.name,
tier = tier,
request = request,
capabilities = tool?.requiredCapabilities ?: emptySet(),
),
)
if (toolCall.function.name != STAGE_COMPLETE_TOOL &&
@@ -676,7 +684,10 @@ abstract class SessionOrchestrator(
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
reason = "blocked by tool-call policy",
// Record the specific rule rationale, not a generic string, so the audit
// trail (and task history) shows why a call was blocked.
reason = assessment.rationale.joinToString("; ")
.ifBlank { "blocked by tool-call policy" },
),
)
val sourceId = toolCall.id ?: invocationId.value
@@ -710,10 +721,15 @@ abstract class SessionOrchestrator(
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
// no approval needed
} else {
val approvalState = approvalRepository.getApprovalState(sessionId)
val activeGrants = approvalState.grants.values.toList()
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
// workspace root so PROJECT grants match later sessions on the same repo.
val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
val activeGrants = (sessionGrants + ledgerGrants).toList()
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
val approvalCtx = ApprovalContext(
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = null),
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
mode = ApprovalMode.PROMPT,
)
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
@@ -926,6 +942,10 @@ abstract class SessionOrchestrator(
): RiskSummary? {
val assessor = toolCallAssessor ?: return null
val policy = effectives.policy ?: return null
// Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents
// through the projection. Unioned with the static allow-list in NetworkHostRule; empty when
// nothing has been granted, which never narrows the static allow-list.
val sessionEgressHosts = resolveSessionEgressHosts(sessionId)
val assessment = assessor.assess(
ToolCallAssessmentInput(
request = request,
@@ -934,6 +954,8 @@ abstract class SessionOrchestrator(
probe = worldProbe,
paramRoles = tool?.paramRoles ?: emptyMap(),
writeManifest = writeManifest,
sessionEgressHosts = sessionEgressHosts,
session = resolveSessionContext(sessionId),
),
)
emit(
@@ -952,6 +974,28 @@ abstract class SessionOrchestrator(
return assessment.toRiskSummary()
}
/**
* Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through
* [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads
* the log, never live state); empty when no grants have been emitted.
*/
internal fun resolveSessionEgressHosts(sessionId: SessionId): Set<String> {
val projection = EgressAllowlistProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}
/**
* Folds this session's tool events through [SessionContextProjection] into the read-model the
* anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
* never live state); empty before anything has happened.
*/
internal fun resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
val projection = SessionContextProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}
private suspend fun buildSchemaEntries(
responseFormat: ResponseFormat,
stageId: StageId,
@@ -1230,6 +1274,10 @@ abstract class SessionOrchestrator(
toolName = toolCall.function.name,
exitCode = result.exitCode,
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
// session projections can read it — matching SandboxedToolExecutor, which
// already does this; the two completion paths were inconsistent.
structuredOutput = result.metadata,
affectedEntities = affected.map { it.toString() },
durationMs = 0,
tier = tier,
@@ -1360,6 +1408,33 @@ abstract class SessionOrchestrator(
)
}
/**
* Stage-level plan checkpointing (BACKLOG §C-A2): make plan adherence observable and replayable
* by emitting a first-class checkpoint event per stage, reconciling produced-vs-expected against
* the confirmed (locked) plan. Only runs on plan-driven (phase-2) sessions — i.e. a log that
* holds an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. The halt itself is owned
* by [verifyProduces]; this method only EMITS — a [StageCheckpointFailedEvent] accompanies that
* halt and surfaces *why* the plan diverged.
*/
private suspend fun emitStageCheckpoint(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
) {
if (eventStore.read(sessionId).none { it.payload is ExecutionPlanLockedEvent }) return
val expected = stageConfig.produces.map { it.name.value }.toSet()
if (expected.isEmpty()) return
val produced = eventStore.read(sessionId)
.mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId?.value }
.toSet()
val cp = StageCheckpointReconciler.reconcile(expected, produced)
if (cp.passed) {
emit(sessionId, StageCheckpointPassedEvent(sessionId, stageId, expected, produced))
} else {
emit(sessionId, StageCheckpointFailedEvent(sessionId, stageId, expected, produced, cp.missing))
}
}
/**
* Entity grounding (role-reliability §3): for a stage that opted in (`ground_references`),
* check every workspace-relative file path its brief named against the filesystem. Existence
@@ -1480,8 +1555,12 @@ abstract class SessionOrchestrator(
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
val produced = verifyProduces(sessionId, stageId, stageConfig)
if (produced is StageExecutionResult.Failure) return produced
// Stage-level plan checkpoint (C-A2): once a stage has produced its declared artifacts,
// record the checkpoint before the heavier brief/static gates run.
emitStageCheckpoint(sessionId, stageId, stageConfig)
val gates: List<suspend () -> StageExecutionResult> = listOf(
{ verifyProduces(sessionId, stageId, stageConfig) },
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
@@ -1749,6 +1828,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)
@@ -1764,11 +1844,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,
@@ -2022,6 +2117,7 @@ abstract class SessionOrchestrator(
*/
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
if (toolName == "shell") return shellCommandPreview(parameters)
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
if (toolName != "file_write") return null
val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String
@@ -2087,3 +2183,54 @@ internal sealed interface InferenceResult {
data class Failed(val reason: String) : InferenceResult
data object Cancelled : InferenceResult
}
/**
* Flattens an LLM tool call's JSON arguments into a [ToolRequest.parameters] map. A JSON array of
* primitives becomes a `List<String>` so list-param accessors read it — otherwise it arrived as a
* `.toString()` string and silently read empty, dropping e.g. a task's `affected_paths` (which in
* turn left the write-scope and cite-before-claim gates with nothing to enforce). A primitive
* becomes its content string; objects and arrays-of-objects keep their JSON text for tools that
* re-parse them (e.g. task_decompose). Malformed JSON yields an empty map.
*/
internal fun parseToolArguments(arguments: String): Map<String, Any> = runCatching {
val obj = Json.parseToJsonElement(arguments) as? JsonObject ?: return@runCatching emptyMap()
obj.entries.associate { (k, v) ->
k to when {
v is JsonPrimitive -> v.content
v is JsonArray && v.all { it is JsonPrimitive } -> v.map { (it as JsonPrimitive).content }
else -> v.toString()
} as Any
}
}.getOrElse { emptyMap() }
/**
* Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered
* child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic
* fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are
* re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments.
*/
internal fun renderDecomposePreview(parameters: Map<String, Any>): String? {
val project = parameters["project"] as? String ?: return null
val tasks = (parameters["tasks"] as? String)
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null
if (tasks.isEmpty()) return null
val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content }
val refToIndex = tasks.mapIndexedNotNull { i, e ->
((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i }
}.toMap()
val parentTitle = (parameters["parent"] as? String)
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject }
?.let { (it["title"] as? JsonPrimitive)?.content }
return buildString {
append("Decompose '").append(project).append("' into:")
parentTitle?.let { append("\n epic: ").append(it) }
tasks.forEachIndexed { i, e ->
val o = e as? JsonObject
append("\n ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)")
val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList())
.map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d }
if (afters.isNotEmpty()) append(" (after: ").append(afters.joinToString(", ")).append(")")
}
}
}
@@ -0,0 +1,23 @@
package com.correx.core.kernel.orchestration
/**
* The reconciliation of a stage's [produced] artifacts against the [expected] produces-slots of the
* locked plan. [missing] is the expected slots that were never produced; [passed] holds when none
* are missing. Extra produced artifacts beyond [expected] do not fail the checkpoint.
*/
internal data class StageCheckpoint(
val expected: Set<String>,
val produced: Set<String>,
) {
val missing: Set<String> get() = expected - produced
val passed: Boolean get() = missing.isEmpty()
}
/**
* Pure reconciler for stage-level plan checkpointing: compares the artifacts a stage produced
* against the artifacts the locked plan expected it to produce. Side-effect free and replay-safe.
*/
internal object StageCheckpointReconciler {
fun reconcile(expected: Set<String>, produced: Set<String>): StageCheckpoint =
StageCheckpoint(expected = expected, produced = produced)
}
@@ -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<CritiqueFinding>,
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<CritiqueOutcomeCorrelatedEvent>, 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())
}
}
@@ -0,0 +1,70 @@
package com.correx.core.kernel.orchestration
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ParseToolArgumentsTest {
@Test
fun `a string array becomes a List so list-param accessors read it`() {
// Regression: arrays were stringified and read back empty, dropping a task's affected_paths.
val params = parseToolArguments("""{"affected_paths":["apps/web/**","build.gradle"]}""")
assertEquals(listOf("apps/web/**", "build.gradle"), params["affected_paths"])
}
@Test
fun `primitives become their content strings`() {
val params = parseToolArguments("""{"project":"webui","force":true,"limit":5}""")
assertEquals("webui", params["project"])
assertEquals("true", params["force"])
assertEquals("5", params["limit"])
}
@Test
fun `objects and arrays-of-objects are kept as JSON text for tools that re-parse them`() {
val params = parseToolArguments(
"""{"parent":{"title":"Epic"},"tasks":[{"title":"A"},{"title":"B"}]}""",
)
assertTrue((params["parent"] as String).contains("\"title\""))
// re-parseable JSON, not a Kotlin List#toString
assertTrue((params["tasks"] as String).trim().startsWith("["))
assertTrue((params["tasks"] as String).contains("\"title\":\"A\""))
}
@Test
fun `malformed json yields an empty map`() {
assertTrue(parseToolArguments("not json").isEmpty())
assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object
}
@Test
fun `decompose preview renders the epic and children with dependency labels`() {
// Args arrive flattened: nested tasks/parent are JSON text, as in a real call.
val preview = renderDecomposePreview(
mapOf(
"project" to "webui",
"parent" to """{"title":"Frontend web UI"}""",
"tasks" to """
[
{"ref":"scaffold","title":"Scaffold app"},
{"title":"Auth view","depends_on":["scaffold"]},
{"title":"Dashboard","depends_on":["0"]}
]
""".trimIndent(),
),
)!!
assertTrue(preview.contains("epic: Frontend web UI"))
assertTrue(preview.contains("1. Scaffold app"))
// dependency labels resolve a ref and a numeric index back to the dependency's title.
assertTrue(preview.contains("2. Auth view (after: Scaffold app)"))
assertTrue(preview.contains("3. Dashboard (after: Scaffold app)"))
}
@Test
fun `decompose preview is null when tasks are unparseable so the caller falls back`() {
assertNull(renderDecomposePreview(mapOf("project" to "webui", "tasks" to "oops")))
assertNull(renderDecomposePreview(mapOf("tasks" to "[]")))
}
}
@@ -0,0 +1,49 @@
package com.correx.core.kernel.orchestration
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class StageCheckpointReconcilerTest {
@Test
fun `all expected produced - passed with no missing`() {
val cp = StageCheckpointReconciler.reconcile(
expected = setOf("plan.json", "diff.patch"),
produced = setOf("plan.json", "diff.patch"),
)
assertTrue(cp.passed)
assertTrue(cp.missing.isEmpty())
}
@Test
fun `one missing - not passed with correct missing set`() {
val cp = StageCheckpointReconciler.reconcile(
expected = setOf("plan.json", "diff.patch"),
produced = setOf("plan.json"),
)
assertFalse(cp.passed)
assertEquals(setOf("diff.patch"), cp.missing)
}
@Test
fun `empty expected - passed`() {
val cp = StageCheckpointReconciler.reconcile(
expected = emptySet(),
produced = setOf("plan.json"),
)
assertTrue(cp.passed)
assertTrue(cp.missing.isEmpty())
}
@Test
fun `extra produced beyond expected - still passed`() {
val cp = StageCheckpointReconciler.reconcile(
expected = setOf("plan.json"),
produced = setOf("plan.json", "notes.md", "scratch.txt"),
)
assertTrue(cp.passed)
assertTrue(cp.missing.isEmpty())
}
}