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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+72
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
-13
@@ -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(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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)
|
||||
}
|
||||
+131
@@ -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())
|
||||
}
|
||||
}
|
||||
+70
@@ -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 "[]")))
|
||||
}
|
||||
}
|
||||
+49
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user