merge: integrate feat/backlog-burndown into master

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
@@ -35,6 +35,7 @@ import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.WorkflowSummary
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.registry.ToolRegistry
import com.correx.infrastructure.artifactscas.CasArtifactStore
@@ -104,8 +105,11 @@ object InfrastructureModule {
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
}
fun createToolRegistry(config: ToolConfig): ToolRegistry =
DefaultToolRegistry.build(config.buildTools())
fun createToolRegistry(
config: ToolConfig,
extraTools: List<Tool> = emptyList(),
): ToolRegistry =
DefaultToolRegistry.build(config.buildTools() + extraTools)
fun createLlamaCppProvider(
modelId: String,
+1
View File
@@ -13,6 +13,7 @@ dependencies {
implementation project(":core:events")
implementation project(":core:approvals")
implementation project(":core:sessions")
implementation project(":core:tasks")
implementation project(":infrastructure:tools:filesystem")
implementation project(":core:artifacts")
implementation project(":core:artifacts-store")
@@ -142,9 +142,13 @@ class FileReadTool(
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val content = lines.subList(start, end).joinToString("\n")
// Record a content hash only for a whole-file read, so the stale-write gate baselines against
// what the agent actually saw; a partial read establishes no baseline.
val metadata = if (startLine == null && endLine == null) mapOf("contentHash" to sha256(path)) else emptyMap()
ToolResult.Success(
invocationId = request.invocationId,
output = content,
metadata = metadata,
)
}.getOrElse {
ToolResult.Failure(
@@ -154,6 +158,10 @@ class FileReadTool(
)
}
private fun sha256(path: Path): String =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
.joinToString("") { "%02x".format(it) }
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
val entries = path.listDirectoryEntries().sortedBy { it.name }
val output = entries.joinToString("\n") { entry ->
@@ -4,12 +4,15 @@ import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.SourceFetchedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionStartedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
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.FileAffectingTool
import com.correx.core.tools.contract.Tool
@@ -82,6 +85,7 @@ class SandboxedToolExecutor(
restoreOrClean(backupMap, success = true)
cleanWorkingDir(workingDir)
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff)
emitResearchSourceEvents(sessionId, request.stageId, result)
emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
result
}
@@ -183,6 +187,37 @@ class SandboxedToolExecutor(
)
}
/**
* Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic
* [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write
* above is left intact for existing consumers. Only fires for results that carry the fetch
* markers ([url] + [content_sha256] + [quality]), so non-fetch tools are untouched. The
* low-quality threshold reuses the extractor's own marker — [ExtractionQuality.LOW_QUALITY], whose
* name is what [WebFetchTool] writes into `quality` — rather than inventing a new policy here.
*/
private suspend fun emitResearchSourceEvents(
sessionId: SessionId,
stageId: StageId,
result: ToolResult.Success,
) {
val md = result.metadata
val url = md["url"]
val contentSha256 = md["content_sha256"]
val quality = md["quality"]
if (url == null || contentSha256 == null || quality == null) return
val byteCount = md["fetched_bytes"]?.toIntOrNull() ?: 0
emit(sessionId, SourceFetchedEvent(sessionId, stageId, url, contentSha256, quality, byteCount))
// Source of truth for the bar is the extractor (ExtractionQuality.LOW_QUALITY); its enum name
// is what WebFetchTool records in `quality`. Keep the marker as a literal to avoid a core ->
// infrastructure dependency inversion.
if (quality == LOW_QUALITY_MARKER) {
val reason = "extraction quality below the minimum-content bar ($byteCount bytes fetched)"
emit(sessionId, LowQualityExtractionEvent(sessionId, stageId, url, contentSha256, reason))
}
}
@Suppress("MaxLineLength")
private fun computeDiff(affectedPaths: Set<Path>, backupMap: Map<Path, Path>): String? {
val diffs = mutableListOf<String>()
@@ -266,4 +301,9 @@ class SandboxedToolExecutor(
)
}
}
private companion object {
/** Mirrors `ExtractionQuality.LOW_QUALITY.name` — the marker WebFetchTool writes into `quality`. */
const val LOW_QUALITY_MARKER = "LOW_QUALITY"
}
}
@@ -0,0 +1,14 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
/**
* Port: record that a session is working a task — emitted on claim (and on an affected_paths edit by
* the claimant) so the gates can fold it into SessionContext.activeTask without scanning task
* streams. The host supplies an adapter that appends a SessionWorkingTaskEvent to the session
* stream. A null binding means no recording (the bare tool still works).
*/
fun interface SessionFactRecorder {
suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List<String>)
}
@@ -0,0 +1,13 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.types.SessionId
/**
* Port: the paths a session has written this run. The host supplies an adapter that folds the
* session's SessionContext (its writes come from the recorded receipt.affectedEntities). The
* cite-before-claim gate in [TaskUpdateTool] uses it to confirm a task being completed actually saw
* changes under its affected_paths. A null binding leaves the gate off.
*/
fun interface SessionWrites {
fun pathsWritten(sessionId: SessionId): Set<String>
}
@@ -0,0 +1,57 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: fetch a task's token-optimized context bundle in one call. Read-only, so
* it sits at the lowest tier (like file_read) — agents call it before starting work on a task.
*/
class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolExecutor {
override val name: String = "task_context"
override val description: String =
"Call this first, before working a task you've claimed or been pointed at, so you don't " +
"re-derive context: it returns the task's goal, acceptance criteria, resolved " +
"dependencies, related links, relevant files, and notes in one bundle."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
val bundle = assembler.assemble(TaskId(id))
?: return ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
return ToolResult.Success(
invocationId = request.invocationId,
output = bundle.render(),
metadata = mapOf("taskId" to id, "status" to bundle.status),
)
}
}
@@ -0,0 +1,131 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/** Agent-facing tool: create a task in a project's work graph. */
class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_create"
override val description: String =
"Open a task when work spans multiple sessions, needs handoff between runs, or the " +
"operator asks to track it. Search first (task_search) to avoid duplicates. Don't " +
"create one for a single self-contained edit you finish now. Returns the new id " +
"(e.g. 'auth-142')."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("project") {
put("type", "string")
put("description", "Project key, e.g. 'auth'. The new task id is '<project>-<n>'.")
}
putJsonObject("title") {
put("type", "string")
put("description", "Short imperative title.")
}
putJsonObject("goal") {
put("type", "string")
put("description", "What 'done' looks like, in a sentence or two.")
}
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Concrete, verifiable criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Globs/paths this task is expected to touch.")
}
putJsonObject("force") {
put("type", "boolean")
put("description", "Create even if an active task with the same title exists. Default false.")
}
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why a duplicate is acceptable. Recorded on the task.")
}
}
put(
"required",
buildJsonArray {
add(JsonPrimitive("project"))
add(JsonPrimitive("title"))
add(JsonPrimitive("goal"))
},
)
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
request.stringParam("title") ?: return ValidationResult.Invalid("Missing 'title' (string).")
request.stringParam("goal") ?: return ValidationResult.Invalid("Missing 'goal' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val precheckFailure = precheck(request)
if (precheckFailure != null) return precheckFailure
val task = service.createTask(
projectId = ProjectId(request.stringParam("project")!!),
title = request.stringParam("title")!!,
goal = request.stringParam("goal")!!,
acceptanceCriteria = request.listParam("acceptance_criteria"),
affectedPaths = request.listParam("affected_paths"),
)
// Provenance: tie the new task to the session that spawned it so its context bundle
// can show the originating run.
service.linkOriginSession(task.taskId, request)
// A forced creation bypassed the duplicate guard — record why, for the audit trail.
if (request.boolParam("force")) {
request.stringParam("force_reason")?.let {
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "[force] created despite the duplicate guard: $it")
}
}
return ToolResult.Success(
invocationId = request.invocationId,
output = "Created ${task.taskId.value}: ${task.state.title}",
metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name),
)
}
/**
* Required-field validation; then either a force-reason requirement (force=true must carry a
* force_reason, recorded after creation) or the duplicate-title guard. Null when OK to create.
*/
private fun precheck(request: ToolRequest): ToolResult.Failure? {
val invalid = validateRequest(request) as? ValidationResult.Invalid
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
if (request.boolParam("force")) {
if (request.stringParam("force_reason") == null) {
return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
}
return null
}
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
return duplicates.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
}
}
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
@@ -0,0 +1,305 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
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.tasks.Task
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: break a large goal into a small task graph in ONE approval, instead of N
* separate [TaskCreateTool] calls. Use when the goal has dependency seams or independent
* review/handoff points (a thing that must land before another, or a piece worth reviewing on its
* own); for a single coherent unit you finish in one run, use task_create. Because a session works
* one task at a time, each node becomes a future claim — this tool decides the *shape*, claiming is
* still lazy (task_ready → claim), never scheduled.
*
* Creates the tasks and their `DEPENDS_ON` edges atomically (one dedup check, one approval) and
* returns the new ids plus which are ready to work now. An optional `parent` epic `DEPENDS_ON` every
* child (so it completes last) and each child `IMPLEMENTS` it (provenance).
*
* Nested args arrive flattened (the orchestrator stringifies non-primitive tool arguments), so the
* `tasks`/`parent` JSON is parsed here directly rather than via the list-param accessor.
*/
class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_decompose"
override val description: String =
"Break a large goal into a small task graph (parent epic + dependency-linked children) in " +
"one shot. Use when the goal has dependency seams or independent review/handoff points; " +
"for a single coherent unit you finish now, use task_create. Creates the tasks and their " +
"DEPENDS_ON links in a single approval; returns the new ids and which are ready to work now."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("project") {
put("type", "string")
put("description", "Project key shared by every task, e.g. 'webui'. Ids become '<project>-<n>'.")
}
putJsonObject("parent") {
put("type", "object")
put(
"description",
"Optional umbrella/epic. It DEPENDS_ON every child (completes last); each child IMPLEMENTS it.",
)
putJsonObject("properties") { unitProperties() }
}
putJsonObject("tasks") {
put("type", "array")
put("description", "The units of work, in order. Declare cross-task ordering with depends_on.")
putJsonObject("items") {
put("type", "object")
putJsonObject("properties") {
putJsonObject("ref") {
put("type", "string")
put("description", "Local handle other tasks cite in depends_on (e.g. 'scaffold').")
}
unitProperties()
putJsonObject("depends_on") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) })
}
}
putJsonObject("force") {
put("type", "boolean")
put("description", "Create even if a title duplicates an active task. Default false.")
}
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why duplicates are acceptable. Recorded on each task.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("project")); add(JsonPrimitive("tasks")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
if (elementOf(request, "tasks") !is JsonArray) {
return ValidationResult.Invalid("Missing 'tasks' (JSON array).")
}
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val project = request.stringParam("project") ?: return fail(request, "Missing 'project' (string).")
val specs = parseSpecs(elementOf(request, "tasks") as? JsonArray)
?: return fail(request, "'tasks' must be a JSON array of {title, goal, ...}.")
if (specs.isEmpty()) return fail(request, "'tasks' must contain at least one task.")
specs.withIndex().firstOrNull { it.value.title.isBlank() || it.value.goal.isBlank() }?.let {
return fail(request, "task #${it.index} is missing 'title' or 'goal'.")
}
val parent = parseSpec(elementOf(request, "parent") as? JsonObject)
if (parent != null && (parent.title.isBlank() || parent.goal.isBlank())) {
return fail(request, "'parent' needs both 'title' and 'goal'.")
}
val adjacency = when (val e = buildEdges(specs)) {
is Edges.Err -> return fail(request, e.message)
is Edges.Ok -> e.adjacency
}
dedupFailure(request, project, specs, parent)?.let { return it }
val pid = ProjectId(project)
val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
wireLinks(adjacency, children, parentTask)
recordProvenance(request, listOfNotNull(parentTask) + children)
val createdIds = (children + listOfNotNull(parentTask)).joinToString(",") { it.taskId.value }
val readyIds = children.filter { service.blockers(it.taskId).isEmpty() }.map { it.taskId.value }
return ToolResult.Success(
invocationId = request.invocationId,
output = render(children, parentTask, readyIds),
metadata = mapOf("taskIds" to createdIds, "readyIds" to readyIds.joinToString(","), "project" to project),
)
}
/** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */
private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) {
adjacency.forEachIndexed { i, deps ->
deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) }
}
if (parentTask != null) {
children.forEach { child ->
// Parent waits on every child; child records the epic it implements.
service.link(parentTask.taskId, child.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
service.link(child.taskId, parentTask.taskId.value, TaskLinkType.IMPLEMENTS, TaskTargetKind.TASK)
}
}
}
private suspend fun recordProvenance(request: ToolRequest, created: List<Task>) {
created.forEach { service.linkOriginSession(it.taskId, request) }
if (!request.boolParam("force")) return
val reason = request.stringParam("force_reason") ?: return
created.forEach {
service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason")
}
}
/** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */
private fun dedupFailure(request: ToolRequest, project: String, specs: List<TaskSpec>, parent: TaskSpec?): ToolResult.Failure? {
val titles = (listOfNotNull(parent?.title) + specs.map { it.title })
val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys
if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
if (request.boolParam("force")) {
return if (request.stringParam("force_reason") == null) {
fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
} else {
null
}
}
val pid = ProjectId(project)
val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value }
return clashes.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
}
}
private fun render(children: List<Task>, parentTask: Task?, readyIds: List<String>): String {
val lines = children.map { c ->
val unmet = service.blockers(c.taskId).map { it.taskId.value }
val state = if (unmet.isEmpty()) "READY" else "blocked by ${unmet.joinToString(", ")}"
"- ${c.taskId.value} '${c.state.title.orEmpty()}' [$state]"
} + listOfNotNull(
parentTask?.let { p ->
val unmet = service.blockers(p.taskId).map { it.taskId.value }
"- ${p.taskId.value} '${p.state.title.orEmpty()}' (epic) [blocked by ${unmet.joinToString(", ")}]"
},
)
val readyLine = if (readyIds.isEmpty()) {
"Nothing is ready yet — check the dependency graph."
} else {
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete."
}
return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" +
lines.joinToString("\n") + "\n" + readyLine
}
// --- parsing (flattened JSON args) ---
private data class TaskSpec(
val ref: String?,
val title: String,
val goal: String,
val acceptanceCriteria: List<String>,
val affectedPaths: List<String>,
val deps: List<String>,
)
private fun parseSpecs(element: JsonArray?): List<TaskSpec>? =
element?.map { parseSpec(it as? JsonObject) ?: return null }
private fun parseSpec(obj: JsonObject?): TaskSpec? {
if (obj == null) return null
return TaskSpec(
ref = str(obj, "ref"),
title = str(obj, "title").orEmpty(),
goal = str(obj, "goal").orEmpty(),
acceptanceCriteria = strList(obj, "acceptance_criteria"),
affectedPaths = strList(obj, "affected_paths"),
deps = strList(obj, "depends_on"),
)
}
private fun str(obj: JsonObject, key: String): String? =
(obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
private fun strList(obj: JsonObject, key: String): List<String> =
(obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList()
/** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */
private fun elementOf(request: ToolRequest, name: String) =
request.parameters[name]?.let { runCatching { Json.parseToJsonElement(it.toString()) }.getOrNull() }
private fun normalize(title: String): String = title.trim().lowercase().replace(Regex("\\s+"), " ")
// --- dependency edges + cycle check ---
private sealed interface Edges {
data class Ok(val adjacency: List<List<Int>>) : Edges
data class Err(val message: String) : Edges
}
private fun buildEdges(specs: List<TaskSpec>): Edges {
val refToIndex = HashMap<String, Int>()
specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } }
val adjacency = ArrayList<List<Int>>()
specs.forEachIndexed { i, s ->
val deps = ArrayList<Int>()
for (d in s.deps) {
val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices }
?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.")
if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.")
deps.add(j)
}
adjacency.add(deps.distinct())
}
cyclePath(adjacency)?.let { path ->
return Edges.Err("Dependency cycle: ${path.joinToString(" -> ") { specs[it].title }}.")
}
return Edges.Ok(adjacency)
}
/** Returns a back-edge cycle as a list of task indices, or null when the graph is acyclic. */
private fun cyclePath(adjacency: List<List<Int>>): List<Int>? {
val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done
val stack = ArrayList<Int>()
fun dfs(u: Int): List<Int>? {
color[u] = 1
stack.add(u)
for (v in adjacency[u]) {
if (color[v] == 1) return stack.subList(stack.indexOf(v), stack.size).toList() + v
if (color[v] == 0) dfs(v)?.let { return it }
}
color[u] = 2
stack.removeAt(stack.lastIndex)
return null
}
for (i in adjacency.indices) if (color[i] == 0) dfs(i)?.let { return it }
return null
}
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
/** The fields a task spec and the parent epic share, declared once for the schema. */
private fun kotlinx.serialization.json.JsonObjectBuilder.unitProperties() {
putJsonObject("title") { put("type", "string"); put("description", "Short imperative title.") }
putJsonObject("goal") { put("type", "string"); put("description", "What 'done' looks like.") }
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Concrete, verifiable criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Globs/paths this task is expected to touch.")
}
}
@@ -0,0 +1,56 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: soft-delete a task (tombstone). History is preserved in the event log;
* the task drops out of active views. Use 'task_update' with action=cancel to record an
* abandoned-but-visible outcome instead.
*/
class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_delete"
override val description: String =
"Use only to remove a task opened by mistake or a duplicate: soft-deletes it (drops " +
"from active views; history is kept). For real work that's been abandoned, use " +
"task_update action=cancel instead so it stays visible."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
return if (service.delete(TaskId(id))) {
ToolResult.Success(request.invocationId, output = "Deleted $id", metadata = mapOf("taskId" to id))
} else {
ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
}
}
}
@@ -0,0 +1,61 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
private const val DEFAULT_LIMIT = 20
/**
* Agent-facing tool: list tasks that are ready to be worked right now — TODO with every dependency
* satisfied. Read-only, lowest tier. This is how an agent looking for work discovers what it can
* pick up; it then claims one with `task_update action=claim`. It surfaces work, it does not assign.
*/
class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_ready"
override val description: String =
"List tasks ready to work now — TODO with all dependencies satisfied — when you're looking " +
"for what to pick up. Claim one with task_update action=claim. Optionally scope to a " +
"project. Returns 'id [STATUS] title' lines."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("project") {
put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.")
}
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
}
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
override suspend fun execute(request: ToolRequest): ToolResult {
val project = request.stringParam("project")?.let { ProjectId(it) }
val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT
val ready = service.ready(project).take(limit)
val output = if (ready.isEmpty()) {
"no ready tasks"
} else {
ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
}
return ToolResult.Success(
invocationId = request.invocationId,
output = output,
metadata = mapOf("count" to ready.size.toString()),
)
}
}
@@ -0,0 +1,72 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
private const val DEFAULT_LIMIT = 20
/**
* Agent-facing tool: find tasks by text across projects (matches id/title/goal/criteria/notes),
* ranked by relevance. Read-only, so it sits at the lowest tier (like [TaskContextTool]) — agents
* use it to discover task ids before fetching a context bundle.
*/
class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_search"
override val description: String =
"Search tasks before opening a new one (avoid duplicates) and to find related or " +
"blocking work, or to look up a task id by text. Matches id/title/goal/acceptance " +
"criteria/notes, ranked by relevance; returns 'id [STATUS] title' lines. Pair with " +
"task_context to load a hit."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") }
putJsonObject("project") {
put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.")
}
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
}
put("required", buildJsonArray { add(JsonPrimitive("query")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("query") ?: return ValidationResult.Invalid("Missing 'query' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val query = request.stringParam("query")
?: return ToolResult.Failure(request.invocationId, "Missing 'query' (string).", recoverable = false)
val project = request.stringParam("project")?.let { ProjectId(it) }
val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT
val hits = service.search(query, project).take(limit)
val output = if (hits.isEmpty()) {
"no tasks match \"$query\""
} else {
hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
}
return ToolResult.Success(
invocationId = request.invocationId,
output = output,
metadata = mapOf("query" to query, "count" to hits.size.toString()),
)
}
}
@@ -0,0 +1,38 @@
package com.correx.infrastructure.tools.task
import com.correx.core.tasks.TaskArtifactResolver
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskDocumentResolver
import com.correx.core.tasks.TaskKnowledgeRetriever
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskSessionResolver
import com.correx.core.tools.contract.Tool
/** Builds the task tool set over a single [TaskService] for registration in the tool registry. */
object TaskTools {
/**
* [retriever] semantically enriches the `task_context` bundle; [documentResolver],
* [artifactResolver] and [sessionResolver] inline DOC/ARTIFACT/SESSION link targets. All
* optional — null leaves those links raw and the bundle deterministic.
*/
fun forService(
service: TaskService,
retriever: TaskKnowledgeRetriever? = null,
documentResolver: TaskDocumentResolver? = null,
artifactResolver: TaskArtifactResolver? = null,
sessionResolver: TaskSessionResolver? = null,
sessionFacts: SessionFactRecorder? = null,
sessionWrites: SessionWrites? = null,
): List<Tool> =
listOf(
TaskCreateTool(service),
TaskDecomposeTool(service),
TaskUpdateTool(service, sessionFacts, sessionWrites),
TaskDeleteTool(service),
TaskSearchTool(service),
TaskReadyTool(service),
TaskContextTool(
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
),
)
}
@@ -0,0 +1,263 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
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.tasks.TaskService
import com.correx.core.tasks.TaskTargetKinds
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.FileSystems
import java.nio.file.Path
/**
* Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph
* link, or attach a note. The task's project is derived from its id, so only the id is required.
*
* [sessionFacts] records the session's active task on claim; [sessionWrites], when bound, powers
* the cite-before-claim gate (completing a task that declared affected_paths but saw no matching
* write this session is blocked, escapable with force).
*/
class TaskUpdateTool(
private val service: TaskService,
private val sessionFacts: SessionFactRecorder? = null,
private val sessionWrites: SessionWrites? = null,
) : Tool, ToolExecutor {
override val name: String = "task_update"
override val description: String =
"Keep a task's state current as you work it: claim before starting, submit_for_review " +
"when ready, complete after review, block/unblock when waiting on something. Also " +
"edits title/goal/criteria/paths, adds a work-graph link, or attaches a note. " +
"Actions: claim, release, block, unblock, submit_for_review, complete, reopen, cancel."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
putJsonObject("title") { put("type", "string"); put("description", "New title.") }
putJsonObject("goal") { put("type", "string"); put("description", "New goal.") }
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Replaces the acceptance criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Replaces the affected paths.")
}
putJsonObject("action") {
put("type", "string")
put(
"description",
"Status transition: claim, release, block, unblock, submit_for_review, " +
"complete, reopen, cancel.",
)
}
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") }
putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") }
putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") }
putJsonObject("link_type") {
put("type", "string")
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
}
putJsonObject("link_kind") {
put("type", "string")
put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.")
}
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
putJsonObject("force") {
put("type", "boolean")
put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.")
}
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return fail(request, "Missing 'id' (string).")
val taskId = TaskId(id)
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
val action = request.stringParam("action")
forceReasonGate(request)?.let { return it }
claimBlock(request, taskId, action)?.let { return it }
completeBlock(request, taskId, action)?.let { return it }
applyFieldEdits(request, taskId)
if (action != null && !applyAction(taskId, action, request)) {
return fail(request, "Unknown action '$action'.")
}
val linkTarget = request.stringParam("link_target")
if (linkTarget != null) {
val type = parseLinkType(request.stringParam("link_type"))
?: return fail(request, "Invalid 'link_type'.")
val kind = resolveTargetKind(request.stringParam("link_kind"), linkTarget)
?: return fail(request, "Invalid 'link_kind'.")
service.link(taskId, linkTarget, type, kind)
}
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
recordForceNote(request, taskId, action)
recordWorkingTask(request, taskId, action)
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
val warning = if (action == "claim") claimWarning(taskId) else ""
return ToolResult.Success(
invocationId = request.invocationId,
output = "Updated $id [$status]$warning",
metadata = mapOf("taskId" to id, "status" to status),
)
}
/** A force override must carry a recorded justification — reject force=true with no force_reason. */
private fun forceReasonGate(request: ToolRequest): ToolResult.Failure? =
if (request.boolParam("force") && request.stringParam("force_reason") == null) {
fail(request, "force=true requires 'force_reason' explaining the override.")
} else {
null
}
/** A forced claim/complete bypassed a gate — record why on the task, for the audit trail. */
private suspend fun recordForceNote(request: ToolRequest, taskId: TaskId, action: String?) {
if (!request.boolParam("force")) return
val reason = request.stringParam("force_reason") ?: return
service.addNote(taskId, TaskNoteAuthor.AGENT, "[force] ${action ?: "update"}: $reason")
}
/** Refuse to claim a task with unmet blockers (escapable with force+reason) — fail before any mutation. */
private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
if (action != "claim" || request.boolParam("force")) return null
val unmet = service.blockers(taskId)
if (unmet.isEmpty()) return null
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.")
}
/**
* Cite-before-claim: refuse to complete a task that declared affected_paths but saw no matching
* write this session — "marked done without doing it." Off when no [sessionWrites] is bound or
* the task declared no scope; escapable with force.
*/
private fun completeBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? {
if (action != "complete" || request.boolParam("force")) return null
val writes = sessionWrites ?: return null
val scope = service.getTask(taskId)?.state?.affectedPaths.orEmpty()
if (scope.isEmpty()) return null
if (matchesAnyGlob(writes.pathsWritten(request.sessionId), scope)) return null
return fail(
request,
"Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " +
"session. Make the change, or force=true with force_reason.",
)
}
/** True if any written path matches any affected_paths glob (workspace-relative; './' tolerated). */
private fun matchesAnyGlob(written: Set<String>, globs: List<String>): Boolean {
if (written.isEmpty()) return false
val fs = FileSystems.getDefault()
val matchers = globs.map { fs.getPathMatcher("glob:${it.removePrefix("./")}") }
return written.any { w ->
val rel = Path.of(w.removePrefix("./"))
matchers.any { it.matches(rel) }
}
}
/**
* Record the session→task working relationship (for SessionContext.activeTask) on a claim, or on
* an affected_paths edit by the recorded claimant (to refresh the snapshot scope). No-op without
* a recorder bound.
*/
private suspend fun recordWorkingTask(request: ToolRequest, taskId: TaskId, action: String?) {
val recorder = sessionFacts ?: return
val task = service.getTask(taskId) ?: return
val isClaim = action == "claim"
val scopeEditByClaimant = request.parameters.containsKey("affected_paths") &&
task.state.claimant == request.sessionId.value
if (isClaim || scopeEditByClaimant) {
recorder.recordWorkingTask(request.sessionId, taskId, task.state.affectedPaths)
}
}
/** When a force-claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */
private fun claimWarning(taskId: TaskId): String {
val blockers = service.blockers(taskId)
if (blockers.isEmpty()) return ""
val listed = blockers.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
return " — WARNING: claimed despite unmet blockers: $listed"
}
private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) {
val title = request.stringParam("title")
val goal = request.stringParam("goal")
if (title != null || goal != null) service.edit(taskId, title, goal)
if (request.parameters.containsKey("acceptance_criteria")) {
service.setAcceptanceCriteria(taskId, request.listParam("acceptance_criteria"))
}
if (request.parameters.containsKey("affected_paths")) {
service.setAffectedPaths(taskId, request.listParam("affected_paths"))
}
}
private suspend fun applyAction(taskId: TaskId, action: String, request: ToolRequest): Boolean {
val claimant = request.stringParam("claimant") ?: request.sessionId.value
val reason = request.stringParam("reason")
when (action) {
"claim" -> {
service.claim(taskId, claimant)
// Record the claiming session too, so a task picked up by a different run than
// the one that created it links both into its work graph.
service.linkOriginSession(taskId, request)
}
"release" -> service.release(taskId)
"block" -> service.block(taskId, reason ?: "blocked")
"unblock" -> service.unblock(taskId)
"submit_for_review" -> service.submitForReview(taskId)
"complete" -> service.complete(taskId)
"reopen" -> service.reopen(taskId)
"cancel" -> service.cancel(taskId, reason)
else -> return false
}
return true
}
private fun parseLinkType(raw: String?): TaskLinkType? =
if (raw == null) TaskLinkType.RELATES_TO
else TaskLinkType.entries.firstOrNull { it.name == raw.uppercase() }
/** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */
private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? =
if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() }
else TaskTargetKinds.infer(target)
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
@@ -0,0 +1,30 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.TaskService
/** A non-blank string parameter, or null when absent/blank/not a string. */
internal fun ToolRequest.stringParam(name: String): String? =
(parameters[name] as? String)?.takeIf { it.isNotBlank() }
/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */
internal fun ToolRequest.listParam(name: String): List<String> =
(parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList()
/** A boolean parameter, accepting a real Boolean or the string "true" (case-insensitive). */
internal fun ToolRequest.boolParam(name: String): Boolean =
parameters[name] == true || stringParam(name)?.toBoolean() == true
/**
* Records the agent's session on the task as a CONTEXT/SESSION link, so the context bundle can
* surface the run(s) that created or worked on it (the session resolver inlines status/intent).
* No-op for a blank session id; duplicate links are deduped by the reducer, so creating and then
* claiming from the same session yields a single edge.
*/
internal suspend fun TaskService.linkOriginSession(taskId: TaskId, request: ToolRequest) {
val session = request.sessionId.value.takeIf { it.isNotBlank() } ?: return
link(taskId, session, TaskLinkType.CONTEXT, TaskTargetKind.SESSION)
}
@@ -0,0 +1,127 @@
package com.correx.infrastructure.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SourceFetchedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
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.ParamRole
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.tools.registry.ToolRegistry
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
class SandboxedToolExecutorResearchSourceTest {
private class CapturingEventStore : EventStore {
val payloads = mutableListOf<EventPayload>()
override suspend fun append(event: NewEvent): StoredEvent {
payloads += event.payload
val seq = payloads.size.toLong()
return StoredEvent(event.metadata, seq, seq, event.payload)
}
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
override fun read(sessionId: SessionId): List<StoredEvent> = emptyList()
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = emptyList()
override fun lastSequence(sessionId: SessionId): Long? = null
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = payloads.size.toLong()
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
override fun allSessionIds(): Set<SessionId> = emptySet()
}
private class SingleToolRegistry(private val tool: Tool) : ToolRegistry {
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
override fun all(): List<Tool> = listOf(tool)
}
/** Stands in for a research fetch: returns the same metadata shape WebFetchTool emits. */
private class FakeFetchTool(private val metadata: Map<String, String>) : Tool, ToolExecutor {
override val name: String = "web_fetch"
override val description: String = "fake"
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS)
override val paramRoles: Map<String, ParamRole> = mapOf("url" to ParamRole.NETWORK_TARGET)
override val parametersSchema: JsonObject = JsonObject(emptyMap())
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
override suspend fun execute(request: ToolRequest): ToolResult =
ToolResult.Success(request.invocationId, output = "md", metadata = metadata)
}
private fun request() = ToolRequest(
ToolInvocationId("inv"), SessionId("s"), StageId("st"), "web_fetch",
mapOf("url" to "https://example.com/a"),
)
private fun runFetch(metadata: Map<String, String>): CapturingEventStore {
val tool = FakeFetchTool(metadata)
val events = CapturingEventStore()
val exec = SandboxedToolExecutor(
delegate = tool,
registry = SingleToolRegistry(tool),
eventDispatcher = EventDispatcher(events),
workDir = Files.createTempDirectory("sbx-research"),
)
runBlocking { exec.execute(request()) }
return events
}
private fun fetchMetadata(quality: String) = mapOf(
"url" to "https://example.com/a",
"content_type" to "text/html",
"content_sha256" to "deadbeef",
"fetched_bytes" to "4096",
"extractor_version" to "html-md-1",
"quality" to quality,
)
@Test
fun `ok fetch emits SourceFetchedEvent and no low-quality event`() {
val events = runFetch(fetchMetadata("OK"))
val fetched = events.payloads.filterIsInstance<SourceFetchedEvent>().single()
assertEquals("https://example.com/a", fetched.url)
assertEquals("deadbeef", fetched.contentSha256)
assertEquals("OK", fetched.quality)
assertEquals(4096, fetched.byteCount)
assertTrue(events.payloads.filterIsInstance<LowQualityExtractionEvent>().isEmpty())
}
@Test
fun `low-quality fetch also emits LowQualityExtractionEvent`() {
val events = runFetch(fetchMetadata("LOW_QUALITY"))
assertEquals(1, events.payloads.filterIsInstance<SourceFetchedEvent>().size)
val low = events.payloads.filterIsInstance<LowQualityExtractionEvent>().single()
assertEquals("https://example.com/a", low.url)
assertEquals("deadbeef", low.contentSha256)
assertTrue(low.reason.isNotBlank())
}
@Test
fun `non-fetch tool result emits no research source events`() {
// No url/content_sha256/quality markers → the promotion path must stay silent.
val events = runFetch(mapOf("some" to "metadata"))
assertTrue(events.payloads.filterIsInstance<SourceFetchedEvent>().isEmpty())
assertTrue(events.payloads.filterIsInstance<LowQualityExtractionEvent>().isEmpty())
}
}
@@ -0,0 +1,441 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tools.contract.ToolResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
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 TaskToolsTest {
private val service = TaskService(InMemoryEventStore())
private val create = TaskCreateTool(service)
private val decompose = TaskDecomposeTool(service)
private val update = TaskUpdateTool(service)
private val delete = TaskDeleteTool(service)
private val search = TaskSearchTool(service)
private val ready = TaskReadyTool(service)
private val context = TaskContextTool(TaskContextAssembler(service))
private var counter = 0
private fun request(tool: String, params: Map<String, Any>) = ToolRequest(
invocationId = ToolInvocationId("inv-${counter++}"),
sessionId = SessionId("s-1"),
stageId = StageId("stage-1"),
toolName = tool,
parameters = params,
)
private suspend fun createTask(): String {
val result = create.execute(
request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")),
)
assertTrue(result is ToolResult.Success)
return (result as ToolResult.Success).metadata.getValue("taskId")
}
@Test
fun `task_create creates a task and returns its id`() = runBlocking {
val id = createTask()
assertEquals("auth-1", id)
assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status)
}
@Test
fun `task_create rejects missing required fields`() = runBlocking {
val result = create.execute(request("task_create", mapOf("project" to "auth")))
assertTrue(result is ToolResult.Failure)
}
// task_decompose receives nested args as JSON strings (the orchestrator flattens non-primitive
// tool arguments), so the tests pass strings to exercise the real parse path — not structured Lists.
private val threeTaskGraph = """
[
{"ref":"scaffold","title":"Scaffold app","goal":"shell + build","affected_paths":["apps/web/**"]},
{"ref":"auth","title":"Auth view","goal":"login","depends_on":["scaffold"]},
{"ref":"dash","title":"Dashboard","goal":"home","depends_on":["scaffold"]}
]
""".trimIndent()
@Test
fun `task_decompose builds a DEPENDS_ON graph with one ready root and a blocked epic`() = runBlocking {
val result = decompose.execute(
request(
"task_decompose",
mapOf(
"project" to "webui",
"parent" to """{"title":"Frontend web UI","goal":"web ui for correx"}""",
"tasks" to threeTaskGraph,
),
),
)
assertTrue(result is ToolResult.Success)
val meta = (result as ToolResult.Success).metadata
// parent created first (webui-1), then children in order (webui-2..4).
assertEquals("webui-2,webui-3,webui-4,webui-1", meta.getValue("taskIds"))
assertEquals("webui-2", meta.getValue("readyIds"))
// affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg).
assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths)
// scaffold is the only ready task; the dependents and the epic are blocked.
assertEquals(listOf("webui-2"), service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value })
assertEquals(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value })
assertEquals(3, service.blockers(TaskId("webui-1")).size)
// child implements the epic; epic depends on the child.
assertTrue(
service.getTask(TaskId("webui-2"))!!.state.links.any {
it.type == TaskLinkType.IMPLEMENTS && it.targetKind == TaskTargetKind.TASK && it.targetId == "webui-1"
},
)
}
@Test
fun `task_decompose resolves depends_on by index`() = runBlocking {
val result = decompose.execute(
request(
"task_decompose",
mapOf(
"project" to "webui",
"tasks" to """[{"title":"Scaffold","goal":"g"},{"title":"Wire","goal":"g","depends_on":["0"]}]""",
),
),
)
assertTrue(result is ToolResult.Success)
assertEquals(listOf("webui-1"), service.blockers(TaskId("webui-2")).map { it.taskId.value })
}
@Test
fun `task_decompose rejects a dependency cycle`() = runBlocking {
val result = decompose.execute(
request(
"task_decompose",
mapOf(
"project" to "webui",
"tasks" to """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]""",
),
),
)
assertTrue(result is ToolResult.Failure)
assertTrue((result as ToolResult.Failure).reason.contains("cycle", ignoreCase = true))
}
@Test
fun `task_decompose rejects an unresolved dependency reference`() = runBlocking {
val result = decompose.execute(
request(
"task_decompose",
mapOf("project" to "webui", "tasks" to """[{"title":"A","goal":"g","depends_on":["ghost"]}]"""),
),
)
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_decompose rejects a task missing title or goal`() = runBlocking {
val result = decompose.execute(
request("task_decompose", mapOf("project" to "webui", "tasks" to """[{"title":"A"}]""")),
)
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_decompose rejects an empty task list`() = runBlocking {
val result = decompose.execute(request("task_decompose", mapOf("project" to "webui", "tasks" to "[]")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_decompose blocks a duplicate title and force needs a recorded reason`() = runBlocking {
create.execute(request("task_create", mapOf("project" to "webui", "title" to "Scaffold app", "goal" to "g")))
val dup = mapOf("project" to "webui", "tasks" to """[{"title":"Scaffold app","goal":"again"}]""")
assertTrue(decompose.execute(request("task_decompose", dup)) is ToolResult.Failure)
assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) is ToolResult.Failure)
val forced = decompose.execute(request("task_decompose", dup + ("force" to true) + ("force_reason" to "intentional rebuild")))
assertTrue(forced is ToolResult.Success)
val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",")
assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") })
}
@Test
fun `task_update applies status action, edits, link and note`() = runBlocking {
val id = createTask()
val taskId = com.correx.core.events.types.TaskId(id)
val result = update.execute(
request(
"task_update",
mapOf(
"id" to id,
"action" to "claim",
"claimant" to "claude-opus",
"title" to "JWT refresh flow",
"link_target" to "adr-7",
"link_type" to "implements",
"note" to "starting",
),
),
)
assertTrue(result is ToolResult.Success)
val state = service.getTask(taskId)!!.state
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant)
assertEquals("JWT refresh flow", state.title)
assertTrue(state.links.any { it.targetId == "adr-7" && it.type == TaskLinkType.IMPLEMENTS })
assertEquals("starting", state.notes.single().body)
}
@Test
fun `task_create and claim auto-link the agent session as CONTEXT`() = runBlocking {
val id = createTask() // session s-1
val taskId = TaskId(id)
// Created from s-1 → one CONTEXT/SESSION edge.
val afterCreate = service.getTask(taskId)!!.state.links.single()
assertEquals("s-1", afterCreate.targetId)
assertEquals(TaskTargetKind.SESSION, afterCreate.targetKind)
assertEquals(TaskLinkType.CONTEXT, afterCreate.type)
// Claiming from the same session dedupes to the same single edge.
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
assertEquals(1, service.getTask(taskId)!!.state.links.count { it.targetId == "s-1" })
}
@Test
fun `task_update rejects an unknown action`() = runBlocking {
val id = createTask()
val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_update on a missing task fails`() = runBlocking {
val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_context returns a rendered bundle with dependencies`() = runBlocking {
val id = createTask()
update.execute(
request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")),
)
val result = context.execute(request("task_context", mapOf("id" to id)))
assertTrue(result is ToolResult.Success)
val output = (result as ToolResult.Success).output
assertTrue(output.startsWith("task $id [TODO]"))
assertTrue(output.contains("adr-7 (IMPLEMENTS)"))
}
@Test
fun `task_context on a missing task fails`() = runBlocking {
assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure)
}
@Test
fun `task_search ranks matches across projects and reports misses`() = runBlocking {
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")))
create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys")))
val hit = search.execute(request("task_search", mapOf("query" to "jwt")))
assertTrue(hit is ToolResult.Success)
val out = (hit as ToolResult.Success).output
assertTrue(out.contains("auth-1"))
assertFalse(out.contains("auth-2"))
val miss = search.execute(request("task_search", mapOf("query" to "zebra")))
assertTrue((miss as ToolResult.Success).output.contains("no tasks match"))
}
@Test
fun `task_ready lists unblocked TODO tasks only`() = runBlocking {
create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1
create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
// auth-2 depends on auth-1 (still TODO) → auth-2 not ready.
update.execute(
request(
"task_update",
mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
),
)
val out = (ready.execute(request("task_ready", emptyMap())) as ToolResult.Success).output
assertTrue(out.contains("auth-1"))
assertFalse(out.contains("auth-2"))
}
@Test
fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking {
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g")))
val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")))
assertTrue(dup is ToolResult.Failure)
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
// force without a reason is rejected.
val noReason = create.execute(
request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)),
)
assertTrue(noReason is ToolResult.Failure)
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
// force with a reason creates, and records the reason as a note.
val forced = create.execute(
request(
"task_create",
mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g",
"force" to true, "force_reason" to "separate refresh path for mobile"),
),
)
assertTrue(forced is ToolResult.Success)
val id = (forced as ToolResult.Success).metadata.getValue("taskId")
val note = service.getTask(TaskId(id))!!.state.notes.single()
assertTrue(note.body.contains("[force]"))
assertTrue(note.body.contains("separate refresh path for mobile"))
}
@Test
fun `task_update blocks claiming over unmet blockers, force overrides with a warning`() = runBlocking {
create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1
create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
update.execute(
request(
"task_update",
mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
),
)
// Plain claim is blocked — auth-1 is unfinished.
val blocked = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim")))
assertTrue(blocked is ToolResult.Failure)
assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
// force without a reason is rejected.
val noReason = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)))
assertTrue(noReason is ToolResult.Failure)
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status)
// force with a reason claims, records the reason as a note, and warns.
val forced = update.execute(
request(
"task_update",
mapOf("id" to "auth-2", "action" to "claim", "force" to true, "force_reason" to "prep work, auth-1 lands soon"),
),
)
assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
assertTrue(service.getTask(TaskId("auth-2"))!!.state.notes.any { it.body.contains("[force]") && it.body.contains("prep work") })
}
@Test
fun `claiming a task records the session working it`() = runBlocking {
create.execute(
request(
"task_create",
mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
),
)
val captured = mutableListOf<Triple<String, String, List<String>>>()
val recorder = SessionFactRecorder { sid, tid, paths -> captured.add(Triple(sid.value, tid.value, paths)) }
TaskUpdateTool(service, recorder).execute(request("task_update", mapOf("id" to "auth-1", "action" to "claim")))
assertEquals(1, captured.size)
assertEquals("auth-1", captured.single().second)
assertEquals(listOf("core/auth/**"), captured.single().third)
}
@Test
fun `task_update blocks completing a task with no writes under its affected_paths`() = runBlocking {
create.execute(
request(
"task_create",
mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
),
)
val id = "auth-1"
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
update.execute(request("task_update", mapOf("id" to id, "action" to "submit_for_review")))
// No matching write this session → complete is blocked, task stays IN_REVIEW.
val noWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { emptySet() })
val blocked = noWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
assertTrue(blocked is ToolResult.Failure)
assertEquals(TaskStatus.IN_REVIEW, service.getTask(TaskId(id))!!.state.status)
// A write under affected_paths → completes.
val withWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { setOf("core/auth/Login.kt") })
val ok = withWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
assertTrue(ok is ToolResult.Success)
assertEquals(TaskStatus.DONE, service.getTask(TaskId(id))!!.state.status)
}
@Test
fun `task_delete tombstones the task`() = runBlocking {
val id = createTask()
val deleted = delete.execute(request("task_delete", mapOf("id" to id)))
assertTrue(deleted is ToolResult.Success)
assertNull(service.getTask(com.correx.core.events.types.TaskId(id)))
// Deleting again now fails: it is gone from active views.
assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure)
}
}
/** Minimal append-capable in-memory store for tool tests. */
private 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(event.metadata, global, seq, 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()
}
@@ -17,6 +17,12 @@ private const val TERMINAL = "done"
class ExecutionPlanCompiler(
private val registry: ArtifactKindRegistry,
// Names of every registered tool. A stage that references a tool the runtime can't resolve
// is dropped silently at run time (mapNotNull), so the stage runs toolless — invisible
// without a live run. Validating here turns that into a compile-time rejection (which the
// freestyle driver feeds back for a retry). Empty = the caller didn't supply the tool
// universe, so validation is skipped (preserves the bare `ExecutionPlanCompiler(registry)`).
private val knownTools: Set<String> = emptySet(),
) {
private val mapper = JsonMapper.builder()
.addModule(kotlinModule())
@@ -27,6 +33,7 @@ class ExecutionPlanCompiler(
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
validateTools(plan)
val stageMap = plan.stages.associate { s ->
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
@@ -34,10 +41,20 @@ class ExecutionPlanCompiler(
val kindId = s.kind ?: s.produces
val kind = registry.get(kindId)
?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'")
// Write-guard manifest = static per-stage globs UNION the plan-derived set (BACKLOG
// §B-§2). Plan stages carry no separate static `writes` glob list today, so the static
// baseline here is empty and the plan-derived paths form the manifest; the union is kept
// explicit so a future static source widens rather than replaces. Sorted for a
// deterministic, replay-stable manifest.
val writeManifest = PlanDerivedManifest.combine(
staticManifest = emptyList(),
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
).sorted()
StageId(s.id) to StageConfig(
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
needs = s.needs.map { ArtifactId(it) }.toSet(),
allowedTools = s.tools.toSet(),
writeManifest = writeManifest,
metadata = mapOf("promptInline" to s.prompt),
)
}
@@ -71,6 +88,22 @@ class ExecutionPlanCompiler(
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
}
/**
* Every tool a stage names must be a registered tool, else the runtime silently drops it
* (resolve → null → mapNotNull) and the stage runs without it. Skipped when [knownTools] is
* empty (the caller didn't supply the tool universe).
*/
private fun validateTools(plan: ExecutionPlanModel) {
if (knownTools.isEmpty()) return
val offenders = plan.stages.flatMap { s -> s.tools.filter { it !in knownTools }.map { s.id to it } }
if (offenders.isEmpty()) return
val detail = offenders.joinToString(", ") { (stage, tool) -> "'$tool' in stage '$stage'" }
throw WorkflowValidationException(
"execution_plan references unknown tool(s): $detail — valid tools: " +
knownTools.sorted().joinToString(", "),
)
}
/**
* A field-equals edge can only ever fire if the producing stage's kind schema declares
* that field — the kind's schema is also the LLM response format, so an undeclared field
@@ -15,6 +15,12 @@ data class PlanStage(
val kind: String? = null,
val needs: List<String> = emptyList(),
val tools: List<String> = emptyList(),
// Workspace-relative file paths/globs this stage declares it will write (BACKLOG §B-§2).
// Drives the plan-derived diff manifest: these are unioned with the static per-stage
// write globs to form the enforced write-guard manifest. Absent/empty = no plan-derived
// contribution (the static manifest, if any, still applies). The planner emits this under
// the JSON key `writes`.
val writes: List<String> = emptyList(),
)
data class PlanEdge(
@@ -0,0 +1,48 @@
package com.correx.infrastructure.workflow
/**
* Plan-derived diff manifest (BACKLOG §B-§2).
*
* The write-guard manifest enforced by the plane-2 `ManifestContainmentRule` was originally a
* STATIC per-stage `writes = [globs]` set (role-reliability §2). This object DERIVES the
* allowed-write set for a stage from the confirmed `impl_plan` artifact instead — the file
* paths/globs a [PlanStage] declares it produces/touches — so the guard tracks the actual locked
* plan rather than only hand-written globs.
*
* The derived set is not a replacement: it AUGMENTS the static baseline. [combine] unions the two,
* and the resulting set is fed verbatim into `StageConfig.writeManifest`, so plan-derived paths are
* matched with the SAME glob/containment semantics the static manifest already uses — no parallel
* matcher is introduced.
*
* ## Extraction rule
* Write targets are read from [PlanStage.writes]: the workspace-relative file paths/globs the
* plan-stage explicitly declares it will write. `produces`/`kind`/`needs` name artifact *slots*
* (logical outputs), not filesystem paths, so they are deliberately NOT treated as write targets.
* Derivation is deterministic and lenient: a stage with no [PlanStage.writes] yields an empty set,
* blank/whitespace entries are dropped, and duplicates collapse.
*/
object PlanDerivedManifest {
/**
* The set of workspace-relative paths/globs [planStage] is permitted to write, derived from its
* declared [PlanStage.writes]. Lenient: a stage with no declared write targets yields the empty
* set (the static manifest, if any, still governs). Deterministic for a given input.
*/
fun deriveAllowedWrites(planStage: PlanStage): Set<String> =
planStage.writes
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
/**
* Unions the static per-stage write globs with the plan-derived set. A write is allowed if it
* matches EITHER source. Blank entries are dropped so the combined set carries only matchable
* globs. The static path is never narrowed — the result only ever widens the static baseline by
* the plan-derived paths.
*/
fun combine(staticManifest: Collection<String>, planDerived: Set<String>): Set<String> =
(staticManifest + planDerived)
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
}
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ExecutionPlanCompilerTest {
@@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest {
assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet())
}
@Test
fun `plan-declared writes are derived into the stage write manifest`() {
val plan = """
{
"goal": "implement a feature",
"stages": [
{
"id": "impl",
"prompt": "Implement it",
"produces": "patch",
"needs": [],
"tools": ["file_write"],
"writes": ["core/feature/**", "docs/feature.md"]
}
],
"edges": [
{ "from": "impl", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = compiler.compile(plan, "manifest-workflow")
val stage = graph.stages.values.single()
assertEquals(setOf("core/feature/**", "docs/feature.md"), stage.writeManifest.toSet())
}
@Test
fun `a stage without declared writes has an empty write manifest`() {
val graph = compiler.compile(validPlan, "no-writes-workflow")
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
}
@Test
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
@@ -241,4 +273,53 @@ class ExecutionPlanCompilerTest {
fun `malformed JSON throws WorkflowValidationException`() {
assertThrows<WorkflowValidationException> { compiler.compile("not-json", "bad-workflow") }
}
// A compiler that knows the tool universe, so plan tool names are validated.
private val toolAware = ExecutionPlanCompiler(
registry,
knownTools = setOf("ShellTool", "file_write", "task_context", "task_update"),
)
@Test
fun `unknown tool name throws WorkflowValidationException naming the offender`() {
// The runtime would silently drop a misspelled tool; here it fails to compile instead.
val bad = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"task_updates\"]")
val ex = assertThrows<WorkflowValidationException> { toolAware.compile(bad, "bad-workflow") }
assertEquals(true, ex.message?.contains("task_updates"))
assertEquals(true, ex.message?.contains("analyse"))
}
@Test
fun `plan threading registered task tools compiles`() {
val plan = """
{
"goal": "implement and review a tracked task",
"stages": [
{
"id": "implement", "prompt": "claim auth-1, build, submit", "produces": "patch",
"needs": [], "tools": ["file_write", "task_context", "task_update"]
},
{
"id": "review", "prompt": "review and complete auth-1", "produces": "patch",
"needs": ["patch"], "tools": ["task_context", "task_update"]
}
],
"edges": [
{ "from": "implement", "to": "review", "condition": { "type": "always_true" } },
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = toolAware.compile(plan, "tracked-workflow")
val implement = graph.stages.values.first { it.metadata["promptInline"] == "claim auth-1, build, submit" }
assertTrue(implement.allowedTools.containsAll(setOf("task_context", "task_update")))
}
@Test
fun `tool validation is skipped when the tool universe is unknown`() {
// The default compiler has no knownTools, so an arbitrary tool name still compiles.
val plan = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"anything_goes\"]")
val graph = compiler.compile(plan, "unvalidated-workflow")
assertEquals(2, graph.stages.size)
}
}
@@ -69,5 +69,12 @@ class FreestylePlanningWorkflowTest {
assertTrue(architectToDone.condition is ArtifactValidated)
assertEquals(2, graph.transitions.size)
// analyst frames the work: search + open one task or decompose into a graph (the architect
// threads the named task into the plan).
assertEquals(
setOf("file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"),
graph.stages[StageId("analyst")]!!.allowedTools,
)
}
}
@@ -0,0 +1,65 @@
package com.correx.infrastructure.workflow
import java.nio.file.FileSystems
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PlanDerivedManifestTest {
@Test
fun `derives the declared write paths of a plan stage`() {
val stage = PlanStage(id = "impl", writes = listOf("core/a/B.kt", "docs/notes.md"))
assertEquals(setOf("core/a/B.kt", "docs/notes.md"), PlanDerivedManifest.deriveAllowedWrites(stage))
}
@Test
fun `a stage with no declared writes derives an empty set`() {
val stage = PlanStage(id = "analyse", produces = "patch", needs = listOf("x"))
assertTrue(PlanDerivedManifest.deriveAllowedWrites(stage).isEmpty())
}
@Test
fun `multiple files are all derived and blank entries dropped`() {
val stage = PlanStage(id = "impl", writes = listOf("a.kt", " ", "b.kt", "", "c.kt"))
assertEquals(setOf("a.kt", "b.kt", "c.kt"), PlanDerivedManifest.deriveAllowedWrites(stage))
}
@Test
fun `combine unions the static globs with the plan-derived set`() {
val combined = PlanDerivedManifest.combine(
staticManifest = listOf("core/**"),
planDerived = setOf("docs/x.md"),
)
assertEquals(setOf("core/**", "docs/x.md"), combined)
}
@Test
fun `combine drops blank entries and de-duplicates across sources`() {
val combined = PlanDerivedManifest.combine(
staticManifest = listOf("core/**", " ", "shared.kt"),
planDerived = setOf("shared.kt", "docs/x.md", ""),
)
assertEquals(setOf("core/**", "shared.kt", "docs/x.md"), combined)
}
@Test
fun `combined manifest matches via the same glob semantics the static manifest uses`() {
// The combined set is fed verbatim into StageConfig.writeManifest, which the plane-2
// ManifestContainmentRule matches with FileSystems glob PathMatchers. Assert that a write
// allowed only by the plan-derived entry, one allowed only by the static glob, and one in
// neither resolve as expected under that exact matcher — no parallel matcher is introduced.
val combined = PlanDerivedManifest.combine(
staticManifest = listOf("core/**"),
planDerived = setOf("docs/x.md"),
)
val matchers = combined.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
fun allowed(rel: String) = matchers.any { it.matches(Path.of(rel)) }
assertTrue(allowed("core/a/B.kt"), "static glob should allow core/a/B.kt")
assertTrue(allowed("docs/x.md"), "plan-derived path should allow docs/x.md")
assertFalse(allowed("apps/server/Main.kt"), "a path in neither source is denied")
}
}
@@ -7,8 +7,11 @@ import com.correx.core.artifacts.kind.JsonSchemaProperty
import com.correx.core.events.types.StageId
import com.correx.core.transitions.conditions.ArtifactFieldEquals
import com.correx.core.transitions.conditions.FieldOperator
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -93,4 +96,32 @@ class ReviewLoopWorkflowTest {
val changes = graph.transitions.first { it.to == StageId("implement") }.condition as ArtifactFieldEquals
assertEquals(FieldOperator.NEQ, changes.operator)
}
private fun repoFile(rel: String): Path? {
var dir: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
while (dir != null) {
val candidate = dir.resolve(rel)
if (candidate.exists()) return candidate
dir = dir.parent
}
return null
}
@Test
fun `shipped review_loop grants the task tools to both stages`() {
val path = repoFile("examples/workflows/review_loop.toml")
assumeTrue(path != null, "review_loop.toml not found from ${System.getProperty("user.dir")}")
val graph = loader.load(path!!)
// implement claims/submits and may create a task, alongside its file write.
assertEquals(
setOf("file_write", "task_create", "task_update", "task_context", "task_search"),
graph.stages[StageId("implement")]!!.allowedTools,
)
// review reads the task and completes it on an approved verdict.
assertEquals(
setOf("task_context", "task_update"),
graph.stages[StageId("review")]!!.allowedTools,
)
}
}
@@ -71,4 +71,28 @@ class RolePipelineWorkflowTest {
.condition as ArtifactFieldEquals
assertEquals(FieldOperator.NEQ, loop.operator)
}
@Test
fun `task tools are granted to the right stages`() {
val path = repoFile("examples/workflows/role_pipeline.toml")
assumeTrue(path != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}")
val graph = TomlWorkflowLoader(registry).load(path!!)
// analyst opens + searches (read-only on files); creation is approval-gated (T2).
assertEquals(
setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"),
graph.stages[StageId("analyst")]!!.allowedTools,
)
// implementer owns the lifecycle: claim/submit + the full file set.
assertEquals(
setOf("file_read", "file_write", "file_edit", "ShellTool") +
setOf("task_create", "task_update", "task_context", "task_search"),
graph.stages[StageId("implementer")]!!.allowedTools,
)
// reviewer reads the task and completes it on an approved verdict.
assertEquals(
setOf("task_context", "task_update"),
graph.stages[StageId("reviewer")]!!.allowedTools,
)
}
}