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
@@ -87,13 +87,18 @@ class DefaultApprovalEngine : ApprovalEngine {
private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean =
when (scope) {
// SESSION grants are scoped to a specific tool name.
// A null toolName on either side means the binding is absent; treat as no-match
// to prevent a legacy/malformed grant from becoming a blanket approval.
is GrantScope.SESSION -> scope.toolName != null
&& requestToolName != null
&& scope.toolName == requestToolName
// SESSION/PROJECT/GLOBAL grants are bound to a specific tool name. A null toolName on
// either side means the binding is absent; treat as no-match so a legacy/malformed grant
// can never become a blanket approval. PROJECT additionally requires the active session's
// workspace to resolve to the same projectId; GLOBAL applies to every session.
is GrantScope.SESSION -> toolBound(scope.toolName, requestToolName)
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
is GrantScope.PROJECT -> toolBound(scope.toolName, requestToolName)
&& context.identity.projectId != null
&& context.identity.projectId == scope.projectId
is GrantScope.GLOBAL -> toolBound(scope.toolName, requestToolName)
}
private fun toolBound(grantToolName: String?, requestToolName: String?): Boolean =
grantToolName != null && requestToolName != null && grantToolName == requestToolName
}
@@ -0,0 +1,99 @@
package com.correx.core.approvals.domain
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.Tier
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalGrant
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.sessions.ApprovalMode
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* Scope matching for the cross-session grant scopes (PROJECT, GLOBAL). A matching grant turns a
* request that would otherwise PROMPT into an AUTO_APPROVED COMPLETED decision; a non-match leaves
* it PENDING (PROMPT mode, tier above threshold).
*/
class GrantScopeMatchingTest {
private val engine = DefaultApprovalEngine()
private val now = Instant.parse("2026-06-21T00:00:00Z")
private val projectA = ProjectId("/repo/a")
private val projectB = ProjectId("/repo/b")
private fun request(tool: String, tier: Tier = Tier.T3) = DomainApprovalRequest(
id = ApprovalRequestId("req-1"),
tier = tier,
validationReportId = ValidationReportId("vr-1"),
riskSummaryId = null,
timestamp = now,
toolName = tool,
)
private fun context(projectId: ProjectId?) = ApprovalContext(
identity = ApprovalScopeIdentity(SessionId("s-1"), stageId = null, projectId = projectId),
mode = ApprovalMode.PROMPT,
)
private fun grant(scope: GrantScope, tiers: Set<Tier> = setOf(Tier.T4)) = ApprovalGrant(
id = GrantId("g-1"),
scope = scope,
permittedTiers = tiers,
reason = "test",
timestamp = now,
)
private fun isAutoApproved(d: com.correx.core.approvals.model.ApprovalDecision) =
d.state == ApprovalStatus.COMPLETED && d.isApproved
@Test
fun `GLOBAL grant auto-approves the bound tool in any project`() {
val g = grant(GrantScope.GLOBAL("write_file"))
val decision = engine.evaluate(request("write_file"), context(projectA), listOf(g), now)
assert(isAutoApproved(decision)) { "GLOBAL grant should auto-approve its tool anywhere" }
assertEquals("grant:g-1", decision.reason)
}
@Test
fun `GLOBAL grant does not approve a different tool`() {
val g = grant(GrantScope.GLOBAL("write_file"))
val decision = engine.evaluate(request("shell"), context(projectA), listOf(g), now)
assertEquals(ApprovalStatus.PENDING, decision.state)
assertNull(decision.outcome)
}
@Test
fun `PROJECT grant approves only the matching project`() {
val g = grant(GrantScope.PROJECT(projectA, "shell"))
val match = engine.evaluate(request("shell"), context(projectA), listOf(g), now)
assert(isAutoApproved(match)) { "PROJECT grant should approve in its own project" }
val otherProject = engine.evaluate(request("shell"), context(projectB), listOf(g), now)
assertEquals(ApprovalStatus.PENDING, otherProject.state)
}
@Test
fun `PROJECT grant does not approve when the session has no project identity`() {
val g = grant(GrantScope.PROJECT(projectA, "shell"))
val decision = engine.evaluate(request("shell"), context(projectId = null), listOf(g), now)
assertEquals(ApprovalStatus.PENDING, decision.state)
}
@Test
fun `a wide grant with no tier ceiling auto-approves a T4 request`() {
// The operator opted out of the prior T2 cap: a grant whose permittedTiers reach T4
// authorizes up to T4 (ceiling semantics), so even a destructive call auto-clears.
val g = grant(GrantScope.GLOBAL("delete"), tiers = setOf(Tier.T4))
val decision = engine.evaluate(request("delete", tier = Tier.T4), context(projectA), listOf(g), now)
assert(isAutoApproved(decision)) { "T4 request should auto-approve under a T4-permitting grant" }
}
}
@@ -0,0 +1,17 @@
package com.correx.core.config
import kotlinx.serialization.Serializable
/**
* Standing agent instructions discovered at the bound workspace root (`CLAUDE.md` and/or
* `AGENTS.md`). Unlike the curated `.correx/project.toml` ProjectProfile, these are the
* free-form markdown briefs the operator already maintains for coding agents: injected as
* L0 for every stage of every session bound to the workspace.
*/
@Serializable
data class AgentInstructions(
val sources: List<String> = emptyList(),
val content: String = "",
) {
fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank()
}
@@ -0,0 +1,35 @@
package com.correx.core.config
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
/**
* Discovers standing agent instructions (`CLAUDE.md`, `AGENTS.md`) at the bound workspace
* root only — no parent walk, no nested lookup. Mirrors [ProjectProfileLoader]: the loaded
* snapshot is bound as an event so replay reads the recorded fact, never the live file.
*/
object AgentInstructionsLoader {
private val FILE_NAMES = listOf("CLAUDE.md", "AGENTS.md")
fun load(workspaceRoot: String): AgentInstructions {
val sections = mutableListOf<String>()
val sources = mutableListOf<String>()
for (name in FILE_NAMES) {
val contents = readIfPresent(Paths.get(workspaceRoot, name))
if (contents == null || contents.isBlank()) continue
sources.add(name)
sections.add("# $name\n\n${contents.trim()}")
}
if (sections.isEmpty()) return AgentInstructions()
return AgentInstructions(sources = sources, content = sections.joinToString("\n\n"))
}
private fun readIfPresent(path: Path): String? {
if (!Files.exists(path)) return null
return runCatching { Files.readString(path) }.getOrElse { e ->
System.err.println("Warning: Failed to read agent instructions at $path: ${e.message}")
null
}
}
}
@@ -57,8 +57,11 @@ internal object SimpleToml {
var depth = 0
var inQuotes = false
var quoteChar = ' '
var escaped = false
for (ch in value) {
when {
escaped -> escaped = false
ch == '\\' && inQuotes && quoteChar == '"' -> escaped = true
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch }
ch == quoteChar && inQuotes -> inQuotes = false
ch == '[' && !inQuotes -> depth++
@@ -92,8 +95,13 @@ internal object SimpleToml {
var current = StringBuilder()
var inQuotes = false
var quoteChar = ' '
var escaped = false
for (ch in content) {
when {
// Keep escape sequences (e.g. \") intact so they reach stripQuotes verbatim; an
// escaped quote must not be treated as the string's closing delimiter.
escaped -> { current.append(ch); escaped = false }
ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true }
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) }
ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) }
ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() }
@@ -104,10 +112,39 @@ internal object SimpleToml {
return result
}
/**
* Strips the surrounding quotes from [value] and, for double-quoted strings, unescapes the
* `\\` and `\"` sequences emitted by the writers (mirrors [ProjectProfileWriter]/[CorrexConfigWriter]).
*/
private fun stripQuotes(value: String): String {
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"")
val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"") && value.length >= 2
val isSingleQuoted = value.startsWith("'") && value.endsWith("'") && value.length >= 2
return when {
isDoubleQuoted -> unescapeDoubleQuoted(value.substring(1, value.length - 1))
isSingleQuoted -> value.substring(1, value.length - 1)
else -> value
}
}
/** Reverses the writer's escaping: `\\` -> `\`, `\"` -> `"`; a trailing lone `\` is kept as-is. */
private fun unescapeDoubleQuoted(value: String): String {
if ('\\' !in value) return value
val sb = StringBuilder(value.length)
var i = 0
while (i < value.length) {
val ch = value[i]
if (ch == '\\' && i + 1 < value.length) {
val next = value[i + 1]
if (next == '\\' || next == '"') {
sb.append(next)
i += 2
continue
}
}
sb.append(ch)
i++
}
return sb.toString()
}
}
@@ -0,0 +1,64 @@
package com.correx.core.config
import java.nio.file.Files
/**
* Serializes a [ProjectProfile] back to TOML text that [ProjectProfileLoader] reads identically:
* the invariant is `load(write(profile)) == profile` for every field the loader understands
* (about, conventions, commands).
*
* Like [CorrexConfigWriter] this **regenerates** the file from the profile object — it does not
* preserve hand-written comments. Empty sections are skipped: a blank `about` and an empty
* `conventions`/`commands` produce no output for that part (the loader reconstructs the defaults
* from their absence), so a [ProjectProfile.isEmpty] profile serializes to an empty string.
*/
object ProjectProfileWriter {
/** Renders [profile] as TOML. String values escape `\` and `"`; empty sections are skipped. */
fun serialize(profile: ProjectProfile): String {
val b = StringBuilder()
if (profile.about.isNotBlank()) {
b.append("about = ").append(str(profile.about)).append('\n')
}
if (profile.conventions.isNotEmpty()) {
b.append("conventions = ").append(list(profile.conventions)).append('\n')
}
if (profile.commands.isNotEmpty()) {
if (b.isNotEmpty()) b.append('\n')
b.append("[commands]\n")
profile.commands.forEach { (key, value) ->
b.append(key).append(" = ").append(str(value)).append('\n')
}
}
return b.toString()
}
/**
* Writes [profile] to `<workspaceRoot>/.correx/project.toml`, creating the `.correx/` directory
* if it is missing. Overwrites any existing file (the writer owns the file once a save happens).
*/
fun write(workspaceRoot: String, profile: ProjectProfile) {
val path = ProjectProfileLoader.profilePath(workspaceRoot)
Files.createDirectories(path.parent)
Files.writeString(path, serialize(profile))
}
private fun str(v: String): String = "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun list(v: List<String>): String = "[" + v.joinToString(", ") { str(it) } + "]"
}
/**
* Appends [text] to this profile's conventions, de-duplicated by exact string match. A blank
* [text] is ignored (returns the profile unchanged). Used when promoting an idea-board entry into
* the curated profile so repeated promotions of the same text don't pile up duplicate conventions.
*/
fun ProjectProfile.withConvention(text: String): ProjectProfile {
val trimmed = text.trim()
if (trimmed.isBlank() || trimmed in conventions) return this
return copy(conventions = conventions + trimmed)
}
@@ -0,0 +1,57 @@
package com.correx.core.config
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
class AgentInstructionsLoaderTest {
@Test
fun `both files present yields both sections and sources`(@TempDir root: Path) {
Files.writeString(root.resolve("CLAUDE.md"), "Claude rules")
Files.writeString(root.resolve("AGENTS.md"), "Agents rules")
val loaded = AgentInstructionsLoader.load(root.toString())
assertEquals(listOf("CLAUDE.md", "AGENTS.md"), loaded.sources)
assertTrue(loaded.content.contains("# CLAUDE.md"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("Claude rules"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("Agents rules"), "content: ${loaded.content}")
assertFalse(loaded.isEmpty())
}
@Test
fun `only one file present yields that one`(@TempDir root: Path) {
Files.writeString(root.resolve("AGENTS.md"), "Agents only")
val loaded = AgentInstructionsLoader.load(root.toString())
assertEquals(listOf("AGENTS.md"), loaded.sources)
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("Agents only"), "content: ${loaded.content}")
assertFalse(loaded.content.contains("CLAUDE.md"), "content: ${loaded.content}")
}
@Test
fun `neither file present is empty`(@TempDir root: Path) {
val loaded = AgentInstructionsLoader.load(root.toString())
assertTrue(loaded.isEmpty())
assertEquals(AgentInstructions(), loaded)
}
@Test
fun `blank file is skipped`(@TempDir root: Path) {
Files.writeString(root.resolve("CLAUDE.md"), " \n ")
Files.writeString(root.resolve("AGENTS.md"), "Real content")
val loaded = AgentInstructionsLoader.load(root.toString())
assertEquals(listOf("AGENTS.md"), loaded.sources)
}
}
@@ -0,0 +1,79 @@
package com.correx.core.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class ProjectProfileWriterTest {
@TempDir
lateinit var tempDir: Path
@Test
fun `write then load round-trips about conventions and commands including embedded quotes`() {
val profile = ProjectProfile(
about = "Event-sourced kernel \"the\" orchestrator, Kotlin/JVM 21",
conventions = listOf(
"no bare try-catch",
"prefer the \"narrow\" interface", // a convention with a double-quote
),
commands = mapOf(
"build" to "./gradlew build",
"test" to "./gradlew check",
),
)
ProjectProfileWriter.write(tempDir.toString(), profile)
val loaded = ProjectProfileLoader.load(tempDir.toString())
assertEquals(profile.about, loaded.about)
assertEquals(profile.conventions, loaded.conventions)
assertEquals(profile.commands, loaded.commands)
assertEquals(profile, loaded)
}
@Test
fun `an empty profile serializes to an empty string and skips empty sections`() {
val serialized = ProjectProfileWriter.serialize(ProjectProfile())
assertEquals("", serialized)
assertFalse(serialized.contains("[commands]"))
}
@Test
fun `blank about is omitted but conventions and commands are still written`() {
val serialized = ProjectProfileWriter.serialize(
ProjectProfile(
about = " ",
conventions = listOf("c1"),
commands = mapOf("run" to "x"),
),
)
assertFalse(serialized.contains("about ="), "blank about must be skipped")
assertTrue(serialized.contains("conventions = [\"c1\"]"))
assertTrue(serialized.contains("[commands]"))
}
@Test
fun `withConvention appends a new convention`() {
val updated = ProjectProfile(conventions = listOf("a")).withConvention("b")
assertEquals(listOf("a", "b"), updated.conventions)
}
@Test
fun `withConvention de-dupes an exact existing convention`() {
val profile = ProjectProfile(conventions = listOf("no bare try-catch"))
val updated = profile.withConvention("no bare try-catch")
assertSame(profile, updated, "an exact duplicate must return the same profile unchanged")
assertEquals(listOf("no bare try-catch"), updated.conventions)
}
@Test
fun `withConvention ignores blank text`() {
val profile = ProjectProfile(conventions = listOf("a"))
assertSame(profile, profile.withConvention(" "))
}
}
+11
View File
@@ -0,0 +1,11 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
testImplementation "org.jetbrains.kotlin:kotlin-test"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,13 @@
package com.correx.core.critique
import com.correx.core.critique.model.CriticCalibrationState
import com.correx.core.events.events.StoredEvent
import com.correx.core.sessions.projections.Projection
class CriticCalibrationProjector(
private val reducer: CriticCalibrationReducer,
) : Projection<CriticCalibrationState> {
override fun initial(): CriticCalibrationState = CriticCalibrationState()
override fun apply(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState =
reducer.reduce(state, event)
}
@@ -0,0 +1,8 @@
package com.correx.core.critique
import com.correx.core.critique.model.CriticCalibrationState
import com.correx.core.events.events.StoredEvent
interface CriticCalibrationReducer {
fun reduce(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState
}
@@ -0,0 +1,22 @@
package com.correx.core.critique
import com.correx.core.critique.model.CalibrationKey
import com.correx.core.critique.model.CalibrationStats
import com.correx.core.critique.model.CriticCalibrationState
import com.correx.core.events.events.CritiqueOutcome
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.StoredEvent
class DefaultCriticCalibrationReducer : CriticCalibrationReducer {
override fun reduce(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState {
val payload = event.payload as? CritiqueOutcomeCorrelatedEvent ?: return state
val key = CalibrationKey(payload.modelHash, payload.role)
val current = state.byKey[key] ?: CalibrationStats()
val updated = when (payload.outcome) {
CritiqueOutcome.UPHELD -> current.copy(upheld = current.upheld + 1)
CritiqueOutcome.DISMISSED -> current.copy(dismissed = current.dismissed + 1)
CritiqueOutcome.INCONCLUSIVE -> current.copy(inconclusive = current.inconclusive + 1)
}
return state.copy(byKey = state.byKey + (key to updated))
}
}
@@ -0,0 +1,33 @@
package com.correx.core.critique.model
import com.correx.core.events.events.CritiqueRole
import kotlinx.serialization.Serializable
/** Composite key: critic precision is tracked per model and per role. */
@Serializable
data class CalibrationKey(val modelHash: String, val role: CritiqueRole)
/**
* Outcome tallies for one [CalibrationKey]. [precision] = upheld / (upheld + dismissed) — the
* share of *decisive* findings that held up — or null when no decisive findings exist yet.
* INCONCLUSIVE outcomes count toward [total] but never toward precision.
*/
@Serializable
data class CalibrationStats(
val upheld: Int = 0,
val dismissed: Int = 0,
val inconclusive: Int = 0,
) {
val total: Int get() = upheld + dismissed + inconclusive
val decisive: Int get() = upheld + dismissed
val precision: Double? get() = if (decisive == 0) null else upheld.toDouble() / decisive
}
/**
* Replay-built view of how each (model, role) critic has performed. Rebuilt from
* [com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent]s; never persisted directly.
*/
@Serializable
data class CriticCalibrationState(
val byKey: Map<CalibrationKey, CalibrationStats> = emptyMap(),
)
@@ -0,0 +1,156 @@
package com.correx.core.critique
import com.correx.core.critique.model.CalibrationKey
import com.correx.core.critique.model.CriticCalibrationState
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.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import kotlinx.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DefaultCriticCalibrationReducerTest {
private val reducer = DefaultCriticCalibrationReducer()
private val projector = CriticCalibrationProjector(reducer)
private var seq = 0L
private fun stored(payload: EventPayload): StoredEvent {
seq += 1
return StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("s1"),
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
}
private fun outcome(
outcome: CritiqueOutcome,
modelHash: String = "m1",
role: CritiqueRole = CritiqueRole.CODE_REVIEWER,
severity: CritiqueSeverity = CritiqueSeverity.MAJOR,
) = stored(
CritiqueOutcomeCorrelatedEvent(
sessionId = SessionId("s1"),
findingId = "f-$seq",
role = role,
modelHash = modelHash,
severity = severity,
outcome = outcome,
),
)
private fun fold(events: List<StoredEvent>): CriticCalibrationState =
events.fold(projector.initial(), projector::apply)
@Test
fun `initial state is empty`() {
assertTrue(projector.initial().byKey.isEmpty())
}
@Test
fun `single UPHELD increments upheld and yields precision 1`() {
val state = fold(listOf(outcome(CritiqueOutcome.UPHELD)))
val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER))
assertEquals(1, stats.upheld)
assertEquals(0, stats.dismissed)
assertEquals(1, stats.total)
assertEquals(1.0, stats.precision)
}
@Test
fun `single DISMISSED increments dismissed and yields precision 0`() {
val state = fold(listOf(outcome(CritiqueOutcome.DISMISSED)))
val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER))
assertEquals(1, stats.dismissed)
assertEquals(0.0, stats.precision)
}
@Test
fun `INCONCLUSIVE counts toward total but not precision`() {
val state = fold(listOf(outcome(CritiqueOutcome.INCONCLUSIVE)))
val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER))
assertEquals(1, stats.inconclusive)
assertEquals(1, stats.total)
assertEquals(0, stats.decisive)
assertNull(stats.precision, "precision is undefined with no decisive findings")
}
@Test
fun `distinct model-role keys are tracked independently`() {
val state = fold(
listOf(
outcome(CritiqueOutcome.UPHELD, modelHash = "m1", role = CritiqueRole.CODE_REVIEWER),
outcome(CritiqueOutcome.DISMISSED, modelHash = "m2", role = CritiqueRole.CODE_REVIEWER),
outcome(CritiqueOutcome.UPHELD, modelHash = "m1", role = CritiqueRole.PLAN_CRITIC),
),
)
assertEquals(3, state.byKey.size)
assertEquals(1.0, state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)).precision)
assertEquals(0.0, state.byKey.getValue(CalibrationKey("m2", CritiqueRole.CODE_REVIEWER)).precision)
assertEquals(1.0, state.byKey.getValue(CalibrationKey("m1", CritiqueRole.PLAN_CRITIC)).precision)
}
@Test
fun `precision is computed over a mix of outcomes for one key`() {
val state = fold(
listOf(
outcome(CritiqueOutcome.UPHELD),
outcome(CritiqueOutcome.UPHELD),
outcome(CritiqueOutcome.UPHELD),
outcome(CritiqueOutcome.DISMISSED),
outcome(CritiqueOutcome.INCONCLUSIVE),
),
)
val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER))
assertEquals(3, stats.upheld)
assertEquals(1, stats.dismissed)
assertEquals(1, stats.inconclusive)
assertEquals(5, stats.total)
assertEquals(0.75, stats.precision) // 3 / (3 + 1)
}
@Test
fun `unrelated events pass through unchanged`() {
val state = fold(
listOf(
stored(
StageCompletedEvent(
sessionId = SessionId("s1"),
stageId = StageId("plan"),
transitionId = TransitionId("t1"),
),
),
outcome(CritiqueOutcome.UPHELD),
stored(
StageCompletedEvent(
sessionId = SessionId("s1"),
stageId = StageId("review"),
transitionId = TransitionId("t2"),
),
),
),
)
assertEquals(1, state.byKey.size)
assertEquals(1, state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)).upheld)
}
}
@@ -0,0 +1,31 @@
package com.correx.core.approvals
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import java.nio.file.Paths
/**
* Reserved event-store stream that holds cross-session approval grants (PROJECT and GLOBAL scopes).
*
* Session-scoped grants live in their own session's stream and die with it; project/global grants
* must outlive any single session and be visible to every other one, so they are appended here
* instead. The approval gate folds this ledger ([com.correx.core.approvals.DefaultApprovalReducer]
* over [DefaultApprovalRepository.getApprovalState]) and unions its grants with the running
* session's own before evaluating — see `SessionOrchestrator`. Revocation appends an
* [com.correx.core.events.events.ApprovalGrantExpiredEvent] to the same stream, which the reducer
* drops, so the audit trail stays intact (invariant #9: record facts, replay reads them).
*
* The id is a sentinel, not a real session; it never carries a workflow run.
*/
val GRANT_LEDGER_SESSION_ID = SessionId("__grant_ledger__")
/**
* Derives a stable [ProjectId] from a workspace root path so PROJECT-scoped grants created in one
* session match later sessions opened on the same repository. Both the grant-creation site (server)
* and the approval gate (orchestrator) run in the same process, so canonicalising to a normalised
* absolute path yields the same id on both sides regardless of how the root was spelled.
*/
object ProjectIdentity {
fun of(workspaceRoot: String): ProjectId =
ProjectId(Paths.get(workspaceRoot).toAbsolutePath().normalize().toString())
}
@@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable
sealed interface GrantScope {
// toolName binds this grant to a specific operation kind (e.g. "shell", "write_file").
// A null toolName is rejected at grant-creation time; it is kept nullable here only
// for backward-compatible deserialization of legacy events.
// for backward-compatible deserialization of legacy events. Every operator-creatable
// scope is tool-bound so a grant can never become a blanket "approve everything"
// (that is YOLO mode, configured separately).
@Serializable data class SESSION(val toolName: String? = null) : GrantScope
@Serializable data class STAGE(val stageId: StageId) : GrantScope
@Serializable data class PROJECT(val projectId: ProjectId) : GrantScope
/** Auto-approve [toolName] for any session whose workspace resolves to [projectId]. */
@Serializable data class PROJECT(val projectId: ProjectId, val toolName: String? = null) : GrantScope
/** Auto-approve [toolName] for every session on this machine (the widest scope). */
@Serializable data class GLOBAL(val toolName: String? = null) : GrantScope
}
@@ -0,0 +1,33 @@
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
/**
* One prior decision/ADR surfaced as semantically near a new architect decision.
* [source] is the L3 entry's turnId (or a decision id) the line came from; [score] is the
* similarity of the prior decision to the new one.
*/
@Serializable
data class RelatedDecision(
val summary: String,
val score: Double,
val source: String,
)
/**
* Display-only architect contradiction surfacing (BACKLOG §B-§4). Lists prior decisions
* semantically near the new one so the operator can spot a reversal. Non-blocking: this event
* only surfaces similarity candidates for an operator to eyeball — it never halts or fails a
* stage, and there is no LLM judge.
*/
@Serializable
@SerialName("PossibleContradictionFlagged")
data class PossibleContradictionFlaggedEvent(
val sessionId: SessionId,
val stageId: StageId,
val decisionSummary: String,
val related: List<RelatedDecision>,
) : EventPayload
@@ -0,0 +1,62 @@
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 critic's verdict for one review iteration. */
@Serializable
enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED }
/**
* The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its
* [verdict] for that iteration. This is the **producing side** of critique calibration: the
* reviewer-loop runtime correlates the sequence of these across iterations into
* [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the
* `:core:critique` projection then folds into per-(model, role) precision.
*
* [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the
* correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash]
* identifies the model that produced the findings (carried through to the outcome event so
* calibration is per-model).
*/
@Serializable
@SerialName("CritiqueFindingsRecorded")
data class CritiqueFindingsRecordedEvent(
val sessionId: SessionId,
val stageId: StageId,
val role: CritiqueRole,
val modelHash: String,
val iteration: Int,
val verdict: CritiqueVerdict,
val findings: List<CritiqueFinding>,
) : EventPayload
/**
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
* INCONCLUSIVE = no signal either way.
*/
@Serializable
enum class CritiqueOutcome { UPHELD, DISMISSED, INCONCLUSIVE }
/**
* Correlates one prior [CritiqueFinding] (by [findingId]) with how it actually turned out, so a
* critic's precision can be tracked per model and per role over time (the calibration projection
* in `:core:critique`). [modelHash] identifies the model that produced the finding; [severity]
* is carried so calibration can be sliced by severity band without re-reading the finding.
*
* This is the recording half only — who decides UPHELD/DISMISSED lives in the reviewer-loop
* runtime and is wired separately.
*/
@Serializable
@SerialName("CritiqueOutcomeCorrelated")
data class CritiqueOutcomeCorrelatedEvent(
val sessionId: SessionId,
val findingId: String,
val role: CritiqueRole,
val modelHash: String,
val severity: CritiqueSeverity,
val outcome: CritiqueOutcome,
) : EventPayload
@@ -0,0 +1,34 @@
package com.correx.core.events.events
import kotlinx.serialization.Serializable
/** Which critic role produced a [CritiqueFinding]. */
@Serializable
enum class CritiqueRole { PLAN_CRITIC, CODE_REVIEWER }
/** Severity of a [CritiqueFinding], highest-impact first. */
@Serializable
enum class CritiqueSeverity { BLOCKER, MAJOR, MINOR, NIT }
/**
* One structured finding emitted by a critic (plan critic) or reviewer (code reviewer),
* replacing the free-text verdict prose those roles produced before. Shared by both roles so
* findings can be counted, ranked, and calibrated uniformly.
*
* [id] is a stable identifier for the finding, used to correlate it with its eventual outcome
* (see [CritiqueOutcomeCorrelatedEvent]). [category] is a free-form classification
* (e.g. "correctness", "missing_requirement", "style"). [location] optionally points at a
* `file:line` or an artifact/stage reference the finding concerns.
*
* Distinct from `core.validation.ValidationIssue`, which models *structural* validation
* (graph/schema) rather than a critic's judgement.
*/
@Serializable
data class CritiqueFinding(
val id: String,
val role: CritiqueRole,
val severity: CritiqueSeverity,
val category: String,
val message: String,
val location: String? = null,
)
@@ -0,0 +1,21 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Grants additional egress [hosts] for this session (e.g. the hosts drawn from an approved
* research source list). Additive to the static `networkAllowedHosts` allow-list: a host is
* permitted if it matches the static list OR any host granted to the session, so egress can be
* widened per-session without loosening the global default. Host matching honours the same
* exact-or-suffix semantics the static allow-list uses (a granted "example.com" covers
* "api.example.com"). [reason] is free-form operator context (e.g. which source list was approved).
*/
@Serializable
@SerialName("EgressHostsGranted")
data class EgressHostsGrantedEvent(
val sessionId: SessionId,
val hosts: Set<String>,
val reason: String = "",
) : EventPayload
@@ -31,3 +31,19 @@ data class IdeaDiscardedEvent(
val ideaId: String,
val sessionId: SessionId,
) : EventPayload
/**
* The operator promoted an idea from the cross-session board into the curated per-repo profile
* (`<workspaceRoot>/.correx/project.toml`, as a `conventions` entry). Like [IdeaDiscardedEvent] this
* is a tombstone — the original [IdeaCapturedEvent] stays in the log (and replays), but the idea-board
* projection filters out any promoted [ideaId]. [sessionId] is the capturing session (so all of one
* idea's events stay co-located); [text] records the convention text written to the profile.
*/
@Serializable
@SerialName("IdeaPromoted")
data class IdeaPromotedEvent(
val ideaId: String,
val sessionId: SessionId,
val text: String,
val timestampMs: Long,
) : EventPayload
@@ -0,0 +1,41 @@
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 research T2 fetch completed and produced content at [contentSha256] (research-workflow-spec §3/§5).
*
* Promotes the fetch-quality + content-hash that previously lived only inside
* [ToolExecutionCompletedEvent] metadata into a first-class event, so "what sources were fetched"
* is queryable and renderable directly from the journal. Additive: the metadata write is retained
* for existing consumers. [quality] mirrors the extractor's quality marker as recorded in metadata.
*/
@Serializable
@SerialName("SourceFetched")
data class SourceFetchedEvent(
val sessionId: SessionId,
val stageId: StageId,
val url: String,
val contentSha256: String,
val quality: String,
val byteCount: Int = 0,
) : EventPayload
/**
* An extraction was retained but flagged low-quality — below the quality bar (research-workflow-spec §5)
* — for operator visibility. Emitted alongside [SourceFetchedEvent] when the fetch cleared rejection
* but fell under the minimum-content threshold (JS-rendered SPA, paywall); the content is still kept,
* the operator is simply warned that synthesis may want to route around it.
*/
@Serializable
@SerialName("LowQualityExtraction")
data class LowQualityExtractionEvent(
val sessionId: SessionId,
val stageId: StageId,
val url: String,
val contentSha256: String,
val reason: String,
) : EventPayload
@@ -1,6 +1,7 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -10,6 +11,20 @@ data class ChatSessionStartedEvent(
val sessionId: SessionId,
) : EventPayload
/**
* Records, in the session's own stream, that this run claimed [taskId] and is working it with the
* declared [affectedPaths] write scope — a session-local fact so the gates know the active task
* without scanning task streams. Re-emitted when the claimant edits affected_paths, so the folded
* scope stays current; SessionContextProjection takes the most recent.
*/
@Serializable
@SerialName("SessionWorkingTask")
data class SessionWorkingTaskEvent(
val sessionId: SessionId,
val taskId: TaskId,
val affectedPaths: List<String> = emptyList(),
) : EventPayload
@Serializable
@SerialName("SessionWorkspaceBound")
data class SessionWorkspaceBoundEvent(
@@ -37,3 +52,12 @@ data class ProjectProfileBoundEvent(
val conventions: List<String>,
val commands: Map<String, String>,
) : EventPayload
@Serializable
@SerialName("AgentInstructionsBound")
data class AgentInstructionsBoundEvent(
val sessionId: SessionId,
val workspaceRoot: String,
val sources: List<String>, // file names found, e.g. ["CLAUDE.md","AGENTS.md"]
val content: String, // concatenated, header-labeled instructions
) : EventPayload
@@ -0,0 +1,42 @@
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
/**
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
*
* The *passed* variant means every expected slot was produced: [producedArtifacts] is a superset of
* [expectedArtifacts] (extra artifacts beyond the expected set do not fail the checkpoint).
*/
@Serializable
@SerialName("StageCheckpointPassed")
data class StageCheckpointPassedEvent(
val sessionId: SessionId,
val stageId: StageId,
val expectedArtifacts: Set<String>,
val producedArtifacts: Set<String>,
) : EventPayload
/**
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
*
* The *failed* variant accompanies the stage halt owned by `verifyProduces` and surfaces *why* the
* plan diverged: [missingArtifacts] are the expected slots ([expectedArtifacts] minus
* [producedArtifacts]) the stage never produced.
*/
@Serializable
@SerialName("StageCheckpointFailed")
data class StageCheckpointFailedEvent(
val sessionId: SessionId,
val stageId: StageId,
val expectedArtifacts: Set<String>,
val producedArtifacts: Set<String>,
val missingArtifacts: Set<String>,
) : EventPayload
@@ -0,0 +1,142 @@
package com.correx.core.events.events
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Marker over every task-lifecycle event. Deliberately *not* part of the serialized
* [EventPayload] hierarchy — concrete events implement both interfaces — so projections
* can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a
* giant `when`. Status is never carried on the wire: it is derived by the task reducer
* from these facts (mirroring how session status is derived from stage events).
*/
sealed interface TaskEvent {
val taskId: TaskId
}
@Serializable
@SerialName("TaskCreated")
data class TaskCreatedEvent(
override val taskId: TaskId,
val projectId: ProjectId,
val key: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskClaimed")
data class TaskClaimedEvent(
override val taskId: TaskId,
val claimant: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReleased")
data class TaskReleasedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskBlocked")
data class TaskBlockedEvent(
override val taskId: TaskId,
val reason: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnblocked")
data class TaskUnblockedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskSubmittedForReview")
data class TaskSubmittedForReviewEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCompleted")
data class TaskCompletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReopened")
data class TaskReopenedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCancelled")
data class TaskCancelledEvent(
override val taskId: TaskId,
val reason: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskEdited")
data class TaskEditedEvent(
override val taskId: TaskId,
val title: String? = null,
val goal: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAcceptanceCriteriaSet")
data class TaskAcceptanceCriteriaSetEvent(
override val taskId: TaskId,
val criteria: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAffectedPathsSet")
data class TaskAffectedPathsSetEvent(
override val taskId: TaskId,
val paths: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskLinked")
data class TaskLinkedEvent(
override val taskId: TaskId,
val targetId: String,
// `linkType`, not `type`: the JSON polymorphic class discriminator is "type".
@SerialName("linkType") val type: TaskLinkType,
val targetKind: TaskTargetKind,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnlinked")
data class TaskUnlinkedEvent(
override val taskId: TaskId,
val targetId: String,
@SerialName("linkType") val type: TaskLinkType,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskNoteAdded")
data class TaskNoteAddedEvent(
override val taskId: TaskId,
val author: TaskNoteAuthor,
val body: String,
) : EventPayload, TaskEvent
/**
* Soft-delete tombstone. The event stays in the log (history is never rewritten); the board
* projection drops the task from active views. Distinct from cancellation, which is a
* lifecycle outcome that stays visible as CANCELLED.
*/
@Serializable
@SerialName("TaskDeleted")
data class TaskDeletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ToolCapability
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent(
val toolName: String,
val tier: Tier,
val request: ToolRequest,
// The tool's declared capabilities, recorded at emission so replay classifies the call by what
// it could actually do (e.g. FILE_READ/FILE_WRITE) without re-deriving from the live registry.
// Defaulted for backward compatibility: events written before this field deserialize as empty.
val capabilities: Set<ToolCapability> = emptySet(),
) : EventPayload
@Serializable
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
@@ -13,14 +14,19 @@ 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.SessionWorkingTaskEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
@@ -28,9 +34,11 @@ import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.JournalCompactedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.InferenceFailedEvent
@@ -46,6 +54,9 @@ import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SourceFetchedEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
@@ -63,6 +74,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskDeletedEvent
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
@@ -116,11 +143,13 @@ val eventModule = SerializersModule {
subclass(PlanLintCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(SessionWorkingTaskEvent::class)
subclass(ChatTurnEvent::class)
subclass(RouterNarrationEvent::class)
subclass(SessionWorkspaceBoundEvent::class)
subclass(OperatorProfileBoundEvent::class)
subclass(ProjectProfileBoundEvent::class)
subclass(AgentInstructionsBoundEvent::class)
subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class)
@@ -133,6 +162,31 @@ val eventModule = SerializersModule {
subclass(WorkflowProposedEvent::class)
subclass(IdeaCapturedEvent::class)
subclass(IdeaDiscardedEvent::class)
subclass(IdeaPromotedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(CritiqueFindingsRecordedEvent::class)
subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class)
subclass(PossibleContradictionFlaggedEvent::class)
subclass(SourceFetchedEvent::class)
subclass(LowQualityExtractionEvent::class)
subclass(EgressHostsGrantedEvent::class)
subclass(TaskCreatedEvent::class)
subclass(TaskClaimedEvent::class)
subclass(TaskReleasedEvent::class)
subclass(TaskBlockedEvent::class)
subclass(TaskUnblockedEvent::class)
subclass(TaskSubmittedForReviewEvent::class)
subclass(TaskCompletedEvent::class)
subclass(TaskReopenedEvent::class)
subclass(TaskCancelledEvent::class)
subclass(TaskEditedEvent::class)
subclass(TaskAcceptanceCriteriaSetEvent::class)
subclass(TaskAffectedPathsSetEvent::class)
subclass(TaskLinkedEvent::class)
subclass(TaskUnlinkedEvent::class)
subclass(TaskNoteAddedEvent::class)
subclass(TaskDeletedEvent::class)
}
}
@@ -19,6 +19,10 @@ typealias TransitionId = TypeId
typealias ArtifactId = TypeId
// Tasks types
typealias TaskId = TypeId
// Context types
typealias ContextPackId = TypeId
typealias ContextEntryId = TypeId
@@ -0,0 +1,18 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]),
* or sessions (by [SessionId]) — the target is stored as a plain id string plus a type.
*/
@Serializable
enum class TaskLinkType {
@SerialName("DEPENDS_ON") DEPENDS_ON,
@SerialName("BLOCKS") BLOCKS,
@SerialName("IMPLEMENTS") IMPLEMENTS,
@SerialName("RELATES_TO") RELATES_TO,
@SerialName("PRODUCED") PRODUCED,
@SerialName("CONTEXT") CONTEXT,
}
@@ -0,0 +1,11 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Who authored a task note — used to keep the agent and human lanes separable. */
@Serializable
enum class TaskNoteAuthor {
@SerialName("AGENT") AGENT,
@SerialName("HUMAN") HUMAN,
}
@@ -0,0 +1,16 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What a task link points at — recorded on the link so the context bundle dispatches resolution
* deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id.
*/
@Serializable
enum class TaskTargetKind {
@SerialName("TASK") TASK,
@SerialName("DOC") DOC,
@SerialName("ARTIFACT") ARTIFACT,
@SerialName("SESSION") SESSION,
}
@@ -0,0 +1,29 @@
package com.correx.core.sessions.projections
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.SessionId
/**
* Folds the additional egress hosts granted to one session. State is the running union of every
* [EgressHostsGrantedEvent.hosts] emitted for [sessionId]; grants are additive (set union) and
* unrelated events — including grants for other sessions — are ignored. The resulting
* `Set<String>` is the per-session input to
* [com.correx.core.toolintent.rules.EgressAllowlist.isAllowed], unioned with the static
* `networkAllowedHosts` at the network gate.
*/
class EgressAllowlistProjection(
private val sessionId: SessionId,
) : Projection<Set<String>> {
override fun initial(): Set<String> = emptySet()
override fun apply(state: Set<String>, event: StoredEvent): Set<String> {
val payload = event.payload
return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) {
state + payload.hosts
} else {
state
}
}
}
@@ -0,0 +1,21 @@
package com.correx.core.tools.contract
import kotlinx.serialization.Serializable
/**
* What a tool is allowed to do, independent of its name. Plane-2 (`core:toolintent`) rules dispatch
* on these — never on tool names — and they are recorded on
* [com.correx.core.events.events.ToolInvocationRequestedEvent] at emission, so replay classifies a
* call by the capability it actually had rather than re-deriving it from the live registry.
*
* Lives in `core:events` (the lowest module) so events can carry it; `core:tools` and everything
* above import it from here — the package name is unchanged, so the move is import-transparent.
*/
@Serializable
enum class ToolCapability {
FILE_READ,
FILE_WRITE,
NETWORK_ACCESS,
SHELL_EXEC,
PROCESS_SPAWN,
}
@@ -6,6 +6,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
@@ -81,6 +82,12 @@ class EventSerializationHardeningTest {
terminalStageId = stageId,
totalStages = 1,
),
"IdeaPromoted" to IdeaPromotedEvent(
ideaId = "idea-1",
sessionId = sessionId,
text = "cache the repo map across sessions",
timestampMs = 1_700_000_000_000,
),
)
@Test
@@ -0,0 +1,36 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.RelatedDecision
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 ContradictionEventSerializationTest {
@Test
fun `PossibleContradictionFlaggedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = PossibleContradictionFlaggedEvent(
sessionId = SessionId("s"),
stageId = StageId("architect"),
decisionSummary = "Use Postgres for the event store.",
related = listOf(
RelatedDecision(
summary = "Decided to use SQLite for the event store.",
score = 0.91,
source = "project:/repo",
),
),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(
encoded.contains("\"type\":\"PossibleContradictionFlagged\""),
"SerialName must be present: $encoded",
)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,33 @@
package com.correx.core.events.serialization
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.EventPayload
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CritiqueCalibrationEventSerializationTest {
@Test
fun `CritiqueOutcomeCorrelatedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = CritiqueOutcomeCorrelatedEvent(
sessionId = SessionId("s"),
findingId = "f-1",
role = CritiqueRole.CODE_REVIEWER,
modelHash = "sha256:abc",
severity = CritiqueSeverity.MAJOR,
outcome = CritiqueOutcome.UPHELD,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(
encoded.contains("\"type\":\"CritiqueOutcomeCorrelated\""),
"SerialName must be present: $encoded",
)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,24 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EgressEventSerializationTest {
@Test
fun `EgressHostsGrantedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = EgressHostsGrantedEvent(
sessionId = SessionId("s"),
hosts = setOf("example.com", "docs.rust-lang.org"),
reason = "approved research source list",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"EgressHostsGranted\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.types.SessionId
@@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest {
assertEquals(sample, decoded)
}
@Test
fun `AgentInstructionsBoundEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = AgentInstructionsBoundEvent(
sessionId = SessionId("s1"),
workspaceRoot = "/repo",
sources = listOf("CLAUDE.md", "AGENTS.md"),
content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"AgentInstructionsBound\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `round-trips with empty fields`() {
val sample: EventPayload = ProjectProfileBoundEvent(
@@ -0,0 +1,47 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.SourceFetchedEvent
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 ResearchSourceEventSerializationTest {
private val sessionId = SessionId("s")
private val stageId = StageId("st")
@Test
fun `SourceFetchedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = SourceFetchedEvent(
sessionId = sessionId,
stageId = stageId,
url = "https://example.com/a",
contentSha256 = "abc123",
quality = "OK",
byteCount = 4096,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"SourceFetched\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `LowQualityExtractionEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = LowQualityExtractionEvent(
sessionId = sessionId,
stageId = stageId,
url = "https://example.com/spa",
contentSha256 = "def456",
reason = "extracted 12 chars, below minimum 200",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"LowQualityExtraction\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,42 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
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 StageCheckpointEventSerializationTest {
@Test
fun `StageCheckpointPassedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = StageCheckpointPassedEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
expectedArtifacts = setOf("plan.json"),
producedArtifacts = setOf("plan.json", "notes.md"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StageCheckpointPassed\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `StageCheckpointFailedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = StageCheckpointFailedEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
expectedArtifacts = setOf("plan.json", "diff.patch"),
producedArtifacts = setOf("plan.json"),
missingArtifacts = setOf("diff.patch"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StageCheckpointFailed\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,68 @@
package com.correx.core.sessions.projections
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
class EgressAllowlistProjectionTest {
private val sessionId = SessionId("s1")
private val projection = EgressAllowlistProjection(sessionId)
private fun stored(payload: EventPayload, eventId: String = "e1", session: SessionId = sessionId) =
StoredEvent(
metadata = EventMetadata(
eventId = EventId(eventId),
sessionId = session,
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = 1L,
sessionSequence = 1L,
payload = payload,
)
private fun fold(vararg events: StoredEvent): Set<String> =
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
@Test
fun `initial state is empty`() {
assertEquals(emptySet(), projection.initial())
}
@Test
fun `grants fold into a union`() {
val result = fold(
stored(EgressHostsGrantedEvent(sessionId, setOf("a.com", "b.com")), "e1"),
stored(EgressHostsGrantedEvent(sessionId, setOf("b.com", "c.com")), "e2"),
)
assertEquals(setOf("a.com", "b.com", "c.com"), result)
}
@Test
fun `grants for other sessions are ignored`() {
val result = fold(
stored(EgressHostsGrantedEvent(sessionId, setOf("mine.com")), "e1"),
stored(EgressHostsGrantedEvent(SessionId("other"), setOf("theirs.com")), "e2"),
)
assertEquals(setOf("mine.com"), result)
}
@Test
fun `unrelated events are ignored`() {
val result = fold(
stored(InitialIntentEvent(sessionId, "do a thing"), "e1"),
stored(EgressHostsGrantedEvent(sessionId, setOf("kept.com")), "e2"),
)
assertEquals(setOf("kept.com"), result)
}
}
@@ -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())
}
}
@@ -2,6 +2,7 @@ package com.correx.core.router
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.router.model.Idea
@@ -10,28 +11,36 @@ import com.correx.core.router.model.Idea
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
* matching [IdeaDiscardedEvent] tombstones it (invariant #1 — the capture stays in the log).
* matching [IdeaDiscardedEvent] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture
* stays in the log).
*/
class IdeaReader(private val eventStore: EventStore) {
/** Active ideas (captured, not later discarded) across all sessions, newest first. */
/** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */
fun activeIdeas(): List<Idea> {
val discarded = mutableSetOf<String>()
val tombstoned = mutableSetOf<String>()
val captured = mutableListOf<Idea>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
is IdeaDiscardedEvent -> discarded += payload.ideaId
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
is IdeaPromotedEvent -> tombstoned += payload.ideaId
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
else -> Unit
}
}
return captured.filterNot { it.id in discarded }.sortedByDescending { it.capturedAtMs }
return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs }
}
/** The session that captured [ideaId], so a discard tombstone lands alongside its capture. */
/** The session that captured [ideaId], so a tombstone lands alongside its capture. */
fun sessionOf(ideaId: String): SessionId? =
capturedOf(ideaId)?.sessionId
/** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */
fun textOf(ideaId: String): String? =
capturedOf(ideaId)?.text
private fun capturedOf(ideaId: String): IdeaCapturedEvent? =
eventStore.allEvents()
.mapNotNull { it.payload as? IdeaCapturedEvent }
.firstOrNull { it.ideaId == ideaId }
?.sessionId
}
@@ -0,0 +1,100 @@
package com.correx.core.router
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class IdeaReaderTest {
private val session = SessionId("s-1")
@Test
fun `a promoted idea is filtered out of the board like a discard`() {
val store = fakeStore(
IdeaCapturedEvent("keep", session, "cache the repo map", timestampMs = 1),
IdeaCapturedEvent("promoted", session, "no bare try-catch", timestampMs = 2),
IdeaCapturedEvent("discarded", session, "old note", timestampMs = 3),
IdeaPromotedEvent("promoted", session, "no bare try-catch", timestampMs = 99),
IdeaDiscardedEvent("discarded", session),
)
val active = IdeaReader(store).activeIdeas()
assertEquals(listOf("keep"), active.map { it.id })
assertFalse(active.any { it.id == "promoted" }, "promoted idea must leave the board")
assertFalse(active.any { it.id == "discarded" }, "discarded idea must leave the board")
}
@Test
fun `textOf returns the captured text and null for an unknown idea`() {
val store = fakeStore(
IdeaCapturedEvent("i1", session, "events are the only source of truth", timestampMs = 1),
)
val reader = IdeaReader(store)
assertEquals("events are the only source of truth", reader.textOf("i1"))
assertNull(reader.textOf("missing"))
}
@Test
fun `textOf still resolves the text after the idea is promoted`() {
val store = fakeStore(
IdeaCapturedEvent("i1", session, "prefer data classes", timestampMs = 1),
IdeaPromotedEvent("i1", session, "prefer data classes", timestampMs = 5),
)
val reader = IdeaReader(store)
// The capture stays in the log (invariant #1), so the text remains readable.
assertEquals("prefer data classes", reader.textOf("i1"))
assertTrue(reader.activeIdeas().isEmpty())
}
private fun fakeStore(vararg payloads: EventPayload): EventStore = FakeEventStore(payloads.toList())
/** Minimal read-only [EventStore]: only [allEvents] is exercised by [IdeaReader]. */
private class FakeEventStore(payloads: List<EventPayload>) : EventStore {
private val stored: List<StoredEvent> = payloads.mapIndexed { index, payload ->
StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$index"),
sessionId = SessionId("s-1"),
timestamp = Instant.fromEpochMilliseconds(index.toLong()),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = index.toLong(),
sessionSequence = index.toLong(),
payload = payload,
)
}
override fun allEvents(): Sequence<StoredEvent> = stored.asSequence()
override suspend fun append(event: NewEvent): StoredEvent = throw NotImplementedError()
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = throw NotImplementedError()
override fun read(sessionId: SessionId): List<StoredEvent> = throw NotImplementedError()
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
throw NotImplementedError()
override fun lastSequence(sessionId: SessionId): Long? = throw NotImplementedError()
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = throw NotImplementedError()
override fun allSessionIds(): Set<SessionId> = throw NotImplementedError()
}
}
@@ -0,0 +1,7 @@
package com.correx.core.sessions
/** Snapshot of standing agent instructions (CLAUDE.md / AGENTS.md) bound at session start. */
data class BoundAgentInstructions(
val sources: List<String>,
val content: String,
)
@@ -1,5 +1,6 @@
package com.correx.core.sessions
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
@@ -61,6 +62,14 @@ class DefaultSessionReducer : SessionReducer {
else -> state.boundProjectProfile
}
val boundAgentInstructions = when (payload) {
is AgentInstructionsBoundEvent -> BoundAgentInstructions(
sources = payload.sources,
content = payload.content,
)
else -> state.boundAgentInstructions
}
return state.copy(
status = newStatus,
createdAt = createdAt,
@@ -68,6 +77,7 @@ class DefaultSessionReducer : SessionReducer {
boundWorkspace = boundWorkspace,
boundProfile = boundProfile,
boundProjectProfile = boundProjectProfile,
boundAgentInstructions = boundAgentInstructions,
)
}
}
@@ -10,4 +10,5 @@ data class SessionState(
val boundWorkspace: BoundWorkspace? = null,
val boundProfile: BoundProfile? = null,
val boundProjectProfile: BoundProjectProfile? = null,
val boundAgentInstructions: BoundAgentInstructions? = null,
)
+12
View File
@@ -0,0 +1,12 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
testImplementation(testFixtures(project(":testing:contracts")))
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,26 @@
package com.correx.core.tasks
/**
* A task reference parsed from a commit message: the task key, and whether it was named with a
* closing keyword (close/fix/resolve …) — which drives the task to DONE rather than IN_PROGRESS.
*/
data class CommitTaskRef(val key: String, val closing: Boolean)
/**
* Extracts task references from a commit message. A closing keyword before a key ("fixes auth-12")
* marks it closing; any other key mention ("see auth-12") is a plain reference; the closing form
* wins when a key appears both ways. Keys are matched loosely (`<word>-<n>`), which is safe because
* [GitTaskSync] only acts on keys that resolve to a real task — unknown matches are ignored.
*/
object CommitTaskParser {
private const val KEY = "[A-Za-z][A-Za-z0-9_]*-\\d+"
private val CLOSING = Regex("(?i)\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b\\s+#?($KEY)")
private val MENTION = Regex("\\b($KEY)\\b")
fun parse(message: String): List<CommitTaskRef> {
val byKey = LinkedHashMap<String, Boolean>()
CLOSING.findAll(message).forEach { byKey[it.groupValues[1]] = true }
MENTION.findAll(message).forEach { byKey.putIfAbsent(it.groupValues[1], false) }
return byKey.map { (key, closing) -> CommitTaskRef(key, closing) }
}
}
@@ -0,0 +1,147 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import kotlinx.datetime.Instant
/**
* Folds task events into [TaskState]. Field-update events always apply; lifecycle events
* apply only on a legal transition — an illegal transition is ignored and counted in
* [TaskState.invalidTransitions] (mirrors how `SessionState` tracks invalid transitions).
*/
class DefaultTaskReducer : TaskReducer {
override fun reduce(state: TaskState, event: StoredEvent): TaskState {
val payload = event.payload as? TaskEvent ?: return state
val ts = event.metadata.timestamp
val base = state.copy(
createdAt = state.createdAt ?: ts,
updatedAt = ts,
)
return reduceContent(state, base, payload, ts)
?: reduceTransition(state, base, payload)
}
/** Field updates that do not touch status. Returns null for lifecycle events. */
private fun reduceContent(
state: TaskState,
base: TaskState,
payload: TaskEvent,
ts: Instant,
): TaskState? = when (payload) {
is TaskCreatedEvent -> base.copy(
status = TaskStatus.TODO,
key = payload.key,
projectId = payload.projectId,
title = payload.title,
goal = payload.goal,
acceptanceCriteria = payload.acceptanceCriteria,
affectedPaths = payload.affectedPaths,
)
is TaskEditedEvent -> base.copy(
title = payload.title ?: state.title,
goal = payload.goal ?: state.goal,
)
is TaskAcceptanceCriteriaSetEvent -> base.copy(acceptanceCriteria = payload.criteria)
is TaskAffectedPathsSetEvent -> base.copy(affectedPaths = payload.paths)
is TaskLinkedEvent -> base.copy(
links = (state.links + TaskLink(payload.targetId, payload.type, payload.targetKind)).distinct(),
)
is TaskUnlinkedEvent -> base.copy(
links = state.links.filterNot {
it.targetId == payload.targetId && it.type == payload.type
},
)
is TaskNoteAddedEvent -> base.copy(
notes = state.notes + TaskNote(payload.author, payload.body, ts),
)
is TaskDeletedEvent -> base.copy(deleted = true)
else -> null
}
/** Lifecycle events: apply on a legal transition, else count it invalid. */
private fun reduceTransition(
state: TaskState,
base: TaskState,
payload: TaskEvent,
): TaskState {
val from = state.status
return when (payload) {
is TaskClaimedEvent ->
base.transitionTo(TaskStatus.IN_PROGRESS, from == TaskStatus.TODO) {
it.copy(claimant = payload.claimant)
}
is TaskReleasedEvent ->
base.transitionTo(TaskStatus.TODO, from == TaskStatus.IN_PROGRESS) {
it.copy(claimant = null)
}
is TaskBlockedEvent ->
base.transitionTo(TaskStatus.BLOCKED, from in WORKING)
is TaskUnblockedEvent ->
base.transitionTo(
if (state.claimant != null) TaskStatus.IN_PROGRESS else TaskStatus.TODO,
from == TaskStatus.BLOCKED,
)
is TaskSubmittedForReviewEvent ->
base.transitionTo(TaskStatus.IN_REVIEW, from == TaskStatus.IN_PROGRESS)
is TaskCompletedEvent ->
base.transitionTo(TaskStatus.DONE, from == TaskStatus.IN_REVIEW)
is TaskReopenedEvent ->
base.transitionTo(TaskStatus.TODO, from in TERMINAL) {
it.copy(claimant = null)
}
is TaskCancelledEvent ->
base.transitionTo(TaskStatus.CANCELLED, from in CANCELLABLE)
else -> state
} ?: state.copy(invalidTransitions = state.invalidTransitions + 1)
}
private inline fun TaskState.transitionTo(
to: TaskStatus,
legal: Boolean,
mutate: (TaskState) -> TaskState = { it },
): TaskState? = if (legal) mutate(copy(status = to)) else null
private companion object {
val WORKING = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW)
val TERMINAL = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
val CANCELLABLE = setOf(
TaskStatus.TODO,
TaskStatus.IN_PROGRESS,
TaskStatus.IN_REVIEW,
TaskStatus.BLOCKED,
)
}
}
@@ -0,0 +1,26 @@
package com.correx.core.tasks
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.core.sessions.projections.replay.EventReplayer
/**
* Reads a project's task board by replaying its stream. Mirrors `DefaultSessionRepository`:
* the repository is a thin read model over an [EventReplayer]; the event log is the source
* of truth and the board is rebuilt from it.
*/
class DefaultTaskRepository(
private val replayer: EventReplayer<TaskBoard>,
private val streamId: SessionId,
) {
fun board(): TaskBoard = replayer.rebuild(streamId)
fun getTask(taskId: TaskId): Task? =
board()[taskId]?.takeUnless { it.deleted }?.let { Task(taskId, it) }
fun list(): List<Task> =
board().filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
fun count(): Int = list().size
}
@@ -0,0 +1,86 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.serialization.Serializable
/** A single commit's identity + message — the unit [GitTaskSync] consumes. */
data class Commit(val sha: String, val message: String) {
fun shortSha(): String = if (sha.length <= SHORT_SHA) sha else sha.substring(0, SHORT_SHA)
private companion object {
const val SHORT_SHA = 7
}
}
/**
* Port for reading recent commits from the repo. Implemented in a higher layer (apps/server shells
* `git log`); core stays decoupled from process execution.
*/
interface GitCommitReader {
suspend fun recent(limit: Int): List<Commit>
}
@Serializable
data class TaskStatusChange(val key: String, val sha: String, val from: String, val to: String)
@Serializable
data class GitSyncReport(val changes: List<TaskStatusChange>)
/**
* Drives task status forward from git signals: a plain mention advances a task to IN_PROGRESS, a
* closing keyword advances it all the way to DONE (walking the legal claim → review → done path).
* It only ever advances — a task already at/past the target, or BLOCKED/CANCELLED, is left alone —
* so re-running over the same commits is idempotent. Each advance leaves a provenance note naming
* the commit.
*/
class GitTaskSync(private val service: TaskService) {
suspend fun sync(commits: List<Commit>): GitSyncReport {
val changes = mutableListOf<TaskStatusChange>()
for (commit in commits) {
for (ref in CommitTaskParser.parse(commit.message)) {
applyRef(ref, commit)?.let { changes += it }
}
}
return GitSyncReport(changes)
}
private suspend fun applyRef(ref: CommitTaskRef, commit: Commit): TaskStatusChange? {
val taskId = TaskId(ref.key)
val from = service.getTask(taskId)?.state?.status ?: return null
val target = if (ref.closing) TaskStatus.DONE else TaskStatus.IN_PROGRESS
if (from !in ADVANCEABLE || rank(from) >= rank(target)) return null
var current = from
while (current in ADVANCEABLE && rank(current) < rank(target)) {
advanceOne(taskId, current)
current = service.getTask(taskId)?.state?.status ?: break
}
if (current == from) return null
service.addNote(taskId, TaskNoteAuthor.AGENT, "[git ${commit.shortSha()}] ${from.name}${current.name}")
return TaskStatusChange(ref.key, commit.shortSha(), from.name, current.name)
}
private suspend fun advanceOne(taskId: TaskId, current: TaskStatus) {
when (current) {
TaskStatus.TODO -> service.claim(taskId, GIT_CLAIMANT)
TaskStatus.IN_PROGRESS -> service.submitForReview(taskId)
TaskStatus.IN_REVIEW -> service.complete(taskId)
else -> Unit
}
}
private fun rank(status: TaskStatus): Int = when (status) {
TaskStatus.TODO -> 0
TaskStatus.IN_PROGRESS -> 1
TaskStatus.IN_REVIEW -> 2
TaskStatus.DONE -> 3
else -> -1 // BLOCKED / CANCELLED sit off the forward path
}
private companion object {
const val GIT_CLAIMANT = "git"
val ADVANCEABLE = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW)
}
}
@@ -0,0 +1,8 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
data class Task(
val taskId: TaskId,
val state: TaskState,
)
@@ -0,0 +1,21 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Port for resolving an ARTIFACT link target (an `ArtifactId`) to an inline summary, so the
* context bundle can carry the producing stage/session and a content excerpt instead of a bare id.
* Implemented in a higher layer (apps/server reads the event log + CAS); `core:tasks` stays
* decoupled from storage. Returns null when the artifact can't be resolved — the link then stays a
* raw related link.
*/
interface TaskArtifactResolver {
suspend fun resolve(targetId: String): ResolvedArtifact?
}
@Serializable
data class ResolvedArtifact(
val stage: String?,
val sessionId: String?,
val excerpt: String?,
)
@@ -0,0 +1,6 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
/** All tasks in a project stream, keyed by id — the unit a single replay produces. */
typealias TaskBoard = Map<TaskId, TaskState>
@@ -0,0 +1,22 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.sessions.projections.Projection
/**
* Folds a per-project task stream into a [TaskBoard]. Each event is routed to its owning
* task by [TaskEvent.taskId]; non-task payloads leave the board untouched.
*/
class TaskBoardProjector(
private val reducer: TaskReducer,
) : Projection<TaskBoard> {
override fun initial(): TaskBoard = emptyMap()
override fun apply(state: TaskBoard, event: StoredEvent): TaskBoard {
val taskId = (event.payload as? TaskEvent)?.taskId ?: return state
val current = state[taskId] ?: TaskState()
return state + (taskId to reducer.reduce(current, event))
}
}
@@ -0,0 +1,163 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
/**
* Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded
* [com.correx.core.events.types.TaskTargetKind]: TASK targets resolve to [RelatedTask] dependencies
* (with live status), DOC targets inline via [documentResolver], ARTIFACT targets via
* [artifactResolver], SESSION targets via [sessionResolver]. Any kind whose resolver is absent or
* comes up empty falls back to a raw [RelatedLink].
*
* When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with
* semantically-relevant snippets over the task's title+goal. Retrieval and resolution failures are
* swallowed (the bundle still returns) so context assembly never fails on a flaky dependency.
*/
class TaskContextAssembler(
private val service: TaskService,
private val retriever: TaskKnowledgeRetriever? = null,
private val documentResolver: TaskDocumentResolver? = null,
private val artifactResolver: TaskArtifactResolver? = null,
private val sessionResolver: TaskSessionResolver? = null,
private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT,
) {
suspend fun assemble(taskId: TaskId): TaskContextBundle? {
val state = service.getTask(taskId)?.state ?: return null
val resolved = ResolvedLinks()
for (link in state.links) {
// Resolution dispatches on the link's recorded targetKind — no guessing from the id.
// A kind whose resolution comes up empty falls back to a raw related link.
when (link.targetKind) {
TaskTargetKind.TASK -> addTask(link, resolved)
TaskTargetKind.DOC -> addDocument(link, resolved)
TaskTargetKind.ARTIFACT -> addArtifact(link, resolved)
TaskTargetKind.SESSION -> addSession(link, resolved)
}
}
val blockedBy = computeBlockedBy(taskId, state)
return TaskContextBundle(
id = taskId.value,
key = state.key,
status = state.status.name,
title = state.title,
goal = state.goal,
acceptanceCriteria = state.acceptanceCriteria,
relevantFiles = state.affectedPaths,
blocked = blockedBy.isNotEmpty(),
blockedBy = blockedBy,
dependencies = resolved.dependencies,
documents = resolved.documents,
artifacts = resolved.artifacts,
sessions = resolved.sessions,
relatedLinks = resolved.related,
notes = state.notes.map { NoteEntry(it.author.name, it.body) },
relevantKnowledge = retrieveKnowledge(state),
)
}
/** Accumulators for one assembly pass — keeps the per-kind helpers to a single parameter. */
private class ResolvedLinks {
val dependencies = mutableListOf<RelatedTask>()
val documents = mutableListOf<TaskDocument>()
val artifacts = mutableListOf<TaskArtifact>()
val sessions = mutableListOf<RelatedSession>()
val related = mutableListOf<RelatedLink>()
}
private fun addTask(link: TaskLink, out: ResolvedLinks) {
val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull()
if (linked != null) {
out.dependencies += RelatedTask(
id = linked.taskId.value,
status = linked.state.status.name,
title = linked.state.title,
link = link.type.name,
)
} else {
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun addDocument(link: TaskLink, out: ResolvedLinks) {
val doc = documentResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() }
if (doc != null) {
out.documents += TaskDocument(
targetId = link.targetId,
type = link.type.name,
title = doc.title,
path = doc.path,
excerpt = doc.excerpt,
)
} else {
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun addArtifact(link: TaskLink, out: ResolvedLinks) {
val artifact = artifactResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() }
if (artifact != null) {
out.artifacts += TaskArtifact(
targetId = link.targetId,
type = link.type.name,
stage = artifact.stage,
sessionId = artifact.sessionId,
excerpt = artifact.excerpt,
)
} else {
out.related += RelatedLink(link.targetId, link.type.name)
}
}
private suspend fun addSession(link: TaskLink, out: ResolvedLinks) {
val session = sessionResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() }
if (session != null) {
out.sessions += RelatedSession(
targetId = link.targetId,
type = link.type.name,
status = session.status,
intent = session.intent,
workflowId = session.workflowId,
)
} else {
out.related += RelatedLink(link.targetId, link.type.name)
}
}
/**
* The task's unfinished dependency blockers, resolved over its project board (see [TaskGraph]),
* each labelled by the direction of the edge. Empty when the project board can't be read.
*/
private fun computeBlockedBy(taskId: TaskId, state: TaskState): List<RelatedTask> {
val board = state.projectId?.let { runCatching { service.list(it) }.getOrNull() } ?: return emptyList()
return TaskGraph.unmetBlockers(Task(taskId, state), board).map { b ->
val viaDependsOn = state.links.any {
it.targetKind == TaskTargetKind.TASK &&
it.type == TaskLinkType.DEPENDS_ON &&
it.targetId == b.taskId.value
}
RelatedTask(
id = b.taskId.value,
status = b.state.status.name,
title = b.state.title,
link = if (viaDependsOn) "DEPENDS_ON" else "BLOCKS",
)
}
}
private suspend fun retrieveKnowledge(state: TaskState): List<KnowledgeHit> {
val active = retriever ?: return emptyList()
val query = listOfNotNull(state.title, state.goal).joinToString(" ").trim()
if (query.isEmpty()) return emptyList()
return runCatching { active.retrieve(query, knowledgeLimit) }.getOrDefault(emptyList())
}
private companion object {
const val DEFAULT_KNOWLEDGE_LIMIT = 5
}
}
@@ -0,0 +1,113 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Token-optimized context bundle for a task — the single payload an agent needs to start work:
* the task itself, its acceptance criteria, resolved dependency tasks, the related (non-task)
* links it should fetch, the relevant files, and notes. Assembled by [TaskContextAssembler].
*/
@Serializable
data class TaskContextBundle(
val id: String,
val key: String?,
val status: String,
val title: String?,
val goal: String?,
val acceptanceCriteria: List<String>,
val relevantFiles: List<String>,
// Dependency-derived readiness (distinct from an explicit BLOCKED status): blockedBy holds the
// unfinished tasks this one is waiting on, so an agent calling task_context knows to wait.
val blocked: Boolean = false,
val blockedBy: List<RelatedTask> = emptyList(),
val dependencies: List<RelatedTask>,
val documents: List<TaskDocument>,
val artifacts: List<TaskArtifact> = emptyList(),
val sessions: List<RelatedSession> = emptyList(),
val relatedLinks: List<RelatedLink>,
val notes: List<NoteEntry>,
val relevantKnowledge: List<KnowledgeHit> = emptyList(),
) {
/** Compact, label-per-line rendering for tool output (cheaper than JSON for the model). */
fun render(): String = buildString {
appendLine("task $id [$status] ${title.orEmpty()}".trimEnd())
goal?.let { appendLine("goal: $it") }
if (blocked) appendLine("BLOCKED — waiting on ${blockedBy.size} unfinished dependency(ies):")
appendList("blocked_by", blockedBy) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
appendList("acceptance_criteria", acceptanceCriteria) { " - $it" }
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}")
appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" }
appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() }
appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() }
appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
appendList("notes", notes) { " - [${it.author}] ${it.body}" }
}.trimEnd()
private fun <T> StringBuilder.appendList(label: String, items: List<T>, line: (T) -> String) {
if (items.isEmpty()) return
appendLine("$label:")
items.forEach { appendLine(line(it)) }
}
}
/** A linked task resolved against the board: enough for the agent to judge readiness. */
@Serializable
data class RelatedTask(
val id: String,
val status: String,
val title: String?,
val link: String,
)
/** A link whose target resolved to a document (ADR/doc): title + excerpt inlined for the agent. */
@Serializable
data class TaskDocument(
val targetId: String,
val type: String,
val title: String,
val path: String,
val excerpt: String,
)
/** A link whose target resolved to a produced artifact: producing stage/session + content excerpt. */
@Serializable
data class TaskArtifact(
val targetId: String,
val type: String,
val stage: String?,
val sessionId: String?,
val excerpt: String?,
)
/** A link whose target resolved to an agent run: status + intent so the agent has the run's gist. */
@Serializable
data class RelatedSession(
val targetId: String,
val type: String,
val status: String,
val intent: String?,
val workflowId: String?,
)
/** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */
@Serializable
data class RelatedLink(
val targetId: String,
val type: String,
)
@Serializable
data class NoteEntry(
val author: String,
val body: String,
)
/** A semantically-retrieved snippet relevant to the task's goal (e.g. a repo file or doc). */
@Serializable
data class KnowledgeHit(
val source: String,
val text: String,
val score: Double,
)
@@ -0,0 +1,24 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.sessions.projections.Projection
/**
* Counts created tasks in a project stream. The numeric suffix of a human key
* (`<prefix>-<n>`) is taken from this count, so ids never collide even after a task is
* cancelled — it counts creations, not currently-live tasks.
*/
class TaskCounterProjection(
private val projectId: String,
) : Projection<TaskCounterState> {
override fun initial(): TaskCounterState =
TaskCounterState(projectId, 0)
override fun apply(
state: TaskCounterState,
event: StoredEvent,
): TaskCounterState =
if (event.payload is TaskCreatedEvent) state.copy(count = state.count + 1) else state
}
@@ -0,0 +1,6 @@
package com.correx.core.tasks
data class TaskCounterState(
val projectId: String,
val count: Int,
)
@@ -0,0 +1,21 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Port for resolving a link target (e.g. "adr-7" or a "docs/…/x.md" path) to an inline document
* summary, so the context bundle can carry the doc's title + excerpt instead of a bare id the
* agent has to fetch separately. Implemented in a higher layer (apps/server reads the repo's
* docs); `core:tasks` stays decoupled from the filesystem. Returns null when the target is not a
* resolvable document — the link then stays a raw related link.
*/
interface TaskDocumentResolver {
suspend fun resolve(targetId: String): ResolvedDocument?
}
@Serializable
data class ResolvedDocument(
val title: String,
val path: String,
val excerpt: String,
)
@@ -0,0 +1,54 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
/**
* Dependency reasoning over the task work graph — turns the `DEPENDS_ON`/`BLOCKS` link types from
* inert metadata into answerable questions: what is a task waiting on, what is it ready to be
* worked, and what does finishing it unblock.
*
* Two directions express the same edge, so both count: a `DEPENDS_ON B` link on A means A waits on
* B; a `BLOCKS A` link on B means the same. A blocker is "unmet" until it reaches a terminal
* status ([FINISHED]); a CANCELLED dependency no longer blocks. All reasoning is over the supplied
* board, so callers scope it (one project, or the whole cross-project set); a link to a task absent
* from the board is treated as already-resolved rather than an unknown blocker.
*/
object TaskGraph {
private val FINISHED = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
/** Tasks that must finish before [task] proceeds: its `DEPENDS_ON` targets plus tasks that `BLOCKS` it. */
fun blockers(task: Task, board: Collection<Task>): List<Task> {
val byId = board.associateBy { it.taskId.value }
val dependsOn = task.linkTargets(TaskLinkType.DEPENDS_ON).mapNotNull { byId[it] }
val inbound = board.filter { it.hasTaskLink(TaskLinkType.BLOCKS, task.taskId.value) }
return (dependsOn + inbound).distinctBy { it.taskId.value }
}
/** The blockers that have not finished — the concrete reason [task] cannot start yet. */
fun unmetBlockers(task: Task, board: Collection<Task>): List<Task> =
blockers(task, board).filter { it.state.status !in FINISHED }
/** True when [task] is TODO and has no unmet blockers — workable now (to be claimed, not assigned). */
fun isReady(task: Task, board: Collection<Task>): Boolean =
task.state.status == TaskStatus.TODO && unmetBlockers(task, board).isEmpty()
/** Every workable task on the board, in board order. */
fun ready(board: Collection<Task>): List<Task> =
board.filter { isReady(it, board) }
/** Tasks [task] is holding up: its own `BLOCKS` targets plus tasks that `DEPENDS_ON` it. */
fun blocking(task: Task, board: Collection<Task>): List<Task> {
val byId = board.associateBy { it.taskId.value }
val downstream = task.linkTargets(TaskLinkType.BLOCKS).mapNotNull { byId[it] }
val dependents = board.filter { it.hasTaskLink(TaskLinkType.DEPENDS_ON, task.taskId.value) }
return (downstream + dependents).distinctBy { it.taskId.value }
}
private fun Task.linkTargets(type: TaskLinkType): List<String> =
state.links.filter { it.targetKind == TaskTargetKind.TASK && it.type == type }.map { it.targetId }
private fun Task.hasTaskLink(type: TaskLinkType, targetId: String): Boolean =
state.links.any { it.targetKind == TaskTargetKind.TASK && it.type == type && it.targetId == targetId }
}
@@ -0,0 +1,64 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
/**
* Renders a task's event log as a human-readable lifecycle timeline. The log is the source of
* truth, so this is the audit trail straight from the facts — one line per event, in order,
* `<timestamp> <what happened>`. Useful for seeing what an agent actually did to a task
* (claim/submit/complete, notes, links, git-driven status) rather than just its current state.
*/
object TaskHistory {
fun render(events: List<StoredEvent>): String {
val lines = events.mapNotNull { e ->
val payload = e.payload as? TaskEvent ?: return@mapNotNull null
"${e.metadata.timestamp} ${describe(payload)}"
}
return if (lines.isEmpty()) "no history" else lines.joinToString("\n")
}
private fun describe(p: TaskEvent): String =
describeContent(p) ?: describeLifecycle(p) ?: (p::class.simpleName ?: "event")
private fun describeContent(p: TaskEvent): String? = when (p) {
is TaskCreatedEvent -> "created ${p.key}: ${p.title}"
is TaskEditedEvent -> "edited"
is TaskAcceptanceCriteriaSetEvent -> "acceptance criteria set (${p.criteria.size})"
is TaskAffectedPathsSetEvent -> "affected paths set (${p.paths.size})"
is TaskLinkedEvent -> "linked ${p.type} -> ${p.targetId} (${p.targetKind})"
is TaskUnlinkedEvent -> "unlinked ${p.type} -> ${p.targetId}"
is TaskNoteAddedEvent -> "note [${p.author}] ${p.body}"
is TaskDeletedEvent -> "deleted"
else -> null
}
private fun describeLifecycle(p: TaskEvent): String? = when (p) {
is TaskClaimedEvent -> "claimed by ${p.claimant}"
is TaskReleasedEvent -> "released"
is TaskBlockedEvent -> "blocked: ${p.reason}"
is TaskUnblockedEvent -> "unblocked"
is TaskSubmittedForReviewEvent -> "submitted for review"
is TaskCompletedEvent -> "completed"
is TaskReopenedEvent -> "reopened"
is TaskCancelledEvent -> "cancelled" + (p.reason?.let { ": $it" } ?: "")
else -> null
}
}
@@ -0,0 +1,11 @@
package com.correx.core.tasks
/**
* Port for semantic enrichment of a task context bundle. Implemented in a higher layer
* (apps/server bridges it to the L3 vector retriever) and injected into [TaskContextAssembler];
* `core:tasks` stays decoupled from the retrieval stack. Implementations must be side-effect-free
* and tolerate being called with an arbitrary natural-language query.
*/
interface TaskKnowledgeRetriever {
suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit>
}
@@ -0,0 +1,11 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
/** One edge in the work graph: a typed pointer from a task to another entity, tagged by kind. */
data class TaskLink(
val targetId: String,
val type: TaskLinkType,
val targetKind: TaskTargetKind,
)
@@ -0,0 +1,54 @@
package com.correx.core.tasks
/**
* Renders the task board to Markdown — a *disposable projection* of the event log, grouped by
* status. It is never a source of truth: regenerate it, don't hand-edit it. Pure and store-free
* so it can render either [TaskService.list] or [TaskService.listAll] and be unit tested directly.
*/
object TaskMarkdown {
// Active work first, terminal states last.
private val ORDER = listOf(
TaskStatus.IN_PROGRESS,
TaskStatus.IN_REVIEW,
TaskStatus.BLOCKED,
TaskStatus.TODO,
TaskStatus.DONE,
TaskStatus.CANCELLED,
)
fun render(tasks: List<Task>): String = buildString {
appendLine("# Tasks")
appendLine()
appendLine("_Generated from the correx event log — disposable; regenerate, don't edit._")
appendLine()
if (tasks.isEmpty()) {
appendLine("_No tasks._")
return@buildString
}
val byStatus = tasks.groupBy { it.state.status }
for (status in ORDER) {
val group = byStatus[status]?.sortedBy { it.taskId.value }.orEmpty()
if (group.isEmpty()) continue
appendLine("## ${heading(status)} (${group.size})")
appendLine()
group.forEach { appendLine(item(it)) }
appendLine()
}
}.trimEnd() + "\n"
private fun item(task: Task): String {
val check = if (task.state.status == TaskStatus.DONE) "x" else " "
val claim = task.state.claimant?.let { " _(@$it)_" }.orEmpty()
return "- [$check] **${task.taskId.value}** ${task.state.title.orEmpty()}$claim".trimEnd()
}
private fun heading(status: TaskStatus): String = when (status) {
TaskStatus.TODO -> "To do"
TaskStatus.IN_PROGRESS -> "In progress"
TaskStatus.IN_REVIEW -> "In review"
TaskStatus.BLOCKED -> "Blocked"
TaskStatus.DONE -> "Done"
TaskStatus.CANCELLED -> "Cancelled"
}
}
@@ -0,0 +1,11 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.datetime.Instant
/** A note on a task, kept in agent/human lanes so consumers can filter by author. */
data class TaskNote(
val author: TaskNoteAuthor,
val body: String,
val at: Instant,
)
@@ -0,0 +1,10 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
interface TaskReducer {
fun reduce(
state: TaskState,
event: StoredEvent,
): TaskState
}
@@ -0,0 +1,46 @@
package com.correx.core.tasks
/**
* Exact/substring task search. A task matches when every whitespace-separated term in the query
* appears (case-insensitive) in some searchable field; results are ranked by the strongest field a
* term hits (title > key > goal > acceptance criteria > notes), ties broken by id for stability.
* Pure and store-free so it can be reused over [TaskService.list]/[TaskService.listAll] and unit
* tested directly.
*/
object TaskSearch {
fun search(tasks: List<Task>, query: String): List<Task> {
val terms = query.trim().lowercase().split(WHITESPACE).filter { it.isNotBlank() }
if (terms.isEmpty()) return tasks
return tasks
.mapNotNull { task -> score(task, terms)?.let { task to it } }
.sortedWith(compareByDescending<Pair<Task, Int>> { it.second }.thenBy { it.first.taskId.value })
.map { it.first }
}
/** Sum of the best per-term field weight, or null when any term matches no field. */
private fun score(task: Task, terms: List<String>): Int? {
val s = task.state
val fields = listOf(
(s.title ?: "") to TITLE_WEIGHT,
(s.key ?: "") to KEY_WEIGHT,
(s.goal ?: "") to GOAL_WEIGHT,
s.acceptanceCriteria.joinToString(" ") to CRITERIA_WEIGHT,
s.notes.joinToString(" ") { it.body } to NOTES_WEIGHT,
).map { (text, weight) -> text.lowercase() to weight }
var total = 0
for (term in terms) {
val best = fields.filter { it.first.contains(term) }.maxOfOrNull { it.second } ?: return null
total += best
}
return total
}
private val WHITESPACE = Regex("\\s+")
private const val TITLE_WEIGHT = 5
private const val KEY_WEIGHT = 4
private const val GOAL_WEIGHT = 3
private const val CRITERIA_WEIGHT = 2
private const val NOTES_WEIGHT = 1
}
@@ -0,0 +1,232 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import kotlinx.datetime.Clock
import java.util.UUID
/**
* Write path for tasks: turns intents into events appended to the shared [EventStore].
* The event log stays the single source of truth — the board is rebuilt by replay, never
* stored. All task events for a project live in one stream ([TaskStreams.forProject]).
*
* Keys are `<project>-<n>`, so a task's project is recoverable from its id; mutating
* methods therefore need only the [TaskId]. They return the rebuilt [Task], or null when the
* task does not exist (so callers — e.g. tools — can report a clean failure). Mutations that
* are illegal for the current status are still appended but are no-ops on replay (the reducer
* counts them in `invalidTransitions`).
*/
@Suppress("TooManyFunctions")
class TaskService(
private val eventStore: EventStore,
private val clock: Clock = Clock.System,
) {
private val boardReplayer =
DefaultEventReplayer(eventStore, TaskBoardProjector(DefaultTaskReducer()))
fun board(projectId: ProjectId): TaskBoard =
boardReplayer.rebuild(TaskStreams.forProject(projectId))
fun list(projectId: ProjectId): List<Task> =
board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
/**
* Every task across every project, for a cross-project board. Enumerates the per-project task
* streams ([TaskStreams.PREFIX]) and rebuilds each — there is no global task stream, so this is
* the union of the per-project boards.
*/
fun listAll(): List<Task> =
eventStore.allSessionIds()
.filter { it.value.startsWith(TaskStreams.PREFIX) }
.flatMap { stream -> list(ProjectId(stream.value.removePrefix(TaskStreams.PREFIX))) }
fun getTask(taskId: TaskId): Task? {
val state = board(projectOf(taskId))[taskId] ?: return null
return if (state.deleted) null else Task(taskId, state)
}
/**
* A task's own events in order — the lifecycle audit trail straight from the log (survives
* soft-delete, unlike [getTask]). Empty when the task never existed. Render with [TaskHistory].
*/
fun history(taskId: TaskId): List<StoredEvent> =
eventStore.read(TaskStreams.forProject(projectOf(taskId)))
.filter { (it.payload as? TaskEvent)?.taskId == taskId }
/** Ranked text search over one project (when given) or the whole cross-project board. */
fun search(query: String, projectId: ProjectId? = null): List<Task> =
TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query)
/**
* Tasks ready to be picked up: TODO with every dependency satisfied (see [TaskGraph]). Surfaces
* workable tasks for an agent or human to claim — it never assigns. Scoped to one project when
* given, else the whole cross-project board.
*/
fun ready(projectId: ProjectId? = null): List<Task> =
TaskGraph.ready(projectId?.let { list(it) } ?: listAll())
/** Unfinished tasks that must complete before [taskId] can proceed (resolved within its project). */
fun blockers(taskId: TaskId): List<Task> {
val task = getTask(taskId) ?: return emptyList()
return TaskGraph.unmetBlockers(task, list(projectOf(taskId)))
}
/** Tasks that [taskId] is holding up — what completing it would unblock (within its project). */
fun blocking(taskId: TaskId): List<Task> {
val task = getTask(taskId) ?: return emptyList()
return TaskGraph.blocking(task, list(projectOf(taskId)))
}
/**
* Active (non-terminal) tasks in [projectId] whose title matches [title] after normalization
* (case- and whitespace-insensitive) — the backstop against an agent re-creating a task it
* should have found by search. A blank title never matches; a DONE/CANCELLED look-alike is not
* a duplicate (that work may legitimately recur).
*/
fun findDuplicates(projectId: ProjectId, title: String): List<Task> {
val needle = normalizeTitle(title)
if (needle.isEmpty()) return emptyList()
return list(projectId).filter {
it.state.status !in TERMINAL_STATUSES && normalizeTitle(it.state.title.orEmpty()) == needle
}
}
private fun normalizeTitle(title: String): String =
title.trim().lowercase().replace(WHITESPACE, " ")
suspend fun createTask(
projectId: ProjectId,
title: String,
goal: String,
acceptanceCriteria: List<String> = emptyList(),
affectedPaths: List<String> = emptyList(),
): Task {
val key = nextKey(projectId)
val taskId = TaskId(key)
append(
projectId,
TaskCreatedEvent(
taskId = taskId,
projectId = projectId,
key = key,
title = title,
goal = goal,
acceptanceCriteria = acceptanceCriteria,
affectedPaths = affectedPaths,
),
)
return requireNotNull(getTask(taskId)) { "task $key missing after creation" }
}
suspend fun edit(taskId: TaskId, title: String? = null, goal: String? = null): Task? =
mutate(taskId) { TaskEditedEvent(it, title = title, goal = goal) }
suspend fun setAcceptanceCriteria(taskId: TaskId, criteria: List<String>): Task? =
mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) }
suspend fun setAffectedPaths(taskId: TaskId, paths: List<String>): Task? =
mutate(taskId) { TaskAffectedPathsSetEvent(it, paths) }
suspend fun claim(taskId: TaskId, claimant: String): Task? =
mutate(taskId) { TaskClaimedEvent(it, claimant) }
suspend fun release(taskId: TaskId): Task? =
mutate(taskId) { TaskReleasedEvent(it) }
suspend fun block(taskId: TaskId, reason: String): Task? =
mutate(taskId) { TaskBlockedEvent(it, reason) }
suspend fun unblock(taskId: TaskId): Task? =
mutate(taskId) { TaskUnblockedEvent(it) }
suspend fun submitForReview(taskId: TaskId): Task? =
mutate(taskId) { TaskSubmittedForReviewEvent(it) }
suspend fun complete(taskId: TaskId): Task? =
mutate(taskId) { TaskCompletedEvent(it) }
suspend fun reopen(taskId: TaskId): Task? =
mutate(taskId) { TaskReopenedEvent(it) }
suspend fun cancel(taskId: TaskId, reason: String? = null): Task? =
mutate(taskId) { TaskCancelledEvent(it, reason) }
suspend fun link(taskId: TaskId, targetId: String, type: TaskLinkType, targetKind: TaskTargetKind): Task? =
mutate(taskId) { TaskLinkedEvent(it, targetId, type, targetKind) }
suspend fun unlink(taskId: TaskId, targetId: String, type: TaskLinkType): Task? =
mutate(taskId) { TaskUnlinkedEvent(it, targetId, type) }
suspend fun addNote(taskId: TaskId, author: TaskNoteAuthor, body: String): Task? =
mutate(taskId) { TaskNoteAddedEvent(it, author, body) }
/** Soft delete: appends a tombstone. After this, [getTask] returns null and [list] omits it. */
suspend fun delete(taskId: TaskId): Boolean {
if (getTask(taskId) == null) return false
append(projectOf(taskId), TaskDeletedEvent(taskId))
return true
}
private suspend fun mutate(taskId: TaskId, event: (TaskId) -> EventPayload): Task? {
if (getTask(taskId) == null) return null
append(projectOf(taskId), event(taskId))
return getTask(taskId)
}
private fun nextKey(projectId: ProjectId): String {
val count = DefaultEventReplayer(eventStore, TaskCounterProjection(projectId.value))
.rebuild(TaskStreams.forProject(projectId)).count
return "${projectId.value}-${count + 1}"
}
private fun projectOf(taskId: TaskId): ProjectId =
ProjectId(taskId.value.substringBeforeLast('-'))
private suspend fun append(projectId: ProjectId, payload: EventPayload) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = TaskStreams.forProject(projectId),
timestamp = clock.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
),
)
}
private companion object {
val TERMINAL_STATUSES = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
val WHITESPACE = Regex("\\s+")
}
}
@@ -0,0 +1,21 @@
package com.correx.core.tasks
import kotlinx.serialization.Serializable
/**
* Port for resolving a SESSION link target (a `SessionId`) to an inline summary, so the context
* bundle can carry the run's status/intent instead of a bare id. Implemented in a higher layer
* (apps/server projects the session's events); `core:tasks` stays decoupled from the session
* aggregate. Returns null when the session can't be resolved — the link then stays a raw related
* link.
*/
interface TaskSessionResolver {
suspend fun resolve(targetId: String): ResolvedSession?
}
@Serializable
data class ResolvedSession(
val status: String,
val intent: String?,
val workflowId: String?,
)
@@ -0,0 +1,26 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import kotlinx.datetime.Instant
/**
* Projected state of a single task, rebuilt by folding its events through [TaskReducer].
* Mirrors the shape of `SessionState`: defaults make the empty/pre-creation state valid.
*/
data class TaskState(
val status: TaskStatus = TaskStatus.TODO,
val key: String? = null,
val projectId: ProjectId? = null,
val title: String? = null,
val goal: String? = null,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
val links: List<TaskLink> = emptyList(),
val claimant: String? = null,
val notes: List<TaskNote> = emptyList(),
val createdAt: Instant? = null,
val updatedAt: Instant? = null,
val invalidTransitions: Int = 0,
/** Soft-deleted tombstone — repository reads hide these; the events remain in the log. */
val deleted: Boolean = false,
)
@@ -0,0 +1,16 @@
package com.correx.core.tasks
/**
* Derived task lifecycle status. Never serialized on events — the reducer computes it from
* the lifecycle facts. Workflow: TODO → IN_PROGRESS → IN_REVIEW → DONE, with BLOCKED
* (reversible) and CANCELLED (terminal).
*/
enum class TaskStatus {
TODO,
IN_PROGRESS,
IN_REVIEW,
BLOCKED,
DONE,
CANCELLED,
;
}
@@ -0,0 +1,16 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
/**
* Task events live in one event stream per project, keyed `tasks:<projectId>`. The
* `tasks:` prefix keeps these streams distinguishable from real agent sessions (the
* EventStore is partitioned by [SessionId], and [SessionId]/`TaskId` are both `TypeId`).
*/
object TaskStreams {
const val PREFIX: String = "tasks:"
fun forProject(projectId: ProjectId): SessionId =
SessionId("$PREFIX${projectId.value}")
}
@@ -0,0 +1,16 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskTargetKind
/**
* Infers what a work-graph link points at from its target id, for callers that don't say
* explicitly. ADR ids (`adr-12`, `ADR_0007`) and `.md` paths are documents; everything else is
* assumed to be another task. Shared by the agent tool and the REST surface so the heuristic
* stays in one place. Always prefer an explicit [TaskTargetKind] when the caller provides one.
*/
object TaskTargetKinds {
private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$")
fun infer(targetId: String): TaskTargetKind =
if (ADR_ID.matches(targetId) || targetId.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK
}
@@ -0,0 +1,29 @@
package com.correx.core.tasks
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class CommitTaskParserTest {
@Test
fun `closing keywords mark refs closing`() {
val refs = CommitTaskParser.parse("Fixes auth-12 and resolves billing-3")
assertEquals(setOf(CommitTaskRef("auth-12", true), CommitTaskRef("billing-3", true)), refs.toSet())
}
@Test
fun `plain mentions are non-closing`() {
assertEquals(listOf(CommitTaskRef("auth-12", false)), CommitTaskParser.parse("see auth-12 for context"))
}
@Test
fun `the closing form wins over a later plain mention of the same key`() {
assertEquals(listOf(CommitTaskRef("auth-1", true)), CommitTaskParser.parse("fix auth-1\n\nfollow-up to auth-1"))
}
@Test
fun `a message with no keys yields empty`() {
assertTrue(CommitTaskParser.parse("just a normal commit message").isEmpty())
}
}
@@ -0,0 +1,147 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class DefaultTaskReducerTest {
private val reducer = DefaultTaskReducer()
private val taskId = TaskId("auth-1")
private val projectId = ProjectId("auth")
private fun stored(payload: EventPayload, seq: Long = 1L) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun reduceAll(vararg payloads: EventPayload): TaskState =
payloads.foldIndexed(TaskState()) { i, state, payload ->
reducer.reduce(state, stored(payload, (i + 1).toLong()))
}
private fun created() = TaskCreatedEvent(
taskId = taskId,
projectId = projectId,
key = "auth-1",
title = "Implement JWT refresh flow",
goal = "users stay authenticated",
)
@Test
fun `TaskCreatedEvent sets fields and TODO status`() {
val state = reduceAll(created())
assertEquals(TaskStatus.TODO, state.status)
assertEquals("auth-1", state.key)
assertEquals(projectId, state.projectId)
assertEquals("Implement JWT refresh flow", state.title)
assertEquals("users stay authenticated", state.goal)
assertEquals(Instant.parse("2026-01-01T00:00:00Z"), state.createdAt)
assertEquals(0, state.invalidTransitions)
}
@Test
fun `claiming moves TODO to IN_PROGRESS and records claimant`() {
val state = reduceAll(created(), TaskClaimedEvent(taskId, claimant = "claude-opus"))
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant)
}
@Test
fun `happy path created to claimed to review to done`() {
val state = reduceAll(
created(),
TaskClaimedEvent(taskId, claimant = "claude-opus"),
TaskSubmittedForReviewEvent(taskId),
TaskCompletedEvent(taskId),
)
assertEquals(TaskStatus.DONE, state.status)
assertEquals(0, state.invalidTransitions)
}
@Test
fun `unblock restores the working status of a claimed task`() {
val state = reduceAll(
created(),
TaskClaimedEvent(taskId, claimant = "claude-opus"),
TaskBlockedEvent(taskId, reason = "waiting on auth-138"),
TaskUnblockedEvent(taskId),
)
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals(0, state.invalidTransitions)
}
@Test
fun `illegal transition is ignored and counted`() {
val state = reduceAll(created(), TaskCompletedEvent(taskId))
assertEquals(TaskStatus.TODO, state.status)
assertEquals(1, state.invalidTransitions)
}
@Test
fun `links and notes accumulate without affecting status`() {
val state = reduceAll(
created(),
TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")),
TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK),
TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"),
)
assertEquals(TaskStatus.TODO, state.status)
assertEquals(listOf("refresh endpoint exists"), state.acceptanceCriteria)
assertEquals(1, state.links.size)
assertEquals(TaskLink("auth-138", TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK), state.links.single())
assertEquals(1, state.notes.size)
assertEquals(TaskNoteAuthor.AGENT, state.notes.single().author)
}
@Test
fun `non-task payload leaves state untouched`() {
val before = reduceAll(created())
val nonTask = RefinementIterationEvent(
sessionId = SessionId("s-1"),
cycleKey = "implement->review",
iteration = 1,
maxIterations = 3,
)
val after = reducer.reduce(before, stored(nonTask, seq = 9L))
assertEquals(before, after)
assertNull(after.claimant)
}
}
@@ -0,0 +1,61 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class GitTaskSyncTest {
private val store = InMemoryEventStore()
private val service = TaskService(store)
private val sync = GitTaskSync(service)
private val project = ProjectId("auth")
@Test
fun `a closing keyword drives a task to DONE and re-running is idempotent`() = runBlocking {
val id = service.createTask(project, "JWT", "g").taskId // auth-1, TODO
val report = sync.sync(listOf(Commit("abc1234567def", "fix auth-1: rotate tokens")))
assertEquals(TaskStatus.DONE, service.getTask(id)?.state?.status)
val change = report.changes.single()
assertEquals("auth-1", change.key)
assertEquals("TODO", change.from)
assertEquals("DONE", change.to)
assertEquals("abc1234", change.sha)
// The same commit applied again advances nothing.
assertTrue(sync.sync(listOf(Commit("abc1234567def", "fix auth-1"))).changes.isEmpty())
}
@Test
fun `a plain mention advances only to IN_PROGRESS`() = runBlocking {
val id = service.createTask(project, "JWT", "g").taskId
sync.sync(listOf(Commit("def4567", "wip on auth-1")))
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(id)?.state?.status)
}
@Test
fun `blocked tasks and unknown keys are left alone`() = runBlocking {
val id = service.createTask(project, "JWT", "g").taskId
service.claim(id, "x")
service.block(id, "waiting on infra")
val report = sync.sync(listOf(Commit("a1", "fix auth-1"), Commit("b2", "closes auth-999")))
assertEquals(TaskStatus.BLOCKED, service.getTask(id)?.state?.status)
assertTrue(report.changes.isEmpty())
}
@Test
fun `each advance leaves a git provenance note`() = runBlocking {
val id = service.createTask(project, "JWT", "g").taskId
sync.sync(listOf(Commit("abc1234567", "closes auth-1")))
assertTrue(service.getTask(id)!!.state.notes.any { it.body.contains("git abc1234") })
}
}
@@ -0,0 +1,50 @@
package com.correx.core.tasks
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/** Minimal append-capable in-memory event store for core:tasks tests, keyed per session. */
internal class InMemoryEventStore : EventStore {
private val events = mutableListOf<StoredEvent>()
private var global = 0L
private val perSession = mutableMapOf<SessionId, Long>()
override suspend fun append(event: NewEvent): StoredEvent {
global += 1
val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
perSession[event.metadata.sessionId] = seq
val stored = StoredEvent(
metadata = event.metadata,
sequence = global,
sessionSequence = seq,
payload = event.payload,
)
events += stored
return stored
}
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
read(sessionId).filter { it.sessionSequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? =
read(sessionId).maxOfOrNull { it.sessionSequence }
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = global
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
}
@@ -0,0 +1,74 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TaskBoardProjectorTest {
private val projector = TaskBoardProjector(DefaultTaskReducer())
private val projectId = ProjectId("auth")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun created(id: TaskId) =
TaskCreatedEvent(id, projectId, id.value, "title ${id.value}", "goal")
private fun fold(vararg events: StoredEvent): TaskBoard =
events.fold(projector.initial()) { board, event -> projector.apply(board, event) }
@Test
fun `interleaved events update each task independently`() {
val one = TaskId("auth-1")
val two = TaskId("auth-2")
val board = fold(
stored(created(one), 1),
stored(created(two), 2),
stored(TaskClaimedEvent(one, claimant = "claude-opus"), 3),
)
assertEquals(2, board.size)
assertEquals(TaskStatus.IN_PROGRESS, board[one]?.status)
assertEquals("claude-opus", board[one]?.claimant)
assertEquals(TaskStatus.TODO, board[two]?.status)
}
@Test
fun `non-task payload leaves the board unchanged`() {
val one = TaskId("auth-1")
val withTask = fold(stored(created(one), 1))
val nonTask = RefinementIterationEvent(
sessionId = SessionId("s-1"),
cycleKey = "implement->review",
iteration = 1,
maxIterations = 3,
)
val after = projector.apply(withTask, stored(nonTask, 2))
assertEquals(withTask, after)
}
}
@@ -0,0 +1,184 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.coroutines.runBlocking
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 TaskContextAssemblerTest {
private val store = InMemoryEventStore()
private val service = TaskService(store)
private val assembler = TaskContextAssembler(service)
private val project = ProjectId("auth")
@Test
fun `bundle resolves linked tasks as dependencies and keeps non-task links raw`() = runBlocking {
val dep = service.createTask(project, "Design auth model", "model")
service.claim(dep.taskId, "claude-opus")
service.submitForReview(dep.taskId)
service.complete(dep.taskId) // dep is DONE
val task = service.createTask(
project,
title = "Implement JWT refresh",
goal = "users stay authenticated",
acceptanceCriteria = listOf("refresh endpoint exists"),
affectedPaths = listOf("backend/auth/**"),
)
service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
// DOC-tagged, but this assembler has no document resolver → stays a raw related link
service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC)
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "kickoff")
val bundle = assembler.assemble(task.taskId)!!
assertEquals("auth-2", bundle.id)
assertEquals("users stay authenticated", bundle.goal)
assertEquals(listOf("refresh endpoint exists"), bundle.acceptanceCriteria)
assertEquals(listOf("backend/auth/**"), bundle.relevantFiles)
val dependency = bundle.dependencies.single()
assertEquals("auth-1", dependency.id)
assertEquals("DONE", dependency.status)
assertEquals("DEPENDS_ON", dependency.link)
assertEquals(RelatedLink("adr-7", "IMPLEMENTS"), bundle.relatedLinks.single())
assertEquals("kickoff", bundle.notes.single().body)
}
@Test
fun `bundle flags an unfinished dependency as a blocker and clears it when done`() = runBlocking {
val dep = service.createTask(project, "Design auth model", "model") // auth-1
service.claim(dep.taskId, "claude-opus") // auth-1 IN_PROGRESS
val task = service.createTask(project, "Implement JWT refresh", "auth") // auth-2
service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
val blocked = assembler.assemble(task.taskId)!!
assertTrue(blocked.blocked)
assertEquals("auth-1", blocked.blockedBy.single().id)
assertEquals("IN_PROGRESS", blocked.blockedBy.single().status)
assertEquals("DEPENDS_ON", blocked.blockedBy.single().link)
assertTrue(blocked.render().contains("BLOCKED"))
service.submitForReview(dep.taskId)
service.complete(dep.taskId)
val cleared = assembler.assemble(task.taskId)!!
assertTrue(!cleared.blocked)
assertTrue(cleared.blockedBy.isEmpty())
}
@Test
fun `render produces a compact labelled bundle`() = runBlocking {
val task = service.createTask(project, "JWT refresh", "stay authed", listOf("rotates"))
val text = assembler.assemble(task.taskId)!!.render()
assertTrue(text.startsWith("task auth-1 [TODO] JWT refresh"))
assertTrue(text.contains("goal: stay authed"))
assertTrue(text.contains("- rotates"))
}
@Test
fun `assemble returns null for an unknown task`() = runBlocking {
assertNull(assembler.assemble(TaskId("auth-999")))
}
@Test
fun `bundle is enriched with knowledge retrieved over the goal`() = runBlocking {
val captured = mutableListOf<String>()
val fakeRetriever = object : TaskKnowledgeRetriever {
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> {
captured += query
return listOf(KnowledgeHit("backend/auth/Jwt.kt", "fun refresh(token)", 0.91))
}
}
val enriched = TaskContextAssembler(service, fakeRetriever)
val task = service.createTask(project, "JWT refresh", "users stay authenticated")
val bundle = enriched.assemble(task.taskId)!!
assertEquals(listOf("JWT refresh users stay authenticated"), captured)
assertEquals("backend/auth/Jwt.kt", bundle.relevantKnowledge.single().source)
assertTrue(bundle.render().contains("relevant_knowledge"))
}
@Test
fun `linked docs are resolved inline and dropped from raw related links`() = runBlocking {
val docs = object : TaskDocumentResolver {
override suspend fun resolve(targetId: String): ResolvedDocument? =
if (targetId == "adr-7") {
ResolvedDocument("ADR 7: use redis", "docs/decisions/adr-0007-redis.md", "faster invalidation")
} else {
null
}
}
val enriched = TaskContextAssembler(service, documentResolver = docs)
val task = service.createTask(project, "JWT refresh", "auth")
service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) // resolves to a doc
service.link(task.taskId, "ext-123", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // stays raw
val bundle = enriched.assemble(task.taskId)!!
val doc = bundle.documents.single()
assertEquals("adr-7", doc.targetId)
assertEquals("ADR 7: use redis", doc.title)
assertEquals("IMPLEMENTS", doc.type)
assertEquals(RelatedLink("ext-123", "RELATES_TO"), bundle.relatedLinks.single())
assertTrue(bundle.render().contains("documents:"))
}
@Test
fun `retrieval failure is swallowed and the bundle still returns`() = runBlocking {
val flaky = object : TaskKnowledgeRetriever {
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
error("embedder offline")
}
val task = service.createTask(project, "JWT refresh", "stay authed")
val bundle = TaskContextAssembler(service, flaky).assemble(task.taskId)!!
assertTrue(bundle.relevantKnowledge.isEmpty())
assertEquals("JWT refresh", bundle.title)
}
@Test
fun `linked artifacts and sessions are resolved inline and unresolved ones stay raw`() = runBlocking {
val artifacts = object : TaskArtifactResolver {
override suspend fun resolve(targetId: String): ResolvedArtifact? =
if (targetId == "art-1") ResolvedArtifact("architect", "sess-9", "approach: redis") else null
}
val sessions = object : TaskSessionResolver {
override suspend fun resolve(targetId: String): ResolvedSession? =
if (targetId == "sess-9") ResolvedSession("COMPLETED", "build auth", "role_pipeline") else null
}
val enriched = TaskContextAssembler(service, artifactResolver = artifacts, sessionResolver = sessions)
val task = service.createTask(project, "JWT refresh", "auth")
service.link(task.taskId, "art-1", TaskLinkType.PRODUCED, TaskTargetKind.ARTIFACT)
service.link(task.taskId, "sess-9", TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
service.link(task.taskId, "art-missing", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // unresolved → raw
val bundle = enriched.assemble(task.taskId)!!
val artifact = bundle.artifacts.single()
assertEquals("art-1", artifact.targetId)
assertEquals("architect", artifact.stage)
assertEquals("sess-9", artifact.sessionId)
assertEquals("PRODUCED", artifact.type)
val session = bundle.sessions.single()
assertEquals("sess-9", session.targetId)
assertEquals("COMPLETED", session.status)
assertEquals("build auth", session.intent)
assertEquals("CONTEXT", session.type)
assertEquals(RelatedLink("art-missing", "RELATES_TO"), bundle.relatedLinks.single())
assertTrue(bundle.render().contains("artifacts:"))
assertTrue(bundle.render().contains("sessions:"))
}
}
@@ -0,0 +1,51 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TaskCounterProjectionTest {
private val projection = TaskCounterProjection("auth")
private val projectId = ProjectId("auth")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun created(id: TaskId) =
TaskCreatedEvent(id, projectId, id.value, "title", "goal")
@Test
fun `counts only created events and survives cancellation`() {
val events = listOf(
stored(created(TaskId("auth-1")), 1),
stored(created(TaskId("auth-2")), 2),
stored(TaskCancelledEvent(TaskId("auth-1")), 3),
)
val state = events.fold(projection.initial()) { s, e -> projection.apply(s, e) }
assertEquals("auth", state.projectId)
assertEquals(2, state.count)
}
}
@@ -0,0 +1,66 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
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 TaskGraphTest {
private fun task(id: String, status: TaskStatus = TaskStatus.TODO, links: List<TaskLink> = emptyList()) =
Task(TaskId(id), TaskState(status = status, key = id, projectId = ProjectId("p"), title = id, links = links))
private fun dependsOn(target: String) = TaskLink(target, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
private fun blocks(target: String) = TaskLink(target, TaskLinkType.BLOCKS, TaskTargetKind.TASK)
@Test
fun `an unfinished depends_on target blocks the task`() {
val a = task("p-1", links = listOf(dependsOn("p-2")))
val b = task("p-2", status = TaskStatus.IN_PROGRESS)
val board = listOf(a, b)
assertEquals(listOf("p-2"), TaskGraph.unmetBlockers(a, board).map { it.taskId.value })
assertFalse(TaskGraph.isReady(a, board))
}
@Test
fun `a finished dependency no longer blocks`() {
val a = task("p-1", links = listOf(dependsOn("p-2")))
val done = task("p-2", status = TaskStatus.DONE)
val board = listOf(a, done)
assertTrue(TaskGraph.unmetBlockers(a, board).isEmpty())
assertTrue(TaskGraph.isReady(a, board))
}
@Test
fun `an inbound BLOCKS edge blocks the target`() {
val a = task("p-1")
val blocker = task("p-2", status = TaskStatus.IN_PROGRESS, links = listOf(blocks("p-1")))
val board = listOf(a, blocker)
assertEquals(listOf("p-2"), TaskGraph.unmetBlockers(a, board).map { it.taskId.value })
}
@Test
fun `ready lists only unblocked TODO tasks`() {
val a = task("p-1", links = listOf(dependsOn("p-2"))) // blocked
val b = task("p-2", status = TaskStatus.IN_PROGRESS) // not TODO
val c = task("p-3") // unblocked TODO → ready
assertEquals(listOf("p-3"), TaskGraph.ready(listOf(a, b, c)).map { it.taskId.value })
}
@Test
fun `blocking lists what completing a task unblocks`() {
val dependent = task("p-1", links = listOf(dependsOn("p-2")))
val target = task("p-2")
val board = listOf(dependent, target)
assertEquals(listOf("p-1"), TaskGraph.blocking(target, board).map { it.taskId.value })
}
}
@@ -0,0 +1,81 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskHistoryTest {
private val taskId = TaskId("auth-1")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun created() = TaskCreatedEvent(
taskId = taskId,
projectId = ProjectId("auth"),
key = "auth-1",
title = "Add JWT refresh",
goal = "users stay authenticated",
)
@Test
fun `renders the lifecycle as a timeline in order`() {
val events = listOf(
stored(created(), 1),
stored(TaskClaimedEvent(taskId, "claude-opus"), 2),
stored(TaskNoteAddedEvent(taskId, TaskNoteAuthor.AGENT, "migration retried"), 3),
stored(TaskSubmittedForReviewEvent(taskId), 4),
stored(TaskCompletedEvent(taskId), 5),
)
val lines = TaskHistory.render(events).lines()
assertEquals(5, lines.size)
assertTrue(lines[0].contains("2026-01-01T00:00:00Z"), lines[0])
assertTrue(lines[0].contains("created auth-1: Add JWT refresh"), lines[0])
assertTrue(lines[1].contains("claimed by claude-opus"), lines[1])
assertTrue(lines[2].contains("note [AGENT] migration retried"), lines[2])
assertTrue(lines[3].contains("submitted for review"), lines[3])
assertTrue(lines[4].contains("completed"), lines[4])
}
@Test
fun `cancellation reason is included`() {
val lines = TaskHistory.render(
listOf(stored(created(), 1), stored(TaskCancelledEvent(taskId, "superseded"), 2)),
).lines()
assertTrue(lines[1].contains("cancelled: superseded"), lines[1])
}
@Test
fun `empty event list renders as no history`() {
assertEquals("no history", TaskHistory.render(emptyList()))
}
}
@@ -0,0 +1,33 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskMarkdownTest {
private fun task(id: String, status: TaskStatus, title: String, claimant: String? = null) =
Task(TaskId(id), TaskState(key = id, status = status, title = title, claimant = claimant))
@Test
fun `renders grouped sections with checkboxes and claimants`() {
val md = TaskMarkdown.render(
listOf(
task("auth-1", TaskStatus.IN_PROGRESS, "JWT refresh", claimant = "claude-opus"),
task("auth-2", TaskStatus.DONE, "Login form"),
),
)
assertTrue(md.startsWith("# Tasks"))
assertTrue(md.contains("disposable"))
assertTrue(md.contains("## In progress (1)"))
assertTrue(md.contains("- [ ] **auth-1** JWT refresh _(@claude-opus)_"))
assertTrue(md.contains("## Done (1)"))
assertTrue(md.contains("- [x] **auth-2** Login form"))
}
@Test
fun `empty board renders a placeholder`() {
assertTrue(TaskMarkdown.render(emptyList()).contains("_No tasks._"))
}
}
@@ -0,0 +1,59 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TaskSearchTest {
private fun task(
id: String,
title: String?,
goal: String? = null,
criteria: List<String> = emptyList(),
notes: List<String> = emptyList(),
) = Task(
TaskId(id),
TaskState(
key = id,
title = title,
goal = goal,
acceptanceCriteria = criteria,
notes = notes.map { TaskNote(TaskNoteAuthor.AGENT, it, Instant.parse("2026-01-01T00:00:00Z")) },
),
)
@Test
fun `requires all terms and ranks title hits above body hits`() {
val titleHit = task("auth-1", title = "JWT refresh flow", goal = "stay authed")
val goalHit = task("auth-2", title = "Token rotation", goal = "rotate the jwt refresh token")
val noMatch = task("auth-3", title = "Logging", goal = "structured logs")
val results = TaskSearch.search(listOf(goalHit, noMatch, titleHit), "jwt refresh")
assertEquals(listOf("auth-1", "auth-2"), results.map { it.taskId.value })
}
@Test
fun `matches across acceptance criteria and notes`() {
val viaNotes = task("auth-1", title = "x", notes = listOf("the migration failed on rollback"))
val viaCriteria = task("auth-2", title = "y", criteria = listOf("rollback is idempotent"))
val results = TaskSearch.search(listOf(viaNotes, viaCriteria), "rollback")
assertEquals(setOf("auth-1", "auth-2"), results.map { it.taskId.value }.toSet())
}
@Test
fun `blank query returns the input unchanged`() {
val tasks = listOf(task("auth-1", "a"), task("auth-2", "b"))
assertEquals(tasks, TaskSearch.search(tasks, " "))
}
@Test
fun `no match yields empty`() {
assertEquals(emptyList<Task>(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra"))
}
}
@@ -0,0 +1,152 @@
package com.correx.core.tasks
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskServiceTest {
private val store = InMemoryEventStore()
private val service = TaskService(store)
private val project = ProjectId("auth")
@Test
fun `createTask allocates sequential human keys per project`() = runBlocking {
val first = service.createTask(project, "Add JWT refresh", "users stay authenticated")
val second = service.createTask(project, "Rotate keys", "tokens rotate")
assertEquals("auth-1", first.taskId.value)
assertEquals("auth-2", second.taskId.value)
assertEquals(TaskStatus.TODO, first.state.status)
assertEquals("Add JWT refresh", first.state.title)
}
@Test
fun `lifecycle round-trips through append and replay`() = runBlocking {
val task = service.createTask(project, "Add JWT refresh", "users stay authenticated")
val id = task.taskId
assertEquals(TaskStatus.IN_PROGRESS, service.claim(id, "claude-opus")?.state?.status)
assertEquals(TaskStatus.IN_REVIEW, service.submitForReview(id)?.state?.status)
val done = service.complete(id)
assertEquals(TaskStatus.DONE, done?.state?.status)
assertEquals("claude-opus", done?.state?.claimant)
}
@Test
fun `links and notes are persisted on the task`() = runBlocking {
val id = service.createTask(project, "Add JWT refresh", "auth").taskId
service.link(id, targetId = "adr-7", type = TaskLinkType.IMPLEMENTS, targetKind = TaskTargetKind.DOC)
val withNote = service.addNote(id, TaskNoteAuthor.AGENT, "migration failed, retrying")
assertEquals(
TaskLink("adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC),
withNote?.state?.links?.single(),
)
assertEquals("migration failed, retrying", withNote?.state?.notes?.single()?.body)
}
@Test
fun `delete is a soft tombstone hidden from reads`() = runBlocking {
val id = service.createTask(project, "throwaway", "oops").taskId
assertTrue(service.delete(id))
assertNull(service.getTask(id))
assertTrue(service.list(project).none { it.taskId == id })
// A second delete is a no-op: the task is already gone from reads.
assertFalse(service.delete(id))
}
@Test
fun `mutating an unknown task returns null`() = runBlocking {
assertNull(service.claim(TaskId("auth-999"), "claude-opus"))
}
@Test
fun `listAll spans every project's stream`() = runBlocking {
service.createTask(project, "a", "g")
service.createTask(ProjectId("billing"), "b", "g")
assertEquals(setOf("auth-1", "billing-1"), service.listAll().map { it.taskId.value }.toSet())
}
@Test
fun `keys keep incrementing even after deletion`() = runBlocking {
val first = service.createTask(project, "one", "g").taskId
service.delete(first)
val third = service.createTask(project, "two", "g")
// count() is creation-based, so the deleted id is never reused.
assertEquals("auth-2", third.taskId.value)
}
@Test
fun `history returns the task's own events in order`() = runBlocking {
val id = service.createTask(project, "Add JWT refresh", "auth").taskId
service.claim(id, "claude-opus")
service.submitForReview(id)
service.complete(id)
// A second task in the same stream must not bleed into this one's history.
service.createTask(project, "unrelated", "g")
val lines = TaskHistory.render(service.history(id)).lines()
assertEquals(4, lines.size)
assertTrue(lines.first().contains("created"))
assertTrue(lines.last().contains("completed"))
}
@Test
fun `findDuplicates matches active same-title tasks but ignores terminal ones`() = runBlocking {
service.createTask(project, "Add JWT refresh", "g") // auth-1, active
// Case- and whitespace-insensitive match.
assertEquals(
listOf("auth-1"),
service.findDuplicates(project, " add jwt REFRESH ").map { it.taskId.value },
)
// A DONE look-alike is not a duplicate — that work may legitimately recur.
val done = service.createTask(project, "Rotate keys", "g").taskId
service.claim(done, "x"); service.submitForReview(done); service.complete(done)
assertTrue(service.findDuplicates(project, "Rotate keys").isEmpty())
assertTrue(service.findDuplicates(project, "totally new").isEmpty())
}
@Test
fun `ready surfaces unblocked work and blockers explain the wait`() = runBlocking {
val a = service.createTask(project, "A", "g").taskId // auth-1
val b = service.createTask(project, "B", "g").taskId // auth-2 depends on auth-1
service.link(b, a.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
assertEquals(setOf("auth-1"), service.ready(project).map { it.taskId.value }.toSet())
assertEquals(listOf("auth-1"), service.blockers(b).map { it.taskId.value })
assertEquals(listOf("auth-2"), service.blocking(a).map { it.taskId.value })
// Finishing auth-1 unblocks auth-2.
service.claim(a, "x"); service.submitForReview(a); service.complete(a)
assertEquals(setOf("auth-2"), service.ready(project).map { it.taskId.value }.toSet())
assertTrue(service.blockers(b).isEmpty())
}
@Test
fun `history survives soft-delete but is empty for an unknown task`() = runBlocking {
val id = service.createTask(project, "throwaway", "oops").taskId
service.delete(id)
// getTask hides a deleted task, but its audit trail (incl. the deletion) remains.
assertNull(service.getTask(id))
assertTrue(service.history(id).isNotEmpty())
assertTrue(service.history(TaskId("auth-999")).isEmpty())
}
}
@@ -0,0 +1,79 @@
package com.correx.core.toolintent
import com.correx.core.events.events.SessionWorkingTaskEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.Projection
import com.correx.core.toolintent.rules.candidatePathStrings
import com.correx.core.tools.contract.ToolCapability
/**
* What a session has done to the workspace this run, plus the task it is working — the single
* read-model every anti-hallucination gate consumes (plane-2 rules via the threaded
* [ToolCallAssessmentInput.session]; tool gates via a provider port). [pendingReads] is
* fold-internal (reads awaiting their completion event) and not meant for consumers.
*/
data class SessionContext(
val reads: Set<String> = emptySet(),
val writes: Set<String> = emptySet(),
val activeTask: ActiveTask? = null,
// Path -> content hash captured when the file was last fully read this session; the stale-write
// gate compares it against the file's current hash. Only whole-file reads contribute.
val readHashes: Map<String, String> = emptyMap(),
val pendingReads: Map<String, List<String>> = emptyMap(),
)
/** The task this session has claimed and is working, with its declared write scope. */
data class ActiveTask(val taskId: String, val scope: List<String>)
/**
* Folds one session's tool events into a [SessionContext]:
* - **reads** — completed invocations whose recorded capability includes FILE_READ (path from the
* request args; a read that failed never completes, so it never counts).
* - **writes** — the resolved paths the orchestrator already records on each completion's
* `receipt.affectedEntities` (via FileAffectingTool), so writes need no param-parsing here.
* - **activeTask** — set when the session claims a task; null until a SessionWorkingTaskEvent lands.
*/
class SessionContextProjection(private val sessionId: SessionId) : Projection<SessionContext> {
override fun initial(): SessionContext = SessionContext()
override fun apply(state: SessionContext, event: StoredEvent): SessionContext =
when (val payload = event.payload) {
is ToolInvocationRequestedEvent -> recordPendingRead(state, payload)
is ToolExecutionCompletedEvent -> recordCompletion(state, payload)
is SessionWorkingTaskEvent ->
if (payload.sessionId == sessionId) {
state.copy(activeTask = ActiveTask(payload.taskId.value, payload.affectedPaths))
} else {
state
}
else -> state
}
private fun recordPendingRead(state: SessionContext, payload: ToolInvocationRequestedEvent): SessionContext {
if (payload.sessionId != sessionId || ToolCapability.FILE_READ !in payload.capabilities) return state
val paths = candidatePathStrings(emptyMap(), payload.request.parameters)
if (paths.isEmpty()) return state
return state.copy(pendingReads = state.pendingReads + (payload.invocationId.value to paths))
}
private fun recordCompletion(state: SessionContext, payload: ToolExecutionCompletedEvent): SessionContext {
if (payload.sessionId != sessionId) return state
val justRead = state.pendingReads[payload.invocationId.value]
val hash = payload.receipt.structuredOutput["contentHash"] as? String
val newHashes = if (justRead != null && hash != null) {
state.readHashes + justRead.associateWith { hash }
} else {
state.readHashes
}
return state.copy(
reads = if (justRead != null) state.reads + justRead else state.reads,
writes = state.writes + payload.receipt.affectedEntities,
readHashes = newHashes,
pendingReads = state.pendingReads - payload.invocationId.value,
)
}
}
@@ -25,6 +25,14 @@ data class ToolCallAssessmentInput(
val paramRoles: Map<String, ParamRole> = emptyMap(),
// Workspace-relative globs the active stage may write. Empty = unrestricted.
val writeManifest: List<String> = emptyList(),
// Egress hosts granted to the active session (folded from EgressHostsGrantedEvent via
// EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty
// means "no per-session grants", which never narrows the static allow-list.
val sessionEgressHosts: Set<String> = emptySet(),
// What this session has done this run (reads, writes) and the task it's working — folded via
// SessionContextProjection. The input to the anti-hallucination rules (read-before-write reads
// .reads, write-scope reads .activeTask). Empty by default so unrelated rules ignore it.
val session: SessionContext = SessionContext(),
)
data class ToolCallAssessment(
@@ -2,6 +2,7 @@ package com.correx.core.toolintent
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest
/** Abstracts filesystem observation so rules are testable and replay never re-stats. */
interface WorldProbe {
@@ -9,6 +10,11 @@ interface WorldProbe {
/** Symlink-resolved real path if it exists; otherwise the normalized absolute path. */
fun resolveReal(path: Path): Path
/** Content hash of the file's current bytes, or null if it can't be read. Used by the
* stale-write gate to compare against the hash captured when the file was read. Defaulted
* so probes/fakes that don't observe content need not implement it. */
fun contentHash(path: Path): String? = null
}
class FileSystemWorldProbe : WorldProbe {
@@ -16,4 +22,9 @@ class FileSystemWorldProbe : WorldProbe {
override fun resolveReal(path: Path): Path =
runCatching { path.toRealPath() }.getOrElse { path.toAbsolutePath().normalize() }
override fun contentHash(path: Path): String? = runCatching {
MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
.joinToString("") { "%02x".format(it) }
}.getOrNull()
}
@@ -0,0 +1,32 @@
package com.correx.core.toolintent.rules
/**
* Pure, stateless host allow-list matching for network egress. The effective allow-list is the
* union of the static `networkAllowedHosts` and any hosts granted to the active session (see
* [com.correx.core.events.events.EgressHostsGrantedEvent]): a host is allowed if it matches the
* static set OR the session set. Matching honours the exact-or-suffix semantics
* [com.correx.core.toolintent.rules.NetworkHostRule] uses — a configured "example.com" covers
* "api.example.com" — and never narrows it; the session set can only widen what is allowed.
*
* An empty union (no static and no session hosts) means "no allow-list configured", which the
* caller treats as allow-all, mirroring the rule's existing behaviour.
*/
object EgressAllowlist {
/**
* True if [host] is permitted by the union of [staticHosts] and [sessionHosts]. Both sets are
* normalised to lower-case; [host] is matched case-insensitively. An empty union allows any host.
*/
fun isAllowed(host: String, staticHosts: Set<String>, sessionHosts: Set<String>): Boolean {
val target = host.lowercase()
val union = staticHosts.asSequence() + sessionHosts.asSequence()
var sawAny = false
for (allowed in union) {
sawAny = true
val a = allowed.lowercase()
if (target == a || target.endsWith(".$a")) return true
}
// No allow-list configured at all → allow-all (matches NetworkHostRule's empty-list behaviour).
return !sawAny
}
}
@@ -38,7 +38,11 @@ class NetworkHostRule(
for (host in hosts(input)) {
val denyHit = denied.any { host == it || host.endsWith(".$it") }
val allowHit = allowed.isEmpty() || allowed.any { host == it || host.endsWith(".$it") }
// Per-session egress grants (folded from EgressHostsGrantedEvent via
// EgressAllowlistProjection at the build site) union with the static allow-list: a host
// granted to the session is allowed even if it is not in the static set. Empty = no
// grants, which never narrows the static allow-list.
val allowHit = EgressAllowlist.isAllowed(host, allowed, input.sessionEgressHosts)
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()),
@@ -0,0 +1,72 @@
package com.correx.core.toolintent.rules
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
/**
* Read-before-write gate (anti-hallucination). Dispatches on FILE_WRITE, covering both file_write
* and file_edit. A write to a path that exists on disk but was not file_read earlier this session
* is BLOCKED — the classic "edited a file whose contents it never saw" failure. A brand-new file
* (not on disk) passes: you cannot read what does not exist, and creating one is legitimate.
*
* The block message names the path; the orchestrator feeds the rationale back as a tool result, so
* the agent reads the file and retries. Read paths and the write target are both resolved through
* the probe (toRealPath), so `./Foo.kt` read then `Foo.kt` written match, and symlinks can't dodge it.
*/
class ReadBeforeWriteRule : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.FILE_WRITE in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val root = input.workspace.workspaceRoot
val readReal = input.session.reads.map { realOf(input, root, it) }.toSet()
val issues = mutableListOf<ValidationIssue>()
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val resolvedInput = resolveInput(root, raw)
val exists = input.probe.exists(resolvedInput)
val read = input.probe.resolveReal(resolvedInput) in readReal
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("path" to raw, "exists" to exists.toString(), "read" to read.toString()),
)
if (exists && !read) {
issues += ValidationIssue(
code = RULE_CODE,
message = "Tool '${input.request.toolName}' targets '$raw', which you have not " +
"read this session — call file_read on it before writing or editing.",
severity = ValidationSeverity.ERROR,
)
disposition = maxAction(disposition, RiskAction.BLOCK)
}
}
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
}
private fun resolveInput(root: Path, raw: String): Path {
val candidate = Path.of(raw)
return if (candidate.isAbsolute) candidate else root.resolve(candidate)
}
private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path =
input.probe.resolveReal(resolveInput(root, raw))
private companion object {
const val RULE_CODE = "READ_BEFORE_WRITE"
}
}
@@ -0,0 +1,62 @@
package com.correx.core.toolintent.rules
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
/**
* Reference-must-exist gate (anti-hallucination). Dispatches on FILE_READ: a read of a path that is
* inside the workspace but does not exist is BLOCKED with "no such path", so an agent that
* hallucinated a filename fails loud and self-corrects (list the directory, fix the name) instead of
* proceeding on an empty/absent read. Out-of-workspace targets are [PathContainmentRule]'s concern
* (it prompts), so this gate stays silent on them to avoid double-handling.
*/
class ReferenceExistsRule : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.FILE_READ in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
val root = input.workspace.workspaceRoot
val issues = mutableListOf<ValidationIssue>()
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw)
val resolvedInput = if (candidate.isAbsolute) candidate else root.resolve(candidate)
val exists = input.probe.exists(resolvedInput)
val inWorkspace = input.probe.resolveReal(resolvedInput).startsWith(workspaceReal)
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("path" to raw, "exists" to exists.toString(), "inWorkspace" to inWorkspace.toString()),
)
if (inWorkspace && !exists) {
issues += ValidationIssue(
code = RULE_CODE,
message = "Tool '${input.request.toolName}' references '$raw', which does not " +
"exist — list the directory or fix the path; do not assume its contents.",
severity = ValidationSeverity.ERROR,
)
disposition = maxAction(disposition, RiskAction.BLOCK)
}
}
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
}
private companion object {
const val RULE_CODE = "REFERENCE_EXISTS"
}
}

Some files were not shown because too many files have changed in this diff Show More