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:
+12
-7
@@ -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
|
||||
}
|
||||
|
||||
+99
@@ -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(" "))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+22
@@ -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))
|
||||
}
|
||||
}
|
||||
+33
@@ -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(),
|
||||
)
|
||||
+156
@@ -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
|
||||
+62
@@ -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,
|
||||
}
|
||||
+29
@@ -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
|
||||
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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(
|
||||
|
||||
+47
@@ -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)
|
||||
}
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.CritiqueFinding
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcome
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.CritiqueVerdict
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Decides how each prior critique finding turned out across a review loop — the producer half of
|
||||
* critique calibration (BACKLOG §B-§6 / pipeline-addenda A3). Pure over the recorded review
|
||||
* iterations, so it is replay-deterministic and unit-testable; the orchestrator runs it once at
|
||||
* loop resolution (see completeWorkflow / failWorkflow) and emits the results, which the
|
||||
* `:core:critique` projection folds into per-(model, role) precision.
|
||||
*
|
||||
* Rules, per critic (grouped by role + modelHash) and per finding id:
|
||||
* - **UPHELD** — the finding was raised in some iteration and is gone by the final review: the
|
||||
* implementer fixed it between rounds, so the critic was right and the finding was actioned.
|
||||
* - **DISMISSED** — the finding persists into the final review AND that review APPROVED: the
|
||||
* reviewer signed off without it being addressed, i.e. treated it as non-blocking.
|
||||
* - **INCONCLUSIVE** — the finding is still open at a non-approved terminal (loop exhausted): no
|
||||
* signal either way.
|
||||
*
|
||||
* Findings carry a stable [CritiqueFinding.id] so the same logical finding is tracked across
|
||||
* iterations.
|
||||
*/
|
||||
object CritiqueOutcomeCorrelator {
|
||||
|
||||
fun correlate(
|
||||
sessionId: SessionId,
|
||||
reviews: List<CritiqueFindingsRecordedEvent>,
|
||||
): List<CritiqueOutcomeCorrelatedEvent> =
|
||||
reviews
|
||||
.groupBy { it.role to it.modelHash }
|
||||
.flatMap { (_, group) -> correlateCritic(sessionId, group) }
|
||||
|
||||
private fun correlateCritic(
|
||||
sessionId: SessionId,
|
||||
critic: List<CritiqueFindingsRecordedEvent>,
|
||||
): List<CritiqueOutcomeCorrelatedEvent> {
|
||||
val ordered = critic.sortedBy { it.iteration }
|
||||
val finalReview = ordered.last()
|
||||
val finalIds = finalReview.findings.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
// Latest appearance per finding id (later iterations overwrite), used for severity and the
|
||||
// owning critic's role/modelHash.
|
||||
val latest = LinkedHashMap<String, Pair<CritiqueFindingsRecordedEvent, CritiqueFinding>>()
|
||||
for (review in ordered) {
|
||||
for (finding in review.findings) {
|
||||
latest[finding.id] = review to finding
|
||||
}
|
||||
}
|
||||
|
||||
return latest.map { (id, appearance) ->
|
||||
val (review, finding) = appearance
|
||||
val outcome = when {
|
||||
id !in finalIds -> CritiqueOutcome.UPHELD
|
||||
finalReview.verdict == CritiqueVerdict.APPROVED -> CritiqueOutcome.DISMISSED
|
||||
else -> CritiqueOutcome.INCONCLUSIVE
|
||||
}
|
||||
CritiqueOutcomeCorrelatedEvent(
|
||||
sessionId = sessionId,
|
||||
findingId = id,
|
||||
role = review.role,
|
||||
modelHash = review.modelHash,
|
||||
severity = finding.severity,
|
||||
outcome = outcome,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
-13
@@ -2,6 +2,8 @@ package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
|
||||
import com.correx.core.approvals.ProjectIdentity
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.approvals.domain.ApprovalEngine
|
||||
@@ -28,6 +30,8 @@ import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
@@ -55,6 +59,8 @@ import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||
import com.correx.core.events.events.StageCheckpointPassedEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
@@ -64,6 +70,7 @@ import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.toolintent.SessionContextProjection
|
||||
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||
import com.correx.core.toolintent.ToolCallAssessor
|
||||
import com.correx.core.toolintent.WorkspacePolicy
|
||||
@@ -75,6 +82,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.sessions.projections.EgressAllowlistProjection
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
@@ -129,7 +137,9 @@ import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import com.correx.core.journal.DecisionJournalRenderer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
|
||||
@@ -403,13 +413,16 @@ abstract class SessionOrchestrator(
|
||||
} ?: emptyList()
|
||||
val projectProfileEntries = session.state.boundProjectProfile
|
||||
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
|
||||
val agentInstructionsEntries = session.state.boundAgentInstructions
|
||||
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
|
||||
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val vocabularyEntries = artifactKindRegistry
|
||||
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
|
||||
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
|
||||
var accumulatedEntries =
|
||||
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
|
||||
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries
|
||||
val contextPack = contextPackBuilder.build(
|
||||
@@ -601,13 +614,7 @@ abstract class SessionOrchestrator(
|
||||
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
|
||||
return toolCalls.flatMap { toolCall ->
|
||||
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
val parameters = runCatching {
|
||||
val element = Json.parseToJsonElement(toolCall.function.arguments)
|
||||
val jsonObject = element as? kotlinx.serialization.json.JsonObject ?: return@runCatching emptyMap()
|
||||
jsonObject.entries.associate { (k, v) ->
|
||||
k to ((v as? kotlinx.serialization.json.JsonPrimitive)?.content ?: v.toString()) as Any
|
||||
}
|
||||
}.getOrElse { emptyMap() }
|
||||
val parameters = parseToolArguments(toolCall.function.arguments)
|
||||
val request = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
@@ -626,6 +633,7 @@ abstract class SessionOrchestrator(
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
request = request,
|
||||
capabilities = tool?.requiredCapabilities ?: emptySet(),
|
||||
),
|
||||
)
|
||||
if (toolCall.function.name != STAGE_COMPLETE_TOOL &&
|
||||
@@ -676,7 +684,10 @@ abstract class SessionOrchestrator(
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
tier = tier,
|
||||
reason = "blocked by tool-call policy",
|
||||
// Record the specific rule rationale, not a generic string, so the audit
|
||||
// trail (and task history) shows why a call was blocked.
|
||||
reason = assessment.rationale.joinToString("; ")
|
||||
.ifBlank { "blocked by tool-call policy" },
|
||||
),
|
||||
)
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
@@ -710,10 +721,15 @@ abstract class SessionOrchestrator(
|
||||
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
|
||||
// no approval needed
|
||||
} else {
|
||||
val approvalState = approvalRepository.getApprovalState(sessionId)
|
||||
val activeGrants = approvalState.grants.values.toList()
|
||||
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
|
||||
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
|
||||
// workspace root so PROJECT grants match later sessions on the same repo.
|
||||
val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
|
||||
val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
|
||||
val activeGrants = (sessionGrants + ledgerGrants).toList()
|
||||
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
|
||||
val approvalCtx = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = null),
|
||||
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
|
||||
mode = ApprovalMode.PROMPT,
|
||||
)
|
||||
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
||||
@@ -926,6 +942,10 @@ abstract class SessionOrchestrator(
|
||||
): RiskSummary? {
|
||||
val assessor = toolCallAssessor ?: return null
|
||||
val policy = effectives.policy ?: return null
|
||||
// Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents
|
||||
// through the projection. Unioned with the static allow-list in NetworkHostRule; empty when
|
||||
// nothing has been granted, which never narrows the static allow-list.
|
||||
val sessionEgressHosts = resolveSessionEgressHosts(sessionId)
|
||||
val assessment = assessor.assess(
|
||||
ToolCallAssessmentInput(
|
||||
request = request,
|
||||
@@ -934,6 +954,8 @@ abstract class SessionOrchestrator(
|
||||
probe = worldProbe,
|
||||
paramRoles = tool?.paramRoles ?: emptyMap(),
|
||||
writeManifest = writeManifest,
|
||||
sessionEgressHosts = sessionEgressHosts,
|
||||
session = resolveSessionContext(sessionId),
|
||||
),
|
||||
)
|
||||
emit(
|
||||
@@ -952,6 +974,28 @@ abstract class SessionOrchestrator(
|
||||
return assessment.toRiskSummary()
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through
|
||||
* [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads
|
||||
* the log, never live state); empty when no grants have been emitted.
|
||||
*/
|
||||
internal fun resolveSessionEgressHosts(sessionId: SessionId): Set<String> {
|
||||
val projection = EgressAllowlistProjection(sessionId)
|
||||
return eventStore.read(sessionId)
|
||||
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds this session's tool events through [SessionContextProjection] into the read-model the
|
||||
* anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
|
||||
* never live state); empty before anything has happened.
|
||||
*/
|
||||
internal fun resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
|
||||
val projection = SessionContextProjection(sessionId)
|
||||
return eventStore.read(sessionId)
|
||||
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
||||
}
|
||||
|
||||
private suspend fun buildSchemaEntries(
|
||||
responseFormat: ResponseFormat,
|
||||
stageId: StageId,
|
||||
@@ -1230,6 +1274,10 @@ abstract class SessionOrchestrator(
|
||||
toolName = toolCall.function.name,
|
||||
exitCode = result.exitCode,
|
||||
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
|
||||
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
|
||||
// session projections can read it — matching SandboxedToolExecutor, which
|
||||
// already does this; the two completion paths were inconsistent.
|
||||
structuredOutput = result.metadata,
|
||||
affectedEntities = affected.map { it.toString() },
|
||||
durationMs = 0,
|
||||
tier = tier,
|
||||
@@ -1360,6 +1408,33 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage-level plan checkpointing (BACKLOG §C-A2): make plan adherence observable and replayable
|
||||
* by emitting a first-class checkpoint event per stage, reconciling produced-vs-expected against
|
||||
* the confirmed (locked) plan. Only runs on plan-driven (phase-2) sessions — i.e. a log that
|
||||
* holds an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. The halt itself is owned
|
||||
* by [verifyProduces]; this method only EMITS — a [StageCheckpointFailedEvent] accompanies that
|
||||
* halt and surfaces *why* the plan diverged.
|
||||
*/
|
||||
private suspend fun emitStageCheckpoint(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
) {
|
||||
if (eventStore.read(sessionId).none { it.payload is ExecutionPlanLockedEvent }) return
|
||||
val expected = stageConfig.produces.map { it.name.value }.toSet()
|
||||
if (expected.isEmpty()) return
|
||||
val produced = eventStore.read(sessionId)
|
||||
.mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId?.value }
|
||||
.toSet()
|
||||
val cp = StageCheckpointReconciler.reconcile(expected, produced)
|
||||
if (cp.passed) {
|
||||
emit(sessionId, StageCheckpointPassedEvent(sessionId, stageId, expected, produced))
|
||||
} else {
|
||||
emit(sessionId, StageCheckpointFailedEvent(sessionId, stageId, expected, produced, cp.missing))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity grounding (role-reliability §3): for a stage that opted in (`ground_references`),
|
||||
* check every workspace-relative file path its brief named against the filesystem. Existence
|
||||
@@ -1480,8 +1555,12 @@ abstract class SessionOrchestrator(
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val produced = verifyProduces(sessionId, stageId, stageConfig)
|
||||
if (produced is StageExecutionResult.Failure) return produced
|
||||
// Stage-level plan checkpoint (C-A2): once a stage has produced its declared artifacts,
|
||||
// record the checkpoint before the heavier brief/static gates run.
|
||||
emitStageCheckpoint(sessionId, stageId, stageConfig)
|
||||
val gates: List<suspend () -> StageExecutionResult> = listOf(
|
||||
{ verifyProduces(sessionId, stageId, stageConfig) },
|
||||
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
||||
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||
@@ -1749,6 +1828,7 @@ abstract class SessionOrchestrator(
|
||||
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
|
||||
sessionId.value, terminalStageId.value, stageCount,
|
||||
)
|
||||
correlateCritiqueOutcomes(sessionId)
|
||||
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Completed(sessionId, terminalStageId)
|
||||
@@ -1764,11 +1844,26 @@ abstract class SessionOrchestrator(
|
||||
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
|
||||
sessionId.value, stageId.value, reason, retryExhausted,
|
||||
)
|
||||
correlateCritiqueOutcomes(sessionId)
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
|
||||
}
|
||||
|
||||
/**
|
||||
* At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that
|
||||
* feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were
|
||||
* recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent
|
||||
* — it skips when outcomes already exist — so it is safe on every terminal path.
|
||||
*/
|
||||
private suspend fun correlateCritiqueOutcomes(sessionId: SessionId) {
|
||||
val events = eventStore.read(sessionId)
|
||||
if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return
|
||||
val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent }
|
||||
if (reviews.isEmpty()) return
|
||||
CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) }
|
||||
}
|
||||
|
||||
internal suspend fun handleCancellation(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
@@ -2022,6 +2117,7 @@ abstract class SessionOrchestrator(
|
||||
*/
|
||||
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
|
||||
if (toolName == "shell") return shellCommandPreview(parameters)
|
||||
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
|
||||
if (toolName != "file_write") return null
|
||||
val path = parameters["path"] as? String ?: return null
|
||||
val operation = parameters["operation"] as? String
|
||||
@@ -2087,3 +2183,54 @@ internal sealed interface InferenceResult {
|
||||
data class Failed(val reason: String) : InferenceResult
|
||||
data object Cancelled : InferenceResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens an LLM tool call's JSON arguments into a [ToolRequest.parameters] map. A JSON array of
|
||||
* primitives becomes a `List<String>` so list-param accessors read it — otherwise it arrived as a
|
||||
* `.toString()` string and silently read empty, dropping e.g. a task's `affected_paths` (which in
|
||||
* turn left the write-scope and cite-before-claim gates with nothing to enforce). A primitive
|
||||
* becomes its content string; objects and arrays-of-objects keep their JSON text for tools that
|
||||
* re-parse them (e.g. task_decompose). Malformed JSON yields an empty map.
|
||||
*/
|
||||
internal fun parseToolArguments(arguments: String): Map<String, Any> = runCatching {
|
||||
val obj = Json.parseToJsonElement(arguments) as? JsonObject ?: return@runCatching emptyMap()
|
||||
obj.entries.associate { (k, v) ->
|
||||
k to when {
|
||||
v is JsonPrimitive -> v.content
|
||||
v is JsonArray && v.all { it is JsonPrimitive } -> v.map { (it as JsonPrimitive).content }
|
||||
else -> v.toString()
|
||||
} as Any
|
||||
}
|
||||
}.getOrElse { emptyMap() }
|
||||
|
||||
/**
|
||||
* Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered
|
||||
* child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic
|
||||
* fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are
|
||||
* re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments.
|
||||
*/
|
||||
internal fun renderDecomposePreview(parameters: Map<String, Any>): String? {
|
||||
val project = parameters["project"] as? String ?: return null
|
||||
val tasks = (parameters["tasks"] as? String)
|
||||
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null
|
||||
if (tasks.isEmpty()) return null
|
||||
val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content }
|
||||
val refToIndex = tasks.mapIndexedNotNull { i, e ->
|
||||
((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i }
|
||||
}.toMap()
|
||||
val parentTitle = (parameters["parent"] as? String)
|
||||
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject }
|
||||
?.let { (it["title"] as? JsonPrimitive)?.content }
|
||||
|
||||
return buildString {
|
||||
append("Decompose '").append(project).append("' into:")
|
||||
parentTitle?.let { append("\n epic: ").append(it) }
|
||||
tasks.forEachIndexed { i, e ->
|
||||
val o = e as? JsonObject
|
||||
append("\n ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)")
|
||||
val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList())
|
||||
.map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d }
|
||||
if (afters.isNotEmpty()) append(" (after: ").append(afters.joinToString(", ")).append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
/**
|
||||
* The reconciliation of a stage's [produced] artifacts against the [expected] produces-slots of the
|
||||
* locked plan. [missing] is the expected slots that were never produced; [passed] holds when none
|
||||
* are missing. Extra produced artifacts beyond [expected] do not fail the checkpoint.
|
||||
*/
|
||||
internal data class StageCheckpoint(
|
||||
val expected: Set<String>,
|
||||
val produced: Set<String>,
|
||||
) {
|
||||
val missing: Set<String> get() = expected - produced
|
||||
val passed: Boolean get() = missing.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reconciler for stage-level plan checkpointing: compares the artifacts a stage produced
|
||||
* against the artifacts the locked plan expected it to produce. Side-effect free and replay-safe.
|
||||
*/
|
||||
internal object StageCheckpointReconciler {
|
||||
fun reconcile(expected: Set<String>, produced: Set<String>): StageCheckpoint =
|
||||
StageCheckpoint(expected = expected, produced = produced)
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.CritiqueFinding
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcome
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.CritiqueRole
|
||||
import com.correx.core.events.events.CritiqueSeverity
|
||||
import com.correx.core.events.events.CritiqueVerdict
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CritiqueOutcomeCorrelatorTest {
|
||||
|
||||
private val sessionId = SessionId("s1")
|
||||
|
||||
private fun finding(id: String, severity: CritiqueSeverity = CritiqueSeverity.MAJOR) = CritiqueFinding(
|
||||
id = id,
|
||||
role = CritiqueRole.CODE_REVIEWER,
|
||||
severity = severity,
|
||||
category = "correctness",
|
||||
message = "msg-$id",
|
||||
)
|
||||
|
||||
private fun review(
|
||||
iteration: Int,
|
||||
verdict: CritiqueVerdict,
|
||||
findings: List<CritiqueFinding>,
|
||||
role: CritiqueRole = CritiqueRole.CODE_REVIEWER,
|
||||
modelHash: String = "model-A",
|
||||
) = CritiqueFindingsRecordedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("reviewer"),
|
||||
role = role,
|
||||
modelHash = modelHash,
|
||||
iteration = iteration,
|
||||
verdict = verdict,
|
||||
findings = findings,
|
||||
)
|
||||
|
||||
private fun outcomeFor(events: List<CritiqueOutcomeCorrelatedEvent>, id: String) =
|
||||
events.single { it.findingId == id }.outcome
|
||||
|
||||
@Test
|
||||
fun `finding fixed between iterations is UPHELD`() {
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
review(2, CritiqueVerdict.APPROVED, emptyList()),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals(CritiqueOutcome.UPHELD, out.single().outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finding persisting into an approved final review is DISMISSED`() {
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
review(2, CritiqueVerdict.APPROVED, listOf(finding("f1"))),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finding still open at a non-approved terminal is INCONCLUSIVE`() {
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
review(2, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.INCONCLUSIVE, outcomeFor(out, "f1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single approved review with a finding dismisses it`() {
|
||||
val reviews = listOf(review(1, CritiqueVerdict.APPROVED, listOf(finding("f1"))))
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mixed fates in one loop are resolved independently per finding`() {
|
||||
val reviews = listOf(
|
||||
// f1 and f2 raised; f1 fixed next round, f2 persists; f3 raised late and persists.
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))),
|
||||
review(2, CritiqueVerdict.APPROVED, listOf(finding("f2"), finding("f3"))),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(3, out.size)
|
||||
assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "f1"))
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f2"))
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findings carry their severity and the producing critic's identity`() {
|
||||
val reviews = listOf(
|
||||
review(
|
||||
1, CritiqueVerdict.APPROVED, listOf(finding("f1", CritiqueSeverity.BLOCKER)),
|
||||
role = CritiqueRole.PLAN_CRITIC, modelHash = "model-Z",
|
||||
),
|
||||
)
|
||||
val event = CritiqueOutcomeCorrelator.correlate(sessionId, reviews).single()
|
||||
assertEquals(CritiqueSeverity.BLOCKER, event.severity)
|
||||
assertEquals(CritiqueRole.PLAN_CRITIC, event.role)
|
||||
assertEquals("model-Z", event.modelHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each critic (role + model) is calibrated separately`() {
|
||||
val planCritic = CritiqueRole.PLAN_CRITIC
|
||||
val reviewer = CritiqueRole.CODE_REVIEWER
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("a")), role = planCritic, modelHash = "m1"),
|
||||
review(2, CritiqueVerdict.APPROVED, emptyList(), role = planCritic, modelHash = "m1"),
|
||||
review(1, CritiqueVerdict.APPROVED, listOf(finding("b")), role = reviewer, modelHash = "m2"),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "a")) // plan critic's finding fixed
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "b")) // reviewer's finding approved-through
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no reviews yields no outcomes`() {
|
||||
assertTrue(CritiqueOutcomeCorrelator.correlate(sessionId, emptyList()).isEmpty())
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ParseToolArgumentsTest {
|
||||
|
||||
@Test
|
||||
fun `a string array becomes a List so list-param accessors read it`() {
|
||||
// Regression: arrays were stringified and read back empty, dropping a task's affected_paths.
|
||||
val params = parseToolArguments("""{"affected_paths":["apps/web/**","build.gradle"]}""")
|
||||
assertEquals(listOf("apps/web/**", "build.gradle"), params["affected_paths"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primitives become their content strings`() {
|
||||
val params = parseToolArguments("""{"project":"webui","force":true,"limit":5}""")
|
||||
assertEquals("webui", params["project"])
|
||||
assertEquals("true", params["force"])
|
||||
assertEquals("5", params["limit"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `objects and arrays-of-objects are kept as JSON text for tools that re-parse them`() {
|
||||
val params = parseToolArguments(
|
||||
"""{"parent":{"title":"Epic"},"tasks":[{"title":"A"},{"title":"B"}]}""",
|
||||
)
|
||||
assertTrue((params["parent"] as String).contains("\"title\""))
|
||||
// re-parseable JSON, not a Kotlin List#toString
|
||||
assertTrue((params["tasks"] as String).trim().startsWith("["))
|
||||
assertTrue((params["tasks"] as String).contains("\"title\":\"A\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed json yields an empty map`() {
|
||||
assertTrue(parseToolArguments("not json").isEmpty())
|
||||
assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decompose preview renders the epic and children with dependency labels`() {
|
||||
// Args arrive flattened: nested tasks/parent are JSON text, as in a real call.
|
||||
val preview = renderDecomposePreview(
|
||||
mapOf(
|
||||
"project" to "webui",
|
||||
"parent" to """{"title":"Frontend web UI"}""",
|
||||
"tasks" to """
|
||||
[
|
||||
{"ref":"scaffold","title":"Scaffold app"},
|
||||
{"title":"Auth view","depends_on":["scaffold"]},
|
||||
{"title":"Dashboard","depends_on":["0"]}
|
||||
]
|
||||
""".trimIndent(),
|
||||
),
|
||||
)!!
|
||||
assertTrue(preview.contains("epic: Frontend web UI"))
|
||||
assertTrue(preview.contains("1. Scaffold app"))
|
||||
// dependency labels resolve a ref and a numeric index back to the dependency's title.
|
||||
assertTrue(preview.contains("2. Auth view (after: Scaffold app)"))
|
||||
assertTrue(preview.contains("3. Dashboard (after: Scaffold app)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decompose preview is null when tasks are unparseable so the caller falls back`() {
|
||||
assertNull(renderDecomposePreview(mapOf("project" to "webui", "tasks" to "oops")))
|
||||
assertNull(renderDecomposePreview(mapOf("tasks" to "[]")))
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StageCheckpointReconcilerTest {
|
||||
|
||||
@Test
|
||||
fun `all expected produced - passed with no missing`() {
|
||||
val cp = StageCheckpointReconciler.reconcile(
|
||||
expected = setOf("plan.json", "diff.patch"),
|
||||
produced = setOf("plan.json", "diff.patch"),
|
||||
)
|
||||
assertTrue(cp.passed)
|
||||
assertTrue(cp.missing.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one missing - not passed with correct missing set`() {
|
||||
val cp = StageCheckpointReconciler.reconcile(
|
||||
expected = setOf("plan.json", "diff.patch"),
|
||||
produced = setOf("plan.json"),
|
||||
)
|
||||
assertFalse(cp.passed)
|
||||
assertEquals(setOf("diff.patch"), cp.missing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty expected - passed`() {
|
||||
val cp = StageCheckpointReconciler.reconcile(
|
||||
expected = emptySet(),
|
||||
produced = setOf("plan.json"),
|
||||
)
|
||||
assertTrue(cp.passed)
|
||||
assertTrue(cp.missing.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extra produced beyond expected - still passed`() {
|
||||
val cp = StageCheckpointReconciler.reconcile(
|
||||
expected = setOf("plan.json"),
|
||||
produced = setOf("plan.json", "notes.md", "scratch.txt"),
|
||||
)
|
||||
assertTrue(cp.passed)
|
||||
assertTrue(cp.missing.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+72
@@ -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"
|
||||
}
|
||||
}
|
||||
+62
@@ -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
Reference in New Issue
Block a user