diff --git a/.correx/project.toml b/.correx/project.toml index 3556a92d..cec04e25 100644 --- a/.correx/project.toml +++ b/.correx/project.toml @@ -5,6 +5,7 @@ conventions = [ "Dependency direction: apps -> core -> infrastructure; no cross-core imports", "No bare try-catch; use runCatching and sealed domain error types", "Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)", + "Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now. When a goal has dependency seams or independent review points, task_decompose it into a parent + DEPENDS_ON-linked children (one approval) instead of one big task; a session works one task at a time, so siblings are claimed by later runs as they unblock.", ] [commands] diff --git a/.gitignore b/.gitignore index b6b36370..a9896119 100644 --- a/.gitignore +++ b/.gitignore @@ -76,9 +76,6 @@ current.log # Go TUI build output apps/tui-go/bin/ -# TUI runtime logs (Kotlin TUI, pre-Go rewrite) -apps/tui/logs/ - # Server runtime logs apps/server/logs/ diff --git a/BACKLOG.md b/BACKLOG.md index 93b64dff..9d130a17 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -34,6 +34,17 @@ mutate a candidate in place, or exceed 2 gen rounds / 1 critique round. Defect d ## Ready to build (unblocked) +- [ ] **structured static-analysis findings** (role-rel §5, salvaged from `feat/backlog-burndown` at + the 2026-06-26 merge) — the merge kept master's command/exit-code static gate; the feature + branch's richer model is worth rebuilding on top of it: parse tool output into + `StaticFinding{tool,file,line,column,severity,ruleId}` (detekt/compiler/ktlint parsers existed) + and mechanically strip already-recorded findings from the reviewer's context + (`excludeStaticFindingsFromReview`) instead of only gating on exit code. Needs a production + runner that resolves the command to the session workspace (the original was a no-op seam). +- [ ] **§6 reviewer structured-findings emission** — the critique-outcome producer + (`CritiqueOutcomeCorrelator` → calibration) merged in from `feat/backlog-burndown` is dormant + until the reviewer/plan-critic actually emit `CritiqueFindingsRecordedEvent`s (a `review_report` + findings array + prompt change). Model-dependent → live-QA. - [ ] **architect contradiction-check** (role-rel §4) — L3 over decision journal + ADRs → `PossibleContradictionFlag` (display-only in v1). - [ ] **symbol grounding** (role-rel §3) — needs a real symbol index (file-path grounding done); also diff --git a/RETRO.md b/RETRO.md index 45e974df..e674e488 100644 --- a/RETRO.md +++ b/RETRO.md @@ -55,3 +55,38 @@ evidence to check. `4e5e4e5`, §5 reviewer narrow-question prompt/needs `3467826`. - **TUI requirements shipped halves** — §2 last-event clock + unknown-event fallback `8b896b5`, §5.1 deterministic command-approval parse card `65df3f4`. + +--- + +## 2026-06-26 — Merged `feat/backlog-burndown` into `master` + +`master` had advanced 16 commits past where `feat/backlog-burndown` branched, and the two had +**independently built four of the same features**. The merge kept **master's** implementation for +each overlap (more complete / wired to production / more robust) and dropped the feature branch's +parallel constellation: + +- **llama-server health probe** — kept master's event-store-backed tokens/sec probe; dropped the + branch's `LlamaLivenessClient`/`HttpLlamaLivenessClient` (liveness-only, throughput unwired). +- **event-store probe** — kept master's `EventStoreHealthProbe`; dropped the branch's + `EventStoreLatencyProbe`. +- **brief echo-back gate** — kept master's `BriefEchoDiff` (Jaccard token-overlap, tolerant of + rewording); dropped the branch's `BriefEchoComparator`/`BriefEchoExtractor` (exact set-diff). +- **static-first reviewer** — kept master's command/exit-code gate (`StaticAnalysisRunner` + + `ProcessStaticAnalysisRunner`, production-wired); dropped the branch's structured-finding + `static_check` *stage* (`StaticCheckStageExecutor`, `StaticFinding`, `ContextFeedback` exclusion) + which was a no-op pending a production runner. **Its structured-findings model is filed as a + follow-up in BACKLOG** so it isn't lost. + +**Net-new feature-branch work brought in and kept** (master had none of these): + +- **Native task tracking** — task aggregate + agent tools (`task_create`/`task_update`/ + `task_context`/`task_search` wired into analyst/implementer/reviewer), dependency-aware work graph + (blockers/ready), claim/cite/scope/stale-write gates, `task_decompose`, REST surface, CLI, git-driven + status, markdown export, and the TUI task board (`OverlayTasks`, `T` key). +- **Critique-outcome producer (role-rel §6)** — `CritiqueOutcomeCorrelator` + + `CritiqueFindingsRecordedEvent`/`CritiqueOutcomeCorrelatedEvent` + the per-model calibration chain + (master had explicitly *deferred* §6). Dormant until the reviewer emits structured findings (live-QA). +- **Stage-level plan checkpointing (C-A2)** — `emitStageCheckpoint` folded into `runPostStageGates`. +- **L0 standing context** — CLAUDE.md / AGENTS.md injected as agent-instruction context entries. +- **Cross-session grants + TUI** — PROJECT/GLOBAL grant scopes + revoke, grant viewer, `@` file picker, + session resume browser (`OverlaySessions`, `R` key). diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt index 8d7fd0e6..cd477b82 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt @@ -9,6 +9,7 @@ import com.correx.apps.cli.commands.RunCommand import com.correx.apps.cli.commands.SessionCommand import com.correx.apps.cli.commands.StatsCommand import com.correx.apps.cli.commands.StatusCommand +import com.correx.apps.cli.commands.TaskCommand import com.correx.apps.cli.commands.UndoCommand import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands @@ -33,4 +34,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands( ReplayCommand(), StatsCommand(), HealthCommand(), + TaskCommand(), ) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt index 2683febf..adc12fa4 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt @@ -13,6 +13,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.serialization.kotlinx.json.json +import java.util.Locale import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -110,9 +111,10 @@ fun renderStats(report: StatsReportDto): String { lines += " calls: ${report.inferenceCount}" lines += " tokens: ${report.promptTokens} prompt + ${report.completionTokens} completion = " + "${report.promptTokens + report.completionTokens}" - lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(report.tokensPerSecond) + lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(Locale.ROOT, report.tokensPerSecond) for (p in report.perProvider) { lines += " %-20s %4d calls %6d tok %8d ms (%.1f tok/s)".format( + Locale.ROOT, p.provider, p.completedCount, p.promptTokens + p.completionTokens, p.totalLatencyMs, p.tokensPerSecond, ) } @@ -121,7 +123,7 @@ fun renderStats(report: StatsReportDto): String { lines += "Tools" lines += " calls: ${report.toolCount} time: ${report.toolMs} ms" for (t in report.perTool) { - lines += " %-24s %4d %8d ms".format(t.toolName, t.completedCount, t.totalDurationMs) + lines += " %-24s %4d %8d ms".format(Locale.ROOT, t.toolName, t.completedCount, t.totalDurationMs) } lines += "" @@ -131,6 +133,7 @@ fun renderStats(report: StatsReportDto): String { lines += " total wait: ${report.approvalWaitMs} ms (avg ${report.avgApprovalWaitMs} ms)" for (tier in report.perTier) { lines += " %-4s req %d res %d wait %d ms (avg %d ms)".format( + Locale.ROOT, tier.tier, tier.requestedCount, tier.resolvedCount, tier.totalWaitMs, tier.avgWaitMs, ) } @@ -145,6 +148,7 @@ fun renderStats(report: StatsReportDto): String { lines += "Time accounting (% of session wall time)" lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format( + Locale.ROOT, report.inferencePct, report.toolPct, report.approvalWaitPct, otherPct(report), ) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt new file mode 100644 index 00000000..70bfce46 --- /dev/null +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -0,0 +1,272 @@ +package com.correx.apps.cli.commands + +import com.correx.apps.cli.CorrexCli +import com.correx.apps.cli.DEFAULT_PORT +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import java.io.File +import java.net.URLEncoder +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private val taskJson = Json { ignoreUnknownKeys = true } + +/** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */ +@Serializable +data class TaskRow( + val id: String, + val key: String? = null, + val status: String = "", + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), + val claimant: String? = null, + val links: List = emptyList(), + val notes: List = emptyList(), +) + +@Serializable +data class TaskLinkRow(val targetId: String, val type: String, val targetKind: String) + +@Serializable +data class TaskNoteRow(val author: String, val body: String) + +/** One line per task: id, status, title, and claimant. */ +fun renderTaskList(tasks: List): String { + if (tasks.isEmpty()) return "no tasks" + return tasks.joinToString("\n") { t -> + val claim = t.claimant?.let { " @$it" }.orEmpty() + "${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd() + } +} + +/** Full single-task view: goal, acceptance criteria, affected paths, links, notes. */ +fun renderTaskDetail(t: TaskRow): String = buildString { + appendLine("task ${t.id} [${t.status}] ${t.title.orEmpty()}".trimEnd()) + t.claimant?.let { appendLine("claimant: $it") } + t.goal?.let { appendLine("goal: $it") } + if (t.acceptanceCriteria.isNotEmpty()) { + appendLine("acceptance criteria:") + t.acceptanceCriteria.forEach { appendLine(" - $it") } + } + if (t.affectedPaths.isNotEmpty()) appendLine("affected paths: ${t.affectedPaths.joinToString(", ")}") + if (t.links.isNotEmpty()) { + appendLine("links:") + t.links.forEach { appendLine(" - ${it.targetId} (${it.type} -> ${it.targetKind})") } + } + if (t.notes.isNotEmpty()) { + appendLine("notes:") + t.notes.forEach { appendLine(" - [${it.author}] ${it.body}") } + } +}.trimEnd() + +class TaskCommand : CliktCommand(name = "task") { + override fun run() = Unit + + init { + subcommands( + TaskListCommand(), + TaskReadyCommand(), + TaskSearchCommand(), + TaskShowCommand(), + TaskBlockersCommand(), + TaskHistoryCommand(), + TaskCreateCommand(), + TaskClaimCommand(), + TaskCompleteCommand(), + TaskExportCommand(), + ) + } +} + +/** Shared host/port options + HTTP plumbing for the task subcommands. */ +abstract class TaskHttpCommand(name: String) : CliktCommand(name = name) { + protected val host by option("--host").default("localhost") + protected val port by option("--port").default("$DEFAULT_PORT") + + protected fun baseUrl(): String = "http://$host:${port.toIntOrNull() ?: DEFAULT_PORT}" + + protected fun jsonOut(): Boolean = (currentContext.findRoot().command as? CorrexCli)?.json ?: false + + protected fun http(block: suspend (HttpClient) -> Unit) = runBlocking { + val client = HttpClient(CIO) { install(ContentNegotiation) { json(taskJson) } } + runCatching { block(client) }.getOrElse { System.err.println("Error: ${it.message}") } + client.close() + } +} + +private fun enc(value: String): String = URLEncoder.encode(value, "UTF-8") + +class TaskListCommand : TaskHttpCommand("list") { + private val project by option("--project", help = "Limit to a project") + private val status by option("--status", help = "Filter by status (TODO, IN_PROGRESS, …)") + + override fun run() = http { client -> + val params = buildList { + project?.let { add("project=${enc(it)}") } + status?.let { add("status=${enc(it)}") } + } + val query = if (params.isEmpty()) "" else "?${params.joinToString("&")}" + val raw = client.get("${baseUrl()}/tasks$query").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskReadyCommand : TaskHttpCommand("ready") { + private val project by option("--project", help = "Limit to a project") + + override fun run() = http { client -> + val query = project?.let { "&project=${enc(it)}" }.orEmpty() + val raw = client.get("${baseUrl()}/tasks?ready=true$query").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskBlockersCommand : TaskHttpCommand("blockers") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id/blockers") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + val raw = resp.bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } + } +} + +class TaskSearchCommand : TaskHttpCommand("search") { + private val query by argument("QUERY", help = "Search text (quote multi-word queries)") + private val project by option("--project", help = "Limit to a project") + + override fun run() = http { client -> + val params = buildList { + add("q=${enc(query)}") + project?.let { add("project=${enc(it)}") } + } + val raw = client.get("${baseUrl()}/tasks?${params.joinToString("&")}").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskShowCommand : TaskHttpCommand("show") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + val raw = resp.bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskDetail(taskJson.decodeFromString(raw))) + } + } +} + +class TaskHistoryCommand : TaskHttpCommand("history") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id/history") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + print(resp.bodyAsText()) + } + } +} + +class TaskCreateCommand : TaskHttpCommand("create") { + private val project by option("--project", help = "Project key, e.g. 'auth'").required() + private val title by option("--title").required() + private val goal by option("--goal").required() + private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple() + private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple() + private val force by option("--force", help = "Create even if a same-title task exists").flag() + + override fun run() = http { client -> + val payload = buildJsonObject { + put("project", project) + put("title", title) + put("goal", goal) + if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) })) + if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) })) + if (force) put("force", true) + } + val resp = client.post("${baseUrl()}/tasks") { + contentType(ContentType.Application.Json) + setBody(payload) + } + val raw = resp.bodyAsText() + when { + resp.status == HttpStatusCode.Conflict -> + System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}") + jsonOut() -> println(raw) + else -> println("created ${taskJson.decodeFromString(raw).id}") + } + } +} + +class TaskClaimCommand : TaskHttpCommand("claim") { + private val id by argument("TASK_ID") + private val by by option("--by", help = "Claimant").required() + + override fun run() = http { client -> + val resp = client.post("${baseUrl()}/tasks/$id/claim") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { put("claimant", by) }) + } + if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("claimed $id") + } +} + +class TaskCompleteCommand : TaskHttpCommand("complete") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.post("${baseUrl()}/tasks/$id/complete") + if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id") + } +} + +class TaskExportCommand : TaskHttpCommand("export") { + private val project by option("--project", help = "Limit to a project") + private val out by option("--out", help = "Write to this file instead of stdout") + + override fun run() = http { client -> + val query = project?.let { "?project=${enc(it)}" }.orEmpty() + val markdown = client.get("${baseUrl()}/tasks/export$query").bodyAsText() + val target = out + if (target != null) { + File(target).writeText(markdown) + println("wrote $target") + } else { + print(markdown) + } + } +} diff --git a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt new file mode 100644 index 00000000..11a11efd --- /dev/null +++ b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt @@ -0,0 +1,45 @@ +package com.correx.apps.cli.commands + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskRenderTest { + + private val task = TaskRow( + id = "auth-1", + key = "auth-1", + status = "IN_PROGRESS", + title = "JWT refresh", + goal = "users stay authenticated", + acceptanceCriteria = listOf("rotates"), + affectedPaths = listOf("backend/auth/**"), + claimant = "claude-opus", + links = listOf(TaskLinkRow("adr-7", "IMPLEMENTS", "DOC")), + notes = listOf(TaskNoteRow("AGENT", "kickoff")), + ) + + @Test + fun `renderTaskList shows one line per task with id, status, title`() { + val out = renderTaskList(listOf(task, TaskRow(id = "billing-3", status = "DONE", title = "Invoice"))) + val lines = out.lines() + assertEquals(2, lines.size) + assertTrue(lines[0].startsWith("auth-1")) + assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus")) + assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE")) + } + + @Test + fun `renderTaskList reports empty`() { + assertEquals("no tasks", renderTaskList(emptyList())) + } + + @Test + fun `renderTaskDetail includes goal, criteria, links and notes`() { + val out = renderTaskDetail(task) + assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh")) + for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) { + assertTrue(out.contains(want), "detail missing: $want") + } + } +} diff --git a/apps/desktop/build.gradle b/apps/desktop/build.gradle deleted file mode 100644 index 2413f3cd..00000000 --- a/apps/desktop/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.jvm' - id 'application' -} - -application { - mainClass = 'com.correx.apps.desktop.MainKt' -} - -dependencies { - implementation "com.github.ajalt.clikt:clikt:5.0.1" -} - -tasks.named("koverVerify").configure { enabled = false } diff --git a/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt deleted file mode 100644 index fe456df0..00000000 --- a/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.correx.apps.desktop - -fun main() { - println("correx :: apps/desktop") -} diff --git a/apps/server/build.gradle b/apps/server/build.gradle index aea15c5e..95399e63 100644 --- a/apps/server/build.gradle +++ b/apps/server/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation project(':core:events') implementation project(':core:approvals') implementation project(':core:sessions') + implementation project(':core:tasks') implementation project(':core:kernel') implementation project(':core:journal') implementation project(':core:inference') diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt index 108b8acb..2bdee935 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt @@ -3,6 +3,7 @@ package com.correx.apps.server import com.correx.apps.server.health.HealthInspectionService import com.correx.apps.server.routes.providerRoutes import com.correx.apps.server.routes.sessionRoutes +import com.correx.apps.server.routes.taskRoutes import com.correx.apps.server.routes.workflowRoutes import com.correx.apps.server.ws.GlobalStreamHandler import io.ktor.http.HttpStatusCode @@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) { sessionRoutes(module) workflowRoutes(module) providerRoutes(module) + taskRoutes(module) } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 5355b801..588c1010 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -48,6 +48,7 @@ import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.SessionProjector import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tasks.TaskService import com.correx.core.tools.registry.ToolRegistry import com.correx.core.transitions.resolution.DefaultTransitionResolver import com.correx.core.validation.artifact.ArtifactPayloadValidator @@ -61,6 +62,10 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule import com.correx.core.toolintent.rules.ManifestContainmentRule import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule +import com.correx.core.toolintent.rules.ReadBeforeWriteRule +import com.correx.core.toolintent.rules.ReferenceExistsRule +import com.correx.core.toolintent.rules.StaleWriteRule +import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference import com.correx.core.kernel.orchestration.JournalCompactionService @@ -81,6 +86,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe import com.correx.core.inference.InferenceProvider import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider import com.correx.infrastructure.tools.DispatchingToolExecutor +import com.correx.infrastructure.tools.task.TaskTools import com.correx.infrastructure.tools.FileEditConfig import com.correx.infrastructure.tools.FileReadConfig import com.correx.infrastructure.tools.FileWriteConfig @@ -215,6 +221,32 @@ fun main() { null }, ) + // Agents create/update/delete tasks through the tool system (tier-gated like any tool); + // the service appends to the same event log. Project is derived from the task id prefix. + // The knowledge retriever is late-bound: the L3 retriever isn't built until later in this + // composition root, so the holder is captured now and its delegate set once L3 exists (below). + val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever() + // Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the + // L3 retriever this needs no late binding. + val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot) + // Inline ARTIFACT/SESSION link targets too: both read the event log (+ CAS) that already exists + // here, so — like the doc resolver — they need no late binding. + val taskArtifactResolver = + com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore) + val taskSessionResolver = + com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore) + // Backs POST /tasks/sync-git: reads recent commits so a git hook / CI step can drive task status. + val gitCommitReader = com.correx.apps.server.tasks.GitCommandCommitReader(workspaceRoot) + val taskService = TaskService(eventStore) + val taskTools = TaskTools.forService( + taskService, + taskKnowledgeRetriever, + taskDocumentResolver, + taskArtifactResolver, + taskSessionResolver, + com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore), + com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore), + ) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( workspaceRoot, @@ -223,6 +255,7 @@ fun main() { toolsConfig, researchToolConfig, ), + extraTools = taskTools, ) val toolExecutor = InfrastructureModule.createToolExecutor( registry = toolRegistry, @@ -238,6 +271,10 @@ fun main() { val toolCallAssessor = ToolCallAssessor( rules = listOf( PathContainmentRule(), + ReadBeforeWriteRule(), + ReferenceExistsRule(), + WriteScopeRule(), + StaleWriteRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( @@ -250,6 +287,7 @@ fun main() { val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace -> val wsRegistry = InfrastructureModule.createToolRegistry( buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig), + extraTools = taskTools, ) val wsExecutor = DispatchingToolExecutor(wsRegistry) WorkspaceTools(registry = wsRegistry, executor = wsExecutor) @@ -316,6 +354,10 @@ fun main() { } else { null } + // L3 retriever now exists — bind it into the task context bundle's knowledge port so the + // task_context tool and GET /tasks/{id}/context return semantically-relevant snippets. + taskKnowledgeRetriever.delegate = + repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) } val orchestrator = DefaultSessionOrchestrator( repositories = repositories, engines = engines, @@ -388,6 +430,16 @@ fun main() { privilegedLocations = privilegedLocations, allowedWorkspaceRoots = allowedWorkspaceRoots, ) + // Display-only architect contradiction surfacing (BACKLOG §B-§4): now live via the + // ServerModule.start() subscription on the architect's `design` ArtifactCreatedEvent (see + // ServerModule.handleArchitectArtifact). Gated on project.enabled because the checker queries the + // same L3 "project:" namespace that ProjectMemoryService.persist populates. + val architectContradictionChecker: com.correx.apps.server.memory.ArchitectContradictionChecker? = + if (correxConfig.project.enabled) { + com.correx.apps.server.memory.ArchitectContradictionChecker(embedder, l3MemoryStore) + } else { + null + } // Built from a config snapshot and reused by ConfigService's rebuild hook so toggling // project.enabled / personalization.* applies live to the next session. fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? = @@ -420,7 +472,7 @@ fun main() { val freestyleDriver = FreestyleDriver( eventStore = eventStore, - compiler = ExecutionPlanCompiler(artifactKindRegistry), + compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()), planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) }, config = defaultOrchestrationConfig, runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) }, @@ -438,7 +490,7 @@ fun main() { .subjects.mapValues { it.value.status } com.correx.apps.server.health.HealthMonitor( eventStore = eventStore, - probes = listOf( + probes = listOfNotNull( com.correx.apps.server.health.DiskWatermarkProbe( monitoredPaths = listOf(dataDir), warnBytes = hc.diskWarnBytes, @@ -479,11 +531,17 @@ fun main() { workspaceResolver = workspaceResolver, narrationMaxPerRun = correxConfig.router.narration.maxPerRun, projectMemory = projectMemory, + architectContradictionChecker = architectContradictionChecker, configHolder = configHolder, freestyleDriver = freestyleDriver, operatorProfile = operatorProfile, profileAdaptationService = profileAdaptationService, healthMonitor = healthMonitor, + taskKnowledgeRetriever = taskKnowledgeRetriever, + taskDocumentResolver = taskDocumentResolver, + taskArtifactResolver = taskArtifactResolver, + taskSessionResolver = taskSessionResolver, + gitCommitReader = gitCommitReader, ) // Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // services. Built after the module so the rebuild hook can swap them in place. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index c5ea422f..c5a78882 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -11,13 +11,19 @@ import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.config.AgentInstructionsLoader import com.correx.core.config.OperatorProfile import com.correx.core.config.ProjectProfileLoader +import com.correx.apps.server.memory.ArchitectContradictionChecker +import com.correx.core.events.events.AgentInstructionsBoundEvent 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.EventMetadata import com.correx.core.events.events.NewEvent import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.OperatorProfileBoundEvent +import com.correx.core.events.events.PossibleContradictionFlaggedEvent import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent @@ -26,6 +32,9 @@ import com.correx.core.events.stores.EventStore import com.correx.core.events.types.EventId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.OrchestrationConfig @@ -87,6 +96,10 @@ class ServerModule( // Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path). // Rebuilt by ConfigService when project.enabled is toggled live. projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null, + // Display-only architect contradiction surfacing (BACKLOG §B-§4). Null disables the hook + // (tests / project.enabled=false). Unlike projectMemory there is no live config-rebuild hook: + // a server restart re-reads project.enabled, which is enough for this informational flag. + private val architectContradictionChecker: ArchitectContradictionChecker? = null, // Live, swappable config. Null only in tests that don't exercise config editing; defaults to a // holder seeded from defaults so callers always have a value to read. val configHolder: com.correx.core.config.ConfigHolder = @@ -102,6 +115,17 @@ class ServerModule( // Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false); // records degraded/restored edge events on the system session, started in [start]. private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null, + // Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it. + val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null, + // Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links. + val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null, + // Resolves linked artifacts inline in the task context bundle. Null leaves them as raw links. + val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null, + // Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links. + val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null, + // Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read + // (the endpoint then only acts on commits supplied in the request body). + val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, @@ -187,6 +211,80 @@ class ServerModule( // Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration: // probes read the environment and record degraded/restored events; replay reads those facts. healthMonitor?.start(moduleScope) + + // Display-only architect contradiction surfacing (BACKLOG §B-§4). Live-only like the hooks + // above: subscribeAll() replays nothing and ServerModule is never built under replay, so a + // restart never re-fires the flag (invariant #8). Never halts a stage — failures are logged + // and swallowed (runCatching) and the flag is emitted purely for the operator to eyeball. + architectContradictionChecker?.let { checker -> + eventStore.subscribeAll() + .filter { + it.payload is ArtifactCreatedEvent && + (it.payload as ArtifactCreatedEvent).stageId.value == ARCHITECT_STAGE_ID + } + .onEach { stored -> + runCatching { + handleArchitectArtifact(checker, stored.payload as ArtifactCreatedEvent) + }.onFailure { log.warn("architect contradiction check failed: {}", it.message) } + } + .launchIn(moduleScope) + } + } + + /** + * Runs the architect contradiction check for a freshly-created `design` artifact and, when the + * checker surfaces a non-null flag, appends the [PossibleContradictionFlaggedEvent]. Display-only: + * the flag is informational and never halts the stage. Factored out of the subscription so the + * extraction/dispatch path is unit-testable without a live event flow. + * + * @return the appended flag, or null when the artifact text could not be resolved or the checker + * found nothing near the architect's decision. + */ + internal suspend fun handleArchitectArtifact( + checker: ArchitectContradictionChecker, + event: ArtifactCreatedEvent, + ): PossibleContradictionFlaggedEvent? { + val decisionText = resolveArchitectDecisionText(event) ?: return null + val flag = checker.check(event.sessionId, event.stageId, decisionText) ?: return null + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(java.util.UUID.randomUUID().toString()), + sessionId = event.sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = flag, + ), + ) + return flag + } + + /** + * Resolves the architect's decision text for [event] from the CAS-stored `design` artifact. + * Finds the content hash by scanning the session for the [ArtifactContentStoredEvent] matching + * the artifact id (emitted at inference time, before [ArtifactCreatedEvent]), fetches the bytes, + * and prefers the design schema's `approach` field ("the chosen approach and why") as the + * decision summary; falls back to the whole artifact text, capped at [DECISION_TEXT_CAP] chars. + * + * @return the decision text, or null when no content hash, bytes, or text could be resolved. + */ + private suspend fun resolveArchitectDecisionText(event: ArtifactCreatedEvent): String? { + val contentHash = eventStore.read(event.sessionId) + .asSequence() + .map { it.payload } + .filterIsInstance() + .firstOrNull { it.artifactId == event.artifactId } + ?.contentHash ?: return null + val text = artifactStore.get(contentHash)?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() } + ?: return null + val approach = runCatching { + (lenientJson.parseToJsonElement(text).jsonObject["approach"] as? JsonPrimitive) + ?.takeIf { it.isString }?.content + }.getOrNull()?.takeIf { it.isNotBlank() } + return (approach ?: text).take(DECISION_TEXT_CAP) } /** @@ -237,6 +335,7 @@ class ServerModule( ) } bindProjectProfile(sessionId) + bindAgentInstructions(sessionId) runCatching { val result = orchestrator.run(sessionId, graph, sessionConfig) freestyleHandoff(sessionId, graph, result) @@ -320,6 +419,37 @@ class ServerModule( ) } + /** + * Bind standing agent instructions (CLAUDE.md / AGENTS.md at the workspace root) as an + * event so stages, router chat triage, and replay read the recorded snapshot, never the + * live file (invariants #8/#9). Mirrors [bindProjectProfile]. + */ + suspend fun bindAgentInstructions(sessionId: SessionId) { + val workspaceRoot = runCatching { + sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot + }.getOrNull() ?: projectMemory?.repoRoot() ?: return + val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) } + if (instructions.isEmpty()) return + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(java.util.UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = AgentInstructionsBoundEvent( + sessionId = sessionId, + workspaceRoot = workspaceRoot, + sources = instructions.sources, + content = instructions.content, + ), + ), + ) + } + /** * Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a * planning graph (fresh run and all resume paths). Locks the plan and executes it, @@ -438,4 +568,14 @@ class ServerModule( .forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId) } } } + + companion object { + /** Workflow stage id that produces the `design` artifact (examples/workflows/role_pipeline.toml). */ + const val ARCHITECT_STAGE_ID = "architect" + + /** Cap on the architect decision text embedded when no `approach` field is present. */ + private const val DECISION_TEXT_CAP = 4000 + + private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } + } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt new file mode 100644 index 00000000..bafddd21 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt @@ -0,0 +1,70 @@ +package com.correx.apps.server.memory + +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 com.correx.core.inference.Embedder +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query + +/** + * Display-only architect contradiction surfacing (BACKLOG §B-§4). When the architect records a + * decision, this embeds the decision text, retrieves semantically-near PRIOR decisions/ADRs from + * L3, and — if any clear the similarity threshold — returns a [PossibleContradictionFlaggedEvent] + * for the operator to eyeball. v1 is purely informational: there is no LLM judge and the caller + * emits the flag without ever halting or failing the stage. + * + * Namespace convention: distilled decision-journal lines are persisted into L3 by + * [ProjectMemoryService] under `turnId = "project:"` (trailing-`:` delimiter). This is + * the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults + * to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by + * [L3RepoKnowledgeRetriever]'s `"repomap::"` filter. Hits are also constrained to PRIOR + * sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run. + */ +class ArchitectContradictionChecker( + private val embedder: Embedder, + private val l3MemoryStore: L3MemoryStore, + private val k: Int = DEFAULT_K, + private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD, + private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX, +) { + /** + * @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when + * none clear the threshold. The CALLER emits it (display-only) — this never halts. + */ + suspend fun check( + sessionId: SessionId, + stageId: StageId, + decisionText: String, + ): PossibleContradictionFlaggedEvent? { + if (decisionText.isBlank()) return null + val vector = embedder.embed(decisionText) + val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) + .filter { it.entry.turnId.startsWith(decisionNamespacePrefix) } + .filter { it.entry.sessionId != sessionId } + .filter { it.score >= scoreThreshold } + .take(k) + .map { + RelatedDecision( + summary = it.entry.text, + score = it.score.toDouble(), + source = it.entry.turnId, + ) + } + if (related.isEmpty()) return null + return PossibleContradictionFlaggedEvent( + sessionId = sessionId, + stageId = stageId, + decisionSummary = decisionText, + related = related, + ) + } + + companion object { + const val DEFAULT_K = 5 + const val DEFAULT_SCORE_THRESHOLD = 0.75 + const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:" + private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4 + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt new file mode 100644 index 00000000..25fbc020 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt @@ -0,0 +1,18 @@ +package com.correx.apps.server.memory + +import com.correx.core.tasks.KnowledgeHit +import com.correx.core.tasks.TaskKnowledgeRetriever + +/** + * Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry + * construction time, which is before the L3 retriever exists in the composition root; this holder + * is created early, captured by the tools and the route, and has its [delegate] set once the L3 + * retriever is available. Until then (or when L3 is disabled) it returns no hits. + */ +class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever { + @Volatile + var delegate: TaskKnowledgeRetriever? = null + + override suspend fun retrieve(query: String, limit: Int): List = + delegate?.retrieve(query, limit) ?: emptyList() +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt new file mode 100644 index 00000000..232411ec --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt @@ -0,0 +1,19 @@ +package com.correx.apps.server.memory + +import com.correx.apps.server.health.SYSTEM_SESSION +import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever +import com.correx.core.tasks.KnowledgeHit +import com.correx.core.tasks.TaskKnowledgeRetriever + +/** + * Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector + * retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not + * session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it). + */ +class RepoKnowledgeTaskRetriever( + private val delegate: RepoKnowledgeRetriever, +) : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List = + delegate.retrieve(SYSTEM_SESSION, query, limit) + .map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt index 61ac841f..a85a9f99 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt @@ -84,6 +84,12 @@ class RepoMapIndexer( "ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?[A-Za-z_$][A-Za-z0-9_$]*)"""), // GDScript: column-0 declarations only, so indented inner-class members never match. "gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?[A-Za-z_][A-Za-z0-9_]*)"""), + // C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative + // (no method capture — return-type heuristics there are noisy and risk false positives). + "cs" to Regex( + """(?m)^\s*(?:public |private |protected |internal |static |sealed |abstract |""" + + """partial )*(?:class|interface|struct|enum|record)\s+(?[A-Za-z_][A-Za-z0-9_]*)""", + ), // Markdown: h1–h3 headings as "symbols" — deeper levels are noise relative to retrieval value. "md" to Regex("""(?m)^#{1,3}\s+(?.+?)\s*$"""), ) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt index 0ff8e2e9..a0b1ed69 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt @@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe { .toList() } val digest = MessageDigest.getInstance("SHA-256") - lines.forEach { digest.update(it.toByteArray()) } + digest.update(lines.joinToString("\n").toByteArray()) return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH) } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt b/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt index e762c82e..09658eee 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt @@ -1,8 +1,10 @@ package com.correx.apps.server.narration +import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StoredEvent @@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.slf4j.LoggerFactory +import java.util.Collections import java.util.concurrent.ConcurrentHashMap class NarrationSubscriber( @@ -28,7 +31,27 @@ class NarrationSubscriber( private val scope: CoroutineScope, private val maxPerRun: Int = DEFAULT_MAX_PER_RUN, ) { - private data class SessionLane(val channel: Channel, var used: Int) + /** + * A queued narration plus the pause-trigger bookkeeping needed to drop it if the + * pause it described resolves before the lane worker delivers it. + * + * [pauseKey] is non-null only for pause-trigger narrations. It is the resolving signal's + * scope: the originating approval request id when known (per-request supersession), or a + * session-wide sentinel for non-approval pauses (per-session supersession). + */ + private data class QueuedNarration(val trigger: NarrationTrigger, val pauseKey: String?) + + /** + * Per-session lane. [pendingPauses] holds the pause keys still queued (not yet delivered + * by the worker). When an approval resolves or the orchestration resumes, the matching + * keys are removed so the worker skips the now-stale pause narration instead of blending + * it with the current status. + */ + private data class SessionLane( + val channel: Channel, + var used: Int, + val pendingPauses: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()), + ) private val lanes = ConcurrentHashMap() @@ -50,6 +73,7 @@ class NarrationSubscriber( instruction = approvalInstruction(p), stageId = p.stageId?.value, ), + pauseKey = pauseKeyForRequest(p.requestId.value), ) is StageCompletedEvent -> enqueue( sid, @@ -100,9 +124,16 @@ class NarrationSubscriber( instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.", stageId = p.stageId.value, ), + pauseKey = SESSION_PAUSE_KEY, ) } } + // An approval resolving makes its queued pause narration stale: drop it (scoped to + // that request) so a resolved pause never narrates as if still pending. + is ApprovalDecisionResolvedEvent -> dropPendingPause(sid, pauseKeyForRequest(p.requestId.value)) + // Resuming supersedes every pause still queued for the session (approval-pending or + // not): the lane is live again, so any pending "paused" narration is stale. + is OrchestrationResumedEvent -> dropAllPendingPauses(sid) else -> Unit } } @@ -122,20 +153,46 @@ class NarrationSubscriber( } } - private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger) { + private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger, pauseKey: String? = null) { val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) } if (lane.used >= maxPerRun) { log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind) return } lane.used += 1 - lane.channel.trySend(trigger) + if (pauseKey != null) { + lane.pendingPauses.add(pauseKey) + } + lane.channel.trySend(QueuedNarration(trigger, pauseKey)) + } + + /** Marks the pause for [pauseKey] resolved so the lane worker skips it if still queued. */ + private fun dropPendingPause(sessionId: SessionId, pauseKey: String) { + lanes[sessionId.value]?.pendingPauses?.remove(pauseKey) + } + + /** Marks every queued pause for the session resolved (used on resume). */ + private fun dropAllPendingPauses(sessionId: SessionId) { + lanes[sessionId.value]?.pendingPauses?.clear() } private fun startLane(sessionId: SessionId): SessionLane { - val channel = Channel(capacity = Channel.UNLIMITED) + val channel = Channel(capacity = Channel.UNLIMITED) + val lane = SessionLane(channel, used = 0) scope.launch { - for (trigger in channel) { + for (queued in channel) { + // A pause whose key was dropped (approval resolved / resumed) is stale: skip it + // rather than narrate a paused state that no longer holds. removeIf-style check: + // claim the key so a re-enqueued pause with the same id is not also dropped. + if (queued.pauseKey != null && !lane.pendingPauses.remove(queued.pauseKey)) { + log.debug( + "skipping stale pause narration for session {} (key {})", + sessionId.value, + queued.pauseKey, + ) + continue + } + val trigger = queued.trigger runCatching { routerFacade.narrate(sessionId, trigger) } .onFailure { e -> if (e is CancellationException) throw e @@ -148,12 +205,17 @@ class NarrationSubscriber( } } } - return SessionLane(channel, used = 0) + return lane } companion object { private const val DEFAULT_MAX_PER_RUN = 100 private const val MAX_PREVIEW_CHARS = 800 + + /** Supersession scope for pauses with no approval request (e.g. USER_REQUESTED). */ + private const val SESSION_PAUSE_KEY = "__session_pause__" private val log = LoggerFactory.getLogger(NarrationSubscriber::class.java) + + private fun pauseKeyForRequest(requestId: String): String = "req:$requestId" } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 0dce009d..f53c04b4 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -5,6 +5,7 @@ import com.correx.core.approvals.Tier import com.correx.core.events.events.ClarificationAnswer import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ClarificationRequestId +import com.correx.core.events.types.GrantId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.router.ChatMode @@ -21,6 +22,11 @@ enum class ApprovalDecision { enum class GrantScopeDto { SESSION, STAGE, + // Cross-session scopes (BACKLOG §grants). PROJECT auto-approves the bound tool for any session + // on the same workspace; GLOBAL for every session on this machine. Both are tool-bound and live + // in the cross-session grant ledger rather than a single session's stream. + PROJECT, + GLOBAL, } @Serializable @@ -44,6 +50,10 @@ sealed class ClientMessage { @Serializable data class ListArtifacts(val sessionId: SessionId) : ClientMessage() + /** Operator request for the session workspace's file paths (the TUI `@` file-ref picker). */ + @Serializable + data class ListFiles(val sessionId: SessionId) : ClientMessage() + /** Operator request for a session's derived metrics (replied to with a SessionStats). */ @Serializable data class GetSessionStats(val sessionId: SessionId) : ClientMessage() @@ -60,6 +70,14 @@ sealed class ClientMessage { @Serializable data class DiscardIdea(val ideaId: String) : ClientMessage() + /** + * Operator request to promote an idea into the bound session's curated project profile (added as + * a `conventions` entry in `/.correx/project.toml`). Tombstones the idea like a + * discard; reply is a fresh IdeaList. + */ + @Serializable + data class PromoteIdea(val ideaId: String) : ClientMessage() + /** Operator request for the current editable config (replied to with a ConfigSnapshot). */ @Serializable data object GetConfig : ClientMessage() @@ -92,10 +110,20 @@ sealed class ClientMessage { val permittedTiers: List, val reason: String, val expiresAt: Instant? = null, - // Required for SESSION scope: binds this grant to a specific tool name. + // Required for SESSION/PROJECT/GLOBAL scope: binds this grant to a specific tool name. + // The projectId for a PROJECT grant is derived server-side from the session's bound + // workspace, never trusted from the client. val toolName: String? = null, ) : ClientMessage() + /** Operator request to revoke a standing (PROJECT/GLOBAL) grant by id; reply is a fresh GrantList. */ + @Serializable + data class RevokeGrant(val grantId: GrantId) : ClientMessage() + + /** Operator request for the active cross-session grants (replied to with a GrantList). */ + @Serializable + data object ListGrants : ClientMessage() + @Serializable data class Ping(val timestamp: Long) : ClientMessage() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index bbd16985..1b3871de 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -121,6 +121,23 @@ data class IdeaDto( val capturedAtMs: Long, ) +/** + * One standing cross-session grant for the TUI grants viewer. [scope] is "PROJECT" or "GLOBAL"; + * [tiers] are the tier names this grant auto-approves; [projectId] is the workspace path a PROJECT + * grant is bound to (null for GLOBAL); [expiresAtMs] is the epoch-ms expiry, or null if it never + * expires. + */ +@Serializable +data class GrantDto( + val grantId: String, + val scope: String, + val toolName: String?, + val projectId: String?, + val tiers: List, + val reason: String, + val expiresAtMs: Long?, +) + internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto( level = level.name, factors = signals.map { signal -> diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 138a2bd3..32f08c91 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -465,6 +465,19 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** + * The session workspace's file paths (reply to ListFiles), for the TUI `@` file-ref picker. + * A snapshot directory walk, not event-derived, so cursors are null. + */ + @Serializable + @SerialName("file.list") + data class FileList( + val sessionId: SessionId, + val paths: List, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + /** * The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a * snapshot read folded from the whole event log), so cursors are null. @@ -477,6 +490,18 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** + * The active cross-session (PROJECT/GLOBAL) grants (reply to ListGrants / CreateGrant / + * RevokeGrant). Not event-derived (a snapshot folded from the grant ledger), so cursors are null. + */ + @Serializable + @SerialName("grant.list") + data class GrantList( + val grants: List, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + /** * A session's derived metrics, returned in response to a GetSessionStats request. Not * event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt new file mode 100644 index 00000000..91dfe4a1 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt @@ -0,0 +1,37 @@ +package com.correx.apps.server.research + +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.types.SessionId + +/** Operator context recorded on the grant emitted by [approveSourceList]. */ +internal const val BATCH_SOURCE_LIST_REASON = "batch source-list approval" + +/** + * Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a whole research + * source list at once instead of clearing each `web_fetch` per-URL. Extracts the distinct hosts from + * [urls] (a source list's URLs, or its source_dossier source lines) and emits a single + * [EgressHostsGrantedEvent] for [sessionId]. That grant folds into the live per-session egress + * allowlist (commit ab7e4be) via [com.correx.core.sessions.projections.EgressAllowlistProjection], so + * subsequent fetches to those hosts auto-clear and no longer prompt. + * + * No-op when [urls] yields no parseable host: nothing to grant, so no event is emitted. Returns the + * granted host set (empty when nothing was emitted) for the caller to report back. + */ +suspend fun approveSourceList( + dispatcher: EventDispatcher, + sessionId: SessionId, + urls: List, +): Set { + val hosts = SourceListHosts.extract(urls) + if (hosts.isEmpty()) return emptySet() + dispatcher.emit( + payload = EgressHostsGrantedEvent( + sessionId = sessionId, + hosts = hosts, + reason = BATCH_SOURCE_LIST_REASON, + ), + sessionId = sessionId, + ) + return hosts +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt new file mode 100644 index 00000000..f261deab --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt @@ -0,0 +1,34 @@ +package com.correx.apps.server.research + +import java.net.URI + +/** + * Pure host extraction for batch source-list approval (BACKLOG §D). Given the URLs (or dossier + * source lines) of a research source list, returns the distinct, lower-cased hostnames the operator + * is approving egress to — the input set for a single [com.correx.core.events.events.EgressHostsGrantedEvent]. + * + * Mirrors [com.correx.core.toolintent.rules.NetworkHostRule]'s URL parsing so the granted hosts line + * up with what the network gate later matches: absolute URLs only (`scheme://host`), parsed via + * [java.net.URI], host lower-cased, port dropped (URI.host excludes it). Entries that don't parse to + * a host — relative paths, free text, malformed URLs — are ignored rather than failing the batch. + * + * source_dossier entries start with the URL but may carry a trailing note + * ("https://example.com/x — explains Y"); the leading whitespace-delimited token is taken so those + * lines still yield their host. + */ +object SourceListHosts { + + /** + * The distinct lower-cased hostnames drawn from [urls]. Deterministic; unparseable/relative + * entries are skipped. Duplicate hosts (and differing-case or differing-port variants of the + * same host) collapse to one. + */ + fun extract(urls: List): Set = + urls.mapNotNullTo(LinkedHashSet()) { hostOf(it) } + + private fun hostOf(raw: String): String? { + val token = raw.trim().substringBefore(' ').substringBefore('\t') + if (!token.contains("://")) return null + return runCatching { URI(token).host?.lowercase() }.getOrNull()?.takeIf { it.isNotEmpty() } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index 0e0c693b..08dbdf4d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -2,6 +2,8 @@ package com.correx.apps.server.routes import com.correx.apps.server.ServerModule import com.correx.apps.server.metrics.MetricsInspectionService +import com.correx.apps.server.research.approveSourceList +import com.correx.core.events.EventDispatcher import com.correx.apps.server.protocol.SessionConfigDto import com.correx.apps.server.replay.ReplayInspectionService import com.correx.apps.server.serialization.payloadDiscriminator @@ -56,6 +58,12 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c @Serializable data class StartSessionResponse(val sessionId: String) +@Serializable +data class ApproveSourcesRequest(val urls: List) + +@Serializable +data class ApproveSourcesResponse(val grantedHosts: List) + private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes") fun Route.sessionRoutes(module: ServerModule) { @@ -79,6 +87,7 @@ fun Route.sessionRoutes(module: ServerModule) { route("/{id}") { getSessionRoute(module) cancelSessionRoute(module) + approveSourcesRoute(module) undoSessionRoute(module) resumeSessionRoute(module) getEventsRoute(module) @@ -129,6 +138,24 @@ private fun Route.cancelSessionRoute(module: ServerModule) { } } +// Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a research +// source list once and egress is granted for all its hosts in one EgressHostsGrantedEvent, so the +// session's subsequent web_fetches to those hosts auto-clear instead of prompting per URL. Body +// carries the source list's URLs (or its source_dossier source lines). +private fun Route.approveSourcesRoute(module: ServerModule) { + post("/approve-sources") { + val id = call.parameters["id"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id") + val body = call.receive() + val granted = approveSourceList( + dispatcher = EventDispatcher(module.eventStore), + sessionId = TypeId(id), + urls = body.urls, + ) + call.respond(HttpStatusCode.OK, ApproveSourcesResponse(grantedHosts = granted.sorted())) + } +} + private fun Route.undoSessionRoute(module: ServerModule) { post("/undo") { val id = call.parameters["id"] diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt new file mode 100644 index 00000000..b1640b03 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -0,0 +1,343 @@ +package com.correx.apps.server.routes + +import com.correx.apps.server.ServerModule +import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.Commit +import com.correx.core.tasks.GitCommitReader +import com.correx.core.tasks.GitTaskSync +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.Task +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskGraph +import com.correx.core.tasks.TaskHistory +import com.correx.core.tasks.TaskMarkdown +import com.correx.core.tasks.TaskSearch +import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskStatus +import com.correx.core.tasks.TaskTargetKinds +import com.correx.core.utils.TypeId +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.RoutingContext +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.patch +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable + +@Serializable +data class TaskLinkDto(val targetId: String, val type: String, val targetKind: String) + +@Serializable +data class TaskNoteDto(val author: String, val body: String, val at: String) + +@Serializable +data class TaskResponse( + val id: String, + val key: String?, + val status: String, + val title: String?, + val goal: String?, + val acceptanceCriteria: List, + val affectedPaths: List, + val claimant: String?, + val links: List, + val notes: List, + val createdAt: String?, + val updatedAt: String?, + // Dependency-graph status for the board: ready = TODO with every dependency satisfied (workable + // now); blockedBy = the ids of unfinished tasks this one waits on (its unmet DEPENDS_ON/BLOCKS). + // Default to "not blocked / not ready" for the endpoints that don't resolve the graph. + val ready: Boolean = false, + val blockedBy: List = emptyList(), +) + +@Serializable +data class CreateTaskRequest( + val project: String, + val title: String, + val goal: String, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), + // Skip the duplicate-title guard and create anyway. + val force: Boolean = false, +) + +// Null fields are left untouched; a present list (even empty) replaces the stored one. +@Serializable +data class EditTaskRequest( + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List? = null, + val affectedPaths: List? = null, +) + +@Serializable +data class ClaimRequest(val claimant: String) + +@Serializable +data class BlockRequest(val reason: String) + +@Serializable +data class CancelRequest(val reason: String? = null) + +@Serializable +data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", val targetKind: String? = null) + +@Serializable +data class NoteRequest(val body: String, val author: String? = null) + +@Serializable +data class CommitDto(val sha: String = "", val message: String) + +// When commits are supplied they are used verbatim; otherwise the server reads the last `limit` +// commits from the repo (when a GitCommitReader is wired). +@Serializable +data class SyncGitRequest(val commits: List = emptyList(), val limit: Int = DEFAULT_SYNC_LIMIT) + +private const val DEFAULT_SYNC_LIMIT = 20 + +/** + * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both + * append to the event log, which stays the single source of truth (nothing is stored separately). + * A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes + * optional `project` (cross-project board without it), `status`, and `q` (ranked text search); + * create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle. + */ +fun Route.taskRoutes(module: ServerModule) { + val service = TaskService(module.eventStore) + val assembler = TaskContextAssembler( + service, + module.taskKnowledgeRetriever, + module.taskDocumentResolver, + module.taskArtifactResolver, + module.taskSessionResolver, + ) + route("/tasks") { + taskCollectionRoutes(service) + taskSyncRoute(service, module.gitCommitReader) + route("/{id}") { + taskCrudRoutes(service, assembler) + taskTransitionRoutes(service) + taskGraphRoutes(service) + } + } +} + +private fun Route.taskCollectionRoutes(service: TaskService) { + get { + // ?ready=true short-circuits to the dependency-aware workable set (TODO, no unmet blockers). + if (call.request.queryParameters["ready"]?.toBoolean() == true) { + val readyProject = call.request.queryParameters["project"] + val workable = service.ready(readyProject?.let { ProjectId(it) }) + return@get call.respond(workable.map { it.toResponse().copy(ready = true) }) + } + val statusRaw = call.request.queryParameters["status"] + val statusFilter = statusRaw?.let { + parseEnum(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it") + } + // project is optional: scoped to one project when given, else the whole cross-project board. + val project = call.request.queryParameters["project"] + val base = (if (project != null) service.list(ProjectId(project)) else service.listAll()) + .filter { statusFilter == null || it.state.status == statusFilter } + // q ranks by relevance; without it the board is most-recently-touched first. + val q = call.request.queryParameters["q"] + val ordered = if (q.isNullOrBlank()) { + base.sortedByDescending { it.state.updatedAt } + } else { + TaskSearch.search(base, q) + } + // Resolve ready/blockedBy against each task's FULL project board (not the filtered list — a + // status filter must not hide a finished blocker). One list() per distinct project, cached. + val boards = HashMap>() + fun enrich(t: Task): TaskResponse { + val pid = t.state.projectId ?: return t.toResponse() + val board = boards.getOrPut(pid) { service.list(pid) } + return t.toResponse().copy( + ready = TaskGraph.isReady(t, board), + blockedBy = TaskGraph.unmetBlockers(t, board).map { it.taskId.value }, + ) + } + call.respond(ordered.map(::enrich)) + } + post { + val body = call.receive() + val project = ProjectId(body.project) + // Duplicate-title guard (skippable with force): 409 with the look-alikes so the caller reuses one. + val duplicates = if (body.force) emptyList() else service.findDuplicates(project, body.title) + if (duplicates.isNotEmpty()) { + return@post call.respond(HttpStatusCode.Conflict, duplicates.map { it.toResponse() }) + } + val task = service.createTask( + projectId = project, + title = body.title, + goal = body.goal, + acceptanceCriteria = body.acceptanceCriteria, + affectedPaths = body.affectedPaths, + ) + call.respond(HttpStatusCode.Created, task.toResponse()) + } + // Disposable Markdown projection of the board (whole board, or one project with ?project=). + get("/export") { + val project = call.request.queryParameters["project"] + val tasks = if (project != null) service.list(ProjectId(project)) else service.listAll() + call.respondText(TaskMarkdown.render(tasks), ContentType.Text.Plain) + } +} + +// Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword +// ("fixes auth-12") advances it to DONE. Idempotent — wire this to a post-commit / post-merge hook +// or a CI step. Acts on commits in the body, or reads the repo's recent log when [reader] is set. +private fun Route.taskSyncRoute(service: TaskService, reader: GitCommitReader?) { + post("/sync-git") { + val body = runCatching { call.receive() }.getOrNull() ?: SyncGitRequest() + val commits = if (body.commits.isNotEmpty()) { + body.commits.map { Commit(it.sha, it.message) } + } else { + reader?.recent(body.limit) ?: emptyList() + } + call.respond(GitTaskSync(service).sync(commits)) + } +} + +private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) { + get { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.getTask(id)) + } + patch { + val id = taskId() ?: return@patch call.respond(HttpStatusCode.BadRequest, "Missing task id") + if (service.getTask(id) == null) return@patch call.respond(HttpStatusCode.NotFound, "Task not found") + val body = call.receive() + if (body.title != null || body.goal != null) service.edit(id, body.title, body.goal) + if (body.acceptanceCriteria != null) service.setAcceptanceCriteria(id, body.acceptanceCriteria) + if (body.affectedPaths != null) service.setAffectedPaths(id, body.affectedPaths) + respondTask(service.getTask(id)) + } + delete { + val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id") + if (service.delete(id)) call.respond(HttpStatusCode.NoContent) + else call.respond(HttpStatusCode.NotFound, "Task not found") + } + get("/context") { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respond(bundle) + } + // Lifecycle audit trail from the event log; visible even after a soft-delete. Empty history + // means the task id never existed. + get("/history") { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + val events = service.history(id) + if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respondText(TaskHistory.render(events), ContentType.Text.Plain) + } + // Unfinished tasks this one is waiting on (its dependencies + inbound blocks). + get("/blockers") { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + if (service.getTask(id) == null) return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respond(service.blockers(id).map { it.toResponse() }) + } +} + +private fun Route.taskTransitionRoutes(service: TaskService) { + post("/claim") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.claim(id, call.receive().claimant)) + } + post("/release") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.release(id)) + } + post("/block") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.block(id, call.receive().reason)) + } + post("/unblock") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.unblock(id)) + } + post("/submit-for-review") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.submitForReview(id)) + } + post("/complete") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.complete(id)) + } + post("/reopen") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.reopen(id)) + } + post("/cancel") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + val reason = runCatching { call.receive() }.getOrNull()?.reason + respondTask(service.cancel(id, reason)) + } +} + +private fun Route.taskGraphRoutes(service: TaskService) { + post("/links") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + val body = call.receive() + val type = parseEnum(body.type) + ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid link type: ${body.type}") + val kind = when (val raw = body.targetKind) { + null -> TaskTargetKinds.infer(body.targetId) + else -> parseEnum(raw) + ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid target kind: $raw") + } + respondTask(service.link(id, body.targetId, type, kind)) + } + delete("/links") { + val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id") + val targetId = call.request.queryParameters["targetId"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'targetId' query parameter") + val typeRaw = call.request.queryParameters["type"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'type' query parameter") + val type = parseEnum(typeRaw) + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid link type: $typeRaw") + respondTask(service.unlink(id, targetId, type)) + } + post("/notes") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + val body = call.receive() + val author = body.author?.let { + parseEnum(it) ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid author: $it") + } ?: TaskNoteAuthor.HUMAN + respondTask(service.addNote(id, author, body.body)) + } +} + +private fun RoutingContext.taskId(): TaskId? = call.parameters["id"]?.let { TypeId(it) } + +private suspend fun RoutingContext.respondTask(task: Task?) { + if (task == null) call.respond(HttpStatusCode.NotFound, "Task not found") + else call.respond(task.toResponse()) +} + +private inline fun > parseEnum(raw: String): T? = + enumValues().firstOrNull { it.name.equals(raw, ignoreCase = true) } + +private fun Task.toResponse(): TaskResponse = TaskResponse( + id = taskId.value, + key = state.key, + status = state.status.name, + title = state.title, + goal = state.goal, + acceptanceCriteria = state.acceptanceCriteria, + affectedPaths = state.affectedPaths, + claimant = state.claimant, + links = state.links.map { TaskLinkDto(it.targetId, it.type.name, it.targetKind.name) }, + notes = state.notes.map { TaskNoteDto(it.author.name, it.body, it.at.toString()) }, + createdAt = state.createdAt?.toString(), + updatedAt = state.updatedAt?.toString(), +) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt new file mode 100644 index 00000000..6a16a92e --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt @@ -0,0 +1,36 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionWorkingTaskEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.infrastructure.tools.task.SessionFactRecorder +import kotlinx.datetime.Clock +import java.util.UUID + +/** + * Adapter for [SessionFactRecorder]: appends a [SessionWorkingTaskEvent] to the session's own stream + * when it claims (or re-scopes) a task, so [com.correx.core.toolintent.SessionContextProjection] + * folds it into the active task without scanning task streams. + */ +class EventStoreSessionFactRecorder(private val eventStore: EventStore) : SessionFactRecorder { + + override suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List) { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = SessionWorkingTaskEvent(sessionId, taskId, affectedPaths), + ), + ) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt new file mode 100644 index 00000000..d6d020ea --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt @@ -0,0 +1,21 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.toolintent.SessionContextProjection +import com.correx.infrastructure.tools.task.SessionWrites + +/** + * Adapter for the [SessionWrites] port: folds a session's events through + * [SessionContextProjection] and returns its writes (the recorded receipt.affectedEntities). + * Powers the cite-before-claim gate. Replay-safe (reads the log, never live state). + */ +class EventStoreSessionWrites(private val eventStore: EventStore) : SessionWrites { + + override fun pathsWritten(sessionId: SessionId): Set { + val projection = SessionContextProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + .writes + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt new file mode 100644 index 00000000..82d97bda --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt @@ -0,0 +1,53 @@ +package com.correx.apps.server.tasks + +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.tasks.ResolvedArtifact +import com.correx.core.tasks.TaskArtifactResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val EXCERPT_MAX = 280 + +/** + * Resolves ARTIFACT link targets for the task context bundle by scanning the event log for the + * artifact's lifecycle events: [ArtifactCreatedEvent]/[ArtifactContentStoredEvent] give the + * producing stage + session, and the content hash is dereferenced against the CAS [ArtifactStore] + * for an excerpt. Returns null when no events match the id — the link then stays a raw related link. + */ +class EventStoreTaskArtifactResolver( + private val eventStore: EventStore, + private val artifactStore: ArtifactStore, + private val excerptMax: Int = EXCERPT_MAX, +) : TaskArtifactResolver { + + override suspend fun resolve(targetId: String): ResolvedArtifact? = withContext(Dispatchers.IO) { + val artifactId = ArtifactId(targetId) + var stage: String? = null + var sessionId: String? = null + var contentHash: ArtifactId? = null + eventStore.allEvents().forEach { stored -> + when (val p = stored.payload) { + is ArtifactCreatedEvent -> if (p.artifactId == artifactId) { + stage = p.stageId.value + sessionId = p.sessionId.value + } + is ArtifactContentStoredEvent -> if (p.artifactId == artifactId) { + stage = stage ?: p.stageId.value + sessionId = sessionId ?: p.sessionId.value + contentHash = p.contentHash + } + else -> Unit + } + } + if (stage == null && sessionId == null && contentHash == null) return@withContext null + val excerpt = contentHash?.let { hash -> + runCatching { artifactStore.get(hash) }.getOrNull() + ?.toString(Charsets.UTF_8)?.trim()?.takeIf { it.isNotEmpty() }?.take(excerptMax) + } + ResolvedArtifact(stage = stage, sessionId = sessionId, excerpt = excerpt) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt new file mode 100644 index 00000000..c64decf3 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt @@ -0,0 +1,91 @@ +package com.correx.apps.server.tasks + +import com.correx.core.tasks.ResolvedDocument +import com.correx.core.tasks.TaskDocumentResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.readText + +private const val EXCERPT_MAX = 280 +private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$") +private val ADR_FILE = Regex("^adr-0*(\\d+)") +private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$") +private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$") + +/** + * Resolves link targets to repo documents for the task context bundle: + * - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr--*.md` (matched by number, + * padding-agnostic); + * - explicit "*.md" paths under the repo root. + * Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter + * `description:` or the first prose paragraph, capped. Returns null for anything else. + */ +class FileTaskDocumentResolver( + private val repoRoot: Path, + private val decisionsDir: Path = repoRoot.resolve("docs/decisions"), +) : TaskDocumentResolver { + + override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) { + resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim()) + } + + private fun resolveAdr(targetId: String): ResolvedDocument? { + val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null + if (!decisionsDir.isDirectory()) return null + val file = Files.list(decisionsDir).use { stream -> + stream.filter { it.isRegularFile() && it.name.endsWith(".md") } + .filter { adrNumber(it.name) == num } + .findFirst() + .orElse(null) + } ?: return null + return readDocument(file) + } + + private fun resolveMarkdownPath(targetId: String): ResolvedDocument? { + if (!targetId.endsWith(".md")) return null + val candidate = repoRoot.resolve(targetId).normalize() + if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null + return readDocument(candidate) + } + + private fun adrNumber(fileName: String): Int? = + ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull() + + private fun readDocument(file: Path): ResolvedDocument { + val text = runCatching { file.readText() }.getOrDefault("") + return ResolvedDocument( + title = extractTitle(text) ?: file.name, + path = repoRoot.relativize(file).toString(), + excerpt = extractExcerpt(text), + ) + } + + private fun extractTitle(text: String): String? { + FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it } + return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim() + } + + private fun extractExcerpt(text: String): String { + val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim() + return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim() + } + + private fun firstParagraph(text: String): String = + stripFrontmatter(text).lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") } + ?: "" + + private fun stripFrontmatter(text: String): String { + if (!text.startsWith("---")) return text + val lines = text.lines() + val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" } + return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt new file mode 100644 index 00000000..c75091a7 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt @@ -0,0 +1,56 @@ +package com.correx.apps.server.tasks + +import com.correx.core.tasks.Commit +import com.correx.core.tasks.GitCommitReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +private const val RS = "" // ASCII record separator, between commits +private const val US = "" // ASCII unit separator, between sha and body +private const val GIT_TIMEOUT_SEC = 10L + +/** + * Reads recent commits by shelling `git log` in the repo. Best-effort: any failure (git missing, + * not a repo, timeout, non-zero exit) yields an empty list so the sync endpoint degrades to a no-op + * rather than erroring. Parsing is split into the testable top-level [parseGitLog]. + */ +class GitCommandCommitReader(private val repoRoot: Path) : GitCommitReader { + + override suspend fun recent(limit: Int): List = withContext(Dispatchers.IO) { + runCatching { runGitLog(limit) }.getOrDefault(emptyList()) + } + + private fun runGitLog(limit: Int): List { + val process = ProcessBuilder( + "git", "-C", repoRoot.toString(), "log", "-n", limit.toString(), "--format=%H$US%B$RS", + ).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + val finished = process.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS) + return if (finished && process.exitValue() == 0) { + parseGitLog(output) + } else { + if (!finished) process.destroy() + emptyList() + } + } +} + +/** + * Parses `git log --format=%H%B` output into commits: records split on the record + * separator, then each split once on the unit separator into sha + message. Top-level + internal + * so it is unit-testable without invoking git. + */ +internal fun parseGitLog(raw: String): List = + raw.split(RS) + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { record -> + val idx = record.indexOf(US) + if (idx < 0) { + null + } else { + Commit(sha = record.substring(0, idx).trim(), message = record.substring(idx + 1).trim()) + } + } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt new file mode 100644 index 00000000..a58ef693 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt @@ -0,0 +1,33 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.stores.EventStore +import com.correx.core.sessions.SessionSummaryProjector +import com.correx.core.tasks.ResolvedSession +import com.correx.core.tasks.TaskSessionResolver +import com.correx.core.utils.TypeId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Resolves SESSION link targets for the task context bundle by projecting the session's events into + * a [com.correx.core.sessions.SessionSummary] (status / intent / workflow). Returns null when the + * session has no events — the link then stays a raw related link. + */ +class SessionSummaryTaskSessionResolver( + private val eventStore: EventStore, +) : TaskSessionResolver { + + private val projector = SessionSummaryProjector() + + override suspend fun resolve(targetId: String): ResolvedSession? = withContext(Dispatchers.IO) { + val sessionId = TypeId(targetId) + val events = eventStore.read(sessionId) + if (events.isEmpty()) return@withContext null + val summary = projector.project(sessionId, events) + ResolvedSession( + status = summary.status, + intent = summary.intent, + workflowId = summary.workflowId, + ) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt new file mode 100644 index 00000000..b2baa73a --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt @@ -0,0 +1,46 @@ +package com.correx.apps.server.workspace + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.relativeTo + +/** + * Lists workspace-relative file paths under a root for the TUI's `@` file-reference picker. + * + * Deliberately distinct from `RepoMapIndexer`: that one reads every file's *contents* to extract + * symbols and only covers source/markdown extensions (it feeds project memory + context). The `@` + * picker wants *all* file types, by path only, as cheaply as possible — so this never opens a file. + * It does share the indexer's directory-ignore convention (skip any path segment in [ignoreDirs]) + * so both agree on what "the workspace" is. Results are sorted and capped to bound the reply. + */ +object WorkspaceFiles { + + val DEFAULT_IGNORE_DIRS = setOf( + ".git", "node_modules", "build", "target", ".gradle", "dist", ".idea", ".correx", ".venv", + ) + const val DEFAULT_LIMIT = 2000 + private const val MAX_DEPTH = 12 + + fun list( + root: Path, + ignoreDirs: Set = DEFAULT_IGNORE_DIRS, + limit: Int = DEFAULT_LIMIT, + ): List { + if (!Files.isDirectory(root)) return emptyList() + val out = ArrayList() + Files.walk(root, MAX_DEPTH).use { stream -> + val iter = stream.iterator() + while (iter.hasNext()) { + val path = iter.next() + if (!path.isRegularFile()) continue + val rel = path.relativeTo(root) + if (rel.any { it.name in ignoreDirs }) continue + out.add(rel.toString()) + } + } + out.sort() + return out.take(limit) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index d5017a42..4695ce40 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -13,12 +13,14 @@ import com.correx.apps.server.protocol.WorkflowDto import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.ToolDecl import com.correx.apps.server.workspace.WorkspaceResolution +import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID import com.correx.core.approvals.GrantScope -import com.correx.core.approvals.Tier import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.IdeaDiscardedEvent +import com.correx.core.events.events.IdeaPromotedEvent import com.correx.core.events.events.InitialIntentEvent import com.correx.core.events.events.NewEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent @@ -30,6 +32,9 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.inference.ProviderHealth import com.correx.core.kernel.orchestration.WorkspaceContext +import com.correx.core.config.ProjectProfileLoader +import com.correx.core.config.ProjectProfileWriter +import com.correx.core.config.withConvention import com.correx.core.router.IdeaReader import com.correx.core.utils.TypeId import com.correx.infrastructure.inference.commons.ResourceProbe @@ -231,8 +236,10 @@ class GlobalStreamHandler(private val module: ServerModule) { .onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) } } is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId)) + is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId)) is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas()) is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame) + is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame) is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId)) is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks()) is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList())) @@ -241,6 +248,8 @@ class GlobalStreamHandler(private val module: ServerModule) { is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame) is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg) is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame) + is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame) + is ClientMessage.ListGrants -> sendFrame(queries.listGrants()) is ClientMessage.ChatInput -> { // The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput // and reach the client as chat.turn frames via streamGlobal — no direct @@ -337,6 +346,57 @@ class GlobalStreamHandler(private val module: ServerModule) { sendFrame(queries.listIdeas()) } + // Promote an idea into the bound session's curated project profile (a `conventions` entry in + // /.correx/project.toml), then tombstone it like a discard so it leaves the board. + // No-ops (just refresh the board) when the idea is missing or its capturing session has no bound + // workspace — promotion needs a workspace to write the profile into. + private suspend fun handlePromoteIdea( + msg: ClientMessage.PromoteIdea, + sendFrame: suspend (ServerMessage) -> Unit, + ) { + runCatching { + val reader = IdeaReader(module.eventStore) + val sessionId = reader.sessionOf(msg.ideaId) ?: return@runCatching + val text = reader.textOf(msg.ideaId) ?: return@runCatching + val workspaceRoot = workspaceRootOf(sessionId) ?: return@runCatching + + withContext(Dispatchers.IO) { + val updated = ProjectProfileLoader.load(workspaceRoot).withConvention(text) + ProjectProfileWriter.write(workspaceRoot, updated) + } + + module.eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = IdeaPromotedEvent( + ideaId = msg.ideaId, + sessionId = sessionId, + text = text, + timestampMs = System.currentTimeMillis(), + ), + ), + ) + }.onFailure { + log.error("promoteIdea failed for ideaId={}: {}", msg.ideaId, it.message, it) + } + sendFrame(queries.listIdeas()) + } + + // The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent (invariant #9), + // or null if the session never bound one. + private fun workspaceRootOf(sessionId: SessionId): String? = + module.eventStore.read(sessionId) + .mapNotNull { it.payload as? SessionWorkspaceBoundEvent } + .lastOrNull() + ?.workspaceRoot + private fun errorResponse(message: String) = ServerMessage.ProtocolError(message) private fun encodeError(message: String): Frame.Text = @@ -346,18 +406,20 @@ class GlobalStreamHandler(private val module: ServerModule) { msg: ClientMessage.CreateGrant, sendFrame: suspend (ServerMessage) -> Unit, ) { - // Validate: tiers must be non-empty and bounded to T2 (server-side ceiling). - // T3/T4 are destructive/escalated tiers that must never be auto-approved via grants. + // No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may + // authorize any tier (including the destructive T3/T4). Every operator-creatable scope is + // still tool-bound (below) so a grant never becomes a blanket "approve everything". if (msg.permittedTiers.isEmpty()) { sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty")) return } - val maxGrantableTier = Tier.T2 - val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level } - if (overBroad.isNotEmpty()) { - sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}")) - return - } + + suspend fun requireToolName(scopeLabel: String): String? = + msg.toolName?.takeIf { it.isNotBlank() } + ?: run { + sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval")) + null + } val scope = when (msg.scope) { GrantScopeDto.SESSION -> { @@ -365,12 +427,7 @@ class GlobalStreamHandler(private val module: ServerModule) { sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId")) return } - // Require toolName — blanket session grants (no operation scope) are rejected. - val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run { - sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval")) - return - } - GrantScope.SESSION(toolName) + GrantScope.SESSION(requireToolName("SESSION") ?: return) } GrantScopeDto.STAGE -> { val sid = msg.stageId ?: run { @@ -379,11 +436,25 @@ class GlobalStreamHandler(private val module: ServerModule) { } GrantScope.STAGE(sid) } + GrantScopeDto.PROJECT -> { + // projectId is derived from the session's bound workspace, never trusted from the client. + val projectId = queries.projectIdForSession(msg.sessionId) ?: run { + sendFrame(errorResponse("CreateGrant: PROJECT scope requires a session with a bound workspace")) + return + } + GrantScope.PROJECT(projectId, requireToolName("PROJECT") ?: return) + } + GrantScopeDto.GLOBAL -> GrantScope.GLOBAL(requireToolName("GLOBAL") ?: return) } + + // SESSION/STAGE grants live in the originating session's stream (they die with it); PROJECT + // and GLOBAL grants outlive any session, so they go to the shared cross-session grant ledger. + val ledgered = scope is GrantScope.PROJECT || scope is GrantScope.GLOBAL + val streamId = if (ledgered) GRANT_LEDGER_SESSION_ID else msg.sessionId val event = NewEvent( metadata = EventMetadata( eventId = EventId(UUID.randomUUID().toString()), - sessionId = msg.sessionId, + sessionId = streamId, timestamp = Clock.System.now(), schemaVersion = 1, causationId = null, @@ -397,11 +468,44 @@ class GlobalStreamHandler(private val module: ServerModule) { expiresAt = msg.expiresAt, sessionId = msg.sessionId, stageId = (scope as? GrantScope.STAGE)?.stageId, - projectId = null, - toolName = (scope as? GrantScope.SESSION)?.toolName, + projectId = (scope as? GrantScope.PROJECT)?.projectId, + toolName = scope.toolNameOrNull(), ), ) module.eventStore.append(event) + // Echo the refreshed standing-grant list so the TUI can confirm a PROJECT/GLOBAL grant landed. + if (ledgered) sendFrame(queries.listGrants()) + } + + /** + * Revokes a standing (PROJECT/GLOBAL) grant by appending an [ApprovalGrantExpiredEvent] to the + * grant ledger — the reducer drops it, so the gate stops honouring it across all sessions while + * the create/revoke pair stays in the audit log. Replies with the refreshed grant list. + */ + private suspend fun handleRevokeGrant( + msg: ClientMessage.RevokeGrant, + sendFrame: suspend (ServerMessage) -> Unit, + ) { + val event = NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = GRANT_LEDGER_SESSION_ID, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = ApprovalGrantExpiredEvent(grantId = msg.grantId), + ) + module.eventStore.append(event) + sendFrame(queries.listGrants()) + } + + private fun GrantScope.toolNameOrNull(): String? = when (this) { + is GrantScope.SESSION -> toolName + is GrantScope.PROJECT -> toolName + is GrantScope.GLOBAL -> toolName + is GrantScope.STAGE -> null } private suspend fun handleStartChatSession( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index cf58b417..c84c554b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -6,8 +6,14 @@ import com.correx.apps.server.health.HealthInspectionService import com.correx.apps.server.metrics.MetricsInspectionService import com.correx.apps.server.protocol.ArtifactSummaryDto import com.correx.apps.server.protocol.ConfigFieldDto +import com.correx.apps.server.protocol.GrantDto import com.correx.apps.server.protocol.IdeaDto import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.workspace.WorkspaceFiles +import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.ProjectIdentity +import com.correx.core.events.types.ProjectId import com.correx.core.router.IdeaReader import com.correx.core.config.EditableConfig import com.correx.core.events.events.ArtifactContentStoredEvent @@ -15,11 +21,14 @@ import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.SessionId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import java.nio.file.Paths import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject @@ -62,6 +71,70 @@ class StreamQueries(private val module: ServerModule) { return ServerMessage.HealthChecks(health = report) } + /** + * The session workspace's file paths for the `@` picker. Resolves the bound workspace root from + * the recorded [SessionWorkspaceBoundEvent] (invariant #9) and lists it by path. Pure snapshot + * read — no events appended (replay-neutral). Empty when the session has no bound workspace. + */ + suspend fun listFiles(sessionId: SessionId): ServerMessage.FileList { + val paths = withContext(Dispatchers.IO) { + workspaceRootOf(sessionId)?.let { WorkspaceFiles.list(Paths.get(it)) } ?: emptyList() + } + return ServerMessage.FileList(sessionId = sessionId, paths = paths) + } + + // The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent, or null. + private fun workspaceRootOf(sessionId: SessionId): String? = + module.eventStore.read(sessionId) + .mapNotNull { it.payload as? SessionWorkspaceBoundEvent } + .lastOrNull() + ?.workspaceRoot + + /** + * The stable [ProjectId] a session's bound workspace resolves to (the same derivation the + * approval gate uses), or null when the session has no bound workspace. Used to scope a PROJECT + * grant to the right repository without trusting a client-supplied id. + */ + fun projectIdForSession(sessionId: SessionId): ProjectId? = + workspaceRootOf(sessionId)?.let { ProjectIdentity.of(it) } + + /** + * The active cross-session grants as a snapshot frame, folded from the grant ledger via the + * approval reducer. Expired grants are dropped so the viewer only lists ones still in force. + * Pure read — no events appended (replay-neutral). + */ + fun listGrants(): ServerMessage.GrantList { + val now = Clock.System.now() + val grants = module.approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values + .filter { grant -> grant.expiresAt.let { it == null || it > now } } + .map { grant -> + GrantDto( + grantId = grant.id.value, + scope = grant.scope.kindName(), + toolName = grant.scope.toolNameOrNull(), + projectId = (grant.scope as? GrantScope.PROJECT)?.projectId?.value, + tiers = grant.permittedTiers.map { it.name }.sorted(), + reason = grant.reason, + expiresAtMs = grant.expiresAt?.toEpochMilliseconds(), + ) + } + return ServerMessage.GrantList(grants = grants) + } + + private fun GrantScope.kindName(): String = when (this) { + is GrantScope.SESSION -> "SESSION" + is GrantScope.STAGE -> "STAGE" + is GrantScope.PROJECT -> "PROJECT" + is GrantScope.GLOBAL -> "GLOBAL" + } + + private fun GrantScope.toolNameOrNull(): String? = when (this) { + is GrantScope.SESSION -> toolName + is GrantScope.PROJECT -> toolName + is GrantScope.GLOBAL -> toolName + is GrantScope.STAGE -> null + } + /** * Reads a session's events to assemble its artifact listing (creation order preserved), * resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read — diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt new file mode 100644 index 00000000..a07b3289 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt @@ -0,0 +1,276 @@ +package com.correx.apps.server + +import com.correx.apps.server.memory.ArchitectContradictionChecker +import com.correx.apps.server.registry.ProviderRegistry +import com.correx.apps.server.registry.WorkflowRegistry +import com.correx.apps.server.registry.WorkflowSummary +import com.correx.apps.server.undo.SessionUndoService +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.context.builder.DefaultContextPackBuilder +import com.correx.core.context.compression.DefaultContextCompressor +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent +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.PossibleContradictionFlaggedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.inference.InferenceProjector +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.ProviderHealth +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.router.l3.L3Hit +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +import com.correx.core.router.model.RouterConfig +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.InfrastructureModule +import com.correx.infrastructure.inference.commons.UnavailableProbe +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.tools.FileEditConfig +import com.correx.infrastructure.tools.FileReadConfig +import com.correx.infrastructure.tools.FileWriteConfig +import com.correx.infrastructure.tools.ShellConfig +import com.correx.infrastructure.tools.ToolConfig +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.util.UUID + +/** Always returns the same vector so the canned L3 store fully controls the scores. */ +private class OnesEmbedder(override val dimension: Int = 8) : Embedder { + override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f } +} + +/** Returns canned hits regardless of the query vector. */ +private class CannedL3MemoryStore(private val hits: List) : L3MemoryStore { + override suspend fun store(entry: L3MemoryEntry) = Unit + override suspend fun query(query: L3Query): List = hits.sortedByDescending { it.score }.take(query.k) + override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) } + override suspend fun close() = Unit +} + +/** Minimal in-memory CAS keyed by content hash. */ +private class MapArtifactStore(private val bytesByHash: Map) : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("00".repeat(32)) + override suspend fun get(id: ArtifactId): ByteArray? = bytesByHash[id.value] + override suspend fun flushBefore(commit: suspend () -> Unit) { commit() } +} + +/** + * Exercises [ServerModule.handleArchitectArtifact]: the extraction/dispatch helper that backs the + * display-only architect contradiction subscription (BACKLOG §B-§4). A full subscription/integration + * test is not required — these assert the helper fetches the design artifact text and emits (or + * skips) the flag. The surrounding module is built with in-memory infra (no real model needed). + */ +class ArchitectContradictionHookTest { + + private val session = SessionId("new-session") + private val priorSession = SessionId("prior-session") + private val architectStage = StageId("architect") + private val designArtifact = ArtifactId("design") + private val contentHash = ArtifactId("cafebabe") + + private fun append(es: EventStore, payload: EventPayload, sid: SessionId = session) = runBlocking { + es.append( + NewEvent( + EventMetadata(EventId(UUID.randomUUID().toString()), sid, Clock.System.now(), 1, null, null), + payload, + ), + ) + } + + private fun priorDecisionHit(text: String, score: Float) = L3Hit( + entry = L3MemoryEntry( + id = UUID.randomUUID().toString(), + sessionId = priorSession, + turnId = "project:/repo", + text = text, + vector = FloatArray(8) { 1f }, + timestampMs = 0L, + ), + score = score, + ) + + private fun flagsIn(es: EventStore): List = + es.read(session).map { it.payload }.filterIsInstance() + + private fun buildModule( + eventStore: EventStore, + artifactStore: ArtifactStore, + checker: ArchitectContradictionChecker?, + tempDir: Path, + ): ServerModule { + val provider = InfrastructureModule.createLlamaCppProvider( + modelId = "test-model", + modelPath = "/dev/null", + baseUrl = "http://127.0.0.1:1", + ) + val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider)) + val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter( + providerRegistry, + com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(), + ) + val toolConfig = ToolConfig( + shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir), + fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)), + fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + ) + val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig) + val toolExecutor = InfrastructureModule.createToolExecutor( + registry = toolRegistry, + eventDispatcher = EventDispatcher(eventStore), + workDir = tempDir, + artifactStore = null, + ) + val engines = OrchestratorEngines( + transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) }, + contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()), + inferenceRouter = inferenceRouter, + validationPipeline = ValidationPipeline(validators = emptyList()), + approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + toolRegistry = toolRegistry, + toolExecutor = toolExecutor, + ) + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())), + orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ), + sessionRepository = DefaultSessionRepository( + DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), + ), + artifactRepository = InfrastructureModule.createArtifactRepository(eventStore), + approvalRepository = InfrastructureModule.createApprovalRepository(eventStore), + ) + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = DefaultRetryCoordinator(eventStore), + artifactStore = artifactStore, + tokenizer = provider.tokenizer, + decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore), + ) + val routerFacade = InfrastructureModule.createRouterFacade( + eventStore = eventStore, + inferenceRouter = inferenceRouter, + config = RouterConfig(), + tokenizer = provider.tokenizer, + ) + val noopProviderRegistry = object : ProviderRegistry { + override fun listAll() = emptyList() + override suspend fun healthCheckAll() = emptyMap() + } + val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = emptyList() + override fun find(workflowId: String): WorkflowGraph? = null + } + return ServerModule( + orchestrator = orchestrator, + eventStore = eventStore, + artifactStore = artifactStore, + sessionRepository = repositories.sessionRepository, + workflowRegistry = noopWorkflowRegistry, + providerRegistry = noopProviderRegistry, + defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir), + routerFacade = routerFacade, + orchestrationRepository = repositories.orchestrationRepository, + approvalRepository = repositories.approvalRepository, + toolRegistry = toolRegistry, + sessionUndoService = SessionUndoService(eventStore, artifactStore, bootRoots = setOf(tempDir)), + resourceProbe = UnavailableProbe, + architectContradictionChecker = checker, + ) + } + + @Test + fun `emits the flag when the checker surfaces a near prior decision`(@TempDir tempDir: Path) = runBlocking { + val es = InMemoryEventStore() + val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}""" + val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray())) + // ArtifactContentStored precedes ArtifactCreated for the same artifactId (inference-time). + append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage)) + val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1) + append(es, created) + val checker = ArchitectContradictionChecker( + OnesEmbedder(), + CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite for the event store.", 0.9f))), + ) + + val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created) + + assertNotNull(flag, "expected a contradiction flag") + assertEquals(session, flag!!.sessionId) + assertEquals(architectStage, flag.stageId) + // The design schema's `approach` field is preferred as the decision text. + assertEquals("Use Postgres for the event store.", flag.decisionSummary) + assertEquals("Decided to use SQLite for the event store.", flag.related.single().summary) + // The flag is appended to the session stream (display-only, but recorded for the operator). + assertEquals(1, flagsIn(es).size) + } + + @Test + fun `returns null and appends nothing when the checker finds no related decision`( + @TempDir tempDir: Path, + ) = runBlocking { + val es = InMemoryEventStore() + val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}""" + val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray())) + append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage)) + val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1) + append(es, created) + val checker = ArchitectContradictionChecker(OnesEmbedder(), CannedL3MemoryStore(emptyList())) + + val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created) + + assertNull(flag) + assertEquals(0, flagsIn(es).size) + } + + @Test + fun `returns null when no content hash was recorded for the artifact`(@TempDir tempDir: Path) = runBlocking { + val es = InMemoryEventStore() + val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1) + append(es, created) + val checker = ArchitectContradictionChecker( + OnesEmbedder(), + CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite.", 0.9f))), + ) + + val flag = buildModule(es, MapArtifactStore(emptyMap()), checker, tempDir) + .handleArchitectArtifact(checker, created) + + assertNull(flag) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt new file mode 100644 index 00000000..36188d50 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt @@ -0,0 +1,107 @@ +package com.correx.apps.server.memory + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.router.l3.L3Hit +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +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 +import java.util.UUID + +private class ContradictionOnesEmbedder(override val dimension: Int = 8) : Embedder { + override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f } +} + +/** Returns canned hits regardless of the query vector, so scores can be controlled precisely. */ +private class CannedL3MemoryStore(private val hits: List) : L3MemoryStore { + override suspend fun store(entry: L3MemoryEntry) = Unit + override suspend fun query(query: L3Query): List = hits.sortedByDescending { it.score }.take(query.k) + override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) } + override suspend fun close() = Unit +} + +class ArchitectContradictionCheckerTest { + + private val newSession = SessionId("new-session") + private val priorSession = SessionId("prior-session") + private val stageId = StageId("architect") + + private fun hit(text: String, score: Float, turnId: String, sessionId: SessionId = priorSession) = + L3Hit( + entry = L3MemoryEntry( + id = UUID.randomUUID().toString(), + sessionId = sessionId, + turnId = turnId, + text = text, + vector = FloatArray(8) { 1f }, + timestampMs = 0L, + ), + score = score, + ) + + @Test + fun `flags a related prior decision above threshold`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Decided to use SQLite for the event store.", score = 0.9f, turnId = "project:/repo")), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + val flag = checker.check(newSession, stageId, "Use Postgres for the event store.") + + assertTrue(flag != null, "expected a flag") + assertEquals(newSession, flag!!.sessionId) + assertEquals(stageId, flag.stageId) + assertEquals("Use Postgres for the event store.", flag.decisionSummary) + assertEquals(1, flag.related.size) + val related = flag.related.single() + assertEquals("Decided to use SQLite for the event store.", related.summary) + assertEquals("project:/repo", related.source) + assertEquals(0.9, related.score, 1e-6) + } + + @Test + fun `returns null when there are no hits`() = runBlocking { + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList())) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `returns null when all hits are below threshold`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Loosely related prior note.", score = 0.5f, turnId = "project:/repo")), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `filters out hits outside the decision namespace`() = runBlocking { + val store = CannedL3MemoryStore( + listOf( + // Above threshold but a repo-map entry, not a decision — must be excluded. + hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"), + ), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `filters out the architect's own in-flight session`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Same-session decision.", score = 0.95f, turnId = "project:/repo", sessionId = newSession)), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt index e5345b91..ae3e6652 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt @@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId import com.correx.infrastructure.persistence.InMemoryEventStore import kotlinx.coroutines.runBlocking 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 java.nio.file.Path @@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest { "entries should be embedded with stateKey tag", ) } + + @Test + fun `repoRoot prefix does not collide across roots — repo vs repo2`(): Unit = runBlocking { + val es = InMemoryEventStore() + val l3 = InMemoryL3MemoryStore() + val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false)) + val embedder = ConstantEmbedderReuse() + + // Index /repo and /repo2 into the same shared L3 store with the same stateKey. + // The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding + // across roots: "/repo" must NOT match tags written for "/repo2". + val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass"))) + val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class"))) + + service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo") + .indexAndRecord(SessionId("session-collide-repo"), "/repo") + service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2") + .indexAndRecord(SessionId("session-collide-repo2"), "/repo2") + + // existsByTurnIdPrefix with the delimiter must not match the other root. + assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist") + assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist") + + // The retriever scoped to /repo must return only /repo's entry, never /repo2's. + val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo") + .retrieve(SessionId("session-collide-query"), "anything", k = 10) + + assertTrue(hits.isNotEmpty(), "retriever for /repo should find /repo entries") + val text = hits.joinToString(" ") { it.text } + assertTrue(text.contains("src/Repo.kt"), "should contain /repo entry; got: $text") + assertFalse(text.contains("src/Repo2.kt"), "must NOT contain /repo2 entry (prefix collision); got: $text") + } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt index f609b72b..9d3fd7a8 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt @@ -75,6 +75,42 @@ class RepoMapIndexerTest { assertFalse(entry.symbols.contains("help"), "indented inner method must not be matched") } + @Test + fun `extracts C# (Godot Mono) top-level type symbols`(@TempDir root: Path) { + root.resolve("Player.cs").writeText( + """ + using Godot; + + namespace Game; + + public sealed partial class Player : CharacterBody2D + { + private int _health = 100; + + public void TakeDamage(int amount) + { + _health -= amount; + } + } + + internal interface IDamageable + { + void TakeDamage(int amount); + } + + public enum State { Idle, Running } + """.trimIndent(), + ) + + val entry = RepoMapIndexer().index(root).single() + assertEquals("Player.cs", entry.path) + assertTrue( + entry.symbols.containsAll(listOf("Player", "IDamageable", "State")), + "missing symbols in: ${entry.symbols}", + ) + assertFalse(entry.symbols.contains("TakeDamage"), "method/field bodies must not be matched") + } + @Test fun `extracts markdown headings as symbols`(@TempDir root: Path) { root.resolve("design.md").writeText( diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt index 3bdf5515..c5f198ab 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt @@ -1,18 +1,23 @@ package com.correx.apps.server.narration +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus 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.EventMetadata import com.correx.core.events.events.EventPayload import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.NewEvent import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ApprovalDecisionId import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.EventId import com.correx.core.events.types.SessionId @@ -24,10 +29,12 @@ import com.correx.core.router.ChatMode import com.correx.core.router.RouterFacade import com.correx.core.router.model.NarrationTrigger import com.correx.core.router.model.RouterResponse +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking @@ -231,4 +238,133 @@ class NarrationSubscriberTest { "instruction must include the source", ) } + + /** + * Router facade that blocks the lane worker on the first narration until [release] completes, + * letting the test enqueue further events (and resolve the pause) while the pause narration is + * still pending in the channel. + */ + private class GatingRouterFacade : RouterFacade { + val calls = CopyOnWriteArrayList() + val release = CompletableDeferred() + // Signals that the worker has pulled the first (warmup) trigger and is now blocked, + // so any narrations enqueued afterwards are guaranteed to still be queued. + val blocked = CompletableDeferred() + private var first = true + + override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse = + error("unused in narration tests") + + override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) { + if (first) { + first = false + blocked.complete(Unit) + release.await() + } + calls.add(trigger) + } + } + + private fun approvalRequested(requestId: String): ApprovalRequestedEvent = ApprovalRequestedEvent( + requestId = ApprovalRequestId(requestId), + tier = Tier.T2, + validationReportId = ValidationReportId("vr-1"), + riskSummaryId = null, + sessionId = sessionId, + stageId = StageId("write_script"), + projectId = null, + toolName = "file_write", + preview = "--- a/x.sh\n+++ b/x.sh", + ) + + private fun approvalResolved(requestId: String): ApprovalDecisionResolvedEvent = ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("dec-1"), + requestId = ApprovalRequestId(requestId), + outcome = ApprovalOutcome.APPROVED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = timestamp, + reason = null, + ) + + @Test + fun `resolved approval drops the still-queued pause narration`(): Unit = runBlocking { + val facade = GatingRouterFacade() + NarrationSubscriber(fakeStore, facade, scope).start() + + while (liveFlow.subscriptionCount.value == 0) yield() + + liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) + // Warmup narration the worker will block on, keeping later narrations queued. + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) + // Wait until the worker is parked on the warmup before enqueuing the pause, so the pause + // is provably still queued (not yet delivered) when its approval resolves. + withTimeout(2_000L) { facade.blocked.await() } + // Pause is enqueued, then its approval resolves before the worker can drain it. + liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L)) + liveFlow.emit(storedEvent(approvalResolved("req-1"), seq = 4L)) + // A current, unrelated narration arrives after resolution and must still surface. + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 5L)) + // Let the (single, FIFO) collector drain its buffer so the resolve is handled pre-release. + delay(100L) + + // Release the worker; it drains the warmup, skips the stale pause, narrates the second stage. + facade.release.complete(Unit) + + withTimeout(2_000L) { while (facade.calls.size < 2) yield() } + delay(100L) // allow a stray (stale) pause narration to arrive if the drop failed + + assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind }) + assertTrue(facade.calls.none { it.kind == "paused" }, "stale pause narration must be dropped") + } + + @Test + fun `unrelated approval resolution leaves the pending pause intact`(): Unit = runBlocking { + val facade = GatingRouterFacade() + NarrationSubscriber(fakeStore, facade, scope).start() + + while (liveFlow.subscriptionCount.value == 0) yield() + + liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) + withTimeout(2_000L) { facade.blocked.await() } + // Pause for req-1, but a *different* request (req-2) resolves: req-1's pause must survive. + liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L)) + liveFlow.emit(storedEvent(approvalResolved("req-2"), seq = 4L)) + delay(100L) + + facade.release.complete(Unit) + + withTimeout(2_000L) { while (facade.calls.size < 2) yield() } + + assertEquals(listOf("stage_completed", "paused"), facade.calls.map { it.kind }) + } + + @Test + fun `resume drops every queued pause narration for the session`(): Unit = runBlocking { + val facade = GatingRouterFacade() + NarrationSubscriber(fakeStore, facade, scope).start() + + while (liveFlow.subscriptionCount.value == 0) yield() + + liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) + withTimeout(2_000L) { facade.blocked.await() } + // A non-approval pause (session-scoped) plus an approval pause, both superseded by resume. + liveFlow.emit( + storedEvent(OrchestrationPausedEvent(sessionId, StageId("a"), "USER_REQUESTED"), seq = 3L), + ) + liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 4L)) + liveFlow.emit(storedEvent(OrchestrationResumedEvent(sessionId, StageId("a")), seq = 5L)) + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 6L)) + delay(100L) + + facade.release.complete(Unit) + + withTimeout(2_000L) { while (facade.calls.size < 2) yield() } + delay(100L) + + assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind }) + assertTrue(facade.calls.none { it.kind == "paused" }, "resume must drop all queued pauses") + } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 7aeb68fa..d342afa3 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -316,6 +316,32 @@ class ServerMessageSerializationTest { assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId) } + @Test + fun `PromoteIdea decodes from the client wire format`() { + val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.PromoteIdea","ideaId":"i1"}""" + val decoded = ProtocolSerializer.decodeClientMessage(wire) + assert(decoded is ClientMessage.PromoteIdea) { "expected PromoteIdea, got $decoded" } + assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId) + } + + @Test + fun `FileList encodes type file_list and paths`() { + val msg = ServerMessage.FileList(sessionId = SessionId("s1"), paths = listOf("README.md", "src/Main.kt")) + val jsonStr = ProtocolSerializer.encodeServerMessage(msg) + assert(jsonStr.contains("\"type\":\"file.list\"")) { "expected type=file.list" } + + val decoded = json.decodeFromString(jsonStr) + assertEquals(listOf("README.md", "src/Main.kt"), decoded.paths) + } + + @Test + fun `ListFiles decodes from the client wire format`() { + val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.ListFiles","sessionId":"s1"}""" + val decoded = ProtocolSerializer.decodeClientMessage(wire) + assert(decoded is ClientMessage.ListFiles) { "expected ListFiles, got $decoded" } + assertEquals("s1", (decoded as ClientMessage.ListFiles).sessionId.value) + } + @Test fun `ClarificationResponse decodes from the client wire format`() { val wire = """ diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt new file mode 100644 index 00000000..d0733a9a --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt @@ -0,0 +1,63 @@ +package com.correx.apps.server.research + +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.types.SessionId +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SourceListApprovalTest { + + private val sessionId = SessionId("s1") + + private fun grants(store: InMemoryEventStore, session: SessionId = sessionId): List = + store.read(session).map { it.payload }.filterIsInstance() + + @Test + fun `emits exactly one EgressHostsGrantedEvent with the extracted hosts for the session`() = runTest { + val store = InMemoryEventStore() + val granted = approveSourceList( + dispatcher = EventDispatcher(store), + sessionId = sessionId, + urls = listOf( + "https://example.com/a", + "https://example.com/b", + "https://docs.kotlinlang.org/spec", + ), + ) + + assertEquals(setOf("example.com", "docs.kotlinlang.org"), granted) + + val emitted = grants(store) + assertEquals(1, emitted.size) + val event = emitted.single() + assertEquals(sessionId, event.sessionId) + assertEquals(setOf("example.com", "docs.kotlinlang.org"), event.hosts) + assertEquals(BATCH_SOURCE_LIST_REASON, event.reason) + } + + @Test + fun `grant is recorded under the right session`() = runTest { + val store = InMemoryEventStore() + approveSourceList(EventDispatcher(store), sessionId, listOf("https://only-mine.com/x")) + + assertEquals(1, grants(store).size) + assertTrue(grants(store, SessionId("other")).isEmpty()) + } + + @Test + fun `no parseable host emits no event`() = runTest { + val store = InMemoryEventStore() + val granted = approveSourceList( + dispatcher = EventDispatcher(store), + sessionId = sessionId, + urls = listOf("/relative", "just text", ""), + ) + + assertTrue(granted.isEmpty()) + assertTrue(grants(store).isEmpty()) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt new file mode 100644 index 00000000..bd0e661d --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt @@ -0,0 +1,69 @@ +package com.correx.apps.server.research + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SourceListHostsTest { + + @Test + fun `distinct hosts are extracted from multiple urls`() { + val hosts = SourceListHosts.extract( + listOf( + "https://example.com/a", + "https://docs.rust-lang.org/book", + "http://api.github.com/repos", + ), + ) + assertEquals(setOf("example.com", "docs.rust-lang.org", "api.github.com"), hosts) + } + + @Test + fun `duplicate hosts collapse`() { + val hosts = SourceListHosts.extract( + listOf( + "https://example.com/one", + "https://example.com/two", + "http://example.com/three", + ), + ) + assertEquals(setOf("example.com"), hosts) + } + + @Test + fun `ports are stripped`() { + val hosts = SourceListHosts.extract(listOf("https://example.com:8443/path")) + assertEquals(setOf("example.com"), hosts) + } + + @Test + fun `host case is normalised to lower case`() { + val hosts = SourceListHosts.extract( + listOf("https://Example.COM/a", "https://EXAMPLE.com/b"), + ) + assertEquals(setOf("example.com"), hosts) + } + + @Test + fun `unparseable and relative entries are ignored`() { + val hosts = SourceListHosts.extract( + listOf( + "/local/path", + "not a url at all", + "ftp ://broken", + "", + "https://kept.com/x", + ), + ) + assertEquals(setOf("kept.com"), hosts) + } + + @Test + fun `dossier source lines yield their leading url host`() { + val hosts = SourceListHosts.extract( + listOf("https://example.com/x — explains Y; bears on sub-question 2"), + ) + assertEquals(setOf("example.com"), hosts) + assertTrue("example.com" in hosts) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt new file mode 100644 index 00000000..7f97789c --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -0,0 +1,482 @@ +package com.correx.apps.server.routes + +import com.correx.apps.server.ServerModule +import com.correx.apps.server.configureServer +import com.correx.apps.server.registry.ProviderRegistry +import com.correx.apps.server.registry.WorkflowRegistry +import com.correx.apps.server.registry.WorkflowSummary +import com.correx.apps.server.undo.SessionUndoService +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.context.builder.DefaultContextPackBuilder +import com.correx.core.context.compression.DefaultContextCompressor +import com.correx.core.events.EventDispatcher +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.inference.InferenceProjector +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.ProviderHealth +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.events.types.ProviderId +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.router.model.RouterConfig +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.InfrastructureModule +import com.correx.infrastructure.inference.commons.UnavailableProbe +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.tools.FileEditConfig +import com.correx.infrastructure.tools.FileReadConfig +import com.correx.infrastructure.tools.FileWriteConfig +import com.correx.infrastructure.tools.ShellConfig +import com.correx.infrastructure.tools.ToolConfig +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +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 TaskRoutesTest { + + private val testJson = Json { ignoreUnknownKeys = true } + + private suspend fun HttpResponse.json(): JsonObject = + testJson.parseToJsonElement(bodyAsText()) as JsonObject + + private suspend fun HttpClient.createTask(project: String = "demo"): JsonObject = + post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"$project","title":"JWT refresh","goal":"users stay authed"}""") + }.json() + + @Test + fun `POST creates a task and GET lists and fetches it`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + + val created = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody( + """{"project":"demo","title":"JWT refresh","goal":"stay authed", + "acceptanceCriteria":["rotates"],"affectedPaths":["auth/**"]}""", + ) + } + assertEquals(HttpStatusCode.Created, created.status) + val body = created.json() + assertEquals("demo-1", body["id"]!!.jsonPrimitive.content) + assertEquals("TODO", body["status"]!!.jsonPrimitive.content) + assertEquals("rotates", body["acceptanceCriteria"]!!.jsonArray.single().jsonPrimitive.content) + + val list = client.get("/tasks?project=demo") + assertEquals(HttpStatusCode.OK, list.status) + val rows = testJson.parseToJsonElement(list.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + + // No project → cross-project board lists it too. + val all = client.get("/tasks") + assertEquals(HttpStatusCode.OK, all.status) + val allRows = testJson.parseToJsonElement(all.bodyAsText()) as JsonArray + assertEquals(1, allRows.size) + assertEquals("demo-1", (allRows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + + val fetched = client.get("/tasks/demo-1") + assertEquals(HttpStatusCode.OK, fetched.status) + assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content) + } + } + + @Test + fun `GET tasks reports dependency-graph ready and blockedBy`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 (TODO, no deps) + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""") + } // demo-2 + // demo-2 DEPENDS_ON demo-1 (TASK kind inferred from the id). + client.post("/tasks/demo-2/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""") + } + + val rows = testJson.parseToJsonElement(client.get("/tasks?project=demo").bodyAsText()) as JsonArray + val byId = rows.associate { (it as JsonObject)["id"]!!.jsonPrimitive.content to it } + val one = byId.getValue("demo-1") as JsonObject + val two = byId.getValue("demo-2") as JsonObject + // default-valued fields are omitted from JSON (encodeDefaults=false), which the TUI reads + // back as zero values — so tolerate absence here too. + fun readyOf(o: JsonObject) = o["ready"]?.jsonPrimitive?.content == "true" + fun blockedByOf(o: JsonObject): List = + (o["blockedBy"] as? JsonArray)?.map { it.jsonPrimitive.content } ?: emptyList() + // demo-1: TODO with nothing in its way → ready, no blockers. + assertTrue(readyOf(one)) + assertTrue(blockedByOf(one).isEmpty()) + // demo-2: waits on the unfinished demo-1 → not ready, blockedBy = [demo-1]. + assertTrue(!readyOf(two)) + assertEquals(listOf("demo-1"), blockedByOf(two)) + } + } + + @Test + fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + val claimed = client.post("/tasks/demo-1/claim") { + contentType(ContentType.Application.Json) + setBody("""{"claimant":"claude-opus"}""") + } + assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content) + assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content) + + val reviewed = client.post("/tasks/demo-1/submit-for-review").json() + assertEquals("IN_REVIEW", reviewed["status"]!!.jsonPrimitive.content) + val completed = client.post("/tasks/demo-1/complete").json() + assertEquals("DONE", completed["status"]!!.jsonPrimitive.content) + } + } + + @Test + fun `PATCH edits fields and POST links and notes mutate the graph`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + val patched = client.patch("/tasks/demo-1") { + contentType(ContentType.Application.Json) + setBody("""{"title":"JWT refresh v2"}""") + } + assertEquals("JWT refresh v2", patched.json()["title"]!!.jsonPrimitive.content) + + // No explicit kind: an ADR id infers to DOC. + val linked = client.post("/tasks/demo-1/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"adr-7","type":"IMPLEMENTS"}""") + } + val link = linked.json()["links"]!!.jsonArray.single() as JsonObject + assertEquals("adr-7", link["targetId"]!!.jsonPrimitive.content) + assertEquals("IMPLEMENTS", link["type"]!!.jsonPrimitive.content) + assertEquals("DOC", link["targetKind"]!!.jsonPrimitive.content) + + val noted = client.post("/tasks/demo-1/notes") { + contentType(ContentType.Application.Json) + setBody("""{"body":"kickoff"}""") + } + val note = noted.json()["notes"]!!.jsonArray.single() as JsonObject + assertEquals("kickoff", note["body"]!!.jsonPrimitive.content) + assertEquals("HUMAN", note["author"]!!.jsonPrimitive.content) + + // Unlink via query params clears the edge. + val unlinked = client.delete("/tasks/demo-1/links?targetId=adr-7&type=IMPLEMENTS") + assertTrue(unlinked.json()["links"]!!.jsonArray.isEmpty()) + } + } + + @Test + fun `DELETE soft-deletes and the task disappears from reads`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + assertEquals(HttpStatusCode.NoContent, client.delete("/tasks/demo-1").status) + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-1").status) + assertEquals(HttpStatusCode.NotFound, client.delete("/tasks/demo-1").status) + } + } + + @Test + fun `GET tasks with q returns only ranked search hits`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1: "JWT refresh" / "users stay authed" + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""") + } + + val res = client.get("/tasks?q=jwt") + assertEquals(HttpStatusCode.OK, res.status) + val rows = testJson.parseToJsonElement(res.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + } + } + + @Test + fun `POST sync-git advances tasks from commit messages`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1, TODO + + val res = client.post("/tasks/sync-git") { + contentType(ContentType.Application.Json) + setBody("""{"commits":[{"sha":"abc1234567","message":"fixes demo-1"}]}""") + } + assertEquals(HttpStatusCode.OK, res.status) + val change = (res.json()["changes"] as JsonArray).single() as JsonObject + assertEquals("demo-1", change["key"]!!.jsonPrimitive.content) + assertEquals("TODO", change["from"]!!.jsonPrimitive.content) + assertEquals("DONE", change["to"]!!.jsonPrimitive.content) + + assertEquals("DONE", client.get("/tasks/demo-1").json()["status"]!!.jsonPrimitive.content) + } + } + + @Test + fun `GET tasks export renders the board as markdown`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 + + val res = client.get("/tasks/export") + assertEquals(HttpStatusCode.OK, res.status) + val md = res.bodyAsText() + assertTrue(md.startsWith("# Tasks")) + assertTrue(md.contains("**demo-1**")) + } + } + + @Test + fun `context bundle is served for a known task`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + val bundle = client.get("/tasks/demo-1/context") + assertEquals(HttpStatusCode.OK, bundle.status) + assertEquals("demo-1", bundle.json()["id"]!!.jsonPrimitive.content) + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/context").status) + } + } + + @Test + fun `ready returns unblocked TODO tasks and blockers explains a wait`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1, TODO + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"second","goal":"g"}""") + } + // demo-2 depends on demo-1 (id infers to a TASK target). + client.post("/tasks/demo-2/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""") + } + + val ready = client.get("/tasks?ready=true") + assertEquals(HttpStatusCode.OK, ready.status) + val rows = testJson.parseToJsonElement(ready.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + + val blockers = client.get("/tasks/demo-2/blockers") + assertEquals(HttpStatusCode.OK, blockers.status) + val blk = testJson.parseToJsonElement(blockers.bodyAsText()) as JsonArray + assertEquals("demo-1", (blk.single() as JsonObject)["id"]!!.jsonPrimitive.content) + } + } + + @Test + fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 + client.post("/tasks/demo-1/claim") { + contentType(ContentType.Application.Json) + setBody("""{"claimant":"claude-opus"}""") + } + + val res = client.get("/tasks/demo-1/history") + assertEquals(HttpStatusCode.OK, res.status) + val timeline = res.bodyAsText() + assertTrue(timeline.contains("created demo-1")) + assertTrue(timeline.contains("claimed by claude-opus")) + + // An id that never existed has no history → 404. + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/history").status) + } + } + + @Test + fun `POST rejects a duplicate title with 409 and force overrides`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 "JWT refresh" + + val dup = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"jwt refresh","goal":"g"}""") + } + assertEquals(HttpStatusCode.Conflict, dup.status) + val rows = testJson.parseToJsonElement(dup.bodyAsText()) as JsonArray + assertEquals("demo-1", (rows.single() as JsonObject)["id"]!!.jsonPrimitive.content) + + val forced = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"jwt refresh","goal":"g","force":true}""") + } + assertEquals(HttpStatusCode.Created, forced.status) + } + } + + @Test + fun `bad requests are rejected`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + assertEquals(HttpStatusCode.BadRequest, client.get("/tasks?status=NONSENSE").status) + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status) + val badLink = client.post("/tasks/demo-1/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"x","type":"NONSENSE"}""") + } + assertEquals(HttpStatusCode.BadRequest, badLink.status) + } + } + + // --- harness ---------------------------------------------------------------------------- + + private fun buildModule(tempDir: Path): ServerModule { + val eventStore: EventStore = InMemoryEventStore() + val artifactStore = object : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop") + override suspend fun get(id: ArtifactId): ByteArray? = null + override suspend fun flushBefore(commit: suspend () -> Unit) = commit() + } + val provider = InfrastructureModule.createLlamaCppProvider( + modelId = "test-model", + modelPath = "/dev/null", + baseUrl = "http://127.0.0.1:1", + ) + val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider)) + val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter( + providerRegistry, + com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(), + ) + val toolConfig = ToolConfig( + shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir), + fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)), + fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + ) + val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig) + val eventDispatcher = EventDispatcher(eventStore) + val toolExecutor = InfrastructureModule.createToolExecutor( + registry = toolRegistry, + eventDispatcher = eventDispatcher, + workDir = tempDir, + artifactStore = null, + ) + val engines = OrchestratorEngines( + transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) }, + contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()), + inferenceRouter = inferenceRouter, + validationPipeline = ValidationPipeline(validators = emptyList()), + approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + toolRegistry = toolRegistry, + toolExecutor = toolExecutor, + ) + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())), + orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ), + sessionRepository = DefaultSessionRepository( + DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), + ), + artifactRepository = InfrastructureModule.createArtifactRepository(eventStore), + approvalRepository = InfrastructureModule.createApprovalRepository(eventStore), + ) + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = DefaultRetryCoordinator(eventStore), + artifactStore = artifactStore, + tokenizer = provider.tokenizer, + decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore), + ) + val routerFacade = InfrastructureModule.createRouterFacade( + eventStore = eventStore, + inferenceRouter = inferenceRouter, + config = RouterConfig(), + tokenizer = provider.tokenizer, + ) + val noopProviderRegistry = object : ProviderRegistry { + override fun listAll() = emptyList() + override suspend fun healthCheckAll() = emptyMap() + } + val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = emptyList() + override fun find(workflowId: String): WorkflowGraph? = null + } + val sessionUndoService = SessionUndoService( + eventStore = eventStore, + artifactStore = artifactStore, + bootRoots = setOf(tempDir), + ) + return ServerModule( + orchestrator = orchestrator, + eventStore = eventStore, + artifactStore = artifactStore, + sessionRepository = repositories.sessionRepository, + workflowRegistry = noopWorkflowRegistry, + providerRegistry = noopProviderRegistry, + defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir), + routerFacade = routerFacade, + orchestrationRepository = repositories.orchestrationRepository, + approvalRepository = repositories.approvalRepository, + toolRegistry = toolRegistry, + sessionUndoService = sessionUndoService, + resourceProbe = UnavailableProbe, + ) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt new file mode 100644 index 00000000..fc4a3728 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt @@ -0,0 +1,72 @@ +package com.correx.apps.server.tasks + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +class FileTaskDocumentResolverTest { + + @TempDir + lateinit var repo: Path + + private fun writeAdr() { + val dir = repo.resolve("docs/decisions").also { it.createDirectories() } + dir.resolve("adr-0007-redis.md").writeText( + """ + --- + name: "ADR 7 Redis Sessions" + description: "use redis for session storage" + --- + + # ADR 7: use redis for session storage + + **status:** accepted + + ## decision + Redis backs session storage. + """.trimIndent(), + ) + } + + @Test + fun `resolves an ADR id padding-agnostically`() = runBlocking { + writeAdr() + val resolver = FileTaskDocumentResolver(repo) + + val byShort = resolver.resolve("adr-7") + val byPadded = resolver.resolve("ADR-0007") + + assertEquals("ADR 7 Redis Sessions", byShort?.title) + assertEquals("use redis for session storage", byShort?.excerpt) + assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path) + assertEquals(byShort, byPadded) + } + + @Test + fun `unknown ADR resolves to null`() = runBlocking { + writeAdr() + assertNull(FileTaskDocumentResolver(repo).resolve("adr-999")) + } + + @Test + fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking { + repo.resolve("docs/notes").createDirectories() + repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n") + + val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md") + + assertEquals("Plan", doc?.title) + assertEquals("The plan body.", doc?.excerpt) + assertEquals("docs/notes/plan.md", doc?.path) + } + + @Test + fun `non-document target resolves to null`() = runBlocking { + assertNull(FileTaskDocumentResolver(repo).resolve("ext-123")) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt new file mode 100644 index 00000000..7efb3708 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt @@ -0,0 +1,30 @@ +package com.correx.apps.server.tasks + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GitCommandCommitReaderTest { + + private val us = "" + private val rs = "" + + @Test + fun `parseGitLog splits records and sha from message`() { + val raw = "abc123${us}fix auth-1: rotate$rs\ndef456${us}wip on auth-2$rs\n" + + val commits = parseGitLog(raw) + + assertEquals(2, commits.size) + assertEquals("abc123", commits[0].sha) + assertEquals("fix auth-1: rotate", commits[0].message) + assertEquals("def456", commits[1].sha) + assertEquals("wip on auth-2", commits[1].message) + } + + @Test + fun `parseGitLog tolerates blank output and malformed records`() { + assertTrue(parseGitLog("").isEmpty()) + assertTrue(parseGitLog("no-separator-here$rs").isEmpty()) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt new file mode 100644 index 00000000..5bbaaf71 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt @@ -0,0 +1,83 @@ +package com.correx.apps.server.tasks + +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.InitialIntentEvent +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.util.UUID + +class TaskLinkResolverTest { + + private val store: EventStore = InMemoryEventStore() + + private suspend fun append(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload) { + store.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = payload, + ), + ) + } + + @Test + fun `session resolver projects status and intent and is null for an unknown id`() = runBlocking { + val sessionId = SessionId("sess-9") + append(sessionId, InitialIntentEvent(sessionId, "build auth")) + append(sessionId, WorkflowStartedEvent(sessionId, "role_pipeline", StageId("architect"))) + append(sessionId, WorkflowCompletedEvent(sessionId, StageId("review"), totalStages = 3)) + + val resolver = SessionSummaryTaskSessionResolver(store) + val resolved = resolver.resolve("sess-9")!! + + assertEquals("COMPLETED", resolved.status) + assertEquals("build auth", resolved.intent) + assertEquals("role_pipeline", resolved.workflowId) + assertNull(resolver.resolve("sess-unknown")) + } + + @Test + fun `artifact resolver reads stage, session and content excerpt and is null for an unknown id`() = runBlocking { + val sessionId = SessionId("sess-9") + val artifactId = ArtifactId("art-1") + val contentHash = ArtifactId("hash-1") + val artifactStore = FakeArtifactStore(mapOf(contentHash to "approach: use redis".toByteArray())) + append(sessionId, ArtifactContentStoredEvent(artifactId, contentHash, sessionId, StageId("architect"))) + append(sessionId, ArtifactCreatedEvent(artifactId, sessionId, StageId("architect"), schemaVersion = 1)) + + val resolver = EventStoreTaskArtifactResolver(store, artifactStore) + val resolved = resolver.resolve("art-1")!! + + assertEquals("architect", resolved.stage) + assertEquals("sess-9", resolved.sessionId) + assertEquals("approach: use redis", resolved.excerpt) + assertNull(resolver.resolve("art-unknown")) + } + + private class FakeArtifactStore(private val blobs: Map) : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = error("unused") + override suspend fun get(id: ArtifactId): ByteArray? = blobs[id] + override suspend fun flushBefore(commit: suspend () -> Unit) = commit() + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt new file mode 100644 index 00000000..62527e61 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt @@ -0,0 +1,55 @@ +package com.correx.apps.server.workspace + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WorkspaceFilesTest { + + @Test + fun `lists all file types by relative path, sorted`(@TempDir root: Path) { + root.resolve("src").createDirectories() + root.resolve("src/Main.kt").writeText("fun main() {}") + root.resolve("config.toml").writeText("a = 1") // not a source ext — must still appear + root.resolve("README.md").writeText("# hi") + + val paths = WorkspaceFiles.list(root) + + assertEquals(listOf("README.md", "config.toml", "src/Main.kt"), paths) + } + + @Test + fun `skips ignored directories`(@TempDir root: Path) { + root.resolve(".git").createDirectories() + root.resolve(".git/HEAD").writeText("ref") + root.resolve("node_modules/pkg").createDirectories() + root.resolve("node_modules/pkg/index.js").writeText("x") + root.resolve("keep.txt").writeText("y") + + val paths = WorkspaceFiles.list(root) + + assertEquals(listOf("keep.txt"), paths) + assertFalse(paths.any { it.startsWith(".git") || it.startsWith("node_modules") }) + } + + @Test + fun `caps the result count`(@TempDir root: Path) { + repeat(10) { root.resolve("f$it.txt").writeText("x") } + val paths = WorkspaceFiles.list(root, limit = 3) + assertEquals(3, paths.size) + assertTrue(paths.all { it.endsWith(".txt") }) + } + + @Test + fun `missing root yields empty`(@TempDir root: Path) { + val missing = root.resolve("nope") + assertTrue(Files.notExists(missing)) + assertEquals(emptyList(), WorkspaceFiles.list(missing)) + } +} diff --git a/apps/tui-go/.gitignore b/apps/tui-go/.gitignore index fefb4607..9ec7c9a2 100644 --- a/apps/tui-go/.gitignore +++ b/apps/tui-go/.gitignore @@ -1 +1,2 @@ /tui-go +/preview diff --git a/apps/tui-go/cmd/preview/main.go b/apps/tui-go/cmd/preview/main.go index 1dee08c9..0e569cb3 100644 --- a/apps/tui-go/cmd/preview/main.go +++ b/apps/tui-go/cmd/preview/main.go @@ -7,14 +7,13 @@ import ( "os" "strconv" - "github.com/charmbracelet/lipgloss" "github.com/correx/tui-go/internal/app" - "github.com/muesli/termenv" ) func main() { - lipgloss.SetColorProfile(termenv.TrueColor) - + // lipgloss v2 renders full truecolor ANSI by default (color downsampling now + // lives in the terminal writer, not a global profile), which is exactly what a + // static screenshot frame wants — so no color-profile setup is needed. kind := "idle" if len(os.Args) > 1 { kind = os.Args[1] diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index d1dee92b..45c4557d 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -1,32 +1,45 @@ module github.com/correx/tui-go -go 1.26 +go 1.25.0 require ( - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/ansi v0.11.6 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/gorilla/websocket v1.5.3 - github.com/muesli/termenv v0.16.0 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect ) diff --git a/apps/tui-go/go.sum b/apps/tui-go/go.sum index 19aef85f..589ecead 100644 --- a/apps/tui-go/go.sum +++ b/apps/tui-go/go.sum @@ -1,50 +1,88 @@ +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= diff --git a/apps/tui-go/internal/app/adaptive_layout_test.go b/apps/tui-go/internal/app/adaptive_layout_test.go index c1a27f9a..101f9148 100644 --- a/apps/tui-go/internal/app/adaptive_layout_test.go +++ b/apps/tui-go/internal/app/adaptive_layout_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // inSessionModel builds an in-session model with deliberately long fields (model name, @@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) { func TestViewNeverOverflowsWidth(t *testing.T) { for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} { m := inSessionModel(w, 30) - assertNoOverflow(t, m.View(), w) + assertNoOverflow(t, m.render(), w) } } @@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) { m.overlay = OverlayStats m.statsFor = m.selectedID m.stats = sampleStats() - assertNoOverflow(t, m.View(), w) + assertNoOverflow(t, m.render(), w) } } diff --git a/apps/tui-go/internal/app/approval_test.go b/apps/tui-go/internal/app/approval_test.go new file mode 100644 index 00000000..e0c78585 --- /dev/null +++ b/apps/tui-go/internal/app/approval_test.go @@ -0,0 +1,256 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key +// approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable +// pending-approval queue (never modal-stacked). Deterministic: an unconnected ws +// client buffers Sent frames, Drain reads them back. + +// approvalModel builds an entered session and queues each given approval through the +// production applyServer path, so the queue/Pending state matches real event flow. +func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) { + client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back + m := NewModel(client) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.selectedID = "s1" + m.sessionEntered = true + m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}} + for _, a := range approvals { + m.applyServer(a) + } + return m, client +} + +// apprReq builds an approval.required frame for the given request id and tier. +func apprReq(reqID, tier string) protocol.ServerMessage { + return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) { + m.RequestID, m.Tier = reqID, tier + m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"} + }) +} + +// applyKey drives a key through the production handleKey entry and returns the model. +func applyKey(m Model, k keyMsg) Model { + updated, _ := m.handleKey(k) + return updated.(Model) +} + +func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} } + +// decodeApproval pulls the single ApprovalResponse frame off the client and returns +// its request id + decision. Fails if there isn't exactly one decision frame. +func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) { + t.Helper() + var resp map[string]any + n := 0 + for _, f := range client.Drain() { + var raw map[string]any + if err := json.Unmarshal(f, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { + resp = raw + n++ + } + } + if n != 1 { + t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n) + } + rid, _ := resp["requestId"].(string) + dec, _ := resp["decision"].(string) + return rid, dec +} + +// noApprovalSent asserts the client buffered no ApprovalResponse frame. +func noApprovalSent(t *testing.T, client *ws.Client) { + t.Helper() + for _, f := range client.Drain() { + var raw map[string]any + _ = json.Unmarshal(f, &raw) + if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { + t.Fatalf("did not expect an ApprovalResponse frame yet") + } + } +} + +func TestApprovalLowTierApprovesImmediately(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T1")) + if m.displayState() != StateApproval { + t.Fatalf("want StateApproval, got %v", m.displayState()) + } + m = applyKey(m, runeKey('y')) + rid, dec := decodeApproval(t, client) + if rid != "req-1" || dec != "APPROVE" { + t.Fatalf("want approve req-1, got %s/%s", rid, dec) + } + if s := m.session("s1"); s == nil || s.Pending != nil { + t.Fatalf("pending gate should be cleared after a low-tier approve") + } + if m.approvalArmed { + t.Fatalf("low-tier approve must not leave an armed confirm") + } +} + +func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T3")) + // First `y` arms; nothing is sent yet. + m = applyKey(m, runeKey('y')) + if !m.approvalArmed { + t.Fatalf("T3 approve should arm a confirm, not send") + } + noApprovalSent(t, client) + if s := m.session("s1"); s == nil || s.Pending == nil { + t.Fatalf("gate must remain pending while armed") + } + // Second `y` confirms and sends. + m = applyKey(m, runeKey('y')) + rid, dec := decodeApproval(t, client) + if rid != "req-1" || dec != "APPROVE" { + t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec) + } + if m.approvalArmed { + t.Fatalf("arm should clear after the confirming keypress") + } +} + +func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T3")) + m = applyKey(m, runeKey('y')) // arm + if !m.approvalArmed { + t.Fatalf("expected armed after first y") + } + // A non-confirm key (here `n` reject) cancels the arm. The arm itself must be + // gone, and no spurious APPROVE may slip through. + m = applyKey(m, runeKey('n')) + if m.approvalArmed { + t.Fatalf("arm should be cancelled by a non-confirm key") + } + rid, dec := decodeApproval(t, client) + if dec != "REJECT" || rid != "req-1" { + t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec) + } +} + +func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) { + // Two T3 gates: arming then pressing ↓ must disarm and only move the cursor — + // no decision may be emitted. + m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3")) + m = applyKey(m, runeKey('y')) // arm req-1 + m = applyKey(m, keyMsg{Type: keyDown}) + if m.approvalArmed { + t.Fatalf("nav key should disarm") + } + noApprovalSent(t, client) + if s := m.session("s1"); s == nil || s.PendingIdx != 1 { + t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx) + } +} + +func TestApprovalQueueNavigatesAndCounts(t *testing.T) { + m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1")) + s := m.session("s1") + if s == nil || len(s.PendingQueue) != 3 { + t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue)) + } + // The band shows a "1/3" count and the current request, never stacked modals. + out := stripANSI(m.renderApprovalBand(m.approvalBandHeight())) + if !strings.Contains(out, "1/3") { + t.Fatalf("band should show queue count 1/3:\n%s", out) + } + // ↓ advances the selection; Pending follows the cursor. + m = applyKey(m, keyMsg{Type: keyDown}) + s = m.session("s1") + if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" { + t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID) + } + out = stripANSI(m.renderApprovalBand(m.approvalBandHeight())) + if !strings.Contains(out, "2/3") { + t.Fatalf("band should show 2/3 after ↓:\n%s", out) + } + // ↑ wraps back. + m = applyKey(m, keyMsg{Type: keyUp}) + if m.session("s1").PendingIdx != 0 { + t.Fatalf("↑ should return to index 0") + } +} + +func TestApprovalResolveFrontAdvancesQueue(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1")) + // Approve the front gate (low tier → immediate). + m = applyKey(m, runeKey('y')) + if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" { + t.Fatalf("want approve req-1, got %s/%s", rid, dec) + } + s := m.session("s1") + if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" { + t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue) + } + // A server-side resolve of the remaining gate empties the queue and exits approval. + m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) { + mm.RequestID, mm.Outcome = "req-2", "APPROVED" + })) + if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 { + t.Fatalf("server resolve should clear the last gate") + } + if m.displayState() != StateInSession { + t.Fatalf("empty queue should leave approval state, got %v", m.displayState()) + } +} + +func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) { + // A resolve for the *back* gate must drop only that one and keep the front shown. + m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1")) + m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) { + mm.RequestID, mm.Outcome = "req-2", "APPROVED" + })) + s := m.session("s1") + if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" { + t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue) + } +} + +func TestApprovalRejectAndSteerRoute(t *testing.T) { + // Reject is single-key for a low tier. + m, client := approvalModel(apprReq("req-1", "T1")) + m = applyKey(m, runeKey('n')) + if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" { + t.Fatalf("want reject req-1, got %s/%s", rid, dec) + } + + // Steer enters the existing note-input path (no decision sent yet). + m2, client2 := approvalModel(apprReq("req-2", "T1")) + m2 = applyKey(m2, runeKey('e')) + if !m2.steering || m2.editMode != ModeInsert { + t.Fatalf("steer key should enter the steering-note input path") + } + noApprovalSent(t, client2) + // Typing a note then Enter approves with the note riding along. + for _, r := range "use sudo" { + m2 = applyKey(m2, runeKey(r)) + } + m2 = applyKey(m2, keyMsg{Type: keyEnter}) + frames := client2.Drain() + var note string + for _, f := range frames { + var raw map[string]any + _ = json.Unmarshal(f, &raw) + if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { + note, _ = raw["steeringNote"].(string) + if raw["decision"] != "APPROVE" { + t.Fatalf("steer+enter should approve, got %v", raw["decision"]) + } + } + } + if note != "use sudo" { + t.Fatalf("steering note should ride along with the decision, got %q", note) + } +} diff --git a/apps/tui-go/internal/app/clarcard.go b/apps/tui-go/internal/app/clarcard.go index 352f12b2..a474d778 100644 --- a/apps/tui-go/internal/app/clarcard.go +++ b/apps/tui-go/internal/app/clarcard.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string { // --- key handling --- -func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Clar == nil { return m, nil @@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleClarTyping(k), nil } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.clarDismissed = true - case tea.KeyUp: + case keyUp: m.clarFocusMove(-1, qs) - case tea.KeyDown: + case keyDown: m.clarFocusMove(1, qs) - case tea.KeyLeft: + case keyLeft: m.clarCursorMove(-1, qs) - case tea.KeyRight: + case keyRight: m.clarCursorMove(1, qs) - case tea.KeySpace: + case keySpace: m.clarSelect(qs) - case tea.KeyEnter: + case keyEnter: return m.clarSubmit() - case tea.KeyRunes: + case keyRunes: switch string(k.Runes) { case "k": m.clarFocusMove(-1, qs) @@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model { +func (m Model) handleClarTyping(k keyMsg) tea.Model { if m.clarFocus >= len(m.clarText) { return m } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.clarTyping = false - case tea.KeyEnter: + case keyEnter: m.clarTyping = false // committing custom text supersedes any chosen option for this question if m.clarFocus < len(m.clarChosen) { m.clarChosen[m.clarFocus] = map[int]bool{} } - case tea.KeyBackspace: + case keyBackspace: if t := m.clarText[m.clarFocus]; len(t) > 0 { m.clarText[m.clarFocus] = t[:len(t)-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.clarText[m.clarFocus] += string(k.Runes) } return m diff --git a/apps/tui-go/internal/app/clarcard_test.go b/apps/tui-go/internal/app/clarcard_test.go index 2a50e8f3..1b8e031c 100644 --- a/apps/tui-go/internal/app/clarcard_test.go +++ b/apps/tui-go/internal/app/clarcard_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) { func TestClarificationSelectAndSubmit(t *testing.T) { m := clarModel() // move the option cursor to "Vue" (index 1) and select it - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight}) - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) + m = applyClarKey(m, keyMsg{Type: keyRight}) + m = applyClarKey(m, keyMsg{Type: keySpace}) if !m.clarChosenAt(0, 1) { t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen) } // submit - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Clar != nil { t.Fatalf("expected clarification cleared after submit") } @@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { }, }) // answer only the first question, then attempt submit - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keySpace}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Clar == nil { t.Fatalf("expected clarification to remain with partial answers") } @@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { func TestClarificationCustomTextAnswer(t *testing.T) { m := clarModel() - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.clarTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "Solid" { - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if m.clarTyping { t.Fatalf("expected typing committed") } @@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) { } } -func applyClarKey(m Model, k tea.KeyMsg) Model { +func applyClarKey(m Model, k keyMsg) Model { updated, _ := m.handleClarificationKey(k) return updated.(Model) } diff --git a/apps/tui-go/internal/app/cmdcard.go b/apps/tui-go/internal/app/cmdcard.go index 5ad86f62..7c2b8c6e 100644 --- a/apps/tui-go/internal/app/cmdcard.go +++ b/apps/tui-go/internal/app/cmdcard.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval diff --git a/apps/tui-go/internal/app/config_overlay.go b/apps/tui-go/internal/app/config_overlay.go index a33326e8..490ca633 100644 --- a/apps/tui-go/internal/app/config_overlay.go +++ b/apps/tui-go/internal/app/config_overlay.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -23,40 +23,40 @@ func (m *Model) openConfig() { // handleConfigKey owns every key while the config overlay is open. In edit mode it captures the // value being typed; otherwise it navigates fields, stages edits, and saves. -func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) { if m.configEditing { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.configEditing = false m.configEditBuf = "" - case tea.KeyEnter: + case keyEnter: if m.configIndex >= 0 && m.configIndex < len(m.configFields) { m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf } m.configEditing = false m.configEditBuf = "" - case tea.KeyBackspace: + case keyBackspace: if n := len(m.configEditBuf); n > 0 { m.configEditBuf = m.configEditBuf[:n-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.configEditBuf += string(k.Runes) } return m, nil } switch { - case k.Type == tea.KeyEsc || runeIs(k, "g"): + case k.Type == keyEsc || runeIs(k, "g"): m.overlay = OverlayNone - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.configIndex > 0 { m.configIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.configIndex < len(m.configFields)-1 { m.configIndex++ } - case k.Type == tea.KeyEnter || k.Type == tea.KeySpace: + case k.Type == keyEnter || k.Type == keySpace: m = m.actOnConfigField() case runeIs(k, "s"): if len(m.configStaged) > 0 { diff --git a/apps/tui-go/internal/app/config_overlay_test.go b/apps/tui-go/internal/app/config_overlay_test.go index 4d0ccd4e..4ff007f5 100644 --- a/apps/tui-go/internal/app/config_overlay_test.go +++ b/apps/tui-go/internal/app/config_overlay_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" ) @@ -23,7 +22,7 @@ func configModel() Model { func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { m := configModel() - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configStaged["project.enabled"] != "true" { t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"]) @@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { func TestConfigEnumCycles(t *testing.T) { m := configModel() m.configIndex = 1 // tui.theme - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configStaged["tui.theme"] != "light" { t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"]) @@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) { m := configModel() m.configIndex = 2 // router.generation.max_tokens // Enter edit mode. - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if !m.configEditing { t.Fatalf("expected edit mode after enter on INT field") @@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) { // Clear the seeded buffer and type a new value. m.configEditBuf = "" for _, r := range "1024" { - updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}}) m = updated.(Model) } - updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configEditing { t.Fatalf("expected edit mode to end after commit") diff --git a/apps/tui-go/internal/app/cursor_test.go b/apps/tui-go/internal/app/cursor_test.go new file mode 100644 index 00000000..a32c4232 --- /dev/null +++ b/apps/tui-go/internal/app/cursor_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "strings" + "testing" +) + +// splitCursor must report the caret's display column/row from a composited frame +// (ANSI ignored) and swap the marker for the replacement glyph. +func TestSplitCursorCoords(t *testing.T) { + // marker sits after 3 visible cells ("a", "b", "c") on the second line. + raw := "first line\n" + "ab\x1b[38;2;1;2;3mc\x1b[m" + cursorMarker + "d" + clean, cur := splitCursor(raw, " ") + if cur == nil { + t.Fatal("expected a cursor when the marker is present") + } + if cur.X != 3 || cur.Y != 1 { + t.Fatalf("cursor at (%d,%d), want (3,1)", cur.X, cur.Y) + } + if strings.Contains(clean, cursorMarker) { + t.Error("marker must be stripped from the returned frame") + } + if strings.Count(clean, " ") == 0 { + t.Error("marker should have been replaced by the repl glyph") + } +} + +// No marker (e.g. normal mode) → no cursor, frame unchanged. +func TestSplitCursorAbsent(t *testing.T) { + raw := "no caret here" + clean, cur := splitCursor(raw, "▏") + if cur != nil { + t.Error("no marker must yield a nil cursor (hidden)") + } + if clean != raw { + t.Error("frame must be unchanged when no marker is present") + } +} + +// The live View hides the cursor in normal mode and shows a real one in insert +// mode; the marker must never leak into the rendered content either way. +func TestViewCursorByMode(t *testing.T) { + m := inSessionModel(120, 40) + + m.editMode = ModeNormal + v := m.View() + if v.Cursor != nil { + t.Error("normal mode must hide the cursor (nil)") + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the raw cursor marker") + } + + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "hello" + m.inputCursor = len(m.inputBuffer) + v = m.View() + if v.Cursor == nil { + t.Fatal("insert mode must show a real cursor") + } + if v.Cursor.Y < 0 || v.Cursor.Y >= m.height || v.Cursor.X < 0 { + t.Errorf("cursor (%d,%d) out of frame bounds", v.Cursor.X, v.Cursor.Y) + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the raw cursor marker") + } + + // The static render keeps a visible glyph and also strips the marker. + if strings.Contains(m.render(), cursorMarker) { + t.Error("render() must strip the marker") + } + + // An open overlay keeps editMode==Insert (so closing resumes typing) but the + // composer must not draw a live cursor behind the modal. + m.overlay = OverlayPalette + if v = m.View(); v.Cursor != nil { + t.Error("an open overlay must suppress the composer cursor") + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the marker with an overlay open") + } +} diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 0b355336..b8c5fdaf 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -22,6 +22,43 @@ func PreviewFrame(kind string, w, h int) string { m.bgUpdates = 3 m.selectedID = "04a546aa" + case "changes": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.rightPanel = 1 + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "write_script" + } + m.routerMessages["04a546aa"] = []RouterEntry{ + {Role: "user", Content: "add a healthcheck script and document it"}, + {Role: "router", Content: "I'll write the script.", Metrics: &TurnMetrics{LatencyMs: 1200, TotalTokens: 340}}, + {Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf localhost:8080/health\n+echo ok\n+exit 0\n"}, + {Role: "router", Content: "Now the docs.", Metrics: &TurnMetrics{LatencyMs: 820, TotalTokens: 150}}, + {Role: "tool", Content: "--- a/README.md\n+++ b/README.md\n@@ -1,1 +1,2 @@\n existing line\n+## Health\n"}, + } + + case "compose": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "fix the healthcheck script\nso it retries three times\nbefore it fails" + m.inputCursor = len(m.inputBuffer) + + case "resume": + m.connected = true + m.currentModel = "llama-cpp:default" + m.overlay = OverlaySessions + m.sessionList = []SessionSummary{ + {SessionID: "04a546aa8b2c", Status: "ACTIVE", WorkflowID: "healthcheck", StageCount: 3, LastActivityAt: "2026-06-22T14:30:05Z"}, + {SessionID: "0d7097bb1f3e", Status: "COMPLETED", WorkflowID: "healthcheck", StageCount: 5, LastActivityAt: "2026-06-21T09:15:00Z"}, + {SessionID: "1dae17cc77aa", Status: "FAILED", WorkflowID: "chat", LastActivityAt: "2025-12-30T18:02:00Z"}, + } + case "workflows": m.connected = true m.currentModel = "llama-cpp:default" @@ -78,14 +115,14 @@ func PreviewFrame(kind string, w, h int) string { s.CurrentStage = "write_script" s.WorkspaceRoot = "/home/kami/Programs/correx" s.Events = sampleEvents() - s.Pending = &Approval{ + s.enqueueApproval(&Approval{ RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", ToolName: "file_write", Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n", Rationale: []string{ "[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh", }, - } + }) } case "approval-steer": @@ -97,11 +134,11 @@ func PreviewFrame(kind string, w, h int) string { m.steerBuffer = "also print the current distro and kernel version" if s := m.session("04a546aa"); s != nil { s.CurrentStage = "write_script" - s.Pending = &Approval{ + s.enqueueApproval(&Approval{ RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", ToolName: "file_write", Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,2 @@\n+#!/usr/bin/env bash\n+echo ok\n", - } + }) } case "approval-shell": @@ -114,14 +151,14 @@ func PreviewFrame(kind string, w, h int) string { s.CurrentStage = "execute_script" s.Events = sampleEvents() s.WorkspaceRoot = "/home/kami/Programs/correx" - s.Pending = &Approval{ + s.enqueueApproval(&Approval{ RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH", ToolName: "shell", Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`, Rationale: []string{ "[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'", }, - } + }) } case "diff": @@ -160,8 +197,120 @@ func PreviewFrame(kind string, w, h int) string { m.selectedID = "04a546aa" m.overlay = OverlayHealth m.health = sampleHealth() + + case "grant-scope": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.grantFor = &Approval{RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", ToolName: "file_write"} + m.grantScopeIndex = 1 + m.overlay = OverlayGrantScope + + case "actions": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.routerConnected = true + m.routerMessages["04a546aa"] = []RouterEntry{ + {Role: "user", Content: "run the healthcheck and write the script"}, + {Role: "router", Content: "I'll create the script, then run it."}, + {Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+4 −0)"}, + {Role: "action", Icon: "⌘", Content: "approved file_write"}, + {Role: "action", Icon: "✓", Content: "shell · exit 0"}, + {Role: "action", Icon: "⊞", Content: "granted file_write · global"}, + {Role: "router", Content: "Done — script written and the healthcheck passes."}, + } + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "execute_script" + s.LastEventAt = nowMillis() - 3000 + } + + case "help": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.overlay = OverlayHelp + + case "events-filter": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + if s := m.session("04a546aa"); s != nil { + s.Events = sampleEvents() + } + m.overlay = OverlayEventInspector + m.eventFilter = "tool" + m.eventFilterTyping = true + + case "grants": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.grants = []protocol.GrantDto{ + {GrantID: "g-1", Scope: "GLOBAL", ToolName: "read_file", Tiers: []string{"T1", "T2"}}, + {GrantID: "g-2", Scope: "PROJECT", ToolName: "file_write", Tiers: []string{"T3"}, ProjectID: "/home/kami/Programs/correx"}, + {GrantID: "g-3", Scope: "GLOBAL", ToolName: "shell", Tiers: []string{"T3", "T4"}}, + } + m.grantIndex = 1 + m.overlay = OverlayGrants + + case "tasks", "task-detail": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.overlay = OverlayTasks + m.taskList = sampleTaskBoard() + if kind == "task-detail" { + m.taskDetail = true + m.taskListIndex = 2 // a blocked child, to show the "waiting on" line + } + } + return m.render() +} + +// sampleTaskBoard is a decomposed work graph: an epic waiting on its three +// children, one of which (webui-2) is ready now, plus an in-flight and a done +// task — exercising every readiness glyph and status color. +func sampleTaskBoard() []TaskSummary { + return []TaskSummary{ + { + ID: "webui-1", Key: "webui-1", Status: "TODO", Title: "Frontend web UI for correx", + Goal: "operator can drive sessions from a browser", + BlockedBy: []string{"webui-2", "webui-3", "webui-4"}, + Links: []TaskLinkWire{{TargetID: "webui-2", Type: "DEPENDS_ON", TargetKind: "TASK"}}, + }, + { + ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold the Vite + React app", + Goal: "buildable shell with routing", Ready: true, + AcceptanceCriteria: []string{"npm run build passes", "lands under apps/web"}, + AffectedPaths: []string{"apps/web/"}, + }, + { + ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth / token entry view", + Goal: "operator pastes a token and connects", BlockedBy: []string{"webui-2"}, + Links: []TaskLinkWire{{TargetID: "webui-1", Type: "IMPLEMENTS", TargetKind: "TASK"}}, + }, + { + ID: "webui-4", Key: "webui-4", Status: "TODO", Title: "Session board view", + Goal: "live roster mirroring the TUI", BlockedBy: []string{"webui-2"}, + }, + { + ID: "api-7", Key: "api-7", Status: "IN_PROGRESS", Title: "CORS + token endpoint", + Goal: "server accepts browser origins", Claimant: "04a546aa8b2c", + }, + {ID: "api-3", Key: "api-3", Status: "DONE", Title: "Stable /sessions JSON shape"}, } - return m.View() } func sampleStats() *protocol.StatsDto { diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go index f8f5f707..906a5460 100644 --- a/apps/tui-go/internal/app/diff.go +++ b/apps/tui-go/internal/app/diff.go @@ -1,10 +1,11 @@ package app import ( + "image/color" "strconv" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type diffKind int @@ -53,7 +54,10 @@ func parseUnifiedDiff(diff string) []diffRow { } for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") { switch { - case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"), + // File headers carry a trailing space ("+++ b/x"); requiring it means a real + // changed line whose content starts with "++"/"--" isn't mistaken for a header + // and silently dropped from the rendered diff (and its row count). + case strings.HasPrefix(ln, "+++ "), strings.HasPrefix(ln, "--- "), strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "): continue case strings.HasPrefix(ln, "@@"): @@ -193,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string { if kind == diffBlank { return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w)) } - var bg, textFg, signFg lipgloss.Color + var bg, textFg, signFg color.Color sign := " " switch kind { case diffAdd: diff --git a/apps/tui-go/internal/app/diff_scroll_test.go b/apps/tui-go/internal/app/diff_scroll_test.go new file mode 100644 index 00000000..7e1e726a --- /dev/null +++ b/apps/tui-go/internal/app/diff_scroll_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "strings" + "testing" +) + +// scrollDiff must clamp to [0, diffMaxScroll] against the same geometry the modal renders to, +// so PgUp/PgDn (and g/G) past either end land exactly on the edge — no dead presses unwinding +// an overshoot. Backs the fullscreen viewer for every tool preview (diff / command / plain). +func TestDiffScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 30) + var b strings.Builder + b.WriteString("--- a/f\n+++ b/f\n@@ -0,0 +1,40 @@\n") + for i := 0; i < 40; i++ { + b.WriteString("+a new line\n") + } + // currentDiff() falls back to the last "tool" transcript entry when no approval is pending. + m.routerMessages[m.selectedID] = []RouterEntry{{Role: "tool", Content: b.String()}} + + max := m.diffMaxScroll() + if max <= 0 { + t.Fatalf("fixture diff must exceed the modal body height to test scrolling; max=%d", max) + } + + m.scrollDiff(10_000) // way past the bottom + if m.diffScrollOffset != max { + t.Fatalf("scroll past end clamped to %d, want max %d", m.diffScrollOffset, max) + } + m.scrollDiff(-10_000) // way past the top + if m.diffScrollOffset != 0 { + t.Fatalf("scroll past top clamped to %d, want 0", m.diffScrollOffset) + } +} + +// A diff that fits the modal body never scrolls. +func TestDiffScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 30) + m.routerMessages[m.selectedID] = []RouterEntry{ + {Role: "tool", Content: "--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-x\n+y\n"}, + } + m.scrollDiff(5) + if m.diffScrollOffset != 0 { + t.Fatalf("short diff should not scroll, got %d", m.diffScrollOffset) + } +} diff --git a/apps/tui-go/internal/app/editor_lines_test.go b/apps/tui-go/internal/app/editor_lines_test.go new file mode 100644 index 00000000..3c4ec62b --- /dev/null +++ b/apps/tui-go/internal/app/editor_lines_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// editorLines splits the buffer on newlines into one display row each, in order. +func TestEditorLinesSplitsOnNewline(t *testing.T) { + m := NewModel(nil) + m.theme = NewTheme(SoftBlue) + m.inputBuffer = "one\ntwo\nthree" + m.inputCursor = len(m.inputBuffer) + + lines := m.editorLines() + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d", len(lines)) + } + for i, want := range []string{"one", "two", "three"} { + if got := strings.TrimRight(ansi.Strip(lines[i]), " ▏"); got != want { + t.Errorf("line %d = %q, want %q", i, got, want) + } + } +} + +// A buffer taller than maxInputLines shows only the last maxInputLines rows (cursor tail). +func TestEditorLinesCapsHeight(t *testing.T) { + m := NewModel(nil) + m.theme = NewTheme(SoftBlue) + m.inputBuffer = strings.Repeat("x\n", maxInputLines+4) + "last" + m.inputCursor = len(m.inputBuffer) + + if got := len(m.editorLines()); got != maxInputLines { + t.Fatalf("want capped at %d lines, got %d", maxInputLines, got) + } +} diff --git a/apps/tui-go/internal/app/event_badge_test.go b/apps/tui-go/internal/app/event_badge_test.go new file mode 100644 index 00000000..081f2cd1 --- /dev/null +++ b/apps/tui-go/internal/app/event_badge_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "strings" + "testing" +) + +// The event-stream badge must track the selected session's lifecycle, not just the socket: +// a terminal/paused session emits no more events, so it must not keep reading "● live". +func TestEventStreamBadgeReflectsSessionStatus(t *testing.T) { + cases := []struct{ status, want string }{ + {"ACTIVE", "● live"}, + {"COMPLETED", "✓ done"}, + {"FAILED", "✗ failed"}, + {"PAUSED awaiting approval", "‖ paused"}, + } + for _, c := range cases { + m := inSessionModel(120, 40) + m.connected = true + m.sessions[0].Status = c.status + if got := m.eventStreamBadge(); !strings.Contains(got, c.want) { + t.Errorf("status %q: badge = %q, want substring %q", c.status, got, c.want) + } + } + + // Disconnected always reads idle, regardless of the session's status. + m := inSessionModel(120, 40) + m.connected = false + m.sessions[0].Status = "ACTIVE" + if got := m.eventStreamBadge(); !strings.Contains(got, "○ idle") { + t.Errorf("disconnected: badge = %q, want idle", got) + } +} diff --git a/apps/tui-go/internal/app/file_picker_test.go b/apps/tui-go/internal/app/file_picker_test.go new file mode 100644 index 00000000..fe89314a --- /dev/null +++ b/apps/tui-go/internal/app/file_picker_test.go @@ -0,0 +1,62 @@ +package app + +import "testing" + +func TestAtTokenBoundary(t *testing.T) { + cases := []struct { + buf string + cur int + want bool + }{ + {"", 0, true}, // start of empty line + {"see ", 4, true}, // just after a space + {"email", 5, false}, // mid-word (e.g. user@host) + {"a b", 2, true}, // after the space + {"a b", 3, false}, // after 'b' + } + for _, c := range cases { + m := NewModel(nil) + m.inputBuffer = c.buf + m.inputCursor = c.cur + if got := m.atTokenBoundary(); got != c.want { + t.Errorf("atTokenBoundary(%q,@%d) = %v, want %v", c.buf, c.cur, got, c.want) + } + } +} + +// `@` consumes itself to open the picker; selecting a file inserts `@path ` at the cursor. +func TestInsertFileRef(t *testing.T) { + m := NewModel(nil) + m.inputBuffer = "look at " + m.inputCursor = len(m.inputBuffer) + + m.insertFileRef("src/main.go") + + if want := "look at @src/main.go "; m.inputBuffer != want { + t.Fatalf("buffer = %q, want %q", m.inputBuffer, want) + } + if m.inputCursor != len(m.inputBuffer) { + t.Fatalf("cursor = %d, want %d", m.inputCursor, len(m.inputBuffer)) + } +} + +func TestFilteredFiles(t *testing.T) { + m := NewModel(nil) + m.files = []string{"cmd/main.go", "internal/app/view.go", "README.md", "internal/app/model.go"} + + m.fileFilter = "" + if len(m.filteredFiles()) != 4 { + t.Fatalf("empty filter should pass all 4, got %d", len(m.filteredFiles())) + } + + m.fileFilter = "app/" + got := m.filteredFiles() + if len(got) != 2 { + t.Fatalf("filter %q matched %d, want 2 (%v)", m.fileFilter, len(got), got) + } + + m.fileFilter = "README" + if got := m.filteredFiles(); len(got) != 1 || got[0] != "README.md" { + t.Fatalf("filter README = %v, want [README.md]", got) + } +} diff --git a/apps/tui-go/internal/app/help_complete_test.go b/apps/tui-go/internal/app/help_complete_test.go new file mode 100644 index 00000000..0489f517 --- /dev/null +++ b/apps/tui-go/internal/app/help_complete_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "strings" + "testing" +) + +// Every key the TUI binds (normal mode, approval band, and the diff/preview viewer) must +// have a row in the ? overlay. Keyed by a distinctive description phrase so deleting a row +// fails loudly here — keep this list in step with handleNormalKey / handleApprovalKey and +// the OverlayDiff handler whenever a binding is added or changed. +func TestHelpCoversEveryKeybind(t *testing.T) { + m := inSessionModel(120, 80) + help := strings.Join(m.helpBody(), "\n") + + want := []string{ + // navigate + "move the session / list selection", // ↑↓ / j k + "open the selected session", // enter + "cycle launch target", // Tab / w + "filter the session list", // / + "jump to your previous / next message", // ^↑ / ^↓ + "scroll output", // PgUp / PgDn (+ ^u / ^d) + "drop selection", // esc + "back to the session list", // l + // session + "compose a message", // i + "right panel: events / changes", // d + "toggle chat / steering mode", // s + "show workflows", // w + "copy the selected message", // y + "open the latest diff / preview", // ^x + "cancel the running session", // c + "re-open a dismissed approval gate", // a + "quit", // q + // overlays + "command palette", // p + "this keybindings help", // ? + "event inspector", // e + "tools / artifacts", // t / v + "resume past sessions", // S / R + "swap model / edit config", // m / g + "grants / idea board", // G / I + // approval band + "approve once", // y / a / enter + "reject", // n / r + "steer (add a note)", // e / s + "approve-always", // A + "walk the pending queue", // ↑ / ↓ + "fullscreen the diff / command preview", // ^x + "dismiss for now", // esc + // diff / preview viewer + "scroll a line", // ↑ / ↓ + "scroll a page", // PgUp / PgDn + "jump to top / end", // g / G + } + for _, w := range want { + if !strings.Contains(help, w) { + t.Errorf("? help is missing a row for %q", w) + } + } +} diff --git a/apps/tui-go/internal/app/ideacard.go b/apps/tui-go/internal/app/ideacard.go index 2ae6b450..34ba62e4 100644 --- a/apps/tui-go/internal/app/ideacard.go +++ b/apps/tui-go/internal/app/ideacard.go @@ -3,7 +3,7 @@ package app import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router diff --git a/apps/tui-go/internal/app/ideacard_test.go b/apps/tui-go/internal/app/ideacard_test.go index 7a7d2dc1..101d40bd 100644 --- a/apps/tui-go/internal/app/ideacard_test.go +++ b/apps/tui-go/internal/app/ideacard_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) { m, client := ideaModel() _ = client.Drain() // drop the ListIdeas frame from openIdeas - updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")}) m = updated.(Model) if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" { diff --git a/apps/tui-go/internal/app/input_history_test.go b/apps/tui-go/internal/app/input_history_test.go new file mode 100644 index 00000000..58800758 --- /dev/null +++ b/apps/tui-go/internal/app/input_history_test.go @@ -0,0 +1,101 @@ +package app + +import "testing" + +// History is a single global ring shared by every input surface (free-chat, the +// workflow-intent line, in-session chat), so ↑/↓ recalls anything you've sent — including +// outside a session, which the per-session map could not do. +func TestRecordInputGlobalRing(t *testing.T) { + m := NewModel(nil) + + m.recordInput("first") + m.recordInput(" second ") // trimmed + m.recordInput("second") // immediate dup ignored + m.recordInput("") // blank ignored + m.recordInput(" ") // blank-after-trim ignored + + want := []string{"first", "second"} + if len(m.inputHistory) != len(want) { + t.Fatalf("inputHistory = %q, want %q", m.inputHistory, want) + } + for i := range want { + if m.inputHistory[i] != want[i] { + t.Fatalf("inputHistory[%d] = %q, want %q", i, m.inputHistory[i], want[i]) + } + } +} + +func TestRecordInputCaps(t *testing.T) { + m := NewModel(nil) + for i := 0; i < 250; i++ { + m.recordInput(string(rune('a'+i%26)) + itoa(i)) + } + if len(m.inputHistory) != 100 { + t.Fatalf("ring len = %d, want 100 (capped)", len(m.inputHistory)) + } +} + +// (itoa is defined in view.go and reused here for the cap test.) + +// ↑/↓ walks the ring with no session selected (selectedID == ""), proving history +// recall works in free-chat / intent contexts, not just in-session. +func TestHistoryWalkWithoutSession(t *testing.T) { + m := NewModel(nil) + m.recordInput("one") + m.recordInput("two") + m.inputBuffer = "draft" + + m.historyPrev() // newest first + if m.inputBuffer != "two" { + t.Fatalf("prev#1 = %q, want %q", m.inputBuffer, "two") + } + m.historyPrev() + if m.inputBuffer != "one" { + t.Fatalf("prev#2 = %q, want %q", m.inputBuffer, "one") + } + m.historyPrev() // clamp at oldest + if m.inputBuffer != "one" { + t.Fatalf("prev#3 (clamp) = %q, want %q", m.inputBuffer, "one") + } + m.historyNext() + if m.inputBuffer != "two" { + t.Fatalf("next#1 = %q, want %q", m.inputBuffer, "two") + } + m.historyNext() // back to the stashed live draft + if m.inputBuffer != "draft" { + t.Fatalf("next#2 = %q, want restored draft %q", m.inputBuffer, "draft") + } +} + +// (animating + caps tests below.) + +// animating() gates the redraw tick: it must be FALSE on an idle screen (normal mode, +// no active session) so terminal selection survives, and TRUE while typing or spinning. +func TestAnimatingGatesIdleRedraw(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeNormal + if m.animating() { + t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped") + } + + // Insert mode must NOT force the frame loop anymore: the composer caret is a real + // terminal cursor (View.Cursor) that the terminal blinks itself, so an idle insert + // screen holds still (and native text selection survives). + m.editMode = ModeInsert + if m.animating() { + t.Fatal("insert mode must NOT animate — the native cursor blinks itself") + } + m.editMode = ModeNormal + + // Other typing surfaces still draw their own carets and keep ticking. + m.steering = true + if !m.animating() { + t.Fatal("steering (drawn caret) must animate") + } + m.steering = false + + m.sessions = []Session{{ID: "s1", Active: true}} + if !m.animating() { + t.Fatal("an active session must animate (spinner)") + } +} diff --git a/apps/tui-go/internal/app/key.go b/apps/tui-go/internal/app/key.go new file mode 100644 index 00000000..ad65323e --- /dev/null +++ b/apps/tui-go/internal/app/key.go @@ -0,0 +1,123 @@ +package app + +import tea "charm.land/bubbletea/v2" + +// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form +// and dropped the v1 KeyType enum the handlers were written against. Rather than +// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same +// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic +// downstream stays untouched. The only place that ever touches the raw v2 key +// is toKeyMsg below, called once at the Update boundary. + +type keyType int + +const ( + keyRunes keyType = iota + keyEnter + keyUp + keyDown + keyLeft + keyRight + keySpace + keyEsc + keyBackspace + keyTab + keyPgUp + keyPgDown + keyCtrlA + keyCtrlC + keyCtrlD + keyCtrlJ + keyCtrlR + keyCtrlU + keyCtrlX + keyCtrlUp + keyCtrlDown + keyOther +) + +// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed +// Runes and modifier flags. Shift is newly meaningful under v2's kitty key +// disambiguation — it powers Shift+Enter newlines (see handleInsertKey). +type keyMsg struct { + Type keyType + Runes []rune + Alt bool + Shift bool + str string +} + +func (k keyMsg) String() string { return k.str } + +// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form. +func toKeyMsg(p tea.KeyPressMsg) keyMsg { + k := p.Key() + out := keyMsg{ + Alt: k.Mod.Contains(tea.ModAlt), + Shift: k.Mod.Contains(tea.ModShift), + str: k.Keystroke(), + } + ctrl := k.Mod.Contains(tea.ModCtrl) + switch k.Code { + case tea.KeyEnter: + out.Type = keyEnter + case tea.KeyEscape: + out.Type = keyEsc + case tea.KeyTab: + out.Type = keyTab + case tea.KeyBackspace: + out.Type = keyBackspace + case tea.KeySpace: + out.Type = keySpace + out.Runes = []rune{' '} + case tea.KeyUp: + if ctrl { + out.Type = keyCtrlUp + } else { + out.Type = keyUp + } + case tea.KeyDown: + if ctrl { + out.Type = keyCtrlDown + } else { + out.Type = keyDown + } + case tea.KeyLeft: + out.Type = keyLeft + case tea.KeyRight: + out.Type = keyRight + case tea.KeyPgUp: + out.Type = keyPgUp + case tea.KeyPgDown: + out.Type = keyPgDown + default: + if ctrl { + switch k.Code { + case 'a': + out.Type = keyCtrlA + case 'c': + out.Type = keyCtrlC + case 'd': + out.Type = keyCtrlD + case 'j': + out.Type = keyCtrlJ + case 'r': + out.Type = keyCtrlR + case 'u': + out.Type = keyCtrlU + case 'x': + out.Type = keyCtrlX + default: + out.Type = keyOther + } + return out + } + if k.Text != "" { + out.Type = keyRunes + out.Runes = []rune(k.Text) + } else { + out.Type = keyOther + } + } + return out +} diff --git a/apps/tui-go/internal/app/markdown.go b/apps/tui-go/internal/app/markdown.go new file mode 100644 index 00000000..503b73ce --- /dev/null +++ b/apps/tui-go/internal/app/markdown.go @@ -0,0 +1,76 @@ +package app + +import ( + "strings" + "sync" + + "github.com/charmbracelet/glamour" +) + +// defaultMarkdownWidth is the wrap width used when the caller doesn't yet know +// the terminal width (e.g. a zero/negative value). 80 columns is a safe terminal +// default that never blows up glamour's word-wrap. +const defaultMarkdownWidth = 80 + +// markdownRenderers memoizes one glamour renderer per wrap width. A glamour +// renderer bakes its word-wrap width in at construction, so we key the cache by +// width and build lazily. Constructing a renderer is comparatively expensive +// (it compiles a style + goldmark parser), hence the cache; the TUI renders the +// same handful of widths over its lifetime. +var ( + markdownMu sync.Mutex + markdownRenderers = map[int]*glamour.TermRenderer{} +) + +// rendererForWidth returns a cached dark-styled glamour renderer wrapped to w, +// or nil if glamour could not build one (caller falls back to plain text). +func rendererForWidth(w int) *glamour.TermRenderer { + markdownMu.Lock() + defer markdownMu.Unlock() + if r, ok := markdownRenderers[w]; ok { + return r + } + // Pin the "dark" standard style so output is deterministic and independent + // of the ambient terminal's background detection (important for tests and + // for headless/over-the-wire sessions). + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(w), + ) + if err != nil { + // Cache the failure as nil so we don't retry-and-fail every frame. + markdownRenderers[w] = nil + return nil + } + markdownRenderers[w] = r + return r +} + +// renderMarkdown renders content as terminal markdown (bold, italics, lists, +// headings, code blocks/inline code) word-wrapped to width, styled for a dark +// terminal. It is robust by construction: on any glamour error — or if the +// renderer can't be built — it returns the original plain content rather than +// crashing the TUI. Trailing whitespace glamour appends is trimmed so it doesn't +// disrupt the surrounding layout. +func renderMarkdown(content string, width int) string { + if width < 1 { + width = defaultMarkdownWidth + } + r := rendererForWidth(width) + if r == nil { + return content + } + out, err := r.Render(content) + if err != nil { + return content + } + // glamour pads block output with leading/trailing blank lines; strip them so + // the rendered turn slots into the feed without extra gaps. + out = strings.Trim(out, "\n") + if strings.TrimSpace(out) == "" { + // Nothing survived rendering (e.g. content was only whitespace) — prefer + // the original so callers never lose the underlying text. + return content + } + return out +} diff --git a/apps/tui-go/internal/app/markdown_test.go b/apps/tui-go/internal/app/markdown_test.go new file mode 100644 index 00000000..ef72270b --- /dev/null +++ b/apps/tui-go/internal/app/markdown_test.go @@ -0,0 +1,94 @@ +package app + +import ( + "strings" + "testing" +) + +// stripANSI removes CSI escape sequences so we can assert on the visible text +// glamour produced without depending on exact styling codes. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + j := i + 2 + for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) { + j++ + } + if j < len(s) { + j++ // consume the final byte + } + i = j + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// Rich markdown should be transformed (output differs from raw input) while the +// literal words survive the round-trip. +func TestRenderMarkdownTransformsAndPreservesText(t *testing.T) { + in := "# Heading\n\nSome **bold** and *italic* text.\n\n- first item\n- second item\n\n`inline code` and:\n\n```go\nfmt.Println(\"hi\")\n```\n" + out := renderMarkdown(in, 80) + + if out == in { + t.Fatalf("markdown input should be rendered, got identical output") + } + + plain := stripANSI(out) + for _, want := range []string{"Heading", "bold", "italic", "first item", "second item", "inline code", "fmt.Println"} { + if !strings.Contains(plain, want) { + t.Errorf("rendered output missing %q\n--- visible ---\n%s", want, plain) + } + } +} + +// A plain, short string must round-trip to contain its text and not panic, +// regardless of how much (or how little) styling glamour adds. +func TestRenderMarkdownPlainStringRoundTrips(t *testing.T) { + in := "just a short plain sentence" + out := renderMarkdown(in, 80) + if !strings.Contains(stripANSI(out), in) { + t.Fatalf("plain string did not survive rendering: %q", stripANSI(out)) + } +} + +// Pathological / empty inputs must never panic and must return something +// containing the input text (trivially true for empty input). +func TestRenderMarkdownPathologicalDoesNotPanic(t *testing.T) { + cases := []string{ + "", + " ", + "\n\n\n", + "```unterminated code fence\nstill going", + strings.Repeat("a", 10_000), + "[](()]][[**__", + } + for _, in := range cases { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("renderMarkdown panicked on %q: %v", in, r) + } + }() + out := renderMarkdown(in, 40) + // Empty/whitespace-only inputs fall back to the original content. + if strings.TrimSpace(in) == "" { + if out != in { + t.Errorf("whitespace input %q should fall back to itself, got %q", in, out) + } + } + }() + } +} + +// A non-positive width must not crash and should fall back to the default +// wrap width rather than feeding glamour an invalid value. +func TestRenderMarkdownZeroWidthFallsBack(t *testing.T) { + out := renderMarkdown("**hello** world", 0) + if !strings.Contains(stripANSI(out), "hello") { + t.Fatalf("zero-width render dropped text: %q", stripANSI(out)) + } +} diff --git a/apps/tui-go/internal/app/modal_scroll_test.go b/apps/tui-go/internal/app/modal_scroll_test.go new file mode 100644 index 00000000..51a85370 --- /dev/null +++ b/apps/tui-go/internal/app/modal_scroll_test.go @@ -0,0 +1,39 @@ +package app + +import "testing" + +// scrollModal must clamp the body offset of a tall fixed-content modal (help / stats) to +// [0, scrollableModalMax] so paging past either end lands exactly — the modal scrolls +// instead of clipping its bottom (incl. the close hint) off-screen. +func TestModalScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 24) // short enough that the help cheat-sheet overflows + m.overlay = OverlayHelp + + body := m.activeModalBody() + max := m.scrollableModalMax(len(body)) + if max <= 0 { + t.Fatalf("help body (%d lines) must exceed the modal viewport (%d) to test scrolling", len(body), m.modalBodyRows()) + } + + m.scrollModal(10_000) // past the bottom + if m.modalScroll != max { + t.Fatalf("scroll past end clamped to %d, want max %d", m.modalScroll, max) + } + m.scrollModal(-10_000) // past the top + if m.modalScroll != 0 { + t.Fatalf("scroll past top clamped to %d, want 0", m.modalScroll) + } +} + +// A modal that fits the screen never scrolls. +func TestModalScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 60) // tall enough that help fits in full + m.overlay = OverlayHelp + if max := m.scrollableModalMax(len(m.activeModalBody())); max != 0 { + t.Fatalf("help should fit at height 60, got max scroll %d", max) + } + m.scrollModal(5) + if m.modalScroll != 0 { + t.Fatalf("a modal that fits should not scroll, got %d", m.modalScroll) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 98c8edb5..16f92e5b 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -1,6 +1,9 @@ package app import ( + "strconv" + "strings" + "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -54,12 +57,20 @@ const ( OverlayStats OverlayIdeas OverlayHealth + OverlaySessions + OverlayTasks + OverlayFiles + OverlayStatusbar + OverlayGrants + OverlayGrantScope + OverlayHelp ) // RouterEntry is one line in a session's conversation transcript. type RouterEntry struct { - Role string // user | router | tool | narration | narration_llm + Role string // user | router | tool | narration | narration_llm | action Content string + Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟) Metrics *TurnMetrics } @@ -111,6 +122,27 @@ type Approval struct { Rationale []string } +// tierNum parses the numeric part of a tier label ("T3" → 3). Unparseable or +// empty tiers return -1, which is treated as low-risk (never requires confirm). +func tierNum(tier string) int { + t := strings.TrimSpace(strings.ToUpper(tier)) + t = strings.TrimPrefix(t, "T") + if t == "" { + return -1 + } + n, err := strconv.Atoi(t) + if err != nil { + return -1 + } + return n +} + +// HighTier reports whether this gate is destructive/high-risk (T3+), so an approve +// must be confirmed with a second keypress rather than acting on one keystroke. +func (a *Approval) HighTier() bool { + return tierNum(a.Tier) >= 3 +} + // ClarQuestion is one open question a stage raised (mirrors the wire DTO). type ClarQuestion struct { ID string @@ -159,10 +191,15 @@ type Session struct { Tools []ToolRecord ToolsByStage map[string][]ManifestTool Events []EventEntry - Pending *Approval - Clar *Clarification // open questions awaiting answers (clarification view) - Propose *Proposal // router workflow suggestion awaiting a pick (propose view) - Active bool // an inference/tool is in flight (drives the spinner) + // Pending is the *current* approval gate shown in the band. It always mirrors + // PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can + // keep reading a single pointer while multiple gates queue up behind it. + Pending *Approval + PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓ + PendingIdx int // selected index into PendingQueue + Clar *Clarification // open questions awaiting answers (clarification view) + Propose *Proposal // router workflow suggestion awaiting a pick (propose view) + Active bool // an inference/tool is in flight (drives the spinner) } // Workflow is a launchable workflow advertised by the server. @@ -192,16 +229,21 @@ type Model struct { wfVisible bool wfPendingID string // workflow chosen, awaiting an intent line before StartSession wfPendingName string - bgUpdates int + // launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N = + // workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden + // folds away the idle status/quick-keys rail. + launcherWf int + railHidden bool + bgUpdates int // input editMode EditMode inputMode InputMode inputBuffer string inputCursor int - history map[string][]string - historyIndex int - savedBuffer string + inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last + historyIndex int // -1 = editing the live buffer; else an index into inputHistory + savedBuffer string // live buffer stashed while walking history // flow flags sessionEntered bool @@ -212,6 +254,11 @@ type Model struct { routerMessages map[string][]RouterEntry routerConnected bool chatMode string + // transcriptSel is the index (into the selected session's transcript) of the message + // the operator has navigated to with ctrl+↑/↓; -1 = no selection (tail-follow). A + // selected message is highlighted, scrolled into view, and is what `y` copies. + transcriptSel int + copiedFlash int // frame at which a copy happened, for a brief "copied" footer note // provider currentModel string @@ -231,7 +278,11 @@ type Model struct { overlay OverlayKind overlayEventIdx int diffScrollOffset int + modalScroll int // body scroll for tall fixed-content modals (help, stats) eventStripShown bool + // in-session right panel: 0 = events, 1 = changes (token usage + changed files), + // 2 = off (output full-width). Cycled with `d`. + rightPanel int // artifact viewer (OverlayArtifacts) — populated by the artifact.list response artifacts []protocol.ArtifactDto @@ -264,20 +315,85 @@ type Model struct { health *protocol.HealthDto healthLoading bool + // session browser (OverlaySessions) — recent sessions fetched over HTTP from + // GET /sessions, so prior runs are reachable after a server restart + TUI reopen + // (the WS stream only carries live sessions, not the historical roster). + sessionList []SessionSummary + sessionListIndex int + sessionListLoading bool + sessionListErr string + + // task board (OverlayTasks) — all tasks across projects, fetched over HTTP from + // GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload). + taskList []TaskSummary + taskListIndex int + taskListLoading bool + taskListErr string + taskDetail bool + taskDetailScroll int + taskFilter string // `/` substring narrow over id/title/goal + taskFilterTyping bool + // command palette paletteFilter string paletteIndex int + // @ file picker (OverlayFiles) — workspace paths from the file.list reply + files []string // all workspace-relative paths for filesFor + filesFor string // sessionId the file list belongs to + filesLoading bool + fileFilter string // the query typed after @ (narrows the list) + fileIndex int + + // status-bar segment visibility (OverlayStatusbar) — persisted TUI-local in tui-prefs.json. + // A segment id present in sbHidden is hidden; absent = shown. sbIndex is the toggle cursor. + sbHidden map[string]bool + sbIndex int + + // actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in + // the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json. + actionsHidden bool + + // outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled + // (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back. + outputScroll int + + // event-inspector filter (OverlayEventInspector): narrows the event list by a substring + // of type/detail. eventFilterTyping is true while the operator is editing the query after /. + eventFilter string + eventFilterTyping bool + + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. + grants []protocol.GrantDto + grantsLoading bool + grantIndex int + // grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval. + // grantScopeIndex selects session/project/global; grantFor holds the approval being widened. + grantScopeIndex int + grantFor *Approval + // animation - frame int // tick counter; drives spinner + caret blink + frame int // tick counter; drives spinner + caret blink + ticking bool // true while a tick loop is scheduled. The loop is gated on animating() + // so an idle screen stops redrawing — which lets native terminal selection survive + // (the 120ms redraw used to wipe a mouse drag every frame). // snapshot phase snapshotPhase bool pendingEvents []protocol.ServerMessage + // lastBase is the full-screen render behind the active modal, stashed by View each + // frame so center() can composite a modal over a dimmed copy of it (transparent + // backdrop) instead of an opaque scrim. Transient render scratch — not real state. + lastBase string + // approval steering input buffer steerBuffer string steering bool + // approvalArmed is set while a high-tier (T3+) approve is awaiting its second + // confirming keypress — the destructive-action safety. Any non-confirm key + // disarms it; a confirming `y`/`a`/enter sends the decision. + approvalArmed bool // clarification view (the interactive question form) clarFocus int // focused question index @@ -301,7 +417,6 @@ func NewModel(client *ws.Client) Model { theme: NewTheme(SoftBlue), wfIndex: -1, inputMode: ModeRouter, - history: map[string][]string{}, historyIndex: -1, routerMessages: map[string][]RouterEntry{}, configStaged: map[string]string{}, @@ -309,6 +424,9 @@ func NewModel(client *ws.Client) Model { providerType: "LOCAL", snapshotPhase: true, eventStripShown: true, + transcriptSel: -1, + sbHidden: loadStatusbarHidden(), + actionsHidden: loadPrefs().InlineActionsHidden, } } @@ -348,6 +466,79 @@ func (m Model) session(id string) *Session { return nil } +// syncPending re-points Pending at the selected queue entry, clamping the index +// so it stays in range as the queue grows and shrinks. Called after any queue +// mutation; Pending is nil exactly when the queue is empty. +func (s *Session) syncPending() { + if len(s.PendingQueue) == 0 { + s.PendingQueue = nil + s.PendingIdx = 0 + s.Pending = nil + return + } + if s.PendingIdx < 0 { + s.PendingIdx = 0 + } + if s.PendingIdx >= len(s.PendingQueue) { + s.PendingIdx = len(s.PendingQueue) - 1 + } + s.Pending = s.PendingQueue[s.PendingIdx] +} + +// enqueueApproval adds a gate to the queue (replacing one with the same request +// id, so a re-sent gate doesn't duplicate). The freshly-added gate is left where +// it is in the order; the current selection is preserved. +func (s *Session) enqueueApproval(a *Approval) { + for i, p := range s.PendingQueue { + if p.RequestID == a.RequestID { + s.PendingQueue[i] = a + s.syncPending() + return + } + } + s.PendingQueue = append(s.PendingQueue, a) + s.syncPending() +} + +// removeApproval drops the gate with requestID and advances the selection. If the +// removed entry was before the cursor the index shifts down to stay on the same +// gate; if it was the selected one the cursor stays put (now showing the next gate). +func (s *Session) removeApproval(requestID string) { + idx := -1 + for i, p := range s.PendingQueue { + if p.RequestID == requestID { + idx = i + break + } + } + if idx < 0 { + return + } + s.PendingQueue = append(s.PendingQueue[:idx], s.PendingQueue[idx+1:]...) + if idx < s.PendingIdx { + s.PendingIdx-- + } + s.syncPending() +} + +// clearApprovals empties the queue (used on resume/completion, where the server +// invalidates every gate at once). +func (s *Session) clearApprovals() { + s.PendingQueue = nil + s.PendingIdx = 0 + s.Pending = nil +} + +// navApproval moves the queue selection by dir (wrapping) and re-syncs Pending. +func (s *Session) navApproval(dir int) { + n := len(s.PendingQueue) + if n <= 1 { + return + } + s.PendingIdx = (s.PendingIdx + dir + n) % n + s.syncPending() +} + // ensureSession returns the session with id, creating an ACTIVE entry if absent. // Session existence is derived from the event stream (any event for an unknown // session vivifies it) rather than a dedicated control frame. diff --git a/apps/tui-go/internal/app/output_scroll_test.go b/apps/tui-go/internal/app/output_scroll_test.go new file mode 100644 index 00000000..240bfc82 --- /dev/null +++ b/apps/tui-go/internal/app/output_scroll_test.go @@ -0,0 +1,41 @@ +package app + +import "testing" + +// scrollOutput must clamp to [0, totalRows-panelHeight] against the same geometry the renderer +// windows to — so PgUp past the top and PgDn past the bottom land exactly on the edges (no +// dead presses unwinding an overshoot). +func TestOutputScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 30) + msgs := make([]RouterEntry, 0, 60) + for i := 0; i < 60; i++ { + msgs = append(msgs, RouterEntry{Role: "router", Content: "a transcript line"}) + } + m.routerMessages[m.selectedID] = msgs + + w, h := m.outputViewport() + rows, _ := m.buildTranscriptRows(w) + max := len(rows) - h + if max <= 0 { + t.Fatalf("fixture transcript (%d rows) must exceed panel height (%d) to test scrolling", len(rows), h) + } + + m.scrollOutput(10_000) // way past the top + if m.outputScroll != max { + t.Fatalf("scroll up clamped to %d, want max %d", m.outputScroll, max) + } + m.scrollOutput(-10_000) // way past the bottom + if m.outputScroll != 0 { + t.Fatalf("scroll down clamped to %d, want 0 (tail-follow)", m.outputScroll) + } +} + +// A transcript that fits the panel never scrolls (stays tail-following). +func TestOutputScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 30) + m.routerMessages[m.selectedID] = []RouterEntry{{Role: "router", Content: "one line"}} + m.scrollOutput(5) + if m.outputScroll != 0 { + t.Fatalf("short transcript should not scroll, got %d", m.outputScroll) + } +} diff --git a/apps/tui-go/internal/app/overlay_composite_test.go b/apps/tui-go/internal/app/overlay_composite_test.go new file mode 100644 index 00000000..11a1b331 --- /dev/null +++ b/apps/tui-go/internal/app/overlay_composite_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func fullScreen(ch string, w, h int) string { + row := strings.Repeat(ch, w) + rows := make([]string, h) + for i := range rows { + rows[i] = row + } + return strings.Join(rows, "\n") +} + +// A composited modal must still fill the whole screen exactly (h lines, each w cols), +// so the alt-screen never shows a torn/short region around a "transparent" modal. +func TestCompositeOverlayGeometry(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 40, 12 + m.lastBase = fullScreen("x", 40, 12) + + modal := lipgloss.NewStyle().Width(10).Border(lipgloss.RoundedBorder()).Render("hi\nthere") + out := m.center(modal) + + lines := strings.Split(out, "\n") + if len(lines) != 12 { + t.Fatalf("height = %d, want 12", len(lines)) + } + for i, ln := range lines { + if w := ansi.StringWidth(ln); w != 40 { + t.Fatalf("line %d visible width = %d, want 40", i, w) + } + } +} + +// The idempotence guard: a modal that is already full-screen (a builder that centered +// internally, then got re-centered) must pass through unchanged — not get re-dimmed. +func TestCenterIdempotentOnFullScreen(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 30, 8 + m.lastBase = fullScreen("y", 30, 8) + + full := fullScreen("M", 30, 8) + if got := m.center(full); got != full { + t.Fatal("center should pass a full-screen modal through unchanged") + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 3e2d2362..dd175e2f 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -2,20 +2,82 @@ package app import ( "fmt" + "image/color" "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) -// center composites a modal over a scrim-filled screen. Immediate-mode: the modal -// is recomputed from state every frame, so there is no separate show/restore path -// to desync (the "diff won't come back" bug class can't occur here). +// center draws a modal centered over a *dimmed* copy of the screen behind it, so the +// transcript stays faintly visible around the modal (a transparent backdrop) rather +// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every +// frame, so there is no show/restore path to desync. +// +// Idempotence guard: some modal builders call center() internally and then get centered +// again by renderOverlay. The inner call already produced a full-screen composite, so a +// second call (input already width×height) returns it unchanged instead of dimming the +// modal itself. Falls back to an opaque scrim when no base was stashed (defensive). func (m Model) center(modal string) string { - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, - lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), - lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) + if m.lastBase == "" { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle(). + Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep))) + } + if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width { + return modal // already a full-screen composite; don't re-dim it + } + return m.compositeOverlay(m.lastBase, modal) +} + +// compositeOverlay strips + dims base into a faint scrim, then splices the modal box +// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind. +// base and the result are both m.width × m.height. +func (m Model) compositeOverlay(base, modal string) string { + w, h := m.width, m.height + dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep) + baseLines := strings.Split(base, "\n") + scrim := make([]string, h) + for i := 0; i < h; i++ { + raw := "" + if i < len(baseLines) { + raw = ansi.Strip(baseLines[i]) + } + scrim[i] = dim.Render(padRightRaw(raw, w)) + } + modalLines := strings.Split(modal, "\n") + mw := lipgloss.Width(modal) + top := (h - len(modalLines)) / 2 + left := (w - mw) / 2 + if top < 0 { + top = 0 + } + if left < 0 { + left = 0 + } + for r, ml := range modalLines { + row := top + r + if row < 0 || row >= h { + continue + } + leftPart := ansi.Truncate(scrim[row], left, "") + rightPart := ansi.TruncateLeft(scrim[row], left+mw, "") + scrim[row] = leftPart + ml + rightPart + } + return strings.Join(scrim, "\n") +} + +// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates). +func padRightRaw(s string, w int) string { + switch sw := ansi.StringWidth(s); { + case sw > w: + return ansi.Truncate(s, w, "") + case sw < w: + return s + strings.Repeat(" ", w-sw) + default: + return s + } } func (m Model) modalWidth() int { @@ -63,6 +125,20 @@ func (m Model) renderOverlay(base string) string { return m.center(m.ideasModal()) case OverlayHealth: return m.center(m.healthModal()) + case OverlaySessions: + return m.center(m.sessionsModal()) + case OverlayTasks: + return m.center(m.tasksModal()) + case OverlayFiles: + return m.center(m.filesModal()) + case OverlayStatusbar: + return m.center(m.statusbarModal()) + case OverlayGrants: + return m.center(m.grantsModal()) + case OverlayGrantScope: + return m.center(m.grantScopeModal()) + case OverlayHelp: + return m.center(m.helpModal()) } return base } @@ -72,7 +148,7 @@ func (m Model) titleLine(text string) string { return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text) } -func mbg(t Theme, s string, fg lipgloss.Color) string { +func mbg(t Theme, s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s) } @@ -132,6 +208,13 @@ func (m Model) renderApprovalBand(h int) string { rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) + t.span(" · risk ", t.P.Faint) + lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk)) + // When more than one gate is queued, show "approval i/n" so the operator knows + // there are others behind this one (↑/↓ to walk them) — never stack modals. + if n := len(s.PendingQueue); n > 1 { + rightHdr = t.span("approval ", t.P.Faint) + + t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) + + t.span(" · ", t.P.Faint) + rightHdr + } innerH := h - 2 ratBlock := 0 @@ -218,8 +301,19 @@ func (m Model) approvalActions() string { hint("enter", "approve + send note"), hint("esc", "keep note, decide below"), }, gap) } - parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")} - if s := m.session(m.selectedID); s != nil && s.Pending != nil && + s := m.session(m.selectedID) + // Armed T3+ approve: a single decisive line so an accidental keystroke can't slip + // a destructive action through — the confirm key must be pressed again. + if m.approvalArmed && s != nil && s.Pending != nil { + warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true). + Render("press y again to confirm " + s.Pending.Tier + " approval") + return warn + gap + hint("any other key", "cancel") + } + parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")} + if s != nil && len(s.PendingQueue) > 1 { + parts = append(parts, hint("↑↓", "queue")) + } + if s != nil && s.Pending != nil && (isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) { parts = append(parts, hint("^x", "fullscreen")) } @@ -264,6 +358,20 @@ func (m Model) diffMaxScroll() int { return max } +// scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to +// [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead +// presses unwinding an overshoot — same contract as the OUTPUT free-scroll). +func (m *Model) scrollDiff(delta int) { + off := m.diffScrollOffset + delta + if mx := m.diffMaxScroll(); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + m.diffScrollOffset = off +} + func (m Model) diffModal() string { if cmd := m.pendingCommand(); cmd != nil { return m.commandModal(cmd) @@ -301,7 +409,7 @@ func (m Model) diffModal() string { for _, ln := range lines { b.WriteString(ln + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) @@ -335,7 +443,7 @@ func (m Model) commandModal(a *Approval) string { for i := off; i < end; i++ { b.WriteString(lines[i] + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}})) return m.center(t.Overlay.Width(w).Render(b.String())) } @@ -350,8 +458,25 @@ func (m Model) eventInspectorModal() string { b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint)) } b.WriteString("\n\n") + // Filter line (shown while editing or when a query is set): a `/` prompt over the query. + if m.eventFilterTyping || m.eventFilter != "" { + caret := mbg(t, "", t.P.BgPanel) + if m.eventFilterTyping && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ") + if m.eventFilter == "" { + b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n") + } else { + b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n") + } + } if len(evs) == 0 { - b.WriteString(mbg(t, "no events", t.P.Faint)) + msg := "no events" + if m.eventFilter != "" { + msg = "no events match \"" + m.eventFilter + "\"" + } + b.WriteString(mbg(t, msg, t.P.Faint)) return m.center(t.Overlay.Width(w).Render(b.String())) } // Budget the plain detail so the styled row never needs ANSI-aware truncation @@ -395,7 +520,7 @@ func (m Model) eventInspectorModal() string { mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim) b.WriteString(row + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) @@ -432,8 +557,13 @@ func (m Model) paletteModal() string { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") titleFg = t.P.FgStrong } - b.WriteString(marker + - lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) + + // keybind column: the bare-key shortcut, so the palette teaches the shortcuts. + keyCell := mbg(t, padRaw("", 4), t.P.BgPanel) + if c.key != "" { + keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4)) + } + b.WriteString(marker + keyCell + + lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) + mbg(t, " "+c.hint, t.P.Faint) + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}})) @@ -441,6 +571,312 @@ func (m Model) paletteModal() string { return m.center(modal) } +// filesModal is the `@` file-reference picker: a filter line over the session workspace's +// paths, windowed around the selection; enter inserts `@path` into the chat input. +func (m Model) filesModal() string { + t := m.theme + w := m.modalWidth() + files := m.filteredFiles() + + var b strings.Builder + b.WriteString(m.titleLine("@ file reference") + "\n\n") + + caret := mbg(t, " ", t.P.BgPanel) + if m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ") + if m.fileFilter == "" { + b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n") + } else { + b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n") + } + + switch { + case m.filesLoading: + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + case len(m.files) == 0: + b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n") + case len(files) == 0: + b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n") + default: + const maxRows = 12 + start := 0 + if m.fileIndex >= maxRows { + start = m.fileIndex - maxRows + 1 + } + end := start + maxRows + if end > len(files) { + end = len(files) + } + for i := start; i < end; i++ { + marker := mbg(t, " ", t.P.BgPanel) + fg := t.P.Fg + if i == m.fileIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + fg = t.P.FgStrong + } + b.WriteString(marker + + lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n") + } + if len(files) > maxRows { + b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n") + } + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +// statusbarModal toggles which status-bar segments are shown. Choices persist to the local +// tui-prefs.json so the bar stays as configured across launches. +func (m Model) statusbarModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("status bar") + "\n\n") + b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n") + + for i, seg := range statusSegments { + marker := mbg(t, " ", t.P.BgPanel) + labelFg := t.P.Fg + if i == m.sbIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + labelFg = t.P.FgStrong + } + box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ") + if !m.sbShow(seg.id) { + box = mbg(t, "[ ] ", t.P.Faint) + } + b.WriteString(marker + box + + lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one. +// These are cross-session auto-approvals, so the board opens from anywhere. +func (m Model) grantsModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("standing grants") + "\n\n") + b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n") + + switch { + case m.grantsLoading: + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + case len(m.grants) == 0: + b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n") + default: + for i, g := range m.grants { + marker := mbg(t, " ", t.P.BgPanel) + fg := t.P.Fg + if i == m.grantIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + fg = t.P.FgStrong + } + scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8)) + tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16)) + tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint) + b.WriteString(marker + scope + tool + tiers) + if g.Scope == "PROJECT" && g.ProjectID != "" { + b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint)) + } + b.WriteString("\n") + } + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked +// for the pending tool — this session, this project, or everywhere. +func (m Model) grantScopeModal() string { + t := m.theme + w := m.modalWidth() + + tool := "" + if m.grantFor != nil { + tool = m.grantFor.ToolName + } + var b strings.Builder + b.WriteString(m.titleLine("approve always — choose scope") + "\n\n") + b.WriteString(mbg(t, " stop asking for ", t.P.Faint) + + lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n") + + for i, opt := range grantScopeOptions() { + marker := mbg(t, " ", t.P.BgPanel) + labelFg := t.P.Fg + if i == m.grantScopeIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + labelFg = t.P.FgStrong + } + key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3)) + title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16)) + b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it. +// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the +// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint +// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather +// than clipping its bottom off-screen (the old compositeOverlay behaviour). +func (m Model) modalBodyRows() int { + if r := m.height - 8; r > 3 { + return r + } + return 3 +} + +// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the +// last page to the bottom. +func (m Model) scrollableModalMax(n int) int { + if max := n - m.modalBodyRows(); max > 0 { + return max + } + return 0 +} + +// activeModalBody returns the body lines of whichever scrollable fixed-content modal is +// open, so the key handler can clamp m.modalScroll against the same content it renders. +func (m Model) activeModalBody() []string { + switch m.overlay { + case OverlayHelp: + return m.helpBody() + case OverlayStats: + if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + return nil + } + return m.statsBody() + } + return nil +} + +// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped +// to [0, scrollableModalMax] — same contract as the diff/output scrollers. +func (m *Model) scrollModal(delta int) { + max := m.scrollableModalMax(len(m.activeModalBody())) + off := m.modalScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.modalScroll = off +} + +// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box, +// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator +// appears in the title row whenever the body is taller than the viewport. +func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string { + t := m.theme + w := m.modalWidth() + rows := m.modalBodyRows() + off := m.modalScroll + if mx := m.scrollableModalMax(len(body)); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + end := off + rows + if end > len(body) { + end = len(body) + } + var b strings.Builder + b.WriteString(m.titleLine(title)) + if titleSuffix != "" { + b.WriteString(mbg(t, titleSuffix, t.P.Faint)) + } + if len(body) > rows { + b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint)) + } + b.WriteString("\n\n") + for i := off; i < end; i++ { + b.WriteString(body[i] + "\n") + } + b.WriteString("\n" + modalHints(t, hint)) + return t.Overlay.Width(w).Render(b.String()) +} + +// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it). +func (m Model) helpBody() []string { + t := m.theme + type kb struct{ key, desc string } + var out []string + section := func(title string, rows []kb) { + out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title)) + for _, r := range rows { + key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16)) + out = append(out, " "+key+mbg(t, r.desc, t.P.Fg)) + } + out = append(out, "") + } + section("navigate", []kb{ + {"↑↓ / j k", "move the session / list selection"}, + {"enter", "open the selected session"}, + {"Tab / w", "idle: cycle launch target (chat / workflow)"}, + {"/", "filter the session list"}, + {"^↑ / ^↓", "jump to your previous / next message"}, + {"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, + {"esc", "drop selection / scroll → follow newest"}, + {"l", "back to the session list"}, + }) + section("session", []kb{ + {"i", "compose a message"}, + {"d", "right panel: events / changes / off"}, + {"s", "toggle chat / steering mode"}, + {"w", "show workflows (idle screen)"}, + {"y", "copy the selected message (OSC52)"}, + {"^x", "open the latest diff / preview"}, + {"c", "cancel the running session"}, + {"a", "re-open a dismissed approval gate"}, + {"q", "quit"}, + }) + section("overlays", []kb{ + {"p", "command palette (all shortcuts)"}, + {"?", "this keybindings help"}, + {"e", "event inspector (/ to filter)"}, + {"t / v", "tools / artifacts"}, + {"S / R", "session stats / resume past sessions"}, + {"T", "task board (across projects)"}, + {"m / g", "swap model / edit config"}, + {"G / I", "grants / idea board"}, + }) + section("approval band", []kb{ + {"y / a / enter", "approve once (T3+ asks again to confirm)"}, + {"n / r", "reject"}, + {"e / s", "steer (add a note)"}, + {"A", "approve-always — choose scope"}, + {"↑ / ↓", "walk the pending queue"}, + {"^x", "fullscreen the diff / command preview"}, + {"esc", "dismiss for now (a re-opens)"}, + }) + section("diff / preview viewer (^x)", []kb{ + {"↑ / ↓", "scroll a line"}, + {"PgUp / PgDn", "scroll a page (^u / ^d half)"}, + {"g / G", "jump to top / end"}, + {"^x / esc", "close"}, + }) + return out +} + +func (m Model) helpModal() string { + return m.renderScrollModal("keybindings", "", m.helpBody(), + [][2]string{{"↑↓", "scroll"}, {"esc", "close"}}) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() @@ -550,6 +986,20 @@ func (m Model) artifactContentMaxScroll(w int) int { return max } +// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to +// the scrollable range (same contract as the diff/output scrollers). +func (m *Model) scrollArtifact(delta int) { + max := m.artifactContentMaxScroll(m.modalWidth() - 6) + off := m.artifactScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.artifactScroll = off +} + func (m Model) artifactsModal() string { t := m.theme w := m.modalWidth() @@ -628,7 +1078,7 @@ func (m Model) artifactsModal() string { b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } @@ -660,14 +1110,14 @@ func (m Model) statsModal() string { t := m.theme w := m.modalWidth() - var b strings.Builder - b.WriteString(m.titleLine("session stats")) - if m.selectedID != "" { - b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) - } - b.WriteString("\n\n") - + // Loading / empty: a small fixed modal, no scroll needed. if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + var b strings.Builder + b.WriteString(m.titleLine("session stats")) + if m.selectedID != "" { + b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) + } + b.WriteString("\n\n") msg := "loading…" if !m.statsLoading && m.stats == nil { msg = "no stats for this session" @@ -677,15 +1127,29 @@ func (m Model) statsModal() string { return t.Overlay.Width(w).Render(b.String()) } + suffix := "" + if m.selectedID != "" { + suffix = " (" + shortID(m.selectedID) + ")" + } + return m.renderScrollModal("session stats", suffix, m.statsBody(), + [][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}}) +} + +// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it). +// Callers must ensure m.stats is loaded for the selected session. +func (m Model) statsBody() []string { + t := m.theme s := m.stats - cw := w - 4 // modal inner content width (borders + padding) + cw := m.modalWidth() - 4 // modal inner content width (borders + padding) + var out []string section := func(label string) string { return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label) } // put clips each data line to the inner width so it never wraps on a slim modal. - put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") } + put := func(styled string) { out = append(out, clip(styled, cw)) } line := func(s string) string { return mbg(t, s, t.P.Fg) } faint := func(s string) string { return mbg(t, s, t.P.Faint) } + blank := func() { out = append(out, "") } // nameW shrinks the breakdown name column on slim terminals. nameW := 20 if cw < 52 { @@ -695,10 +1159,10 @@ func (m Model) statsModal() string { // header row: duration + event count put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) + faint(" events ") + line(itoa(int(s.EventCount)))) - b.WriteString("\n") + blank() // inference - b.WriteString(section("Inference") + "\n") + out = append(out, section("Inference")) put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s", s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond))) for i, p := range s.PerProvider { @@ -709,10 +1173,10 @@ func (m Model) statsModal() string { put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s", nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond))) } - b.WriteString("\n") + blank() // tools - b.WriteString(section("Tools") + "\n") + out = append(out, section("Tools")) put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs))) for i, tl := range s.PerTool { if i >= statsBreakdownRows { @@ -721,35 +1185,34 @@ func (m Model) statsModal() string { } put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs))) } - b.WriteString("\n") + blank() // approvals - b.WriteString(section("Approvals") + "\n") + out = append(out, section("Approvals")) put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms", s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs))) for _, tr := range s.PerTier { put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs))) } - b.WriteString("\n") + blank() // failures f := s.Failures - b.WriteString(section("Failures") + "\n") + out = append(out, section("Failures")) put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d", f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures))) - b.WriteString("\n") + blank() // time accounting other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct if other < 0 { other = 0 } - b.WriteString(section("Time") + "\n") + out = append(out, section("Time")) put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%", s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other))) - b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) - return t.Overlay.Width(w).Render(b.String()) + return out } func (m Model) healthModal() string { @@ -820,7 +1283,7 @@ func shortID(id string) string { return id[:13] + "…" } -func tierColor(t Theme, tier int) lipgloss.Color { +func tierColor(t Theme, tier int) color.Color { switch { case tier >= 3: return t.P.Bad diff --git a/apps/tui-go/internal/app/prefs.go b/apps/tui-go/internal/app/prefs.go new file mode 100644 index 00000000..c6727065 --- /dev/null +++ b/apps/tui-go/internal/app/prefs.go @@ -0,0 +1,99 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// prefs is the TUI's local, persisted preferences (display-only client state — kept out +// of the server config, which is shared/replayed). Stored as JSON in the correx config dir. +type prefs struct { + StatusbarHidden []string `json:"statusbarHidden"` + InlineActionsHidden bool `json:"inlineActionsHidden"` +} + +// statusSegment is one toggleable status-bar segment. The always-on segments (correx label, +// connection, session name, spinner, background-updates) are not listed — they are load-bearing. +type statusSegment struct{ id, label string } + +var statusSegments = []statusSegment{ + {"stage", "current stage (⟐)"}, + {"status", "session status"}, + {"workspace", "workspace path (⌂)"}, + {"model", "model name"}, + {"clock", "last-event clock"}, + {"gauge", "resource gauge"}, +} + +func prefsPath() string { + dir := os.Getenv("CORREX_CONFIG_HOME") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir = filepath.Join(home, ".config", "correx") + } + return filepath.Join(dir, "tui-prefs.json") +} + +// loadPrefs reads the whole prefs file (best-effort: a missing or corrupt file yields the +// zero value, i.e. everything shown / actions visible). +func loadPrefs() prefs { + var p prefs + path := prefsPath() + if path == "" { + return p + } + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &p) + } + return p +} + +// savePrefs writes the whole prefs file (best-effort; ignores IO errors so a read-only home +// never crashes the UI). Callers load, mutate one field, then save so nothing is clobbered. +func savePrefs(p prefs) { + path := prefsPath() + if path == "" { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if data, err := json.MarshalIndent(p, "", " "); err == nil { + _ = os.WriteFile(path, data, 0o644) + } +} + +// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or +// corrupt file yields an empty set, i.e. everything shown). +func loadStatusbarHidden() map[string]bool { + out := map[string]bool{} + for _, id := range loadPrefs().StatusbarHidden { + out[id] = true + } + return out +} + +// saveStatusbarHidden writes the hidden set back to disk, preserving the other prefs fields. +// Order follows statusSegments for a stable file. +func saveStatusbarHidden(hidden map[string]bool) { + var ids []string + for _, seg := range statusSegments { + if hidden[seg.id] { + ids = append(ids, seg.id) + } + } + p := loadPrefs() + p.StatusbarHidden = ids + savePrefs(p) +} + +// saveInlineActionsHidden persists the inline-action-rows toggle, preserving other prefs fields. +func saveInlineActionsHidden(hidden bool) { + p := loadPrefs() + p.InlineActionsHidden = hidden + savePrefs(p) +} diff --git a/apps/tui-go/internal/app/proposecard.go b/apps/tui-go/internal/app/proposecard.go index cbbe3b6c..c1f71c53 100644 --- a/apps/tui-go/internal/app/proposecard.go +++ b/apps/tui-go/internal/app/proposecard.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -24,7 +24,7 @@ func (m *Model) proposeInitState() { // --- key handling --- -func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Propose == nil { return m, nil @@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } n := len(s.Propose.Candidates) // index n == the custom slot switch k.Type { - case tea.KeyEsc: + case keyEsc: m.proposeDismissed = true - case tea.KeyUp: + case keyUp: m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) - case tea.KeyDown: + case keyDown: m.proposeCursor = clampInt(m.proposeCursor+1, 0, n) - case tea.KeyEnter, tea.KeySpace: + case keyEnter, keySpace: return m.proposeSubmit() - case tea.KeyRunes: + case keyRunes: switch string(k.Runes) { case "k": m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) @@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.proposeTyping = false - case tea.KeyEnter: + case keyEnter: return m.proposeSubmit() // commit the custom answer and send it as chat - case tea.KeyBackspace: + case keyBackspace: if len(m.proposeText) > 0 { m.proposeText = m.proposeText[:len(m.proposeText)-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.proposeText += string(k.Runes) } return m, nil diff --git a/apps/tui-go/internal/app/proposecard_test.go b/apps/tui-go/internal/app/proposecard_test.go index cd8d9f7b..7890e415 100644 --- a/apps/tui-go/internal/app/proposecard_test.go +++ b/apps/tui-go/internal/app/proposecard_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) { func TestProposePickLaunchesChosenWorkflow(t *testing.T) { m, client := proposeModel() // move cursor to the second candidate (role_pipeline) and launch it - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown}) - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyProposeKey(m, keyMsg{Type: keyDown}) + m = applyProposeKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Propose != nil { t.Fatalf("expected proposal cleared after launch") @@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) { func TestProposeFirstCandidateIsDefault(t *testing.T) { m, client := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate + m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate wf, _ := decodeStart(t, client) if wf != "research" { t.Fatalf("expected first candidate launched by default, got %q", wf) @@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) { func TestProposeCustomAnswerContinuesChat(t *testing.T) { m, client := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.proposeTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "do it manually" { - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyProposeKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Propose != nil { t.Fatalf("expected proposal cleared after custom answer") @@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) { func TestProposeEscDismisses(t *testing.T) { m, _ := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + m = applyProposeKey(m, keyMsg{Type: keyEsc}) if m.displayState() != StateInSession { t.Fatalf("expected StateInSession after esc, got %v", m.displayState()) } @@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) { } } -func applyProposeKey(m Model, k tea.KeyMsg) Model { +func applyProposeKey(m Model, k keyMsg) Model { updated, _ := m.handleProposeKey(k) return updated.(Model) } diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go new file mode 100644 index 00000000..ac2d5251 --- /dev/null +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -0,0 +1,671 @@ +package app + +import ( + "bufio" + "os" + "regexp" + "strings" + "testing" + + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// render_matrix_test.go is the per-event-type golden render matrix (BACKLOG §E §2): +// one case per server event/entry kind the TUI handles. Each case drives a +// representative protocol.ServerMessage through the *production* path (applyServer), +// renders the surface it lands on (View / a card / a modal / the router transcript), +// strips ANSI, and asserts the render carries that kind's defining, stable text. +// +// Goldens are inline expected substrings rather than golden files: it matches the +// module's existing render-test convention (clarcard_test, ideacard_test, …), needs no +// -update dance, and pins the *meaning* of each render (the tool name, stage id, status +// label, card title) without coupling to exact lipgloss styling or timestamps. +// +// The coverage guard (TestRenderMatrixCoversEveryEventType) is the load-bearing part: +// it parses every protocol.Type* constant and fails if a new event kind is added +// without either a matrix case or an explicit non-rendering classification. + +// matrixSurface selects which render surface a case asserts against — each is a real +// production render entry point, fed off the same Model that applyServer mutated. +type matrixSurface int + +const ( + surfaceView matrixSurface = iota // full immediate-mode frame, View() + surfaceEvents // right-panel event-stream rows (eventRows) + surfaceRouter // left transcript rows (routerRows) + surfaceApprovalBand // docked approval band (renderApprovalBand) + surfaceClarModal // clarification modal + surfaceProposeModal // workflow-propose modal + surfaceIdeasModal // idea board overlay + surfaceStatsModal // session-stats overlay + surfaceConfigModal // config editor overlay + surfaceArtifactsModal // artifact viewer overlay + surfaceModelsModal // model swap overlay + surfaceStatusBar // top status bar (renderStatus) +) + +// fixedNow is a deterministic timestamp used for every event carrying OccurredAt. The +// matrix asserts only event type/detail text, never the rendered clock — formatTime is +// local (machine-TZ dependent), so the time string is intentionally not part of any golden. +const fixedNow int64 = 1_700_000_000_000 + +// matrixCase is one event/entry kind in the matrix. +type matrixCase struct { + name string // human label / subtest name + kinds []string // protocol.Type* values this case exercises + build func() protocol.ServerMessage // a representative frame + surface matrixSurface // where its render lands + // prep mutates the model before applyServer (e.g. select + enter a session so an + // in-session surface is reachable, or open the overlay an *.list reply fills). + prep func(m *Model) + want []string // ANSI-stripped substrings the render must contain +} + +// newMatrixModel builds a deterministic Model: fixed width/theme, an entered session, +// an unconnected ws client (Send buffers, never dials). +func newMatrixModel() Model { + m := NewModel(ws.New("", 0)) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.connected = true + m.selectedID = "s1" + m.sessionEntered = true + m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}} + return m +} + +// renderSurface renders the requested surface off m and strips ANSI. +func renderSurface(m Model, s matrixSurface) string { + var raw string + switch s { + case surfaceView: + raw = m.render() + case surfaceEvents: + // Render the event-stream rows at full panel width so a row's detail isn't + // clipped at the slim right-panel edge (the production builder, just wide). + raw = strings.Join(m.eventRows(110, 40), "\n") + case surfaceRouter: + raw = strings.Join(m.routerRows(100, 30), "\n") + case surfaceApprovalBand: + raw = m.renderApprovalBand(m.approvalBandHeight()) + case surfaceClarModal: + raw = m.clarificationModal() + case surfaceProposeModal: + raw = m.proposeModal() + case surfaceIdeasModal: + raw = m.ideasModal() + case surfaceStatsModal: + raw = m.statsModal() + case surfaceConfigModal: + raw = m.configModal() + case surfaceArtifactsModal: + raw = m.artifactsModal() + case surfaceModelsModal: + raw = m.modelsModal() + case surfaceStatusBar: + raw = m.renderStatus() + } + return stripANSI(raw) +} + +// matrixCases is the matrix: one entry per event/entry kind the renderer handles. +func matrixCases() []matrixCase { + str := func(s string) *string { return &s } + return []matrixCase{ + // --- session lifecycle (status bar + narration feed) --- + { + name: "session.announced", + kinds: []string{protocol.TypeSessionAnnounced}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionAnnounced, withWF("healthcheck")) }, + want: []string{"healthcheck", "ACTIVE"}, + }, + { + name: "session.paused", + kinds: []string{protocol.TypeSessionPaused}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionPaused) }, + want: []string{"paused"}, + }, + { + name: "session.resumed", + kinds: []string{protocol.TypeSessionResumed}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionResumed) }, + want: []string{"resumed"}, + }, + { + name: "session.completed", + kinds: []string{protocol.TypeSessionCompleted}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionCompleted) }, + want: []string{"workflow complete"}, + }, + { + name: "session.failed", + kinds: []string{protocol.TypeSessionFailed}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionFailed, withReason("schema mismatch")) }, + want: []string{"workflow failed", "schema mismatch"}, + }, + + // --- chat / router transcript --- + { + name: "chat.turn (router)", + kinds: []string{protocol.TypeChatTurn}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { + return msg(protocol.TypeChatTurn, func(m *protocol.ServerMessage) { + m.Role, m.Content = "ROUTER", "running the healthcheck now" + }) + }, + want: []string{"running the healthcheck now"}, + }, + { + name: "router.narration", + kinds: []string{protocol.TypeRouterNarration}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { + return msg(protocol.TypeRouterNarration, func(m *protocol.ServerMessage) { + m.Content = "moving on to validation" + }) + }, + want: []string{"moving on to validation", "◆"}, + }, + + // --- stage lifecycle (event stream rows) --- + { + name: "stage.started", + kinds: []string{protocol.TypeStageStarted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageStarted, withStage("write_script")) }, + want: []string{"StageStarted", "write_script"}, + }, + { + name: "stage.completed", + kinds: []string{protocol.TypeStageCompleted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageCompleted, withStage("write_script")) }, + want: []string{"StageCompleted", "write_script"}, + }, + { + name: "stage.failed", + kinds: []string{protocol.TypeStageFailed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageFailed, withStage("generate")) }, + want: []string{"StageFailed", "generate"}, + }, + + // --- inference lifecycle (event stream rows) --- + { + name: "inference.started", + kinds: []string{protocol.TypeInferenceStarted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceStarted, withStage("plan")) }, + want: []string{"InferenceStarted", "plan"}, + }, + { + name: "inference.completed", + kinds: []string{protocol.TypeInferenceDone}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceDone, withStage("plan"), func(m *protocol.ServerMessage) { m.Summary = "ok" }) + }, + want: []string{"InferenceCompleted", "plan"}, + }, + { + name: "inference.timed_out", + kinds: []string{protocol.TypeInferenceTimeout}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceTimeout, withStage("plan")) }, + want: []string{"InferenceTimedOut", "plan"}, + }, + { + name: "inference.failed", + kinds: []string{protocol.TypeInferenceFailed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceFailed, withStage("plan"), withReason("oom")) + }, + want: []string{"InferenceFailed", "plan", "oom"}, + }, + { + name: "inference.retry", + kinds: []string{protocol.TypeInferenceRetry}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceRetry, withStage("plan"), func(m *protocol.ServerMessage) { + m.AttemptNumber, m.MaxAttempts, m.FailureReason = 2, 3, "rate limited" + }) + }, + want: []string{"RetryAttempted", "plan", "2/3", "rate limited"}, + }, + + // --- tools --- + { + name: "tool.started", + kinds: []string{protocol.TypeToolStarted}, + surface: surfaceStatusBar, // started shows as the active spinner; record lands in s.Tools + build: func() protocol.ServerMessage { return msg(protocol.TypeToolStarted, withTool("file_write")) }, + want: []string{"active"}, + }, + { + name: "tool.completed", + kinds: []string{protocol.TypeToolCompleted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeToolCompleted, withTool("file_write"), func(m *protocol.ServerMessage) { m.Summary = "wrote 2 files" }) + }, + want: []string{"ToolCompleted", "file_write"}, + }, + { + name: "tool.failed", + kinds: []string{protocol.TypeToolFailed}, + surface: surfaceView, + prep: func(m *Model) { + // failed marks an already-started tool record; seed one so the status flips. + s := m.session("s1") + s.Tools = []ToolRecord{{Name: "file_write", Status: ToolStarted}} + }, + build: func() protocol.ServerMessage { return msg(protocol.TypeToolFailed, withTool("file_write")) }, + // tool.failed is a state-only mutation (no event row, no card); assert it + // renders the in-session frame without crashing and keeps the session header. + want: []string{"healthcheck"}, + }, + { + name: "tool.rejected", + kinds: []string{protocol.TypeToolRejected}, + surface: surfaceView, + prep: func(m *Model) { + s := m.session("s1") + s.Tools = []ToolRecord{{Name: "shell_exec", Status: ToolStarted}} + }, + build: func() protocol.ServerMessage { return msg(protocol.TypeToolRejected, withTool("shell_exec")) }, + want: []string{"healthcheck"}, + }, + { + name: "tool.assessed", + kinds: []string{protocol.TypeToolAssessed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeToolAssessed, withTool("file_write"), func(m *protocol.ServerMessage) { + m.Disposition = "BLOCK" + m.AssessedIssues = []protocol.AssessedIssueDto{{Code: "PATH_ESCAPE", Message: "outside workspace", Severity: "HIGH"}} + }) + }, + want: []string{"ToolAssessed", "BLOCK", "PATH_ESCAPE"}, + }, + + // --- artifacts / plan (event stream rows) --- + { + name: "artifact.created", + kinds: []string{protocol.TypeArtifactCreated}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeArtifactCreated, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" }) + }, + want: []string{"ArtifactCreated", "art-7"}, + }, + { + name: "artifact.validated", + kinds: []string{protocol.TypeArtifactValid}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeArtifactValid, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" }) + }, + want: []string{"ArtifactValidated", "art-7"}, + }, + { + name: "plan.locked", + kinds: []string{protocol.TypePlanLocked}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypePlanLocked, func(m *protocol.ServerMessage) { m.StageIDs = []string{"plan", "build", "verify"} }) + }, + want: []string{"PlanLocked", "plan", "build", "verify"}, + }, + + // --- workspace bind (status bar) --- + { + name: "session.workspace_bound", + kinds: []string{protocol.TypeWorkspaceBound}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return msg(protocol.TypeWorkspaceBound, func(m *protocol.ServerMessage) { m.WorkspaceRoot = "/tmp/corx-ws" }) + }, + want: []string{"corx-ws"}, + }, + + // --- approval gate (docked band card) --- + { + name: "approval.required", + kinds: []string{protocol.TypeApprovalRequired}, + surface: surfaceApprovalBand, + build: func() protocol.ServerMessage { + return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) { + m.RequestID, m.Tier = "req-1", "T3" + m.Preview = str("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n") + m.RiskSummary = &protocol.RiskSummaryDto{Level: "HIGH", Rationale: []string{"[PATH_OUTSIDE_WORKSPACE] /etc/hosts"}} + }) + }, + want: []string{"file_write", "T3", "HIGH", "PATH_OUTSIDE_WORKSPACE"}, + }, + { + name: "approval.resolved", + kinds: []string{protocol.TypeApprovalResolved}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeApprovalResolved, func(m *protocol.ServerMessage) { m.Outcome, m.Reason = "APPROVED", "operator ok" }) + }, + want: []string{"ApprovalResolved", "APPROVED", "operator ok"}, + }, + + // --- clarification (modal card) --- + { + name: "clarification.required", + kinds: []string{protocol.TypeClarification}, + surface: surfaceClarModal, + build: func() protocol.ServerMessage { + return msg(protocol.TypeClarification, withStage("analyst"), func(m *protocol.ServerMessage) { + m.RequestID = "req-1" + m.Questions = []protocol.ClarificationQuestionDto{ + {ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"}, + } + }) + }, + want: []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack"}, + }, + + // --- workflow proposal (modal card) --- + { + name: "workflow.proposed", + kinds: []string{protocol.TypeWorkflowProposed}, + surface: surfaceProposeModal, + build: func() protocol.ServerMessage { + return msg(protocol.TypeWorkflowProposed, func(m *protocol.ServerMessage) { + m.ProposalID, m.Prompt, m.OriginalRequest = "prop-1", "Run one of these?", "scan the repo" + m.Candidates = []protocol.ProposedWorkflowDto{ + {WorkflowID: "research", Reason: "gather + report"}, + {WorkflowID: "role_pipeline", Reason: "full build"}, + } + }) + }, + want: []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline"}, + }, + + // --- idea board (overlay; cross-session, no session scope) --- + { + name: "idea.list", + kinds: []string{protocol.TypeIdeaList}, + surface: surfaceIdeasModal, + prep: func(m *Model) { m.overlay = OverlayIdeas }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeIdeaList, Ideas: []protocol.IdeaDto{ + {IdeaID: "i1", Text: "cache the repo map", CapturedAtMs: 1000}, + }} + }, + want: []string{"ideas", "cache the repo map"}, + }, + + // --- session stats (overlay) --- + { + name: "session.stats", + kinds: []string{protocol.TypeSessionStats}, + surface: surfaceStatsModal, + prep: func(m *Model) { m.overlay = OverlayStats; m.statsFor = "s1" }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeSessionStats, SessionID: "s1", Stats: sampleStats()} + }, + want: []string{"file_write", "read_file"}, + }, + + // --- config snapshot (overlay) --- + { + name: "config.snapshot", + kinds: []string{protocol.TypeConfigSnapshot}, + surface: surfaceConfigModal, + prep: func(m *Model) { m.overlay = OverlayConfig }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeConfigSnapshot, ConfigFields: []protocol.ConfigFieldDto{ + {Key: "router.model", Type: "string", Value: "qwen2.5-coder"}, + }} + }, + want: []string{"router.model", "qwen2.5-coder"}, + }, + + // --- artifact list (overlay) --- + { + name: "artifact.list", + kinds: []string{protocol.TypeArtifactList}, + surface: surfaceArtifactsModal, + prep: func(m *Model) { m.overlay = OverlayArtifacts; m.artifactsFor = "s1" }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeArtifactList, SessionID: "s1", Artifacts: []protocol.ArtifactDto{ + {ArtifactID: "art-7", StageID: "plan", SchemaVersion: 1, Phase: "PLAN", Content: str("{\"ok\":true}")}, + }} + }, + want: []string{"art-7"}, + }, + + // --- model list / changed (overlay + status chrome) --- + { + name: "model.list", + kinds: []string{protocol.TypeModelList}, + surface: surfaceModelsModal, + prep: func(m *Model) { m.overlay = OverlayModels }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeModelList, Models: []string{"qwen2.5-coder", "llama3"}, Current: "qwen2.5-coder"} + }, + want: []string{"qwen2.5-coder", "llama3"}, + }, + { + name: "model.changed", + kinds: []string{protocol.TypeModelChanged}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeModelChanged, ModelID: "llama3-swapped", Loaded: true} + }, + want: []string{"llama3-swapped"}, + }, + + // --- provider status (status bar model + locality) --- + { + name: "provider.status_changed", + kinds: []string{protocol.TypeProviderStatus}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeProviderStatus, ProviderID: "llama-cpp:qwen"} + }, + want: []string{"llama-cpp:qwen", "local"}, + }, + + // --- resource gauge (status bar) --- + { + name: "resource.status", + kinds: []string{protocol.TypeResourceStatus}, + surface: surfaceStatusBar, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false }, // gauge shows on the idle bar + build: func() protocol.ServerMessage { + used, total, util := int64(4096), int64(8192), 42 + return protocol.ServerMessage{Type: protocol.TypeResourceStatus, + GpuMemoryUsedMb: &used, GpuMemoryTotalMb: &total, GpuUtilizationPct: &util} + }, + want: []string{"VRAM 4096/8192M", "GPU 42%"}, + }, + + // --- workflow list → idle launch target (Tab-selected, shown at the input) --- + { + name: "workflow.list", + kinds: []string{protocol.TypeWorkflowList}, + surface: surfaceView, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.launcherWf = 1 }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{ + {WorkflowID: "healthcheck", Description: "kick the tires"}, + }} + }, + want: []string{"healthcheck"}, + }, + + // --- session snapshot (reopen: rebuilds a session from the recent-events tail) --- + { + name: "session_snapshot", + kinds: []string{protocol.TypeSessionSnapshot}, + surface: surfaceEvents, + prep: func(m *Model) { m.sessions = nil; m.selectedID = "snap1"; m.sessionEntered = true }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{ + Type: protocol.TypeSessionSnapshot, SessionID: "snap1", WorkflowID: "refactor", + State: &protocol.SessionStateDto{Status: "PAUSED"}, + RecentEvents: []protocol.EventEntryDto{{Timestamp: fixedNow, Type: "StageStarted", Detail: "plan"}}, + } + }, + want: []string{"StageStarted", "plan"}, + }, + + // --- stage tool manifest (declared, not-yet-run tools — state only) --- + { + name: "stage.tool_manifest", + kinds: []string{protocol.TypeStageManifest}, + surface: surfaceView, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeStageManifest, SessionID: "s1", Stages: []protocol.StageToolDecl{ + {StageID: "plan", Tools: []protocol.ToolDecl{{Name: "read_file", Tier: 1}}}, + }} + }, + // manifest seeds s.ToolsByStage (surfaced by the tool palette, not the main + // frame); assert the in-session frame still renders cleanly with the session. + want: []string{"healthcheck"}, + }, + + // --- unknown / future event: raw fallback row, never dropped (§2) --- + { + name: "unknown event (raw fallback)", + kinds: []string{"some.future.event"}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: "some.future.event", SessionID: "s1", OccurredAt: fixedNow} + }, + want: []string{"some.future.event", "raw"}, + }, + } +} + +// --- frame builders (keep cases terse) --- + +type msgOpt func(*protocol.ServerMessage) + +func msg(typ string, opts ...msgOpt) protocol.ServerMessage { + m := protocol.ServerMessage{Type: typ, SessionID: "s1", OccurredAt: fixedNow} + for _, o := range opts { + o(&m) + } + return m +} + +func withWF(id string) msgOpt { return func(m *protocol.ServerMessage) { m.WorkflowID = id } } +func withStage(id string) msgOpt { return func(m *protocol.ServerMessage) { m.StageID = id } } +func withTool(name string) msgOpt { return func(m *protocol.ServerMessage) { m.ToolName = name } } +func withReason(r string) msgOpt { return func(m *protocol.ServerMessage) { m.Reason = r } } + +// TestRenderMatrix is the table-driven golden render matrix: every event/entry kind, +// driven through applyServer, asserts its render carries its defining stable text. +func TestRenderMatrix(t *testing.T) { + for _, c := range matrixCases() { + t.Run(c.name, func(t *testing.T) { + m := newMatrixModel() + if c.prep != nil { + c.prep(&m) + } + m.applyServer(c.build()) + out := renderSurface(m, c.surface) + for _, want := range c.want { + if !strings.Contains(out, want) { + t.Fatalf("render for %q missing %q\n--- visible ---\n%s", c.name, want, out) + } + } + }) + } +} + +// nonRenderingEventTypes are protocol.Type* constants the TUI handles but that have no +// per-kind render of their own — they are explicitly classified here so the coverage +// guard stays exhaustive. Adding a new Type* constant forces a choice: give it a matrix +// case, or list it here with a reason. Either way the guard can't silently pass. +var nonRenderingEventTypes = map[string]string{ + protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render", + protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream", + protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", + protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript", + protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript", + protocol.TypeHealthChecks: "query reply: populates the health-checks overlay, not the transcript", +} + +// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans +// every protocol.Type* constant declared in protocol.go and fails if any is neither +// covered by a matrix case nor explicitly classified as non-rendering. A new event kind +// added to the protocol without a matching render assertion breaks this test. +func TestRenderMatrixCoversEveryEventType(t *testing.T) { + declared := declaredEventTypes(t) + if len(declared) < 30 { + t.Fatalf("expected to scan ~40 protocol Type* constants, found only %d — parser drift?", len(declared)) + } + + covered := map[string]bool{} + for _, c := range matrixCases() { + for _, k := range c.kinds { + covered[k] = true + } + } + + var missing []string + for typ := range declared { + if covered[typ] { + continue + } + if _, ok := nonRenderingEventTypes[typ]; ok { + continue + } + missing = append(missing, typ) + } + if len(missing) > 0 { + t.Fatalf("event types with no matrix case and no non-rendering classification: %v\n"+ + "add a case to matrixCases() (preferred) or list it in nonRenderingEventTypes with a reason", missing) + } + + // Reverse guard: every classified non-rendering type must still be a real constant + // (catches a typo or a removed constant rotting the allowlist). + for typ := range nonRenderingEventTypes { + if !declared[typ] { + t.Errorf("nonRenderingEventTypes lists %q which is not a declared protocol Type* value", typ) + } + } +} + +// declaredEventTypes parses protocol.go and returns the value of every Type* string +// constant (e.g. "stage.started"). Deriving the authoritative set from source — rather +// than a hand-kept list — is what makes the coverage guard catch *new* event kinds. +func declaredEventTypes(t *testing.T) map[string]bool { + t.Helper() + const src = "../protocol/protocol.go" + f, err := os.Open(src) + if err != nil { + t.Fatalf("open %s: %v", src, err) + } + defer f.Close() + + // Matches lines like: TypeStageStarted = "stage.started" + re := regexp.MustCompile(`^\s*Type[A-Za-z0-9]+\s*=\s*"([^"]+)"`) + out := map[string]bool{} + sc := bufio.NewScanner(f) + for sc.Scan() { + if mm := re.FindStringSubmatch(sc.Text()); mm != nil { + out[mm[1]] = true + } + } + if err := sc.Err(); err != nil { + t.Fatalf("scan %s: %v", src, err) + } + return out +} diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index a323028d..d05f5215 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -22,8 +22,11 @@ func containsFold(haystack, needle string) bool { return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) } +// formatTime renders an event/activity instant as a local wall-clock time, matching the +// operator's clock (and the local date shown in the session list). It was previously forced +// to UTC, so every event timestamp read hours off the user's actual time. func formatTime(epochMillis int64) string { - return time.UnixMilli(epochMillis).UTC().Format("15:04:05") + return time.UnixMilli(epochMillis).Format("15:04:05") } // inferCategory maps an event type string to a display category, matching the @@ -125,7 +128,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { case protocol.TypeSessionResumed: if s := m.session(msg.SessionID); s != nil { s.Status = "ACTIVE" - s.Pending = nil + s.clearApprovals() s.LastEventAt = nowMillis() } case protocol.TypeSessionCompleted: @@ -134,6 +137,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.Active = false s.Clar = nil s.Propose = nil + s.clearApprovals() } case protocol.TypeSessionFailed: m.touch(msg.SessionID, "FAILED") @@ -141,6 +145,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.Active = false s.Clar = nil s.Propose = nil + s.clearApprovals() } case protocol.TypeStageStarted: if s := m.session(msg.SessionID); s != nil { @@ -196,6 +201,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } s.LastEventAt = nowMillis() } + // Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it + // stays in the tool list + EVENTS panel. case protocol.TypeToolCompleted: if s := m.session(msg.SessionID); s != nil { s.Active = false @@ -204,7 +211,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName) } if msg.Diff != nil && *msg.Diff != "" { + // A diff = a write/edit: name the file + the line delta, then keep the + // existing collapsed diff row (^x opens the full diff). + path, add, del := diffSummary(*msg.Diff) + m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del)) m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff}) + } else { + m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary)) } case protocol.TypeToolAssessed: if s := m.session(msg.SessionID); s != nil { @@ -222,11 +235,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.markTool(msg.ToolName, ToolFailed) s.LastEventAt = msg.OccurredAt } + m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason)) case protocol.TypeToolRejected: if s := m.session(msg.SessionID); s != nil { s.markTool(msg.ToolName, ToolRejected) s.LastEventAt = nowMillis() } + m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason)) case protocol.TypeArtifactCreated: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) @@ -252,13 +267,25 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { m.onWorkflowProposed(msg) case protocol.TypeApprovalResolved: if s := m.session(msg.SessionID); s != nil { - s.Pending = nil + // The resolved gate names no tool on the wire; recover it from the pending + // queue before dropping the gate, for the inline row. + tool := "" + for _, a := range s.PendingQueue { + if a.RequestID == msg.RequestID { + tool = a.ToolName + break + } + } + // Drop just the resolved gate; any others stay queued and the band + // advances to the next rather than vanishing entirely. + s.removeApproval(msg.RequestID) detail := msg.Outcome if msg.Reason != "" { detail += " — " + msg.Reason } s.addEvent(msg.OccurredAt, "ApprovalResolved", detail) s.LastEventAt = nowMillis() + m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason)) } case protocol.TypeSessionSnapshot: m.onSnapshot(msg) @@ -325,6 +352,19 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { case protocol.TypeHealthChecks: m.health = msg.Health m.healthLoading = false + case protocol.TypeFileList: + m.files = msg.Paths + m.filesFor = msg.SessionID + m.filesLoading = false + if m.fileIndex >= len(m.filteredFiles()) { + m.fileIndex = 0 + } + case protocol.TypeGrantList: + m.grants = msg.Grants + m.grantsLoading = false + if m.grantIndex >= len(m.grants) { + m.grantIndex = 0 + } case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -370,13 +410,81 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, - protocol.TypeIdeaList, protocol.TypeHealthChecks: + protocol.TypeIdeaList, protocol.TypeHealthChecks, + protocol.TypeFileList, protocol.TypeGrantList: return "" default: return msg.SessionID } } +// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) --- + +// diffSummary extracts the written path and +/- line counts from a unified diff for the +// inline write row. Falls back to "file" when no `+++` header is present. +func diffSummary(diff string) (path string, added, removed int) { + path = "file" + for _, ln := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(ln, "+++ "): + p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ ")) + p = strings.TrimPrefix(p, "b/") + if p != "" && p != "/dev/null" { + path = p + } + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "): + added++ + case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "): + removed++ + } + } + return path, added, removed +} + +// countSuffix renders the " (+a −b)" delta, or "" when nothing changed. +func countSuffix(added, removed int) string { + if added == 0 && removed == 0 { + return "" + } + return " (+" + itoa(added) + " −" + itoa(removed) + ")" +} + +// actionToolText joins a tool label with a short, clipped result summary. +func actionToolText(label, summary string) string { + s := strings.TrimSpace(summary) + if s == "" { + return label + } + return label + " · " + clip(s, 48) +} + +func approvalIcon(outcome string) string { + if outcome == "REJECTED" { + return "✕" + } + return "⌘" +} + +// approvalActionText renders the inline approval row, noting an auto-approval that fired via a +// standing grant (reason "grant:"). +func approvalActionText(outcome, tool, reason string) string { + verb := "approved" + switch outcome { + case "REJECTED": + verb = "rejected" + case "AUTO_APPROVED": + verb = "auto-approved" + } + txt := verb + if tool != "" { + txt = verb + " " + tool + } + if strings.HasPrefix(reason, "grant:") { + txt += " · via grant" + } + return txt +} + // onSessionAnnounced fills in a session's workflow identity (the announce is the // only event carrying workflowId) and applies auto-focus. The session entry itself // was already created by the auto-vivify path in applyServer. @@ -414,7 +522,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { Rationale: rationale, } if s := m.session(msg.SessionID); s != nil { - s.Pending = info + s.enqueueApproval(info) } } @@ -456,25 +564,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) { } func (m *Model) onSnapshot(msg protocol.ServerMessage) { - var pending *Approval - if len(msg.PendingAppr) > 0 { - a := msg.PendingAppr[0] - pending = &Approval{ + var queue []*Approval + for _, a := range msg.PendingAppr { + queue = append(queue, &Approval{ RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier, Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview), - } + }) } status := "running" if msg.State != nil { status = msg.State.Status } - if pending != nil { + if len(queue) > 0 { status = "PAUSED awaiting approval" } sess := Session{ ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID, - Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending, + Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue, } + sess.syncPending() if msg.State != nil && msg.State.CurrentStageID != nil { sess.CurrentStage = *msg.State.CurrentStageID } diff --git a/apps/tui-go/internal/app/sessions_overlay.go b/apps/tui-go/internal/app/sessions_overlay.go new file mode 100644 index 00000000..4963ad1b --- /dev/null +++ b/apps/tui-go/internal/app/sessions_overlay.go @@ -0,0 +1,274 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// SessionSummary is one recent session from GET /sessions. Mirrors the server's +// SessionSummaryResponse (apps/server SessionRoutes.kt) — the wire shape. +type SessionSummary struct { + SessionID string `json:"sessionId"` + Status string `json:"status"` + Intent string `json:"intent"` + WorkflowID string `json:"workflowId"` + StageCount int `json:"stageCount"` + CreatedAt string `json:"createdAt"` + LastActivityAt string `json:"lastActivityAt"` +} + +// sessionsLoadedMsg carries the result of a GET /sessions fetch. Exactly one of +// sessions / err is meaningful; err non-empty means the fetch failed and the +// overlay shows it instead of crashing. +type sessionsLoadedMsg struct { + sessions []SessionSummary + err string +} + +// sessionResumeMsg is the outcome of asking the server to resume a session +// (POST /sessions/{id}/resume). On success its events replay onto the live +// stream the app already drains; on failure the overlay shows the error. +type sessionResumeMsg struct { + sessionID string + err string +} + +// sessionsHTTPTimeout bounds the REST calls so a wedged server can't hang the UI. +const sessionsHTTPTimeout = 5 * time.Second + +// fetchSessions is a tea.Cmd that GETs the recent-session roster over HTTP and +// reports it as a sessionsLoadedMsg. base is the http://host:port origin (from +// the ws client); an empty base yields an error message rather than a panic. +func fetchSessions(base string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return sessionsLoadedMsg{err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Get(base + "/sessions") + if err != nil { + return sessionsLoadedMsg{err: "fetch failed: " + err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return sessionsLoadedMsg{err: "server returned " + resp.Status} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return sessionsLoadedMsg{err: "read failed: " + err.Error()} + } + var out []SessionSummary + if err := json.Unmarshal(body, &out); err != nil { + return sessionsLoadedMsg{err: "decode failed: " + err.Error()} + } + return sessionsLoadedMsg{sessions: out} + } +} + +// resumeSession is a tea.Cmd that asks the server to rehydrate and relaunch a +// session (POST /sessions/{id}/resume). The server replays the rehydrated +// session onto the global stream this client already reads, so no WS re-dial is +// needed — the existing transport carries it. +func resumeSession(base, id string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return sessionResumeMsg{sessionID: id, err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Post(base+"/sessions/"+id+"/resume", "application/json", nil) + if err != nil { + return sessionResumeMsg{sessionID: id, err: "resume failed: " + err.Error()} + } + defer resp.Body.Close() + // 202 Accepted on success; 200 (already running) is fine too. + if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { + return sessionResumeMsg{sessionID: id, err: "server returned " + resp.Status} + } + return sessionResumeMsg{sessionID: id} + } +} + +// openSessions opens the recent-session browser and kicks off the HTTP fetch. +// It is global (not bound to the selected session), so it opens from anywhere — +// including the idle list after a fresh reconnect with no live sessions yet. +func (m *Model) openSessions() tea.Cmd { + m.overlay = OverlaySessions + m.sessionListIndex = 0 + m.sessionListErr = "" + m.sessionListLoading = true + return fetchSessions(m.client.HTTPBase()) +} + +// handleSessionsKey owns every key while the session browser is open: navigate +// the roster, esc closes, enter resumes the selected session. +func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) { + switch { + case k.Type == keyEsc || runeIs(k, "R"): + m.overlay = OverlayNone + case k.Type == keyUp || runeIs(k, "k"): + if m.sessionListIndex > 0 { + m.sessionListIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.sessionListIndex < len(m.sessionList)-1 { + m.sessionListIndex++ + } + case k.Type == keyEnter: + if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) { + return m.openSelectedSession() + } + } + return m, nil +} + +// openSelectedSession focuses the highlighted recent session locally and asks the +// server to resume it. Focusing it first means its replayed events land on the +// session the operator is now looking at; the resume cmd does the rest over the +// existing stream (no WS re-dial — see resumeSession). +func (m Model) openSelectedSession() (tea.Model, tea.Cmd) { + sum := m.sessionList[m.sessionListIndex] + s := m.ensureSession(sum.SessionID) + if sum.WorkflowID != "" { + s.WorkflowID = sum.WorkflowID + if s.Name == "" { + s.Name = sum.WorkflowID + } + } + if sum.Status != "" { + s.Status = sum.Status + } + m.selectedID = sum.SessionID + m.sessionEntered = true + m.overlay = OverlayNone + return m, resumeSession(m.client.HTTPBase(), sum.SessionID) +} + +func (m Model) sessionsModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("sessions")) + if len(m.sessionList) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.sessionListIndex+1)+"/"+itoa(len(m.sessionList))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + if m.sessionListErr != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.sessionListErr, w-8)) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.sessionListLoading && len(m.sessionList) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if len(m.sessionList) == 0 { + b.WriteString(mbg(t, " no recent sessions", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + // Windowed roster, keeping the selected row in view. + bodyH := m.height*70/100 - 6 + if bodyH < 4 { + bodyH = 4 + } + off := 0 + if len(m.sessionList) > bodyH { + off = m.sessionListIndex - bodyH/2 + if off < 0 { + off = 0 + } + if off > len(m.sessionList)-bodyH { + off = len(m.sessionList) - bodyH + } + } + end := off + bodyH + if end > len(m.sessionList) { + end = len(m.sessionList) + } + for i := off; i < end; i++ { + b.WriteString(m.sessionListRow(i) + "\n") + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "select"}, {"enter", "resume"}, {"R/esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +// sessionListRow renders one roster line: marker, short id, status, current +// stage (if any), and relative age of the last activity. +func (m Model) sessionListRow(i int) string { + t := m.theme + s := m.sessionList[i] + + marker := mbg(t, " ", t.P.BgPanel) + idFg := t.P.Fg + if i == m.sessionListIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + idFg = t.P.FgStrong + } + + idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortSessionID(s.SessionID), 8)) + statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true). + Render(padRaw(statusLabel(s.Status), 9)) + + stage := s.WorkflowID + if s.StageCount > 0 { + stage += " ·" + itoa(s.StageCount) + "stg" + } + stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2) + + dateCell := mbg(t, padRaw(absDateISO(s.LastActivityAt), 12), t.P.Dim) + ageCell := mbg(t, "· "+relativeAge(s.LastActivityAt), t.P.Faint) + + return marker + idCell + " " + statusCell + " " + stageCell + " " + dateCell + " " + ageCell +} + +// absDateISO renders an ISO-8601 instant (the server's LastActivityAt) as a compact local +// date via shortDateTime, or "—" when absent/unparseable. +func absDateISO(iso string) string { + if iso == "" { + return "—" + } + ts, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + if ts, err = time.Parse(time.RFC3339, iso); err != nil { + return "—" + } + } + return shortDateTime(ts.UnixMilli()) +} + +// shortSessionID trims a long session id to a recognizable prefix for the list. +func shortSessionID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} + +// relativeAge renders an ISO-8601 instant string (Instant.toString() on the +// server) as a compact "Ns ago" age, falling back to "—" when absent/unparseable. +func relativeAge(iso string) string { + if iso == "" { + return "—" + } + ts, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + ts, err = time.Parse(time.RFC3339, iso) + if err != nil { + return "—" + } + } + return agoLabel(nowMillis() - ts.UnixMilli()) +} diff --git a/apps/tui-go/internal/app/sessions_overlay_test.go b/apps/tui-go/internal/app/sessions_overlay_test.go new file mode 100644 index 00000000..00e5023d --- /dev/null +++ b/apps/tui-go/internal/app/sessions_overlay_test.go @@ -0,0 +1,150 @@ +package app + +import ( + "strings" + "testing" +) + +func sessionsModel() Model { + m := NewModel(nil) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + return m +} + +// sampleSummaries is a deterministic two-row roster used across the tests. +func sampleSummaries() []SessionSummary { + return []SessionSummary{ + {SessionID: "aaaaaaaa-1111", Status: "ACTIVE", WorkflowID: "refactor", StageCount: 2}, + {SessionID: "bbbbbbbb-2222", Status: "FAILED", WorkflowID: "healthcheck"}, + } +} + +func TestSessionsOverlayToggle(t *testing.T) { + m := sessionsModel() + // `R` opens the browser from the idle list. + updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("R")}) + m = updated.(Model) + if m.overlay != OverlaySessions { + t.Fatalf("expected OverlaySessions after R, got %d", m.overlay) + } + if !m.sessionListLoading { + t.Fatalf("expected loading state right after open") + } + // `esc` closes it (routed through the overlay key handler). + updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after esc, got %d", m.overlay) + } +} + +func TestSessionsLoadedPopulatesAndRenders(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionListLoading = true + m.applySessionsLoaded(sessionsLoadedMsg{sessions: sampleSummaries()}) + if m.sessionListLoading { + t.Fatalf("expected loading cleared after load") + } + if len(m.sessionList) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(m.sessionList)) + } + out := m.sessionsModal() + if !strings.Contains(out, "aaaaaaaa") || !strings.Contains(out, "bbbbbbbb") { + t.Fatalf("modal missing session ids:\n%s", out) + } + if !strings.Contains(out, "ACTIVE") || !strings.Contains(out, "FAILED") { + t.Fatalf("modal missing statuses:\n%s", out) + } +} + +func TestSessionsNavigationBoundsChecked(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionList = sampleSummaries() + // Up at the top is a no-op (clamped at 0). + updated, _ := m.handleSessionsKey(keyMsg{Type: keyUp}) + m = updated.(Model) + if m.sessionListIndex != 0 { + t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex) + } + // Down moves to row 1. + updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex) + } + // Down again is clamped at the last row. + updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex) + } + // `j` is the vim-style alias for down (still clamped here). + updated, _ = m.handleSessionsKey(keyMsg{Type: keyRunes, Runes: []rune("j")}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex) + } +} + +func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionList = sampleSummaries() + m.sessionListIndex = 1 // the FAILED healthcheck row + updated, cmd := m.handleSessionsKey(keyMsg{Type: keyEnter}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after resume, got %d", m.overlay) + } + if m.selectedID != "bbbbbbbb-2222" { + t.Fatalf("expected selected id bbbbbbbb-2222, got %q", m.selectedID) + } + if !m.sessionEntered { + t.Fatalf("expected session entered after resume") + } + if s := m.session("bbbbbbbb-2222"); s == nil || s.WorkflowID != "healthcheck" { + t.Fatalf("expected vivified session with workflow healthcheck, got %+v", s) + } + // A resume cmd is emitted; with a nil client (no server addr) it resolves to a + // sessionResumeMsg carrying the error — exercised here so it can't panic. + if cmd == nil { + t.Fatalf("expected a resume cmd to be emitted") + } + if msg, ok := cmd().(sessionResumeMsg); !ok { + t.Fatalf("expected sessionResumeMsg from resume cmd, got %T", cmd()) + } else if msg.sessionID != "bbbbbbbb-2222" { + t.Fatalf("resume msg for wrong session: %q", msg.sessionID) + } +} + +func TestSessionsFetchErrorShownNoPanic(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionListLoading = true + m.applySessionsLoaded(sessionsLoadedMsg{err: "server returned 503 Service Unavailable"}) + if m.sessionListLoading { + t.Fatalf("expected loading cleared on error") + } + if m.sessionListErr == "" { + t.Fatalf("expected error recorded") + } + out := m.sessionsModal() + if !strings.Contains(out, "503") { + t.Fatalf("modal should surface the fetch error:\n%s", out) + } +} + +func TestRelativeAgeParsing(t *testing.T) { + if got := relativeAge(""); got != "—" { + t.Fatalf("empty age should be em-dash, got %q", got) + } + if got := relativeAge("not-a-timestamp"); got != "—" { + t.Fatalf("unparseable age should be em-dash, got %q", got) + } + if got := relativeAge("2020-01-01T00:00:00Z"); !strings.HasSuffix(got, "ago") { + t.Fatalf("a real instant should render an age, got %q", got) + } +} diff --git a/apps/tui-go/internal/app/slash_test.go b/apps/tui-go/internal/app/slash_test.go new file mode 100644 index 00000000..271346ec --- /dev/null +++ b/apps/tui-go/internal/app/slash_test.go @@ -0,0 +1,43 @@ +package app + +import ( + "testing" +) + +// `/` as the first char of a chat line opens the command palette inline (opencode-style), +// without typing a literal slash. +func TestSlashOpensPaletteOnEmptyLine(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "" + + next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) + nm := next.(Model) + + if nm.overlay != OverlayPalette { + t.Fatalf("'/' on an empty line should open the palette; overlay=%d", nm.overlay) + } + if nm.inputBuffer != "" { + t.Fatalf("'/' should not be typed literally; buffer=%q", nm.inputBuffer) + } +} + +// Mid-line `/` is a literal slash (e.g. a path), not a command trigger. +func TestSlashLiteralMidLine(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "path" + m.inputCursor = len("path") + + next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) + nm := next.(Model) + + if nm.overlay != OverlayNone { + t.Fatalf("mid-line '/' must stay literal, not open the palette; overlay=%d", nm.overlay) + } + if nm.inputBuffer != "path/" { + t.Fatalf("mid-line '/' should be typed; buffer=%q", nm.inputBuffer) + } +} diff --git a/apps/tui-go/internal/app/status_bar_test.go b/apps/tui-go/internal/app/status_bar_test.go new file mode 100644 index 00000000..c42aa053 --- /dev/null +++ b/apps/tui-go/internal/app/status_bar_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "strings" + "testing" +) + +// Toggling a segment hides it from the rendered status bar; toggling again restores it. +// Reuses inSessionModel (adaptive_layout_test.go): stage "write_script", a long model id. +func TestStatusBarToggleHidesSegment(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) // isolate persistence + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} // start from a clean prefs state + + if !strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be visible by default") + } + m.toggleStatusSegment("stage") + if strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be hidden after toggle") + } + if !strings.Contains(m.renderStatus(), "llama-cpp") { + t.Fatal("model should still be visible (only stage was hidden)") + } + m.toggleStatusSegment("stage") + if !strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be visible again after second toggle") + } +} + +func TestStatusBarModelHide(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} + m.toggleStatusSegment("model") + if strings.Contains(m.renderStatus(), "llama-cpp") { + t.Fatal("model should be hidden after toggle") + } +} + +// A toggle persists to tui-prefs.json and is reloaded on the next launch. +func TestStatusBarPrefsPersist(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} + + m.toggleStatusSegment("gauge") + m.toggleStatusSegment("clock") + + reloaded := loadStatusbarHidden() + if !reloaded["gauge"] || !reloaded["clock"] { + t.Fatalf("persisted hidden set = %v, want gauge+clock hidden", reloaded) + } + if reloaded["stage"] { + t.Fatal("stage was never toggled; should not be persisted as hidden") + } +} diff --git a/apps/tui-go/internal/app/tasks_overlay.go b/apps/tui-go/internal/app/tasks_overlay.go new file mode 100644 index 00000000..66c15047 --- /dev/null +++ b/apps/tui-go/internal/app/tasks_overlay.go @@ -0,0 +1,493 @@ +package app + +import ( + "encoding/json" + "image/color" + "io" + "net/http" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse +// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields +// decode to the zero value ("" / nil) here. +type TaskSummary struct { + ID string `json:"id"` + Key string `json:"key"` + Status string `json:"status"` + Title string `json:"title"` + Goal string `json:"goal"` + AcceptanceCriteria []string `json:"acceptanceCriteria"` + AffectedPaths []string `json:"affectedPaths"` + Claimant string `json:"claimant"` + Links []TaskLinkWire `json:"links"` + Notes []TaskNoteWire `json:"notes"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + // Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps); + // BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values. + Ready bool `json:"ready"` + BlockedBy []string `json:"blockedBy"` +} + +type TaskLinkWire struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + TargetKind string `json:"targetKind"` +} + +type TaskNoteWire struct { + Author string `json:"author"` + Body string `json:"body"` + At string `json:"at"` +} + +// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks / +// err is meaningful; a non-empty err means the fetch failed and the board shows it. +type tasksLoadedMsg struct { + tasks []TaskSummary + err string +} + +// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and +// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws +// client); an empty base yields an error message rather than a panic. +func fetchTasks(base string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return tasksLoadedMsg{err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Get(base + "/tasks") + if err != nil { + return tasksLoadedMsg{err: "fetch failed: " + err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return tasksLoadedMsg{err: "server returned " + resp.Status} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return tasksLoadedMsg{err: "read failed: " + err.Error()} + } + var out []TaskSummary + if err := json.Unmarshal(body, &out); err != nil { + return tasksLoadedMsg{err: "decode failed: " + err.Error()} + } + return tasksLoadedMsg{tasks: out} + } +} + +// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is +// global (not bound to a session), so it opens from anywhere. +func (m *Model) openTasks() tea.Cmd { + m.overlay = OverlayTasks + m.taskListIndex = 0 + m.taskListErr = "" + m.taskDetail = false + m.taskDetailScroll = 0 + m.taskFilter = "" + m.taskFilterTyping = false + m.taskListLoading = true + return fetchTasks(m.client.HTTPBase()) +} + +// filteredTasks narrows the board by the `/` query — a case-insensitive substring over +// id/title/goal. An empty query returns the whole list. +func (m Model) filteredTasks() []TaskSummary { + if strings.TrimSpace(m.taskFilter) == "" { + return m.taskList + } + q := strings.ToLower(m.taskFilter) + var out []TaskSummary + for _, tk := range m.taskList { + if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) { + out = append(out, tk) + } + } + return out +} + +// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and +// surfacing any fetch error in place of a crash. +func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { + m.taskListLoading = false + if msg.err != "" { + m.taskListErr = msg.err + return + } + m.taskListErr = "" + m.taskList = msg.tasks + if m.taskListIndex >= len(m.taskList) { + m.taskListIndex = 0 + } +} + +// selectedTask returns the highlighted task, or false when the list is empty / the +// cursor is out of range. +func (m Model) selectedTask() (TaskSummary, bool) { + f := m.filteredTasks() + if m.taskListIndex < 0 || m.taskListIndex >= len(f) { + return TaskSummary{}, false + } + return f[m.taskListIndex], true +} + +// handleTasksKey owns every key while the board is open. In list mode: navigate, r +// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and +// enter/esc returns to the list. +func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) { + if m.taskDetail { + switch { + case k.Type == keyEsc || k.Type == keyEnter: + m.taskDetail = false + case k.Type == keyUp || runeIs(k, "k"): + m.scrollTaskDetail(-1) + case k.Type == keyDown || runeIs(k, "j"): + m.scrollTaskDetail(1) + case k.Type == keyPgUp: + m.scrollTaskDetail(-m.taskDetailBodyH()) + case k.Type == keyPgDown: + m.scrollTaskDetail(m.taskDetailBodyH()) + case runeIs(k, "g"): + m.taskDetailScroll = 0 + case runeIs(k, "G"): + m.taskDetailScroll = m.taskDetailMaxScroll() + } + return m, nil + } + // While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it). + if m.taskFilterTyping { + switch { + case k.Type == keyEnter || k.Type == keyEsc: + m.taskFilterTyping = false + case k.Type == keyBackspace: + if n := len(m.taskFilter); n > 0 { + m.taskFilter = m.taskFilter[:n-1] + m.taskListIndex = 0 + } + case k.Type == keyRunes || k.Type == keySpace: + m.taskFilter += string(k.Runes) + m.taskListIndex = 0 + } + return m, nil + } + switch { + case k.Type == keyEsc || runeIs(k, "T"): + m.overlay = OverlayNone + case runeIs(k, "/"): + m.taskFilterTyping = true + case runeIs(k, "c"): + m.taskFilter = "" + m.taskListIndex = 0 + case k.Type == keyUp || runeIs(k, "k"): + if m.taskListIndex > 0 { + m.taskListIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.taskListIndex < len(m.filteredTasks())-1 { + m.taskListIndex++ + } + case runeIs(k, "r"): + return m, m.openTasks() + case k.Type == keyEnter: + if _, ok := m.selectedTask(); ok { + m.taskDetail = true + m.taskDetailScroll = 0 + } + } + return m, nil +} + +// taskDetailBodyH is the number of detail lines visible in the detail pane. +func (m Model) taskDetailBodyH() int { + h := m.height*70/100 - 6 + if h < 4 { + h = 4 + } + return h +} + +// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom. +func (m Model) taskDetailMaxScroll() int { + max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH() + if max < 0 { + max = 0 + } + return max +} + +// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max]. +func (m *Model) scrollTaskDetail(delta int) { + max := m.taskDetailMaxScroll() + off := m.taskDetailScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.taskDetailScroll = off +} + +func (m Model) tasksModal() string { + if m.taskDetail { + return m.taskDetailModal() + } + t := m.theme + w := m.modalWidth() + tasks := m.filteredTasks() + + var b strings.Builder + b.WriteString(m.titleLine("tasks")) + if len(tasks) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + if m.taskListErr != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.taskListErr, w-8)) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.taskListLoading && len(m.taskList) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + // Filter prompt: shown while editing the `/` query or whenever one is set. + if m.taskFilterTyping || m.taskFilter != "" { + b.WriteString(m.taskFilterLine() + "\n\n") + } + + if len(tasks) == 0 { + msg := " no tasks" + if m.taskFilter != "" { + msg = " no tasks match \"" + m.taskFilter + "\"" + } + b.WriteString(mbg(t, msg, t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, m.taskListHints())) + return t.Overlay.Width(w).Render(b.String()) + } + + // Windowed roster, keeping the selected row in view. + bodyH := m.height*70/100 - 6 + if bodyH < 4 { + bodyH = 4 + } + off := 0 + if len(tasks) > bodyH { + off = m.taskListIndex - bodyH/2 + if off < 0 { + off = 0 + } + if off > len(tasks)-bodyH { + off = len(tasks) - bodyH + } + } + end := off + bodyH + if end > len(tasks) { + end = len(tasks) + } + for i := off; i < end; i++ { + b.WriteString(m.taskListRow(tasks, i) + "\n") + } + + b.WriteString("\n" + modalHints(t, m.taskListHints())) + return t.Overlay.Width(w).Render(b.String()) +} + +// taskListHints is the list-mode footer (shared by the populated and empty-filter views). +func (m Model) taskListHints() [][2]string { + return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}} +} + +// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing. +func (m Model) taskFilterLine() string { + t := m.theme + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ") + caret := mbg(t, "", t.P.BgPanel) + if m.taskFilterTyping && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + if m.taskFilter == "" { + return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint) + } + return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret +} + +// taskListRow renders one board line: marker, id, status, title, and claimant. +func (m Model) taskListRow(tasks []TaskSummary, i int) string { + t := m.theme + tk := tasks[i] + + marker := mbg(t, " ", t.P.BgPanel) + idFg := t.P.Fg + if i == m.taskListIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + idFg = t.P.FgStrong + } + + idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(tk.ID, 12)) + statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true). + Render(padRaw(tk.Status, 12)) + + claim := "" + if tk.Claimant != "" { + claim = " @" + shortSessionID(tk.Claimant) + } + readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph + titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim)) + if titleW < 8 { + titleW = 8 + } + titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg) + claimCell := mbg(t, claim, t.P.Faint) + + return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell +} + +// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads +// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it +// is waiting on unfinished blockers, blank for anything already in flight or finished. +func taskReadyCell(t Theme, tk TaskSummary) string { + switch { + case tk.Ready: + return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2)) + case len(tk.BlockedBy) > 0: + return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2)) + default: + return mbg(t, " ", t.P.BgPanel) + } +} + +func (m Model) taskDetailModal() string { + t := m.theme + w := m.modalWidth() + tk, _ := m.selectedTask() + + body := m.taskDetailBody(w - 4) + bodyH := m.taskDetailBodyH() + off := m.taskDetailScroll + if mx := m.taskDetailMaxScroll(); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + + var b strings.Builder + b.WriteString(m.titleLine("task " + tk.ID)) + b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true). + Render(" " + tk.Status)) + if len(body) > bodyH { + b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + end := off + bodyH + if end > len(body) { + end = len(body) + } + for i := off; i < end; i++ { + b.WriteString(body[i] + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}})) + return t.Overlay.Width(w).Render(b.String()) +} + +// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance +// criteria, affected paths, links, and notes. Built entirely from the list payload, so +// it needs no extra fetch. +func (m Model) taskDetailBody(width int) []string { + t := m.theme + tk, ok := m.selectedTask() + if !ok { + return []string{mbg(t, " (no task selected)", t.P.Faint)} + } + if width < 8 { + width = 8 + } + var out []string + section := func(label string) { + out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)) + } + put := func(s string, fg color.Color) { + for _, ln := range wrapLines([]string{s}, width) { + out = append(out, mbg(t, ln, fg)) + } + } + + put(tk.Title, t.P.FgStrong) + if tk.Claimant != "" { + out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint)) + } + if tk.Ready { + out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK)) + } else if len(tk.BlockedBy) > 0 { + out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim)) + } + out = append(out, "") + + if tk.Goal != "" { + section("goal") + put(tk.Goal, t.P.Fg) + out = append(out, "") + } + if len(tk.AcceptanceCriteria) > 0 { + section("acceptance criteria") + for _, c := range tk.AcceptanceCriteria { + put("- "+c, t.P.Fg) + } + out = append(out, "") + } + if len(tk.AffectedPaths) > 0 { + section("affected paths") + for _, p := range tk.AffectedPaths { + out = append(out, mbg(t, clip(" "+p, width), t.P.Dim)) + } + out = append(out, "") + } + if len(tk.Links) > 0 { + section("links") + for _, l := range tk.Links { + out = append(out, mbg(t, clip(" "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg)) + } + out = append(out, "") + } + if len(tk.Notes) > 0 { + section("notes") + for _, n := range tk.Notes { + put("["+n.Author+"] "+n.Body, t.P.Fg) + } + out = append(out, "") + } + + // Drop the trailing blank so the body never has a dangling empty line. + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return out +} + +// taskStatusColor maps a task status to a palette color for the board / detail header. +func taskStatusColor(t Theme, status string) color.Color { + switch status { + case "DONE": + return t.P.OK + case "IN_PROGRESS": + return t.P.Accent + case "IN_REVIEW": + return t.P.Accent2 + case "BLOCKED": + return t.P.Bad + case "CANCELLED": + return t.P.Dim + default: // TODO + return t.P.Faint + } +} diff --git a/apps/tui-go/internal/app/tasks_overlay_test.go b/apps/tui-go/internal/app/tasks_overlay_test.go new file mode 100644 index 00000000..2ccba01b --- /dev/null +++ b/apps/tui-go/internal/app/tasks_overlay_test.go @@ -0,0 +1,186 @@ +package app + +import ( + "strings" + "testing" +) + +func tasksModel() Model { + m := NewModel(nil) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + return m +} + +// sampleTasks is a deterministic two-row board used across the tests. +func sampleTasks() []TaskSummary { + return []TaskSummary{ + { + ID: "auth-1", Key: "auth-1", Status: "IN_PROGRESS", Title: "JWT refresh", + Goal: "users stay authenticated", AcceptanceCriteria: []string{"rotates"}, + Claimant: "aaaaaaaa-1111", + Links: []TaskLinkWire{{TargetID: "adr-7", Type: "IMPLEMENTS", TargetKind: "DOC"}}, + Notes: []TaskNoteWire{{Author: "AGENT", Body: "kickoff"}}, + }, + {ID: "billing-3", Key: "billing-3", Status: "DONE", Title: "Invoice export"}, + } +} + +func TestTasksOverlayToggle(t *testing.T) { + m := tasksModel() + // `T` opens the board from the idle list. + updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("T")}) + m = updated.(Model) + if m.overlay != OverlayTasks { + t.Fatalf("expected OverlayTasks after T, got %d", m.overlay) + } + if !m.taskListLoading { + t.Fatalf("expected loading state right after open") + } + // `esc` closes it (routed through the overlay key handler). + updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after esc, got %d", m.overlay) + } +} + +func TestTasksLoadedPopulatesAndRenders(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskListLoading = true + m.applyTasksLoaded(tasksLoadedMsg{tasks: sampleTasks()}) + if m.taskListLoading { + t.Fatalf("expected loading cleared after load") + } + if len(m.taskList) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(m.taskList)) + } + out := m.tasksModal() + if !strings.Contains(out, "auth-1") || !strings.Contains(out, "billing-3") { + t.Fatalf("modal missing task ids:\n%s", out) + } + if !strings.Contains(out, "IN_PROGRESS") || !strings.Contains(out, "DONE") { + t.Fatalf("modal missing statuses:\n%s", out) + } +} + +func TestTasksEnterOpensDetailWithBundleFields(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + m.taskListIndex = 0 + + updated, _ := m.handleTasksKey(keyMsg{Type: keyEnter}) + m = updated.(Model) + if !m.taskDetail { + t.Fatalf("expected detail pane open after enter") + } + out := m.tasksModal() + for _, want := range []string{"users stay authenticated", "rotates", "adr-7", "kickoff"} { + if !strings.Contains(out, want) { + t.Fatalf("detail missing %q:\n%s", want, out) + } + } + // esc returns to the list, not all the way out. + updated, _ = m.handleTasksKey(keyMsg{Type: keyEsc}) + m = updated.(Model) + if m.taskDetail { + t.Fatalf("expected detail closed after esc") + } + if m.overlay != OverlayTasks { + t.Fatalf("expected to stay on the board after esc from detail, got %d", m.overlay) + } +} + +func TestTasksNavigationBoundsChecked(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + // Up at the top is a no-op (clamped at 0). + updated, _ := m.handleTasksKey(keyMsg{Type: keyUp}) + m = updated.(Model) + if m.taskListIndex != 0 { + t.Fatalf("expected index clamped to 0 at top, got %d", m.taskListIndex) + } + // Down then clamped at the last row. + updated, _ = m.handleTasksKey(keyMsg{Type: keyDown}) + m = updated.(Model) + updated, _ = m.handleTasksKey(keyMsg{Type: keyDown}) + m = updated.(Model) + if m.taskListIndex != 1 { + t.Fatalf("expected index clamped to 1 at bottom, got %d", m.taskListIndex) + } +} + +func TestTasksFilterNarrowsList(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + + // `/` enters filter typing; a query narrows the board to matching rows. + updated, _ := m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) + m = updated.(Model) + if !m.taskFilterTyping { + t.Fatalf("expected filter typing after /") + } + for _, r := range "invoice" { + updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune{r}}) + m = updated.(Model) + } + if got := m.filteredTasks(); len(got) != 1 || got[0].ID != "billing-3" { + t.Fatalf("expected only billing-3 to match 'invoice', got %+v", got) + } + // enter stops editing but keeps the query; the selected task is the filtered one. + updated, _ = m.handleTasksKey(keyMsg{Type: keyEnter}) + m = updated.(Model) + if m.taskFilterTyping { + t.Fatalf("expected typing to stop on enter") + } + if sel, ok := m.selectedTask(); !ok || sel.ID != "billing-3" { + t.Fatalf("expected selected task billing-3 under filter, got %+v ok=%v", sel, ok) + } + // `c` clears the filter back to the full board. + updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("c")}) + m = updated.(Model) + if len(m.filteredTasks()) != 2 { + t.Fatalf("expected full board after clear, got %d", len(m.filteredTasks())) + } +} + +func TestTasksReadyAndBlockedSurfaced(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = []TaskSummary{ + {ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold app", Ready: true}, + {ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth view", BlockedBy: []string{"webui-2"}}, + } + + // A ready task's detail says so; a blocked task's detail names what it waits on. + m.taskDetail = true + m.taskListIndex = 0 + if out := m.tasksModal(); !strings.Contains(out, "ready to work") { + t.Fatalf("expected ready marker in detail:\n%s", out) + } + m.taskListIndex = 1 + if out := m.tasksModal(); !strings.Contains(out, "waiting on webui-2") { + t.Fatalf("expected blocked-by detail naming the blocker:\n%s", out) + } +} + +func TestTasksFetchErrorShownNoPanic(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskListLoading = true + m.applyTasksLoaded(tasksLoadedMsg{err: "server returned 503 Service Unavailable"}) + if m.taskListLoading { + t.Fatalf("expected loading cleared on error") + } + if m.taskListErr == "" { + t.Fatalf("expected error recorded") + } + out := m.tasksModal() + if !strings.Contains(out, "503") { + t.Fatalf("modal should surface the fetch error:\n%s", out) + } +} diff --git a/apps/tui-go/internal/app/theme.go b/apps/tui-go/internal/app/theme.go index 914d26bd..150cdeec 100644 --- a/apps/tui-go/internal/app/theme.go +++ b/apps/tui-go/internal/app/theme.go @@ -1,36 +1,40 @@ package app -import "github.com/charmbracelet/lipgloss" +import ( + "image/color" + + "charm.land/lipgloss/v2" +) // Palette is the "Soft" chrome from docs/visual/ref with the blue accent: // rounded borders, opaque dark panels, generous spacing. type Palette struct { - Bg lipgloss.Color // panel/background fill - BgDeep lipgloss.Color // outermost backdrop (slightly darker) - BgPanel lipgloss.Color // inside-panel fill - Sel lipgloss.Color // selection row background - Border lipgloss.Color // idle panel border - BorderHi lipgloss.Color // active panel border - Fg lipgloss.Color // primary text - FgStrong lipgloss.Color // headings / emphasis - Dim lipgloss.Color // labels, secondary text - Faint lipgloss.Color // tertiary, hints - Accent lipgloss.Color // blue accent - Accent2 lipgloss.Color // secondary accent + Bg color.Color // panel/background fill + BgDeep color.Color // outermost backdrop (slightly darker) + BgPanel color.Color // inside-panel fill + Sel color.Color // selection row background + Border color.Color // idle panel border + BorderHi color.Color // active panel border + Fg color.Color // primary text + FgStrong color.Color // headings / emphasis + Dim color.Color // labels, secondary text + Faint color.Color // tertiary, hints + Accent color.Color // blue accent + Accent2 color.Color // secondary accent - OK lipgloss.Color - Warn lipgloss.Color - Bad lipgloss.Color + OK color.Color + Warn color.Color + Bad color.Color - DiffAdd lipgloss.Color // added-line cell background - DiffDel lipgloss.Color // removed-line cell background + DiffAdd color.Color // added-line cell background + DiffDel color.Color // removed-line cell background - CatLifecycle lipgloss.Color - CatContext lipgloss.Color - CatInference lipgloss.Color - CatTool lipgloss.Color - CatDomain lipgloss.Color - CatApproval lipgloss.Color + CatLifecycle color.Color + CatContext color.Color + CatInference color.Color + CatTool color.Color + CatDomain color.Color + CatApproval color.Color } // SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the @@ -91,7 +95,7 @@ type Theme struct { // NewTheme builds the style set for a palette. func NewTheme(p Palette) Theme { base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg) - roundBase := func(border lipgloss.Color) lipgloss.Style { + roundBase := func(border color.Color) lipgloss.Style { return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). @@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme { } // categoryColor maps an event category label to its accent color. -func (t Theme) categoryColor(cat string) lipgloss.Color { +func (t Theme) categoryColor(cat string) color.Color { switch cat { case "Lifecycle": return t.P.CatLifecycle diff --git a/apps/tui-go/internal/app/tool_summary_test.go b/apps/tui-go/internal/app/tool_summary_test.go new file mode 100644 index 00000000..737afa9b --- /dev/null +++ b/apps/tui-go/internal/app/tool_summary_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "strings" + "testing" +) + +// The collapsed tool row for a write/edit must summarise the diff by its row count (what +// ^x shows), not the raw diff byte length that used to read as a meaningless "N chars". +func TestToolRowSummary_DiffReportsRows(t *testing.T) { + diff := "--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@\n context\n-old line\n+new line\n+added line\n" + got := toolRowSummary(diff) + wantRows := previewRowCount(diff) + if !strings.Contains(got, "diff (") || !strings.Contains(got, itoa(wantRows)+" rows") { + t.Fatalf("diff summary = %q, want a diff row count of %d", got, wantRows) + } + if strings.Contains(got, "tool output") { + t.Errorf("a diff should not be labelled 'tool output': %q", got) + } +} + +// Non-diff output reports a true character (rune) count, not a byte count. +func TestToolRowSummary_NonDiffCountsRunes(t *testing.T) { + content := "café — déjà" // 11 runes, but 14 bytes (é, —, à are multi-byte) + got := toolRowSummary(content) + if !strings.Contains(got, "(11 chars)") { + t.Fatalf("non-diff summary = %q, want 11 chars (runes, not bytes)", got) + } +} + +// A changed line whose content starts with "++"/"--" must still be counted (and rendered): +// requiring the trailing space on the file-header guard prevents mistaking it for a header. +func TestDiffSummary_CountsDoublePrefixContentLines(t *testing.T) { + // The added line's content is "++flag"; in the diff that's "+" + "++flag" = "+++flag". + diff := "--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+++flag\n+normal\n" + _, added, removed := diffSummary(diff) + if added != 2 { + t.Errorf("added = %d, want 2 (the '+++flag' content line must count)", added) + } + if removed != 0 { + t.Errorf("removed = %d, want 0", removed) + } + // The renderer must keep the line too (2 add rows), not drop it as a header. + if n := previewRowCount(diff); n != 2 { + t.Errorf("rendered rows = %d, want 2", n) + } +} diff --git a/apps/tui-go/internal/app/transcript_test.go b/apps/tui-go/internal/app/transcript_test.go new file mode 100644 index 00000000..073a99b7 --- /dev/null +++ b/apps/tui-go/internal/app/transcript_test.go @@ -0,0 +1,67 @@ +package app + +import "testing" + +func transcriptModel(roles ...string) Model { + m := NewModel(nil) + m.selectedID = "s1" + msgs := make([]RouterEntry, len(roles)) + for i, r := range roles { + msgs[i] = RouterEntry{Role: r, Content: r + itoa(i)} + } + m.routerMessages["s1"] = msgs + return m +} + +// ctrl+↑/↓ jumps between USER messages: from no selection ↑ grabs the most recent user +// message, keeps stepping to older ones (clamping), and ↓ steps back down, dropping to +// tail-follow (-1) once past the last user message. +func TestTranscriptNavUser(t *testing.T) { + // indices: 0 1 2 3 4 + m := transcriptModel("user", "router", "user", "router", "user") + + if m.transcriptSel != -1 { + t.Fatalf("initial sel = %d, want -1", m.transcriptSel) + } + m.transcriptNavUser(-1) // up from bottom → newest user (idx 4) + if m.transcriptSel != 4 { + t.Fatalf("up#1 = %d, want 4", m.transcriptSel) + } + m.transcriptNavUser(-1) // → user idx 2 + if m.transcriptSel != 2 { + t.Fatalf("up#2 = %d, want 2", m.transcriptSel) + } + m.transcriptNavUser(-1) // → user idx 0 + if m.transcriptSel != 0 { + t.Fatalf("up#3 = %d, want 0", m.transcriptSel) + } + m.transcriptNavUser(-1) // clamp at oldest + if m.transcriptSel != 0 { + t.Fatalf("up#4 (clamp) = %d, want 0", m.transcriptSel) + } + m.transcriptNavUser(1) // → user idx 2 + if m.transcriptSel != 2 { + t.Fatalf("down#1 = %d, want 2", m.transcriptSel) + } + m.transcriptNavUser(1) // → user idx 4 + m.transcriptNavUser(1) // past last user → tail-follow + if m.transcriptSel != -1 { + t.Fatalf("down past last = %d, want -1 (tail-follow)", m.transcriptSel) + } +} + +// `y` copies the selected message, or the latest user/router turn when nothing is selected. +func TestCopyText(t *testing.T) { + m := transcriptModel("user", "router", "tool") + + // no selection → most recent user/router turn (the "router" at idx 1, since "tool" + // is skipped). + if got := m.copyText(); got != "router1" { + t.Fatalf("copyText (no sel) = %q, want %q", got, "router1") + } + + m.transcriptSel = 0 + if got := m.copyText(); got != "user0" { + t.Fatalf("copyText (sel idx 0) = %q, want %q", got, "user0") + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 83e37cbd..e0f099cd 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -1,10 +1,13 @@ package app import ( + "fmt" + "os" "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" + osc52 "github.com/aymanbagabas/go-osc52/v2" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -15,6 +18,12 @@ type serverMsg struct{ m protocol.ServerMessage } type connMsg struct{ s ws.Status } type tickMsg struct{} +// copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank. +const copiedFlashFrames = 12 + +// fileListMax caps how many @-picker rows are filtered/rendered at once. +const fileListMax = 200 + func readServer(c *ws.Client) tea.Cmd { return func() tea.Msg { return serverMsg{<-c.Incoming()} } } @@ -27,9 +36,51 @@ func tick() tea.Cmd { return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) } -// Init starts the channel readers and the cursor-blink tick. +// Init starts the channel readers. The animation tick is NOT started here — it is +// kicked lazily by tickKick once something actually needs to animate (an active +// session's spinner, a blinking caret), so an idle screen never auto-redraws. func (m Model) Init() tea.Cmd { - return tea.Batch(readServer(m.client), readConn(m.client), tick()) + return tea.Batch(readServer(m.client), readConn(m.client)) +} + +// animating reports whether anything on screen needs the frame counter to advance: +// a spinning session, a blinking caret (any typing surface), or the reconnect state. +// When false, the tick loop stops and the screen holds still — so a native terminal +// text selection survives instead of being wiped by the next redraw. +func (m Model) animating() bool { + // Note: ModeInsert is intentionally NOT here — the composer caret is now a real + // terminal cursor (View.Cursor) that the terminal blinks itself, so insert mode no + // longer needs the frame loop. The other typing surfaces keep their drawn carets. + if m.steering || m.clarTyping || m.proposeTyping || m.configEditing { + return true + } + if m.reconnecting { + return true + } + if m.overlay == OverlayPalette || m.overlay == OverlayFiles { // blinking filter caret + return true + } + if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { + return true // keep ticking briefly so the "copied" note animates out + } + for i := range m.sessions { + if m.sessions[i].Active { + return true + } + } + return false +} + +// tickKick starts the tick loop iff animation is needed and no loop is already +// running. Returns nil otherwise. The single-loop invariant: ticking is true exactly +// while a loop is scheduled (set here, cleared by tickMsg when it stops), so this +// never spawns a second concurrent loop. +func (m *Model) tickKick() tea.Cmd { + if m.animating() && !m.ticking { + m.ticking = true + return tick() + } + return nil } // Update is the single reducer. View renders purely from the returned model. @@ -41,22 +92,61 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tickMsg: m.frame++ - return m, tick() + if m.animating() { + return m, tick() + } + m.ticking = false // loop stops; tickKick will restart it when animation resumes + return m, nil case connMsg: m.applyConn(msg.s) - return m, readConn(m.client) + return m, tea.Batch(readConn(m.client), m.tickKick()) case serverMsg: m.applyServerPhased(msg.m) - return m, readServer(m.client) + return m, tea.Batch(readServer(m.client), m.tickKick()) - case tea.KeyMsg: - return m.handleKey(msg) + case sessionsLoadedMsg: + m.applySessionsLoaded(msg) + return m, nil + + case sessionResumeMsg: + if msg.err != "" { + m.sessionListErr = "resume: " + msg.err + } + return m, nil + + case tasksLoadedMsg: + m.applyTasksLoaded(msg) + return m, nil + + case tea.KeyPressMsg: + next, cmd := m.handleKey(toKeyMsg(msg)) + // A keypress may have entered a typing/active state (insert mode, steering, …); + // kick the tick so the caret/spinner animates again. + if nm, ok := next.(Model); ok { + return nm, tea.Batch(cmd, nm.tickKick()) + } + return next, cmd } return m, nil } +// applySessionsLoaded folds a GET /sessions result into the browser, clamping the +// cursor and surfacing any fetch error in place of a crash. +func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) { + m.sessionListLoading = false + if msg.err != "" { + m.sessionListErr = msg.err + return + } + m.sessionListErr = "" + m.sessionList = msg.sessions + if m.sessionListIndex >= len(m.sessionList) { + m.sessionListIndex = 0 + } +} + func (m *Model) applyConn(s ws.Status) { switch { case s.Connected: @@ -92,12 +182,12 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) { // --- key handling --- -func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) { debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s", k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay, m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID) // Ctrl+C is a universal hard-quit safety; everything else is bare-key modal. - if k.Type == tea.KeyCtrlC { + if k.Type == keyCtrlC { m.quitting = true return m, tea.Quit } @@ -111,6 +201,24 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.displayState() == StateWorkflowPropose { return m.handleProposeKey(k) } + // Transcript message-jump works in any edit mode while in-session, so you can scroll + // back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer. + if m.displayState() == StateInSession { + switch k.Type { + case keyCtrlUp: + m.transcriptNavUser(-1) + return m, nil + case keyCtrlDown: + m.transcriptNavUser(1) + return m, nil + } + } + // On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any + // edit mode, so it works whether or not the input is focused. + if m.displayState() == StateIdle && k.Type == keyTab { + m.cycleLauncherWf() + return m, nil + } if m.editMode == ModeInsert { return m.handleInsertKey(k) } @@ -118,47 +226,64 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleNormalKey processes vim-style bare-key commands. -func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) { ds := m.displayState() + // The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm + // gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the + // general bindings (e=events, t=tools, …) from shadowing the decision keys. + if ds == StateApproval { + return m.handleApprovalKey(k) + } switch k.Type { - case tea.KeyUp: + case keyUp: m.navUp() return m, nil - case tea.KeyDown: + case keyDown: m.navDown() return m, nil - case tea.KeyEnter: + case keyEnter: return m.normalEnter() - case tea.KeyEsc: - if ds == StateApproval { - m.approvalDismissed = true + case keyEsc: + // In-session, esc first drops a transcript selection or output scroll (back to + // tail-follow); otherwise it clears the session-list filter. + if m.transcriptSel >= 0 { + m.transcriptSel = -1 + return m, nil + } + if m.outputScroll != 0 { + m.outputScroll = 0 return m, nil } m.filter = "" return m, nil - case tea.KeyCtrlA: - if ds == StateApproval { - return m.decide("APPROVE") - } + case keyPgUp: + m.scrollOutput(m.outputViewportPage()) return m, nil - case tea.KeyCtrlR: - if ds == StateApproval { - return m.decide("REJECT") - } + case keyPgDown: + m.scrollOutput(-m.outputViewportPage()) return m, nil - case tea.KeyCtrlX: + case keyCtrlU: + m.scrollOutput(m.outputViewportPage() / 2) + return m, nil + case keyCtrlD: + m.scrollOutput(-m.outputViewportPage() / 2) + return m, nil + case keyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 } return m, nil } - if k.Type != tea.KeyRunes { + if k.Type != keyRunes { return m, nil } switch string(k.Runes) { case "i": m.enterInsert(ModeRouter) + case "?": + m.overlay = OverlayHelp + m.modalScroll = 0 case "/": m.enterInsert(ModeFilter) case "j": @@ -166,12 +291,9 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "k": m.navUp() case "p": - m.overlay = OverlayPalette - m.paletteFilter = "" - m.paletteIndex = 0 + m.openPalette() case "e": - m.overlay = OverlayEventInspector - m.overlayEventIdx = 0 + m.openEventInspector() case "t": m.overlay = OverlayToolPalette case "v": @@ -180,20 +302,30 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.openIdeas() case "H": m.openHealth() + case "R": + // Resume browser: recent sessions over HTTP, reachable even after a server + // restart + TUI reopen (the live stream only carries running sessions). + return m, m.openSessions() + case "T": + // Task board: all tasks across projects over HTTP (GET /tasks). + return m, m.openTasks() case "S": m.openStats() + case "G": + m.openGrants() case "g": m.openConfig() case "m": m.openModelsOverlay() case "w": + // Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…). if ds == StateIdle { - m.wfVisible = !m.wfVisible - if m.wfVisible { - m.wfIndex = 0 - } else { - m.wfIndex = -1 - } + m.cycleLauncherWf() + } + case "d": + // In-session: cycle the right panel (events → changes → off). + if ds == StateInSession { + m.cycleRightPanel() } case "l": if ds != StateIdle { @@ -201,33 +333,25 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.approvalDismissed = false } case "s": - if ds == StateApproval { - m.steering = true - m.editMode = ModeInsert - } else { - m.cycleChatMode() + m.cycleChatMode() + case "y": + // Copy the selected message (or the latest assistant turn if none is selected) + // to the system clipboard over OSC52 — SSH-safe, unlike a mouse drag. + if cmd := m.copyMessage(); cmd != nil { + m.copiedFlash = m.frame + return m, cmd } case "c": if m.selectedID != "" { m.client.Send(protocol.CancelSession(m.selectedID)) } case "a": - if ds == StateApproval { - return m.decide("APPROVE") - } + // In-session (gate peeked away with esc): `a` brings the band back. if ds == StateInSession { if s := m.session(m.selectedID); s != nil && s.Pending != nil { m.approvalDismissed = false } } - case "r": - if ds == StateApproval { - return m.decide("REJECT") - } - case "A": - if ds == StateApproval { - return m.autoApprove() - } case "q": m.quitting = true return m, tea.Quit @@ -235,27 +359,93 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a +// single keypress; a T3+ approve arms first and only a second confirming key sends +// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything. +func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) { + s := m.session(m.selectedID) + if s == nil || s.Pending == nil { + return m, nil + } + // Queue navigation never resolves a gate; it just changes which one is shown. + switch { + case k.Type == keyUp || runeIs(k, "k"): + s.navApproval(-1) + m.approvalArmed = false + return m, nil + case k.Type == keyDown || runeIs(k, "j"): + s.navApproval(1) + m.approvalArmed = false + return m, nil + } + + // Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of + // sending; the same key pressed again (while armed) confirms. + approveKey := k.Type == keyEnter || k.Type == keyCtrlA || + runeIs(k, "y") || runeIs(k, "a") + if approveKey { + if s.Pending.HighTier() && !m.approvalArmed { + m.approvalArmed = true + return m, nil + } + return m.decide("APPROVE") + } + + // Any other key cancels a pending T3 arm rather than acting on it. + m.approvalArmed = false + + switch { + case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"): + return m.decide("REJECT") + case runeIs(k, "e") || runeIs(k, "s"): + // Steer/edit flows into the existing steering-note input path. + m.steering = true + m.editMode = ModeInsert + return m, nil + case runeIs(k, "A"): + return m.autoApprove() + case runeIs(k, "l"): + m.sessionEntered = false + m.approvalDismissed = false + return m, nil + case k.Type == keyEsc: + m.approvalDismissed = true + return m, nil + case k.Type == keyCtrlX: + if m.currentDiff() != "" { + m.overlay = OverlayDiff + m.diffScrollOffset = 0 + } + return m, nil + case runeIs(k, "q"): + m.quitting = true + return m, tea.Quit + } + // A bare disarm (e.g. an unrelated key while armed) just falls through. + return m, nil +} + // handleInsertKey processes typing (chat/filter/steer note). -func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) { if m.steering { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.steering = false m.editMode = ModeNormal - case tea.KeyEnter: + case keyEnter: return m.submitApproval() - case tea.KeyBackspace: + case keyBackspace: if n := len(m.steerBuffer); n > 0 { m.steerBuffer = m.steerBuffer[:n-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.steerBuffer += string(k.Runes) } return m, nil } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.editMode = ModeNormal if m.inputMode == ModeFilter { m.inputMode = ModeRouter @@ -266,38 +456,133 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.clearInput() m.inputMode = ModeRouter } - case tea.KeyEnter: + case keyEnter: + // Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter + // is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation; + // Ctrl+J below stays as a fallback for terminals without kitty support. + if (k.Shift || k.Alt) && m.inputMode != ModeFilter { + m.appendRunes("\n") + return m, nil + } return m.submit() - case tea.KeyBackspace: + case keyCtrlJ: + // Reliable "newline without sending" (Ctrl+J is the literal LF key) for the + // single-line filter excluded. + if m.inputMode != ModeFilter { + m.appendRunes("\n") + } + return m, nil + case keyBackspace: m.backspace() m.syncFilter() - case tea.KeyLeft: + case keyLeft: if m.inputCursor > 0 { m.inputCursor-- } - case tea.KeyRight: + case keyRight: if m.inputCursor < len(m.inputBuffer) { m.inputCursor++ } - case tea.KeyUp: + case keyUp: if m.inputMode == ModeFilter { m.listNav(-1) } else { m.historyPrev() } - case tea.KeyDown: + case keyDown: if m.inputMode == ModeFilter { m.listNav(1) } else { m.historyNext() } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: + // opencode-style slash commands: `/` as the first char of a chat line opens the + // command palette inline instead of typing a literal slash. Mid-line `/` is literal. + if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" { + m.editMode = ModeNormal + m.clearInput() + m.openPalette() + return m, nil + } + // `@` at a token boundary opens the workspace file picker; the selection inserts + // `@path`. Mid-word `@` (e.g. an email) is literal. + if m.inputMode == ModeRouter && string(k.Runes) == "@" && m.atTokenBoundary() { + m.openFiles() + return m, nil + } m.appendRunes(string(k.Runes)) m.syncFilter() } return m, nil } +// openPalette opens the command palette with a cleared filter. Shared by the `p` +// normal-mode key and the inline `/` trigger in the chat input. +func (m *Model) openPalette() { + m.overlay = OverlayPalette + m.paletteFilter = "" + m.paletteIndex = 0 +} + +// atTokenBoundary reports whether the input cursor sits at the start of a word (start of +// line or just after a space) — where `@` should begin a file reference rather than be literal. +func (m Model) atTokenBoundary() bool { + if m.inputCursor <= 0 { + return true + } + if m.inputCursor <= len(m.inputBuffer) { + return m.inputBuffer[m.inputCursor-1] == ' ' + } + return false +} + +// openFiles opens the `@` file picker for the selected session and requests its workspace +// paths (cached per session). Editing mode is left in Insert so closing returns to typing. +func (m *Model) openFiles() { + m.overlay = OverlayFiles + m.fileFilter = "" + m.fileIndex = 0 + if m.filesFor != m.selectedID { + m.files = nil + m.filesLoading = true + } + m.client.Send(protocol.ListFiles(m.selectedID)) +} + +// insertFileRef inserts `@path ` at the input cursor (the `@` was consumed to open the picker). +func (m *Model) insertFileRef(path string) { + ref := "@" + path + " " + c := m.inputCursor + if c > len(m.inputBuffer) { + c = len(m.inputBuffer) + } + m.inputBuffer = m.inputBuffer[:c] + ref + m.inputBuffer[c:] + m.inputCursor = c + len(ref) + m.fileFilter = "" +} + +// filteredFiles narrows the workspace paths by the typed filter (case-insensitive substring), +// capped for render. +func (m Model) filteredFiles() []string { + if m.fileFilter == "" { + if len(m.files) > fileListMax { + return m.files[:fileListMax] + } + return m.files + } + f := strings.ToLower(m.fileFilter) + out := make([]string, 0, fileListMax) + for _, p := range m.files { + if strings.Contains(strings.ToLower(p), f) { + out = append(out, p) + if len(out) >= fileListMax { + break + } + } + } + return out +} + // beginIntent stashes the chosen workflow and switches to a text-entry prompt for the // freeform request. Submitting sends StartSession(id, intent); an empty line starts with no // intent (fixed-task workflows). Esc cancels. @@ -341,27 +626,42 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) { return m, nil } +// autoApprove (A) opens the scope picker so the operator chooses how broadly to stop being +// asked: this session (the old behaviour), this project, or globally. The actual grant + +// approval are sent by applyGrantScope once a scope is picked. func (m Model) autoApprove() (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Pending == nil { return m, nil } - p := s.Pending - m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName)) - m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) - s.Pending = nil - m.steerBuffer = "" - m.steering = false - m.approvalDismissed = false + m.grantFor = s.Pending + m.grantScopeIndex = 0 + m.overlay = OverlayGrantScope return m, nil } -func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { // Config overlay owns all its keys (esc cancels an in-progress edit rather than closing). if m.overlay == OverlayConfig { return m.handleConfigKey(k) } - if k.Type == tea.KeyEsc { + // Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't + // share the generic no-cmd esc path below. + if m.overlay == OverlaySessions { + return m.handleSessionsKey(k) + } + // Tasks overlay owns all its keys: r refresh emits a cmd and enter toggles a detail + // pane, so it can't share the generic no-cmd esc path below. + if m.overlay == OverlayTasks { + return m.handleTasksKey(k) + } + if k.Type == keyEsc { + // In the event inspector, esc first stops an in-progress filter edit (keeping the + // query) rather than closing the overlay. + if m.overlay == OverlayEventInspector && m.eventFilterTyping { + m.eventFilterTyping = false + return m, nil + } m.overlay = OverlayNone return m, nil } @@ -369,69 +669,128 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case OverlayPalette: return m.handlePaletteKey(k) case OverlayDiff: + // A long diff/preview is unreadable one line at a time, so the fullscreen + // viewer pages like the OUTPUT transcript: ↑↓/jk by line, PgUp/PgDn by a + // full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds. + page := m.diffBodyHeight() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): - if m.diffScrollOffset > 0 { - m.diffScrollOffset-- - } - case k.Type == tea.KeyDown || runeIs(k, "j"): - if m.diffScrollOffset < m.diffMaxScroll() { - m.diffScrollOffset++ - } - case k.Type == tea.KeyCtrlX: + case k.Type == keyUp || runeIs(k, "k"): + m.scrollDiff(-1) + case k.Type == keyDown || runeIs(k, "j"): + m.scrollDiff(1) + case k.Type == keyPgUp: + m.scrollDiff(-page) + case k.Type == keyPgDown: + m.scrollDiff(page) + case k.Type == keyCtrlU: + m.scrollDiff(-page / 2) + case k.Type == keyCtrlD: + m.scrollDiff(page / 2) + case runeIs(k, "g"): + m.diffScrollOffset = 0 + case runeIs(k, "G"): + m.diffScrollOffset = m.diffMaxScroll() + case k.Type == keyCtrlX: m.overlay = OverlayNone } case OverlayEventInspector: evs := m.currentEvents() + if m.eventFilterTyping { + switch { + case k.Type == keyEnter: + m.eventFilterTyping = false + case k.Type == keyBackspace: + if n := len(m.eventFilter); n > 0 { + m.eventFilter = m.eventFilter[:n-1] + m.overlayEventIdx = 0 + } + case k.Type == keyRunes || k.Type == keySpace: + m.eventFilter += string(k.Runes) + m.overlayEventIdx = 0 + } + return m, nil + } switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case runeIs(k, "/"): + m.eventFilterTyping = true + case runeIs(k, "c"): + m.eventFilter = "" + m.overlayEventIdx = 0 + case k.Type == keyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.overlayEventIdx < len(evs)-1 { m.overlayEventIdx++ } } + case OverlayHelp: + // The cheat-sheet scrolls when it's taller than the screen; any non-scroll key + // dismisses it (esc handled above), keeping the quick-glance feel. + page := m.modalBodyRows() + switch { + case k.Type == keyUp || runeIs(k, "k"): + m.scrollModal(-1) + case k.Type == keyDown || runeIs(k, "j"): + m.scrollModal(1) + case k.Type == keyPgUp: + m.scrollModal(-page) + case k.Type == keyPgDown: + m.scrollModal(page) + case k.Type == keyCtrlU: + m.scrollModal(-page / 2) + case k.Type == keyCtrlD: + m.scrollModal(page / 2) + case runeIs(k, "g"): + m.modalScroll = 0 + case runeIs(k, "G"): + m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) + default: + m.overlay = OverlayNone + } case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone } case OverlayArtifacts: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.artifactsIndex > 0 { m.artifactsIndex-- m.artifactScroll = 0 } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.artifactsIndex < len(m.artifacts)-1 { m.artifactsIndex++ m.artifactScroll = 0 } - case k.Type == tea.KeyPgUp: - if m.artifactScroll > 0 { - m.artifactScroll-- - } - case k.Type == tea.KeyPgDown: - contentW := m.modalWidth() - 6 - if m.artifactScroll < m.artifactContentMaxScroll(contentW) { - m.artifactScroll++ - } + case k.Type == keyPgUp: + m.scrollArtifact(-m.artifactContentBodyH()) + case k.Type == keyPgDown: + m.scrollArtifact(m.artifactContentBodyH()) + case k.Type == keyCtrlU: + m.scrollArtifact(-m.artifactContentBodyH() / 2) + case k.Type == keyCtrlD: + m.scrollArtifact(m.artifactContentBodyH() / 2) + case runeIs(k, "g"): + m.artifactScroll = 0 + case runeIs(k, "G"): + m.artifactScroll = m.artifactContentMaxScroll(m.modalWidth() - 6) case runeIs(k, "v"): m.overlay = OverlayNone } case OverlayModels: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.modelsIndex > 0 { m.modelsIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.modelsIndex < len(m.availableModels)-1 { m.modelsIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) { m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex])) m.overlay = OverlayNone @@ -443,7 +802,25 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlay = OverlayNone } case OverlayStats: - if runeIs(k, "S") { + page := m.modalBodyRows() + switch { + case k.Type == keyUp || runeIs(k, "k"): + m.scrollModal(-1) + case k.Type == keyDown || runeIs(k, "j"): + m.scrollModal(1) + case k.Type == keyPgUp: + m.scrollModal(-page) + case k.Type == keyPgDown: + m.scrollModal(page) + case k.Type == keyCtrlU: + m.scrollModal(-page / 2) + case k.Type == keyCtrlD: + m.scrollModal(page / 2) + case runeIs(k, "g"): + m.modalScroll = 0 + case runeIs(k, "G"): + m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) + case runeIs(k, "S"): m.overlay = OverlayNone } case OverlayHealth: @@ -452,11 +829,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayIdeas: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.ideasIndex > 0 { m.ideasIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.ideasIndex < len(m.ideas)-1 { m.ideasIndex++ } @@ -472,10 +849,169 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "I"): m.overlay = OverlayNone } + case OverlayFiles: + files := m.filteredFiles() + switch { + case k.Type == keyUp: + if m.fileIndex > 0 { + m.fileIndex-- + } + case k.Type == keyDown: + if m.fileIndex < len(files)-1 { + m.fileIndex++ + } + case k.Type == keyEnter: + if m.fileIndex >= 0 && m.fileIndex < len(files) { + m.insertFileRef(files[m.fileIndex]) + } + m.overlay = OverlayNone // back to typing (still in insert mode) + case k.Type == keyBackspace: + if n := len(m.fileFilter); n > 0 { + m.fileFilter = m.fileFilter[:n-1] + m.fileIndex = 0 + } + case k.Type == keyRunes || k.Type == keySpace: + m.fileFilter += string(k.Runes) + m.fileIndex = 0 + } + case OverlayStatusbar: + switch { + case k.Type == keyUp || runeIs(k, "k"): + if m.sbIndex > 0 { + m.sbIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.sbIndex < len(statusSegments)-1 { + m.sbIndex++ + } + case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"): + if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) { + m.toggleStatusSegment(statusSegments[m.sbIndex].id) + } + case runeIs(k, "g"): + m.overlay = OverlayNone + } + case OverlayGrants: + switch { + case k.Type == keyUp || runeIs(k, "k"): + if m.grantIndex > 0 { + m.grantIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.grantIndex < len(m.grants)-1 { + m.grantIndex++ + } + case k.Type == keyEnter || runeIs(k, "x"): + if m.grantIndex >= 0 && m.grantIndex < len(m.grants) { + g := m.grants[m.grantIndex] + m.client.Send(protocol.RevokeGrant(g.GrantID)) + m.appendAction(m.selectedID, "⊟", "revoked "+g.ToolName+" · "+strings.ToLower(g.Scope)) + // The server replies with a fresh grant.list, which refreshes m.grants. + } + case runeIs(k, "G"): + m.overlay = OverlayNone + } + case OverlayGrantScope: + return m.handleGrantScopeKey(k) } return m, nil } +// grantScopeOption is one breadth choice in the A (approve-always) scope picker. wire is the +// CreateGrant scope name: SESSION is per-session (dies with it), PROJECT covers any session on +// the same repo, GLOBAL every session on this machine. +type grantScopeOption struct{ key, wire, title, hint string } + +func grantScopeOptions() []grantScopeOption { + return []grantScopeOption{ + {"s", "SESSION", "this session", "until this session ends"}, + {"p", "PROJECT", "this project", "any session on this repo"}, + {"g", "GLOBAL", "everywhere", "any session on this machine"}, + } +} + +// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter +// confirms the highlighted scope. esc (handled upstream) cancels without granting. +func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) { + opts := grantScopeOptions() + switch { + case k.Type == keyUp || runeIs(k, "k"): + if m.grantScopeIndex > 0 { + m.grantScopeIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.grantScopeIndex < len(opts)-1 { + m.grantScopeIndex++ + } + case runeIs(k, "s"): + m.grantScopeIndex = 0 + return m.applyGrantScope() + case runeIs(k, "p"): + m.grantScopeIndex = 1 + return m.applyGrantScope() + case runeIs(k, "g"): + m.grantScopeIndex = 2 + return m.applyGrantScope() + case k.Type == keyEnter: + return m.applyGrantScope() + } + return m, nil +} + +// applyGrantScope creates the chosen-scope grant for the pending tool, approves the current +// call, and closes the picker. The SESSION choice is identical to the old A behaviour. +func (m Model) applyGrantScope() (tea.Model, tea.Cmd) { + p := m.grantFor + if p == nil { + m.overlay = OverlayNone + return m, nil + } + opts := grantScopeOptions() + idx := m.grantScopeIndex + if idx < 0 || idx >= len(opts) { + idx = 0 + } + scope := opts[idx].wire + m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName)) + m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) + m.appendAction(p.SessionID, "⊞", "granted "+p.ToolName+" · "+strings.ToLower(scope)) + if s := m.session(p.SessionID); s != nil { + s.removeApproval(p.RequestID) + } + m.grantFor = nil + m.overlay = OverlayNone + m.steerBuffer = "" + m.steering = false + m.approvalArmed = false + m.approvalDismissed = false + return m, nil +} + +// openGrants opens the standing-grants viewer and requests the active PROJECT/GLOBAL grants. +// The board is global (not session-scoped), so it opens from anywhere. +func (m *Model) openGrants() { + m.overlay = OverlayGrants + m.grantIndex = 0 + m.grantsLoading = true + m.client.Send(protocol.ListGrants()) +} + +// toggleStatusSegment flips a status-bar segment's visibility and persists the change. +func (m *Model) toggleStatusSegment(id string) { + if m.sbHidden == nil { + m.sbHidden = map[string]bool{} + } + if m.sbHidden[id] { + delete(m.sbHidden, id) + } else { + m.sbHidden[id] = true + } + saveStatusbarHidden(m.sbHidden) +} + +// sbShow reports whether a status-bar segment is currently visible. +func (m Model) sbShow(id string) bool { return !m.sbHidden[id] } + // openArtifacts opens the artifact viewer for the selected session and requests its // artifacts from the server. No-op when no session is selected. func (m *Model) openArtifacts() { @@ -511,6 +1047,7 @@ func (m *Model) openStats() { return } m.overlay = OverlayStats + m.modalScroll = 0 // Reuse a cached report only if it's for this session; otherwise show a loading state. if m.statsFor != m.selectedID { m.stats = nil @@ -542,53 +1079,64 @@ func (m *Model) openModelsOverlay() { } } -func runeIs(k tea.KeyMsg, s string) bool { - return k.Type == tea.KeyRunes && string(k.Runes) == s +func runeIs(k keyMsg, s string) bool { + return k.Type == keyRunes && string(k.Runes) == s } -func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) { cmds := m.filteredPalette() switch k.Type { - case tea.KeyUp: + case keyUp: if m.paletteIndex > 0 { m.paletteIndex-- } - case tea.KeyDown: + case keyDown: if m.paletteIndex < len(cmds)-1 { m.paletteIndex++ } - case tea.KeyEnter: + case keyEnter: if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { return m.execPalette(cmds[m.paletteIndex].id) } - case tea.KeyBackspace: + case keyBackspace: if n := len(m.paletteFilter); n > 0 { m.paletteFilter = m.paletteFilter[:n-1] m.paletteIndex = 0 } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.paletteFilter += string(k.Runes) m.paletteIndex = 0 } return m, nil } -type paletteCmd struct{ id, title, hint string } +// paletteCmd is one command-palette row. key is the bare-key shortcut that runs the same +// action directly (shown in the palette so the shortcuts are discoverable); empty when the +// command has no direct key. +type paletteCmd struct{ id, key, title, hint string } func paletteCommands() []paletteCmd { return []paletteCmd{ - {"workflows", "start workflow", "open the workflow picker"}, - {"tools", "tool palette", "tools for the current stage"}, - {"models", "swap model", "pick / pin the local model"}, - {"events", "event inspector", "browse the event stream"}, - {"artifacts", "view artifacts", "browse this session's artifacts"}, - {"stats", "session stats", "metrics for the selected session"}, - {"health", "health checks", "system health probe status"}, - {"config", "edit config", "view / change correx settings"}, - {"mode", "toggle mode", "switch chat / steering"}, - {"cancel", "cancel session", "stop the selected session"}, - {"back", "back to list", "leave the current session"}, - {"quit", "quit", "exit correx"}, + {"workflows", "w", "start workflow", "open the workflow picker"}, + {"tools", "t", "tool palette", "tools for the current stage"}, + {"models", "m", "swap model", "pick / pin the local model"}, + {"events", "e", "event inspector", "browse the event stream"}, + {"artifacts", "v", "view artifacts", "browse this session's artifacts"}, + {"stats", "S", "session stats", "metrics for the selected session"}, + {"health", "H", "health checks", "system health probe status"}, + {"sessions", "R", "resume session", "browse / resume recent sessions"}, + {"tasks", "T", "task board", "browse tasks across projects"}, + {"config", "g", "edit config", "view / change correx settings"}, + {"grants", "G", "grants", "view / revoke standing grants"}, + {"statusbar", "", "status bar", "show / hide status-bar segments"}, + {"rail", "", "idle rail", "show / hide the idle status + keys rail"}, + {"panel", "d", "right panel", "in-session: events → changes → off"}, + {"actions", "", "inline actions", "show / hide tool & action rows in output"}, + {"help", "?", "help", "keybinding cheat-sheet"}, + {"mode", "s", "toggle mode", "switch chat / steering"}, + {"cancel", "c", "cancel session", "stop the selected session"}, + {"back", "l", "back to list", "leave the current session"}, + {"quit", "q", "quit", "exit correx"}, } } @@ -599,7 +1147,7 @@ func (m Model) filteredPalette() []paletteCmd { } var out []paletteCmd for _, c := range paletteCommands() { - if strings.Contains(strings.ToLower(c.title+" "+c.hint), f) { + if strings.Contains(strings.ToLower(c.key+" "+c.title+" "+c.hint), f) { out = append(out, c) } } @@ -617,16 +1165,33 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { case "models": m.openModelsOverlay() case "events": - m.overlay = OverlayEventInspector - m.overlayEventIdx = 0 + m.openEventInspector() case "artifacts": m.openArtifacts() case "stats": m.openStats() case "health": m.openHealth() + case "sessions": + return m, m.openSessions() + case "tasks": + return m, m.openTasks() case "config": m.openConfig() + case "grants": + m.openGrants() + case "statusbar": + m.overlay = OverlayStatusbar + m.sbIndex = 0 + case "rail": + m.railHidden = !m.railHidden + case "panel": + m.cycleRightPanel() + case "actions": + m.toggleInlineActions() + case "help": + m.overlay = OverlayHelp + m.modalScroll = 0 case "mode": m.cycleChatMode() case "cancel": @@ -664,6 +1229,16 @@ func (m *Model) backspace() { m.inputCursor = c - 1 } +// cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping. +func (m *Model) cycleLauncherWf() { + m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1) +} + +// cycleRightPanel advances the in-session right panel: events → changes → off → events. +func (m *Model) cycleRightPanel() { + m.rightPanel = (m.rightPanel + 1) % 3 +} + func (m *Model) cycleChatMode() { if m.chatMode == ChatModeChat { m.chatMode = ChatModeSteering @@ -696,6 +1271,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { // Intent line for a workflow being started. if m.wfPendingID != "" { id := m.wfPendingID + m.recordInput(text) m.client.Send(protocol.StartSession(id, text)) m.wfPendingID = "" m.wfPendingName = "" @@ -710,6 +1286,20 @@ func (m Model) submit() (tea.Model, tea.Cmd) { m.beginIntent(m.workflows[m.wfIndex]) return m, nil } + // Launcher: a workflow is Tab-selected → start it with the typed text as its brief + // (empty brief is allowed, like the intent flow). The server makes the session; we + // focus it when its events arrive (pendingWorkflowFocus). + if m.launcherWf > 0 && m.launcherWf <= len(m.workflows) { + wf := m.workflows[m.launcherWf-1] + m.recordInput(text) + m.client.Send(protocol.StartSession(wf.ID, text)) + m.clearInput() + m.inputMode = ModeRouter + m.editMode = ModeNormal + m.pendingWorkflowFocus = true + m.launcherWf = 0 + return m, nil + } // Entering an already-selected session (blank submit). if text == "" && m.selectedID != "" { m.sessionEntered = true @@ -727,6 +1317,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { s.Name = "chat" m.selectedID = id m.sessionEntered = true + m.recordInput(text) m.client.Send(protocol.StartChatSession(id, text)) m.clearInput() return m, nil @@ -736,12 +1327,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { return m, nil } sid := m.selectedID - hist := m.history[sid] - hist = append(hist, text) - if len(hist) > 50 { - hist = hist[len(hist)-50:] - } - m.history[sid] = hist + m.recordInput(text) // No optimistic echo — the USER turn arrives as a chat.turn event. m.client.Send(protocol.ChatInput(sid, text, m.chatMode)) m.clearInput() @@ -761,10 +1347,11 @@ func (m Model) decide(decision string) (tea.Model, tea.Cmd) { note = &n } m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note)) - s.Pending = nil + s.removeApproval(s.Pending.RequestID) m.steerBuffer = "" m.steering = false m.editMode = ModeNormal + m.approvalArmed = false m.approvalDismissed = false return m, nil } @@ -840,13 +1427,32 @@ func (m *Model) listNav(dir int) { } if m.selectedID != list[n].ID { m.bgUpdates = 0 + m.transcriptSel = -1 // a different session's transcript: drop the stale selection } m.selectedID = list[n].ID m.wfIndex = -1 } +// recordInput appends a submitted line to the global history ring (shared across +// free-chat, the workflow-intent line, and in-session chat), deduping the immediate +// repeat and capping the ring. Called from every submit path so ↑/↓ recalls anything +// you've sent, in any input context. +func (m *Model) recordInput(text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == text { + return + } + m.inputHistory = append(m.inputHistory, text) + if len(m.inputHistory) > 100 { + m.inputHistory = m.inputHistory[len(m.inputHistory)-100:] + } +} + func (m *Model) historyPrev() { - hist := m.history[m.selectedID] + hist := m.inputHistory if len(hist) == 0 { return } @@ -864,7 +1470,7 @@ func (m *Model) historyPrev() { } func (m *Model) historyNext() { - hist := m.history[m.selectedID] + hist := m.inputHistory switch { case m.historyIndex == -1: return @@ -882,6 +1488,157 @@ func (m *Model) appendRouter(sid string, e RouterEntry) { m.routerMessages[sid] = append(m.routerMessages[sid], e) } +// appendAction adds an inline "external feedback" row (a tool call, write/edit, approval, or +// grant) to a session's OUTPUT transcript, interleaved with chat turns by arrival order. The +// rows are always recorded; rendering is gated on actionsHidden so toggling reveals history. +func (m *Model) appendAction(sid, icon, text string) { + if sid == "" { + return + } + m.routerMessages[sid] = append(m.routerMessages[sid], RouterEntry{Role: "action", Icon: icon, Content: text}) +} + +// toggleInlineActions flips the inline-action-rows visibility and persists it. +func (m *Model) toggleInlineActions() { + m.actionsHidden = !m.actionsHidden + saveInlineActionsHidden(m.actionsHidden) +} + +// scrollOutput moves the OUTPUT transcript by delta rows (positive = up / back through history), +// clamped to the transcript height against the same geometry the renderer uses. A 0 delta or a +// transcript shorter than the panel is a no-op (stays tail-following). +func (m *Model) scrollOutput(delta int) { + if delta == 0 { + return + } + w, h := m.outputViewport() + rows, _ := m.buildTranscriptRows(w) + max := len(rows) - h + if max < 0 { + max = 0 + } + m.outputScroll += delta + if m.outputScroll < 0 { + m.outputScroll = 0 + } + if m.outputScroll > max { + m.outputScroll = max + } +} + +// outputViewport mirrors View/renderMain to give the OUTPUT transcript's inner (w, h), so the +// scroll handler clamps against the exact geometry the renderer windows to. +func (m Model) outputViewport() (w, h int) { + bottomH := inputH + if m.displayState() == StateApproval { + bottomH = m.approvalBandHeight() + } + mainH := m.height - statusH - footerH - bottomH + if mainH < 3 { + mainH = 3 + } + if m.narrow() { + outH := mainH * 55 / 100 + if outH < 3 { + outH = 3 + } + evH := mainH - outH + if evH < 3 { + evH = 3 + outH = mainH - evH + } + return m.width - 4, outH - 2 + } + leftW := m.width * 63 / 100 + return leftW - 4, mainH - 2 +} + +// outputViewportPage is the PgUp/PgDn step (a panel minus one row of overlap for context). +func (m Model) outputViewportPage() int { + _, h := m.outputViewport() + if h < 2 { + return 1 + } + return h - 1 +} + +// transcriptNavUser moves the transcript selection to the previous (dir<0) or next +// (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ +// grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. +// Messages only ever append, so a stored index stays pointing at the same message. +func (m *Model) transcriptNavUser(dir int) { + msgs := m.routerMessages[m.selectedID] + n := len(msgs) + if n == 0 { + return + } + if dir < 0 { + start := m.transcriptSel + if start < 0 || start > n { + start = n // searching upward from the bottom + } + for i := start - 1; i >= 0; i-- { + if msgs[i].Role == "user" { + m.transcriptSel = i + return + } + } + return // already at the oldest user message + } + if m.transcriptSel < 0 { + return // already tailing the bottom + } + for i := m.transcriptSel + 1; i < n; i++ { + if msgs[i].Role == "user" { + m.transcriptSel = i + return + } + } + m.transcriptSel = -1 // past the last user message → resume tail-follow +} + +// copyMessage returns a command that copies the selected message — or, with no +// selection, the latest assistant/router turn — to the system clipboard via OSC52. +// Returns nil when there is nothing to copy. +func (m Model) copyMessage() tea.Cmd { + if text := m.copyText(); strings.TrimSpace(text) != "" { + return osc52Copy(text) + } + return nil +} + +// copyText is the content `y` will yank: the selected message, or — with no selection — +// the most recent user/router turn. Empty when there is nothing to copy. +func (m Model) copyText() string { + msgs := m.routerMessages[m.selectedID] + if len(msgs) == 0 { + return "" + } + if m.transcriptSel >= 0 && m.transcriptSel < len(msgs) { + return msgs[m.transcriptSel].Content + } + for i := len(msgs) - 1; i >= 0; i-- { + if r := msgs[i].Role; r == "router" || r == "user" { + return msgs[i].Content + } + } + return "" +} + +// osc52Copy writes the clipboard sequence to stderr (separate from the alt-screen on +// stdout). Wrapped for tmux passthrough when running inside tmux so the copy still +// reaches the outer terminal (e.g. over SSH / Termius). +func osc52Copy(text string) tea.Cmd { + return func() tea.Msg { + seq := osc52.New(text) + if os.Getenv("TMUX") != "" { + seq = seq.Tmux() + } + fmt.Fprint(os.Stderr, seq) + return nil + } +} + // currentDiff returns the most recent tool diff in the selected transcript. func (m Model) currentDiff() string { if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" { @@ -896,9 +1653,30 @@ func (m Model) currentDiff() string { return "" } +// openEventInspector opens the event inspector with a fresh (cleared) filter. +func (m *Model) openEventInspector() { + m.overlay = OverlayEventInspector + m.overlayEventIdx = 0 + m.eventFilter = "" + m.eventFilterTyping = false +} + +// currentEvents is the selected session's events, narrowed by the inspector filter (a +// case-insensitive substring of type/detail) when one is set. func (m Model) currentEvents() []EventEntry { - if s := m.session(m.selectedID); s != nil { + s := m.session(m.selectedID) + if s == nil { + return nil + } + if m.eventFilter == "" { return s.Events } - return nil + q := strings.ToLower(m.eventFilter) + out := make([]EventEntry, 0, len(s.Events)) + for _, e := range s.Events { + if strings.Contains(strings.ToLower(e.Type+" "+e.Detail), q) { + out = append(out, e) + } + } + return out } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index a385d8a0..39cc70b6 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -2,10 +2,13 @@ package app import ( "fmt" + "image/color" "os" "strings" + "time" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -27,8 +30,63 @@ const ( // and full-width chrome. func (m Model) narrow() bool { return m.width < narrowWidth } -// View renders the whole frame purely from the model (immediate-mode). -func (m Model) View() string { +// cursorMarker is a private-use rune the composer drops into the caret cell so +// View() can locate it in the fully-composited frame and place a real terminal +// cursor there (see splitCursor). It measures one display column — same as the +// "▏" glyph it stands in for — so the layout is identical whether it resolves to +// a live cursor or the static glyph. +const cursorMarker = "" + +// View renders the whole frame purely from the model (immediate-mode). In +// bubbletea v2 the View carries terminal state, so renderRaw() builds the string +// and View() resolves the caret to a real blinking cursor + sets the window title. +func (m Model) View() tea.View { + content, cur := splitCursor(m.renderRaw(), " ") + return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()} +} + +// render returns the frame as a plain string with the caret drawn as the static +// "▏" glyph — for the preview tool and golden tests, which have no live cursor. +func (m Model) render() string { + content, _ := splitCursor(m.renderRaw(), "▏") + return content +} + +// splitCursor finds the caret marker in a composited frame, replaces it with repl +// (a space for the live cursor cell, "▏" for static), and returns the cursor's +// screen position — nil when no marker is present (e.g. vim normal mode), which +// tells bubbletea to hide the cursor. +func splitCursor(raw, repl string) (string, *tea.Cursor) { + idx := strings.Index(raw, cursorMarker) + if idx < 0 { + return raw, nil + } + pre := raw[:idx] + y := strings.Count(pre, "\n") + lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line + x := ansi.StringWidth(raw[lineStart:idx]) + clean := strings.Replace(raw, cursorMarker, repl, 1) + cur := tea.NewCursor(x, y) + cur.Shape = tea.CursorBar + return clean, cur +} + +// windowTitle names the terminal window/tab after the active session, falling +// back to the model name and then the bare program name. +func (m Model) windowTitle() string { + if m.displayState() == StateInSession { + if s := m.session(m.selectedID); s != nil && s.Name != "" { + return "correx — " + s.Name + } + } + if m.currentModel != "" { + return "correx — " + m.currentModel + } + return "correx" +} + +// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode). +func (m Model) renderRaw() string { if m.quitting { return "" } @@ -36,11 +94,15 @@ func (m Model) View() string { return m.theme.Screen.Render("correx — terminal too small") } - bottom := m.renderInput() - bottomH := inputH - if m.displayState() == StateApproval { + ds := m.displayState() + bottom, bottomH := m.renderInput(), m.inputBarHeight() + switch { + case ds == StateApproval: bottomH = m.approvalBandHeight() bottom = m.renderApprovalBand(bottomH) + case ds == StateIdle: + // Launcher: the input is centered inside the main area, so there's no bottom bar. + bottom, bottomH = "", 0 } mainH := m.height - statusH - footerH - bottomH @@ -48,12 +110,14 @@ func (m Model) View() string { mainH = 3 } - base := lipgloss.JoinVertical(lipgloss.Left, - m.renderStatus(), - m.renderMain(mainH), - bottom, - m.renderFooter(), - ) + sections := []string{m.renderStatus(), m.renderMain(mainH)} + if bottomH > 0 { + sections = append(sections, bottom) + } + sections = append(sections, m.renderFooter()) + base := lipgloss.JoinVertical(lipgloss.Left, sections...) + // Stash the base so center() can composite a modal over a dimmed copy of it. + m.lastBase = base if m.displayState() == StateClarification { return m.center(m.clarificationModal()) @@ -72,7 +136,7 @@ func (m Model) View() string { func (m Model) renderStatus() string { t := m.theme bg := t.P.BgPanel - span := func(s string, fg lipgloss.Color) string { + span := func(s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s) } nrw := m.narrow() @@ -93,29 +157,31 @@ func (m Model) renderStatus() string { if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { parts = append(parts, span(s.Name, t.P.FgStrong)) - if s.CurrentStage != "" { + if s.CurrentStage != "" && m.sbShow("stage") { parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) } - if s.Status != "" { + if s.Status != "" && m.sbShow("status") { parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) } - if s.WorkspaceRoot != "" && !nrw { + if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") { parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint)) } } - model := m.currentModel - if model == "" { - model = "no model" - } - if nrw { - parts = append(parts, span(model, t.P.Fg)) - } else { - loc := "local" - if m.providerType == "REMOTE" { - loc = "remote" + if m.sbShow("model") { + model := m.currentModel + if model == "" { + model = "no model" + } + if nrw { + parts = append(parts, span(model, t.P.Fg)) + } else { + loc := "local" + if m.providerType == "REMOTE" { + loc = "remote" + } + parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } - parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } left := strings.Join(parts, sep) @@ -123,7 +189,7 @@ func (m Model) renderStatus() string { // Last-event clock (§2): always visible in-session, so a silent agent is never // mistaken for a healthy one. Updates every frame tick; turns warn-colored when an // active session has gone quiet past the stale threshold. - if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { + if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") { gap := nowMillis() - s.LastEventAt clockFg := t.P.Faint if s.Active && gap > staleEventThresholdMs { @@ -135,7 +201,7 @@ func (m Model) renderStatus() string { } right = append(right, span(label, clockFg)) } - if g := m.gaugeText(); g != "" && !nrw { + if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") { right = append(right, span(g, t.P.Faint)) } if s := m.session(m.selectedID); s != nil && s.Active { @@ -221,6 +287,16 @@ func (m Model) renderFooter() string { switch { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} + if m.inputMode != ModeFilter { + hints = append(hints, hint("^J", "newline")) + } + // The `/` command menu only triggers on an empty chat line (mid-line `/` is literal). + if m.inputMode == ModeRouter && m.inputBuffer == "" { + hints = append(hints, hint("/", "cmds")) + } + if m.inputMode == ModeRouter { + hints = append(hints, hint("@", "files")) + } case m.displayState() == StateApproval: // Approval actions live in the band itself; the footer offers navigation only. hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} @@ -232,15 +308,20 @@ func (m Model) renderFooter() string { hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")} case m.displayState() == StateIdle: if nrw { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")} } default: // StateInSession if nrw { - hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("H", "health"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("H", "health"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + // Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`, + // so the footer keeps the high-traffic keys + the new transcript nav/copy. + hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + } + if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { + hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied")) } if s := m.session(m.selectedID); s != nil && s.Pending != nil { hints = append(hints, hint("a", "approval (pending)")) @@ -262,39 +343,215 @@ func (m Model) renderFooter() string { // --- main split --- func (m Model) renderMain(h int) string { + if m.displayState() == StateIdle { + return m.renderLauncher(m.width, h) + } if m.narrow() { return m.renderMainNarrow(h) } leftW := m.width * 63 / 100 rightW := m.width - leftW - var leftTitle, rightTitle string - var leftBody, rightBody []string - leftActive, rightActive := false, false + // In-session / approval. The right panel cycles (d): events, changes (token usage + + // changed files), or off (output takes the full width). + if m.rightPanel == 2 { + return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true) + } + left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true) + var right string + if m.rightPanel == 1 { + right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false) + } else { + right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false) + } + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} - switch m.displayState() { - case StateIdle: - leftTitle = "sessions" - if m.wfVisible { - leftTitle = "workflows" - leftBody = m.workflowRows(leftW - 4) - } else { - leftBody = m.sessionRows(leftW - 4) +// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style +// list of files the session has written (summed +/- from each write's diff). ^x opens the +// full diff. Drives the right panel when rightPanel == 1. +func (m Model) changesRows(w, h int) []string { + t := m.theme + turns, toks := 0, 0 + for _, e := range m.routerMessages[m.selectedID] { + if e.Role == "router" && e.Metrics != nil { + turns++ + toks += e.Metrics.TotalTokens } - leftActive = true - rightTitle = "welcome" - rightBody = m.welcomeRows(rightW - 4) - case StateInSession, StateApproval: - leftTitle = "output" - leftBody = m.routerRows(leftW-4, h-2) - leftActive = true - rightTitle = "events" - rightBody = m.eventRows(rightW-4, h) + } + rows := []string{ + t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) + + t.span(" · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim), + "", } - left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive) - right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive) - return lipgloss.JoinHorizontal(lipgloss.Top, left, right) + type chg struct{ add, del int } + var order []string + seen := map[string]*chg{} + for _, e := range m.routerMessages[m.selectedID] { + if e.Role != "tool" || !isUnifiedDiff(e.Content) { + continue + } + p, a, d := diffSummary(e.Content) + if seen[p] == nil { + seen[p] = &chg{} + order = append(order, p) + } + seen[p].add += a + seen[p].del += d + } + if len(order) == 0 { + return append(rows, t.span("no file changes yet", t.P.Faint)) + } + rows = append(rows, t.span("changes (^x for full diff)", t.P.Faint)) + for _, p := range order { + c := seen[p] + icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ") + delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) + + t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("−"+itoa(c.del)) + rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+" "+delta) + } + return rows +} + +// renderLauncher is the idle screen: a compact, opencode-style input centered in the main +// area, with a hideable right rail of status + quick keys. The session list isn't shown — +// it's a keypress away (R). The input's lower-right shows · , Tab-cycled. +func (m Model) renderLauncher(w, h int) string { + t := m.theme + railW := 26 + if m.railHidden || m.narrow() || w < 66 { + railW = 0 + } + leftW := w - railW + + inW := leftW * 82 / 100 + if inW > 84 { + inW = 84 + } + if inW < 24 { + inW = 24 + } + left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg))) + if railW == 0 { + return left + } + rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg))) + return lipgloss.JoinHorizontal(lipgloss.Top, left, rail) +} + +// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the +// box scrolls to keep the cursor's tail in view. +const maxInputLines = 6 + +// editorLines renders the input buffer as styled display lines with the caret at the cursor, +// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space. +func (m Model) editorLines() []string { + t := m.theme + caret := "" + // Only the focused composer (no modal over it) owns the live cursor; an open + // overlay keeps editMode==Insert but draws its own filter caret instead. + if m.editMode == ModeInsert && m.overlay == OverlayNone { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker) + } + buf := m.inputBuffer + cur := m.inputCursor + if cur > len(buf) { + cur = len(buf) + } + if cur < 0 { + cur = 0 + } + style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) } + before := strings.Split(buf[:cur], "\n") + after := strings.Split(buf[cur:], "\n") + var lines []string + for _, ln := range before[:len(before)-1] { + lines = append(lines, style(ln)) + } + lines = append(lines, style(before[len(before)-1])+caret+style(after[0])) + for _, ln := range after[1:] { + lines = append(lines, style(ln)) + } + if len(lines) > maxInputLines { + lines = lines[len(lines)-maxInputLines:] + } + return lines +} + +// launcherInput renders the compact (growing) input box plus a status sub-line +// ( · left, action hints right). +func (m Model) launcherInput(w int) string { + t := m.theme + insert := m.editMode == ModeInsert + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ") + indent := t.span(" ", t.P.Bg) + var content []string + if m.inputBuffer == "" && !insert { + content = []string{prompt + t.span("Ask anything…", t.P.Faint)} + } else { + for i, ln := range m.editorLines() { + if i == 0 { + content = append(content, prompt+ln) + } else { + content = append(content, indent+ln) + } + } + } + box := t.box("", content, w, len(content)+2, insert) + + wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName()) + leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint) + hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint) + return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg) +} + +// launcherRail is the idle status + quick-keys panel on the right. +func (m Model) launcherRail(w int) string { + t := m.theme + var rows []string + if m.connected { + rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected")) + } else { + rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline")) + } + count := t.span(plural(len(m.sessions), "session"), t.P.Dim) + if a := m.activeSessionCount(); a > 0 { + count += t.span(" · "+itoa(a)+" active", t.P.Faint) + } + key := func(k, d string) string { + return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim) + } + rows = append(rows, count, "", + key("i", "type"), + key("Tab", "workflow"), + key("R", "resume"), + key("?", "keys"), + key("p", "commands"), + ) + return t.box("correx", rows, w, len(rows)+2, false) +} + +// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled. +func (m Model) launcherWfName() string { + if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) { + return "chat" + } + return m.workflows[m.launcherWf-1].ID +} + +// activeSessionCount is the number of sessions not in a terminal (completed/failed) state. +func (m Model) activeSessionCount() int { + n := 0 + for _, s := range m.sessions { + u := strings.ToUpper(s.Status) + if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") { + n++ + } + } + return n } // renderMainNarrow gives the main area a single full-width column when the terminal @@ -303,15 +560,8 @@ func (m Model) renderMain(h int) string { // — the strip is too valuable to drop, the constraint is width not height). func (m Model) renderMainNarrow(h int) string { w := m.width - if m.displayState() == StateIdle { - title := "sessions" - body := m.sessionRows(w - 4) - if m.wfVisible { - title, body = "workflows", m.workflowRows(w-4) - } - return m.theme.box(title, body, w, h, true) - } - // in-session / approval: stack output over events. + // Idle is handled by renderLauncher (which drops the rail when narrow); this only + // runs for in-session / approval: stack output over events. outH := h * 55 / 100 if outH < 3 { outH = 3 @@ -351,17 +601,17 @@ func (m Model) renderInput() string { insert := m.editMode == ModeInsert caret := t.span(" ", t.P.Bg) - if insert && m.caretVisible() { - caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + if insert && m.overlay == OverlayNone { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker) } - var line string + var content []string switch { case m.inputBuffer == "" && !insert: - line = t.span(placeholder, t.P.Faint) + content = []string{t.span(placeholder, t.P.Faint)} case m.inputBuffer == "": - line = caret + t.span(placeholder, t.P.Faint) + content = []string{caret + t.span(placeholder, t.P.Faint)} default: - line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret + content = m.editorLines() } // status sub-line: · · @@ -387,115 +637,104 @@ func (m Model) renderInput() string { sub += t.span(" · ", t.P.Faint) + t.span(mode, t.P.Dim) - body := []string{line, sub} - return t.box("", body, m.width, inputH, insert) + body := append(content, sub) + return t.box("", body, m.width, len(body)+2, insert) +} + +// inputBarHeight is the bottom input bar's current height (it grows with multi-line input): +// content lines (capped at maxInputLines) + the status sub-line + 2 borders. +func (m Model) inputBarHeight() int { + n := 1 + if m.inputBuffer != "" { + n = len(m.editorLines()) + } + return n + 1 + 2 } // --- body row builders --- -func (m Model) sessionRows(w int) []string { - t := m.theme - list := m.filteredSessions() - if len(list) == 0 { - return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)} +// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the +// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years. +// Empty for a zero/absent time (renders as no date rather than a bogus epoch). +func shortDateTime(ms int64) string { + if ms <= 0 { + return "" } - rows := make([]string, 0, len(list)) - for _, s := range list { - sel := s.ID == m.selectedID && !m.wfVisible - short := s.ID - if len(short) > 6 { - short = short[:6] - } - nameFg := t.P.Fg - bar := t.span(" ", t.P.Bg) - if sel { - bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ") - nameFg = t.P.FgStrong - } - idCell := t.span("["+short+"] ", t.P.Faint) - statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true). - Render(padRaw(statusLabel(s.Status), 9)) - nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name) - stage := "" - if s.CurrentStage != "" { - stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim) - } - rows = append(rows, bar+idCell+statusCell+nameCell+stage) + ts := time.UnixMilli(ms) + if ts.Year() != time.Now().Year() { + return ts.Format("Jan 02 2006") } - return rows -} - -func (m Model) workflowRows(w int) []string { - t := m.theme - if len(m.workflows) == 0 { - return []string{t.span("no workflows advertised", t.P.Faint)} - } - rows := make([]string, 0, len(m.workflows)) - for i, wf := range m.workflows { - marker := t.span(" ", t.P.Bg) - fg := t.P.Fg - if i == m.wfIndex { - marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ") - fg = t.P.FgStrong - } - rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint)) - } - return rows -} - -func (m Model) welcomeRows(w int) []string { - t := m.theme - title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") + - lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") + - lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness") - - var status string - if m.connected { - status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") + - t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim) - } else { - status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected") - } - - rows := []string{title, status, "", - t.span("Select a session from the left", t.P.Dim), - t.span("or type a name to start a new one.", t.P.Dim), ""} - - if n := len(m.sessions); n > 0 { - rows = append(rows, t.span("recent:", t.P.Faint)) - start := 0 - if n > 5 { - start = n - 5 - } - for _, s := range m.sessions[start:] { - mark := statusGlyph(t, s.Status) - rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark) - } - } - return rows + return ts.Format("Jan 02 15:04") } func (m Model) routerRows(w, h int) []string { t := m.theme - msgs := m.routerMessages[m.selectedID] - if len(msgs) == 0 { + if len(m.routerMessages[m.selectedID]) == 0 { return []string{t.span("awaiting router response…", t.P.Faint)} } + rows, msgStart := m.buildTranscriptRows(w) + // With a selection, scroll so the selected message is visible (top-aligned, clamped to the + // end); otherwise honour the free-scroll offset (0 = tail-follow the newest output). + if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { + start := msgStart[m.transcriptSel] + if start > len(rows)-h { + start = len(rows) - h + } + if start < 0 { + start = 0 + } + return rows[start : start+h] + } + if len(rows) > h { + off := m.outputScroll + if max := len(rows) - h; off > max { + off = max + } + if off < 0 { + off = 0 + } + end := len(rows) - off + return rows[end-h : end] + } + return rows +} + +// buildTranscriptRows renders the selected session's transcript to display rows (and records each +// message's first row index for scroll-to-selection). Split out so the scroll handler can measure +// the full height against the same content the renderer windows. +func (m Model) buildTranscriptRows(w int) ([]string, []int) { + t := m.theme + msgs := m.routerMessages[m.selectedID] var rows []string - for _, e := range msgs { + msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection + for mi, e := range msgs { + msgStart[mi] = len(rows) switch e.Role { case "user": + if mi == m.transcriptSel { + // Selected message: an inverted tag + a highlighted background bar so it's + // unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy). + tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" › ") + rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content)) + break + } tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›") rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong)) case "router": - for _, ln := range wrap(e.Content, w) { - rows = append(rows, t.span(ln, t.P.Fg)) + // The router turn is model output: render it as markdown (bold, + // lists, headings, code) wrapped to the panel width. glamour applies + // its own inline foreground colors; we keep the opaque panel by + // fixing each line's background. On any failure renderMarkdown hands + // back the plain content, which still flows through this path fine. + rendered := renderMarkdown(e.Content, w) + for _, ln := range strings.Split(rendered, "\n") { + rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln)) } if s := metricsSuffix(e.Metrics); s != "" { rows = append(rows, t.span(s, t.P.Faint)) } case "tool": - rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim)) + rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim)) case "narration_llm": diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆") lines := wrap(e.Content, w-2) @@ -511,24 +750,23 @@ func (m Model) routerRows(w, h int) []string { } case "narration", "stage": rows = append(rows, t.span(e.Content, t.P.Dim)) + case "action": + // Inline "external feedback" row: a side-effecting action (tool call, write/edit, + // approval, grant) surfaced in the conversation flow. Muted via actionsHidden. + if m.actionsHidden { + break + } + icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon) + rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim)) } } - // keep last h rows - if len(rows) > h { - rows = rows[len(rows)-h:] - } - return rows + return rows, msgStart } func (m Model) eventRows(w, h int) []string { t := m.theme header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") + - t.span(" ", t.P.Bg) - if m.connected { - header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") - } else { - header += t.span("○ idle", t.P.Faint) - } + t.span(" ", t.P.Bg) + m.eventStreamBadge() s := m.session(m.selectedID) if s == nil || len(s.Events) == 0 { @@ -550,6 +788,30 @@ func (m Model) eventRows(w, h int) []string { return append([]string{header, ""}, evRows...) } +// eventStreamBadge reflects whether the *selected session's* stream is still live, not +// just whether the socket is connected: a completed / failed / paused session emits no +// more events, so the old connection-only "● live" went stale the moment a session ended. +func (m Model) eventStreamBadge() string { + t := m.theme + if !m.connected { + return t.span("○ idle", t.P.Faint) + } + if s := m.session(m.selectedID); s != nil { + badge := func(fg color.Color, text string) string { + return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text) + } + switch u := strings.ToUpper(s.Status); { + case strings.Contains(u, "FAIL"): + return badge(t.P.Bad, "✗ failed") + case strings.Contains(u, "COMPLETE"): + return badge(t.P.OK, "✓ done") + case strings.Contains(u, "PAUSE"): + return badge(t.P.Warn, "‖ paused") + } + } + return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") +} + // --- width helpers --- // shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar. @@ -561,7 +823,7 @@ func shortPath(p string) string { } // fill left-justifies a styled line and pads with bg to width w. -func (m Model) fill(s string, w int, bg lipgloss.Color) string { +func (m Model) fill(s string, w int, bg color.Color) string { return " " + padTo(s, w-1, bg) } @@ -569,7 +831,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string { // so the line never overflows w. The left segment is shrunk first (the right holds // the high-signal status — connection clock / active spinner); the right is clamped // only as a last resort. ANSI-aware so truncation can't slice through escape codes. -func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { +func (m Model) justify(left, right string, w int, bg color.Color) string { avail := w - 2 // leading + trailing space lw := lipgloss.Width(left) rw := lipgloss.Width(right) @@ -595,7 +857,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { // padTo pads (or truncates) a possibly-styled string to visible width w, filling // with the given background so the panel stays opaque. -func padTo(s string, w int, bg lipgloss.Color) string { +func padTo(s string, w int, bg color.Color) string { vw := lipgloss.Width(s) if vw == w { return s @@ -622,7 +884,7 @@ func padRaw(s string, w int) string { return s + strings.Repeat(" ", w-n) } -func statusColor(t Theme, status string) lipgloss.Color { +func statusColor(t Theme, status string) color.Color { u := strings.ToUpper(status) switch { case strings.Contains(u, "FAIL"): @@ -659,7 +921,7 @@ func statusGlyph(t Theme, status string) string { } // span renders text with foreground fg over the panel background. -func (t Theme) span(s string, fg lipgloss.Color) string { +func (t Theme) span(s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s) } @@ -739,7 +1001,17 @@ func itoa(n int) string { return string(b[i:]) } -func itoaLen(s string) string { return itoa(len(s)) } +// toolRowSummary collapses a tool-output transcript entry into a one-line pointer. A +// write/edit entry holds a unified diff, so summarise it by what ^x actually shows — the +// diff's row count — instead of the raw diff byte length (which read as a meaningless +// "N chars": it counted +/-/@@/header bytes, not anything the operator cares about, and +// len() is bytes not characters). Non-diff output falls back to a true character count. +func toolRowSummary(content string) string { + if isUnifiedDiff(content) { + return "· diff (" + itoa(previewRowCount(content)) + " rows) — ^x to view" + } + return "· tool output (" + itoa(len([]rune(content))) + " chars) — ^x to view" +} // metricsSuffix formats the faint latency+token annotation for a ROUTER turn. func metricsSuffix(mtr *TurnMetrics) string { diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 73d21080..b6787cc3 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -56,6 +56,8 @@ const ( TypeSessionStats = "session.stats" TypeIdeaList = "idea.list" TypeHealthChecks = "health.checks" + TypeFileList = "file.list" + TypeGrantList = "grant.list" ) // ServerMessage is a flat decode of every server->client variant. Field names @@ -154,6 +156,23 @@ type ServerMessage struct { // idea.list — the cross-session idea board Ideas []IdeaDto `json:"ideas"` + + // file.list — the session workspace's file paths (for the @ file-ref picker) + Paths []string `json:"paths"` + + // grant.list — the active cross-session (PROJECT/GLOBAL) standing grants + Grants []GrantDto `json:"grants"` +} + +// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto. +type GrantDto struct { + GrantID string `json:"grantId"` + Scope string `json:"scope"` // "PROJECT" | "GLOBAL" + ToolName string `json:"toolName"` + ProjectID string `json:"projectId"` + Tiers []string `json:"tiers"` + Reason string `json:"reason"` + ExpiresAtMs *int64 `json:"expiresAtMs"` } // IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto. @@ -410,6 +429,12 @@ func ListArtifacts(sessionID string) []byte { return encode("ListArtifacts", map[string]any{"sessionId": sessionID}) } +// ListFiles asks the server for the session workspace's file paths (replied to with file.list), +// used to populate the @ file-reference picker. +func ListFiles(sessionID string) []byte { + return encode("ListFiles", map[string]any{"sessionId": sessionID}) +} + // GetSessionStats asks the server for a session's derived metrics (replied to with session.stats). func GetSessionStats(sessionID string) []byte { return encode("GetSessionStats", map[string]any{"sessionId": sessionID}) @@ -471,7 +496,8 @@ func ClearModelPin() []byte { } // CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate. -// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3". +// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3". +// For PROJECT/GLOBAL the server derives the projectId from the session's workspace. func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte { return encode("CreateGrant", map[string]any{ "sessionId": sessionID, @@ -484,6 +510,16 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN }) } +// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list. +func RevokeGrant(grantID string) []byte { + return encode("RevokeGrant", map[string]any{"grantId": grantID}) +} + +// ListGrants requests the active cross-session grants (replied to with a grant.list). +func ListGrants() []byte { + return encode("ListGrants", map[string]any{}) +} + // Hello is the first frame sent on every (re)connect. It carries the client's // working directory so the server can bind a workspace before any session starts. func Hello(workingDir string) []byte { diff --git a/apps/tui-go/internal/ws/client.go b/apps/tui-go/internal/ws/client.go index 7e59ccb6..4313ff04 100644 --- a/apps/tui-go/internal/ws/client.go +++ b/apps/tui-go/internal/ws/client.go @@ -28,23 +28,36 @@ type Status struct { // Client is a reconnecting WebSocket client. Construct with New, then Run in a // goroutine. Incoming and Conn are drained by the UI. type Client struct { - url string - send chan []byte - in chan protocol.ServerMessage - conn chan Status + url string + httpBase string + send chan []byte + in chan protocol.ServerMessage + conn chan Status } // New builds a client targeting ws://host:port/stream. func New(host string, port int) *Client { - u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"} + hp := hostPort(host, port) + u := url.URL{Scheme: "ws", Host: hp, Path: "/stream"} return &Client{ - url: u.String(), - send: make(chan []byte, 64), - in: make(chan protocol.ServerMessage, 256), - conn: make(chan Status, 16), + url: u.String(), + httpBase: (&url.URL{Scheme: "http", Host: hp}).String(), + send: make(chan []byte, 64), + in: make(chan protocol.ServerMessage, 256), + conn: make(chan Status, 16), } } +// HTTPBase is the http://host:port origin the WS client connects to, for REST +// calls (e.g. GET /sessions) that share the server's host/port. Empty for a nil +// client (the test harness constructs Models with a nil client). +func (c *Client) HTTPBase() string { + if c == nil { + return "" + } + return c.httpBase +} + func hostPort(host string, port int) string { h := host if h == "" { diff --git a/apps/tui-go/main.go b/apps/tui-go/main.go index 9d40068b..cf18bddc 100644 --- a/apps/tui-go/main.go +++ b/apps/tui-go/main.go @@ -8,7 +8,7 @@ import ( "fmt" "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/correx/tui-go/internal/app" "github.com/correx/tui-go/internal/ws" ) @@ -33,7 +33,8 @@ func main() { defer cancel() go client.Run(ctx) - p := tea.NewProgram(app.NewModel(client), tea.WithAltScreen()) + // Alt-screen is requested per-frame via the View's AltScreen field in v2. + p := tea.NewProgram(app.NewModel(client)) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "correx tui error:", err) os.Exit(1) diff --git a/apps/worker/build.gradle b/apps/worker/build.gradle deleted file mode 100644 index 9def4d38..00000000 --- a/apps/worker/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.jvm' - id 'application' -} - -application { - mainClass = 'com.correx.apps.worker.MainKt' -} - -dependencies { - implementation "com.github.ajalt.clikt:clikt:5.0.1" -} - -tasks.named("koverVerify").configure { enabled = false } diff --git a/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt b/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt deleted file mode 100644 index 55fa81ad..00000000 --- a/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.correx.apps.worker - -fun main() { - println("correx :: apps/worker") -} diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt index d62e1124..79de330f 100644 --- a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt @@ -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 } diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt new file mode 100644 index 00000000..7d91a973 --- /dev/null +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt @@ -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 = 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" } + } +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt new file mode 100644 index 00000000..f0358778 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt @@ -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 = emptyList(), + val content: String = "", +) { + fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank() +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt new file mode 100644 index 00000000..26acf784 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt @@ -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() + val sources = mutableListOf() + 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 + } + } +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 00352588..1dd5451c 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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() } } diff --git a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt new file mode 100644 index 00000000..40808484 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt @@ -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 `/.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 = "[" + 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) +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt new file mode 100644 index 00000000..5a66e986 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt @@ -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) + } +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt new file mode 100644 index 00000000..c8ab2ead --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt @@ -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(" ")) + } +} diff --git a/core/critique/build.gradle b/core/critique/build.gradle new file mode 100644 index 00000000..4e8d68f9 --- /dev/null +++ b/core/critique/build.gradle @@ -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 } diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt new file mode 100644 index 00000000..d0919260 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt @@ -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 { + override fun initial(): CriticCalibrationState = CriticCalibrationState() + override fun apply(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState = + reducer.reduce(state, event) +} diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt new file mode 100644 index 00000000..493d6632 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt @@ -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 +} diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt b/core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt new file mode 100644 index 00000000..3630d8a2 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt @@ -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)) + } +} diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt b/core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt new file mode 100644 index 00000000..7b4ddf50 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt @@ -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 = emptyMap(), +) diff --git a/core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt b/core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt new file mode 100644 index 00000000..a5fdc2e1 --- /dev/null +++ b/core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt @@ -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): 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) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt new file mode 100644 index 00000000..4f93db21 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt @@ -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()) +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt index 93430a18..b0c63de7 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt @@ -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 } diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt new file mode 100644 index 00000000..aaf5b554 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt @@ -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, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt new file mode 100644 index 00000000..ab63058c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt @@ -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, +) : 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 diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt new file mode 100644 index 00000000..1a4e5839 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt @@ -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, +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt new file mode 100644 index 00000000..bd389e1c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt @@ -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, + val reason: String = "", +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt index 6f7629c9..f7a69346 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt @@ -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 + * (`/.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 diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt new file mode 100644 index 00000000..48bfd0cb --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt @@ -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 diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index da552b48..86087dcd 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -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 = emptyList(), +) : EventPayload + @Serializable @SerialName("SessionWorkspaceBound") data class SessionWorkspaceBoundEvent( @@ -37,3 +52,12 @@ data class ProjectProfileBoundEvent( val conventions: List, val commands: Map, ) : EventPayload + +@Serializable +@SerialName("AgentInstructionsBound") +data class AgentInstructionsBoundEvent( + val sessionId: SessionId, + val workspaceRoot: String, + val sources: List, // file names found, e.g. ["CLAUDE.md","AGENTS.md"] + val content: String, // concatenated, header-labeled instructions +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt new file mode 100644 index 00000000..e0e8cb6a --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt @@ -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, + val producedArtifacts: Set, +) : 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, + val producedArtifacts: Set, + val missingArtifacts: Set, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt new file mode 100644 index 00000000..dac33e83 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt @@ -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 = emptyList(), + val affectedPaths: List = 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, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskAffectedPathsSet") +data class TaskAffectedPathsSetEvent( + override val taskId: TaskId, + val paths: List, +) : 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 diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt index b6a22f1f..66c33901 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt @@ -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 = emptySet(), ) : EventPayload @Serializable diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index aa71d5a5..07f28ea0 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) } } diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt index dcc3e58d..d40471ec 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt @@ -19,6 +19,10 @@ typealias TransitionId = TypeId typealias ArtifactId = TypeId +// Tasks types +typealias TaskId = TypeId + + // Context types typealias ContextPackId = TypeId typealias ContextEntryId = TypeId diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt new file mode 100644 index 00000000..f1981591 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt @@ -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, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt new file mode 100644 index 00000000..7f2fd37c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt @@ -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, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt new file mode 100644 index 00000000..e57cf254 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt @@ -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, +} diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt new file mode 100644 index 00000000..61710af3 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt @@ -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` 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> { + + override fun initial(): Set = emptySet() + + override fun apply(state: Set, event: StoredEvent): Set { + val payload = event.payload + return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) { + state + payload.hosts + } else { + state + } + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt new file mode 100644 index 00000000..1474960b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt @@ -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, +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt index cc9481b6..e3a75b92 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt @@ -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 diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt new file mode 100644 index 00000000..84fa47dd --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt new file mode 100644 index 00000000..7b1bcc98 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt new file mode 100644 index 00000000..3ecbac76 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt index 58627b9a..06367684 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt @@ -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( diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt new file mode 100644 index 00000000..54263f1a --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt new file mode 100644 index 00000000..d84db0a5 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt b/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt new file mode 100644 index 00000000..7783fb62 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt @@ -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 = + 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) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 5112ecff..622cebc7 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -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, + ) +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt new file mode 100644 index 00000000..cde4f074 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt @@ -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, + ): List = + reviews + .groupBy { it.role to it.modelHash } + .flatMap { (_, group) -> correlateCritic(sessionId, group) } + + private fun correlateCritic( + sessionId: SessionId, + critic: List, + ): List { + 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>() + 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, + ) + } + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 64565a90..ad3144fa 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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 { + 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 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? { 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` 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 = 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? { + 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(")") + } + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt new file mode 100644 index 00000000..de206782 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt @@ -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, + val produced: Set, +) { + val missing: Set 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, produced: Set): StageCheckpoint = + StageCheckpoint(expected = expected, produced = produced) +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt new file mode 100644 index 00000000..072f2dc6 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt @@ -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, + 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, 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()) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt new file mode 100644 index 00000000..612a96b4 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt @@ -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 "[]"))) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt new file mode 100644 index 00000000..e325a166 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt @@ -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()) + } +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt b/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt index f9212085..d724c88a 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt @@ -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 { - val discarded = mutableSetOf() + val tombstoned = mutableSetOf() val captured = mutableListOf() 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 } diff --git a/core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt b/core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt new file mode 100644 index 00000000..0a48ae10 --- /dev/null +++ b/core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt @@ -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) : EventStore { + private val stored: List = 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 = stored.asSequence() + + override suspend fun append(event: NewEvent): StoredEvent = throw NotImplementedError() + override suspend fun appendAll(events: List): List = throw NotImplementedError() + override fun read(sessionId: SessionId): List = throw NotImplementedError() + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + throw NotImplementedError() + override fun lastSequence(sessionId: SessionId): Long? = throw NotImplementedError() + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + override fun subscribeAll(): Flow = emptyFlow() + override suspend fun lastGlobalSequence(): Long = throw NotImplementedError() + override fun allSessionIds(): Set = throw NotImplementedError() + } +} diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt new file mode 100644 index 00000000..cb5db1a0 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt @@ -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, + val content: String, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt index d6622dbd..32ad5e84 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -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, ) } } diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt index d2d21f48..4d28ef3a 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -10,4 +10,5 @@ data class SessionState( val boundWorkspace: BoundWorkspace? = null, val boundProfile: BoundProfile? = null, val boundProjectProfile: BoundProjectProfile? = null, + val boundAgentInstructions: BoundAgentInstructions? = null, ) diff --git a/core/tasks/build.gradle b/core/tasks/build.gradle new file mode 100644 index 00000000..9fb5488c --- /dev/null +++ b/core/tasks/build.gradle @@ -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 } diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt new file mode 100644 index 00000000..7c1515f6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt @@ -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 (`-`), 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 { + val byKey = LinkedHashMap() + 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) } + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt new file mode 100644 index 00000000..4591db35 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt @@ -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, + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt new file mode 100644 index 00000000..830caf82 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt @@ -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, + 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 = + board().filterValues { !it.deleted }.map { (id, state) -> Task(id, state) } + + fun count(): Int = list().size +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt new file mode 100644 index 00000000..f4819c35 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt @@ -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 +} + +@Serializable +data class TaskStatusChange(val key: String, val sha: String, val from: String, val to: String) + +@Serializable +data class GitSyncReport(val changes: List) + +/** + * 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): GitSyncReport { + val changes = mutableListOf() + 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) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt new file mode 100644 index 00000000..41b246ed --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt @@ -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, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt new file mode 100644 index 00000000..547d9e72 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt @@ -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?, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt new file mode 100644 index 00000000..2c47929b --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt @@ -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 diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt new file mode 100644 index 00000000..569d78ff --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt @@ -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 { + + 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)) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt new file mode 100644 index 00000000..fd8eaeab --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt @@ -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() + val documents = mutableListOf() + val artifacts = mutableListOf() + val sessions = mutableListOf() + val related = mutableListOf() + } + + 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 { + 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 { + 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 + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt new file mode 100644 index 00000000..9b809bb4 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -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, + val relevantFiles: List, + // 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 = emptyList(), + val dependencies: List, + val documents: List, + val artifacts: List = emptyList(), + val sessions: List = emptyList(), + val relatedLinks: List, + val notes: List, + val relevantKnowledge: List = 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 StringBuilder.appendList(label: String, items: List, 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, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt new file mode 100644 index 00000000..ddc32ac1 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt @@ -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 + * (`-`) 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 { + + 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 +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt new file mode 100644 index 00000000..5a6ed45b --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt @@ -0,0 +1,6 @@ +package com.correx.core.tasks + +data class TaskCounterState( + val projectId: String, + val count: Int, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt new file mode 100644 index 00000000..9c3dde9a --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt @@ -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, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt new file mode 100644 index 00000000..2a2e05b6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt @@ -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): List { + 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): List = + 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): Boolean = + task.state.status == TaskStatus.TODO && unmetBlockers(task, board).isEmpty() + + /** Every workable task on the board, in board order. */ + fun ready(board: Collection): List = + 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): List { + 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 = + 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 } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt new file mode 100644 index 00000000..ff4a0a64 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt @@ -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, + * ` `. 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): 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 + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt new file mode 100644 index 00000000..5d2593be --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt @@ -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 +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt new file mode 100644 index 00000000..b7cfb582 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt @@ -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, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt new file mode 100644 index 00000000..d21c62d7 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt @@ -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): 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" + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt new file mode 100644 index 00000000..091ee7de --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt @@ -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, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt new file mode 100644 index 00000000..48c2c44d --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt @@ -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 +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt new file mode 100644 index 00000000..73ceb47c --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt @@ -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, query: String): List { + 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> { 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): 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 +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt new file mode 100644 index 00000000..ad31a48c --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -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 `-`, 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 = + 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 = + 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 = + 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 = + 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 = + 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 { + 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 { + 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 { + 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 = emptyList(), + affectedPaths: List = 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): Task? = + mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) } + + suspend fun setAffectedPaths(taskId: TaskId, paths: List): 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+") + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt new file mode 100644 index 00000000..945828db --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt @@ -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?, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt new file mode 100644 index 00000000..32ddd882 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt @@ -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 = emptyList(), + val affectedPaths: List = emptyList(), + val links: List = emptyList(), + val claimant: String? = null, + val notes: List = 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, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt new file mode 100644 index 00000000..3abc2be9 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt @@ -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, + ; +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt new file mode 100644 index 00000000..2fcee168 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt @@ -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:`. 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}") +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt new file mode 100644 index 00000000..8a163cff --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt @@ -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 +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt new file mode 100644 index 00000000..f6fc1abf --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt @@ -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()) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt new file mode 100644 index 00000000..fae08179 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt @@ -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) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt new file mode 100644 index 00000000..17044b8b --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt @@ -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") }) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt new file mode 100644 index 00000000..02daa0d9 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt @@ -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() + private var global = 0L + private val perSession = mutableMapOf() + + 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): List = events.map { append(it) } + + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sessionSequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = + read(sessionId).maxOfOrNull { it.sessionSequence } + + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + + override fun subscribeAll(): Flow = emptyFlow() + + override suspend fun lastGlobalSequence(): Long = global + + override fun allEvents(): Sequence = events.asSequence() + + override fun allSessionIds(): Set = events.map { it.metadata.sessionId }.toSet() +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt new file mode 100644 index 00000000..4450982a --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt @@ -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) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt new file mode 100644 index 00000000..03e45880 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt @@ -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() + val fakeRetriever = object : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List { + 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 = + 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:")) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt new file mode 100644 index 00000000..dc707cc0 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt @@ -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) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt new file mode 100644 index 00000000..320628b2 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt @@ -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 = 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 }) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt new file mode 100644 index 00000000..472fd313 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt @@ -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())) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt new file mode 100644 index 00000000..dfb7e0da --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt @@ -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._")) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt new file mode 100644 index 00000000..e149092a --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt @@ -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 = emptyList(), + notes: List = 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(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra")) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt new file mode 100644 index 00000000..fea1f8ec --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -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()) + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt new file mode 100644 index 00000000..efc63ed1 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt @@ -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 = emptySet(), + val writes: Set = 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 = emptyMap(), + val pendingReads: Map> = emptyMap(), +) + +/** The task this session has claimed and is working, with its declared write scope. */ +data class ActiveTask(val taskId: String, val scope: List) + +/** + * 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 { + + 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, + ) + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 9a98bf29..79cb4777 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -25,6 +25,14 @@ data class ToolCallAssessmentInput( val paramRoles: Map = emptyMap(), // Workspace-relative globs the active stage may write. Empty = unrestricted. val writeManifest: List = 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 = 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( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt index d68a6e38..342c2e52 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt @@ -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() } diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt new file mode 100644 index 00000000..5a00c415 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt @@ -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, sessionHosts: Set): 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 + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt index 5674c9fb..004d65f1 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt @@ -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()), diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt new file mode 100644 index 00000000..53a81ed1 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt @@ -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): 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() + val observations = mutableListOf() + 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" + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt new file mode 100644 index 00000000..56d9133f --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt @@ -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): 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() + val observations = mutableListOf() + 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" + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/StaleWriteRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/StaleWriteRule.kt new file mode 100644 index 00000000..880cbd6a --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/StaleWriteRule.kt @@ -0,0 +1,74 @@ +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 + +/** + * Stale-write gate. Blocks writing a file whose on-disk content has changed since the session read + * it — the agent's view is stale (a concurrent/external edit). Only files that were *fully* read + * baseline a hash; files the session has itself written this run are excluded, so the agent's own + * edits never look stale. Catches the genuine "someone/something changed it under me" case without + * tripping on normal read→edit flow. The block tells the agent to re-read before writing. + */ +class StaleWriteRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val readHashes = input.session.readHashes + if (readHashes.isEmpty()) return ToolCallAssessment() + + val root = input.workspace.workspaceRoot + val hashByReal = readHashes.entries.associate { realOf(input, root, it.key) to it.value } + val writtenReal = input.session.writes.map { realOf(input, root, it) }.toSet() + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val resolved = resolveInput(root, raw) + val real = input.probe.resolveReal(resolved) + val recorded = hashByReal[real] + if (recorded == null || real in writtenReal) continue // not read in full, or self-edited + val current = input.probe.contentHash(resolved) + val stale = current != null && current != recorded + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "stale" to stale.toString()), + ) + if (stale) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' targets '$raw', which changed on disk " + + "since you read it — re-read it before writing so you don't clobber the change.", + 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 = "STALE_WRITE" + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt new file mode 100644 index 00000000..85dd7438 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt @@ -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.FileSystems +import java.nio.file.Path + +/** + * Write-scope adherence. When the session has claimed a task that declared affected_paths, an + * in-workspace write outside that scope is BLOCKED — but **declarably**: the message tells the + * agent that if the change is genuinely needed (a dependency, a caller the plan didn't foresee) it + * should widen the task's affected_paths via task_update (which re-records the scope), then retry. + * So legitimate work is never trapped — only forced to be explicit, turning silent scope-creep into + * a recorded, auditable scope change. No active task or no declared scope ⇒ nothing to enforce; + * out-of-workspace targets are PathContainmentRule's concern. + */ +class WriteScopeRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val active = input.session.activeTask + if (active == null || active.scope.isEmpty()) return ToolCallAssessment() + + val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot) + val matchers = active.scope.map { FileSystems.getDefault().getPathMatcher("glob:${it.removePrefix("./")}") } + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val candidate = Path.of(raw) + val resolvedReal = input.probe.resolveReal( + if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate), + ) + if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate + val rel = workspaceReal.relativize(resolvedReal) + val inScope = matchers.any { it.matches(rel) } + + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "inScope" to inScope.toString(), "task" to active.taskId), + ) + + if (!inScope) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' writes '$raw', outside claimed task " + + "${active.taskId}'s affected_paths (${active.scope}). If this change is needed, " + + "add its path via task_update affected_paths (note why), then retry.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private companion object { + const val RULE_CODE = "WRITE_SCOPE" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt new file mode 100644 index 00000000..dd2515c2 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt @@ -0,0 +1,52 @@ +package com.correx.core.toolintent + +import com.correx.core.toolintent.rules.EgressAllowlist +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EgressAllowlistTest { + + @Test + fun `host in static set only is allowed`() { + assertTrue(EgressAllowlist.isAllowed("good.com", setOf("good.com"), emptySet())) + } + + @Test + fun `host in session set only is allowed`() { + assertTrue(EgressAllowlist.isAllowed("granted.com", emptySet(), setOf("granted.com"))) + } + + @Test + fun `host in neither set is denied when an allow-list exists`() { + assertFalse(EgressAllowlist.isAllowed("other.com", setOf("good.com"), setOf("granted.com"))) + } + + @Test + fun `empty union allows any host`() { + assertTrue(EgressAllowlist.isAllowed("anywhere.example.com", emptySet(), emptySet())) + } + + @Test + fun `suffix match is honoured for static hosts`() { + assertTrue(EgressAllowlist.isAllowed("api.good.com", setOf("good.com"), emptySet())) + } + + @Test + fun `suffix match is honoured for session hosts`() { + assertTrue(EgressAllowlist.isAllowed("docs.granted.com", emptySet(), setOf("granted.com"))) + } + + @Test + fun `suffix match does not leak across unrelated domains`() { + // "evilgood.com" must NOT match a configured "good.com" — only true subdomains. + assertFalse(EgressAllowlist.isAllowed("evilgood.com", setOf("good.com"), emptySet())) + } + + @Test + fun `matching is case-insensitive`() { + assertTrue(EgressAllowlist.isAllowed("API.Good.COM", setOf("good.com"), emptySet())) + assertTrue(EgressAllowlist.isAllowed("api.good.com", setOf("GOOD.COM"), emptySet())) + assertTrue(EgressAllowlist.isAllowed("api.granted.com", emptySet(), setOf("Granted.Com"))) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt index d59f9796..3eaf1369 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt @@ -23,6 +23,7 @@ class NetworkHostRuleTest { private fun input( params: Map, capabilities: Set = setOf(ToolCapability.NETWORK_ACCESS), + sessionEgressHosts: Set = emptySet(), ) = ToolCallAssessmentInput( request = ToolRequest( ToolInvocationId("i"), @@ -34,6 +35,7 @@ class NetworkHostRuleTest { capabilities = capabilities, workspace = WorkspacePolicy(Path.of("/work")), probe = FakeProbe(), + sessionEgressHosts = sessionEgressHosts, ) @Test @@ -79,6 +81,46 @@ class NetworkHostRuleTest { assertTrue(r.issues.isEmpty()) } + @Test + fun `session-granted host outside static allow-list proceeds`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://granted.com/x"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `host in neither static nor session allow-list yields PROMPT_USER`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://other.com"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code) + } + + @Test + fun `session-granted host honours suffix subdomain match`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://api.granted.com/p"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `denied host wins over a matching session grant`() { + val rule = NetworkHostRule(emptySet(), setOf("granted.com")) + val r = rule.assess( + input(mapOf("url" to "https://granted.com/x"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("NETWORK_HOST_DENIED", r.issues.single().code) + } + @Test fun `non-URL string param is ignored`() { val rule = NetworkHostRule(setOf("good.com"), setOf("evil.com")) diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt new file mode 100644 index 00000000..76c8e1ee --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt @@ -0,0 +1,80 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +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.toolintent.rules.ReadBeforeWriteRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReadBeforeWriteRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = ReadBeforeWriteRule() + + /** A probe where existence is configurable; resolveReal just normalizes (no symlinks). */ + private class FakeProbe(private val existing: Set = emptySet()) : WorldProbe { + override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() + + private fun input( + pathArg: String, + probe: WorldProbe, + read: Set = emptySet(), + tool: String = "file_edit", + ) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), tool, mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = probe, + session = SessionContext(reads = read), + ) + + @Test + fun `applies only to FILE_WRITE`() { + assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ))) + assertFalse(rule.appliesTo(emptySet())) + } + + @Test + fun `editing an existing file that was not read is blocked`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("READ_BEFORE_WRITE", r.issues.single().code) + assertEquals("false", r.observations.single().facts["read"]) + } + + @Test + fun `editing an existing file that was read proceeds`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf(target))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `creating a brand-new file proceeds without a prior read`() { + val target = "/work/project/src/New.kt" // not in existing → new file + val r = rule.assess(input(target, FakeProbe(existing = emptySet()), tool = "file_write")) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `a relative read of the same file satisfies an absolute write`() { + val target = "/work/project/src/A.kt" + // read recorded as a workspace-relative path; resolves to the same real path as the write. + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf("src/A.kt"))) + assertEquals(RiskAction.PROCEED, r.disposition) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt new file mode 100644 index 00000000..13ff5d96 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt @@ -0,0 +1,62 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +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.toolintent.rules.ReferenceExistsRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReferenceExistsRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = ReferenceExistsRule() + + private class FakeProbe(private val existing: Set = emptySet()) : WorldProbe { + override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() + + private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_READ), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = probe, + ) + + @Test + fun `applies only to FILE_READ`() { + assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_READ))) + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) + } + + @Test + fun `reading a non-existent in-workspace path is blocked`() { + val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet()))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("REFERENCE_EXISTS", r.issues.single().code) + } + + @Test + fun `reading an existing path proceeds`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `a non-existent path outside the workspace is left to other gates`() { + val r = rule.assess(input("/tmp/elsewhere.txt", FakeProbe(existing = emptySet()))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertEquals("false", r.observations.single().facts["inWorkspace"]) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt new file mode 100644 index 00000000..7857422f --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt @@ -0,0 +1,106 @@ +package com.correx.core.toolintent + +import com.correx.core.approvals.Tier +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.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.SessionWorkingTaskEvent +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.EventId +import com.correx.core.events.types.TaskId +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.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SessionContextProjectionTest { + + private val session = SessionId("s") + private val projection = SessionContextProjection(session) + + private var seq = 0L + private fun stored(payload: EventPayload) = StoredEvent( + metadata = EventMetadata(EventId("e-$seq"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null), + sequence = ++seq, + sessionSequence = seq, + payload = payload, + ) + + private fun readRequested(invId: String, path: String) = stored( + ToolInvocationRequestedEvent( + invocationId = ToolInvocationId(invId), + sessionId = session, + stageId = StageId("st"), + toolName = "file_read", + tier = Tier.T1, + request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)), + capabilities = setOf(ToolCapability.FILE_READ), + ), + ) + + private fun completed( + invId: String, + affected: List = emptyList(), + structured: Map = emptyMap(), + ) = stored( + ToolExecutionCompletedEvent( + invocationId = ToolInvocationId(invId), + sessionId = session, + toolName = "tool", + receipt = ToolReceipt( + invocationId = ToolInvocationId(invId), + toolName = "tool", + exitCode = 0, + outputSummary = "ok", + structuredOutput = structured, + affectedEntities = affected, + durationMs = 1, + tier = Tier.T1, + timestamp = Instant.parse("2026-01-01T00:00:00Z"), + ), + ), + ) + + private fun fold(events: List) = + events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + + @Test + fun `a completed read enters reads but a pending one does not`() { + assertEquals(setOf("src/A.kt"), fold(listOf(readRequested("r1", "src/A.kt"), completed("r1"))).reads) + assertTrue(fold(listOf(readRequested("r1", "src/A.kt"))).reads.isEmpty()) + } + + @Test + fun `writes come from the completion's affectedEntities`() { + val ctx = fold(listOf(completed("w1", affected = listOf("core/A.kt", "core/B.kt")))) + assertEquals(setOf("core/A.kt", "core/B.kt"), ctx.writes) + assertTrue(ctx.reads.isEmpty()) + } + + @Test + fun `a full read records its content hash for the read path`() { + val ctx = fold( + listOf(readRequested("r1", "src/A.kt"), completed("r1", structured = mapOf("contentHash" to "H1"))), + ) + assertEquals(mapOf("src/A.kt" to "H1"), ctx.readHashes) + } + + @Test + fun `a SessionWorkingTask event sets the active task and the latest wins`() { + val ctx = fold( + listOf( + stored(SessionWorkingTaskEvent(session, TaskId("auth-1"), listOf("core/a/**"))), + stored(SessionWorkingTaskEvent(session, TaskId("auth-2"), listOf("core/auth/**"))), + ), + ) + assertEquals("auth-2", ctx.activeTask?.taskId) + assertEquals(listOf("core/auth/**"), ctx.activeTask?.scope) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt new file mode 100644 index 00000000..28cc54ce --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt @@ -0,0 +1,101 @@ +package com.correx.core.toolintent + +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.events.ToolRequest +import com.correx.core.events.risk.RiskAction +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.ToolInvocationId +import com.correx.core.sessions.projections.EgressAllowlistProjection +import com.correx.core.toolintent.rules.NetworkHostRule +import com.correx.core.tools.contract.ToolCapability +import kotlinx.datetime.Instant +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Mirrors the build-site wiring in SessionOrchestrator.resolveSessionEgressHosts: a session's + * EgressHostsGrantedEvents are folded through EgressAllowlistProjection, the resulting set is placed + * on ToolCallAssessmentInput.sessionEgressHosts, and NetworkHostRule consults it. Uses an in-memory + * list of StoredEvents as the fake store, matching EgressAllowlistProjectionTest's style. + */ +class SessionEgressResolutionTest { + + private val sessionId = SessionId("s1") + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun stored(payload: EventPayload, eventId: String, 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, + ) + + // Replicates SessionOrchestrator.resolveSessionEgressHosts: fold the session log through the + // projection. The store is faked as a plain List. + private fun resolveSessionEgressHosts(events: List): Set { + val projection = EgressAllowlistProjection(sessionId) + return events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + } + + private fun input(url: String, sessionEgressHosts: Set) = ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), + sessionId, + StageId("st"), + "http_fetch", + mapOf("url" to url), + ), + capabilities = setOf(ToolCapability.NETWORK_ACCESS), + workspace = WorkspacePolicy(Path.of("/work")), + probe = FakeProbe(), + sessionEgressHosts = sessionEgressHosts, + ) + + @Test + fun `granted egress host resolves into the assessment input and is allowed`() { + val events = listOf( + stored(InitialIntentEvent(sessionId, "do a thing"), "e1"), + stored(EgressHostsGrantedEvent(sessionId, setOf("granted.com")), "e2"), + ) + val resolved = resolveSessionEgressHosts(events) + assertEquals(setOf("granted.com"), resolved) + + // Host is not in the static allow-list but is granted to the session → PROCEED. + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess(input("https://granted.com/x", resolved)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `no grant resolves to empty and a non-static host prompts`() { + val events = listOf(stored(InitialIntentEvent(sessionId, "do a thing"), "e1")) + val resolved = resolveSessionEgressHosts(events) + assertEquals(emptySet(), resolved) + + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess(input("https://granted.com/x", resolved)) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt new file mode 100644 index 00000000..2e091c4c --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt @@ -0,0 +1,60 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +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.toolintent.rules.StaleWriteRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StaleWriteRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = StaleWriteRule() + private val target = "/work/project/src/A.kt" + + private class FakeProbe(private val hashes: Map) : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + override fun contentHash(path: Path): String? = hashes[path.toAbsolutePath().normalize()] + } + + private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() + + private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_edit", mapOf("path" to target)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = FakeProbe(mapOf(abs(target) to onDisk)), + session = session, + ) + + @Test + fun `unchanged since read proceeds`() { + val r = rule.assess(input(onDisk = "H1", SessionContext(readHashes = mapOf(target to "H1")))) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `changed on disk since read is blocked`() { + val r = rule.assess(input(onDisk = "H2", SessionContext(readHashes = mapOf(target to "H1")))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("STALE_WRITE", r.issues.single().code) + } + + @Test + fun `a file the session wrote itself is not stale`() { + val session = SessionContext(readHashes = mapOf(target to "H1"), writes = setOf(target)) + assertEquals(RiskAction.PROCEED, rule.assess(input(onDisk = "H2", session)).disposition) + } + + @Test + fun `a file never fully read is not this gate's concern`() { + assertEquals(RiskAction.PROCEED, rule.assess(input(onDisk = "H2", SessionContext())).disposition) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt new file mode 100644 index 00000000..63389229 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt @@ -0,0 +1,55 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +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.toolintent.rules.WriteScopeRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WriteScopeRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = WriteScopeRule() + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = FakeProbe(), + session = SessionContext(activeTask = active), + ) + + private val scoped = ActiveTask("auth-2", listOf("core/auth/**")) + + @Test + fun `no active task means nothing to enforce`() { + assertEquals(RiskAction.PROCEED, rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition) + } + + @Test + fun `a write inside the claimed task scope proceeds`() { + val r = rule.assess(input("/work/project/core/auth/Login.kt", scoped)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertEquals("true", r.observations.single().facts["inScope"]) + } + + @Test + fun `a write outside the claimed task scope is blocked and names the task`() { + val r = rule.assess(input("/work/project/core/billing/Charge.kt", scoped)) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("WRITE_SCOPE", r.issues.single().code) + assertTrue(r.issues.single().message.contains("auth-2")) + assertTrue(r.issues.single().message.contains("task_update affected_paths")) + } +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt b/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt deleted file mode 100644 index 1302b90b..00000000 --- a/core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.correx.core.tools.contract - -enum class ToolCapability { - FILE_READ, - FILE_WRITE, - NETWORK_ACCESS, - SHELL_EXEC, - PROCESS_SPAWN -} diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt new file mode 100644 index 00000000..b835e715 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt @@ -0,0 +1,230 @@ +package com.correx.core.validation.scene + +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity + +/** + * Standalone structural validator for Godot `.tscn` / `.tres` text (an INI-like format). + * + * Pure logic: it takes the file content as a [String] and returns a [ValidationReport]. It does + * NOT touch the filesystem and is intentionally not wired into any orchestration/pipeline. + * + * It checks three structural properties: + * 1. Section headers are well-formed and of a known type. + * 2. `ExtResource(...)` / `SubResource(...)` references resolve to a declared `id`. + * 3. Node `parent="..."` paths resolve to an already-declared node. + */ +class SceneValidator { + + private companion object { + const val CODE_MALFORMED_HEADER = "scene.malformed_header" + const val CODE_UNKNOWN_SECTION = "scene.unknown_section" + const val CODE_UNRESOLVED_RESOURCE = "scene.unresolved_resource" + const val CODE_BAD_PARENT = "scene.bad_parent" + + val KNOWN_SECTIONS = setOf( + "gd_scene", + "gd_resource", + "ext_resource", + "sub_resource", + "node", + "resource", + "connection", + ) + + val EXT_RESOURCE_REF = Regex("""ExtResource\(\s*"?([^")\s]+)"?\s*\)""") + val SUB_RESOURCE_REF = Regex("""SubResource\(\s*"?([^")\s]+)"?\s*\)""") + } + + fun validate(content: String): ValidationReport { + val issues = mutableListOf() + val lines = content.split("\n").map { it.trim() } + + // Collect declared ids up front so resource-reference resolution is independent of the + // order references appear relative to their declarations. + val headers = lines.filter { it.startsWith("[") }.mapNotNull { parseHeader(it) } + val extResourceIds = headers + .filter { it.type == "ext_resource" } + .mapNotNull { it.attributes["id"] } + .toSet() + val subResourceIds = headers + .filter { it.type == "sub_resource" } + .mapNotNull { it.attributes["id"] } + .toSet() + + val knownNodePaths = mutableSetOf() + var rootDeclared = false + + lines.forEachIndexed { index, line -> + val lineNumber = index + 1 + if (line.startsWith("[")) { + val header = parseHeader(line) + if (header == null) { + issues += issue( + CODE_MALFORMED_HEADER, + "Malformed section header at line $lineNumber: '$line'", + ValidationSeverity.ERROR, + ) + return@forEachIndexed + } + if (header.type !in KNOWN_SECTIONS) { + issues += issue( + CODE_UNKNOWN_SECTION, + "Unknown section type '${header.type}' at line $lineNumber", + ValidationSeverity.WARNING, + ) + } + if (header.type == "node") { + handleNode(header, lineNumber, rootDeclared, knownNodePaths, issues) + rootDeclared = true + } + } else { + checkResourceReferences(line, lineNumber, extResourceIds, subResourceIds, issues) + } + } + + return ValidationReport( + sections = listOf(ValidationSection(name = "scene", issues = issues)), + ) + } + + private fun checkResourceReferences( + line: String, + lineNumber: Int, + extResourceIds: Set, + subResourceIds: Set, + issues: MutableList, + ) { + EXT_RESOURCE_REF.findAll(line).forEach { match -> + val id = match.groupValues[1] + if (id !in extResourceIds) { + issues += issue( + CODE_UNRESOLVED_RESOURCE, + "Unresolved ExtResource(\"$id\") at line $lineNumber: no [ext_resource] declares id=\"$id\"", + ValidationSeverity.ERROR, + ) + } + } + SUB_RESOURCE_REF.findAll(line).forEach { match -> + val id = match.groupValues[1] + if (id !in subResourceIds) { + issues += issue( + CODE_UNRESOLVED_RESOURCE, + "Unresolved SubResource(\"$id\") at line $lineNumber: no [sub_resource] declares id=\"$id\"", + ValidationSeverity.ERROR, + ) + } + } + } + + /** + * Registers a node's path into [knownNodePaths]. Emits [CODE_BAD_PARENT] when a non-root node + * references a parent path that has not yet been declared. [rootDeclared] tells whether a root + * node has already been seen (the first node is the root). + */ + private fun handleNode( + header: Header, + lineNumber: Int, + rootDeclared: Boolean, + knownNodePaths: MutableSet, + issues: MutableList, + ) { + val name = header.attributes["name"] + val parent = header.attributes["parent"] + + when { + // First node is the root: no parent, or parent=".". + !rootDeclared -> { + if (parent != null && parent != ".") { + issues += issue( + CODE_BAD_PARENT, + "Root node at line $lineNumber declares parent=\"$parent\" but no parent is declared yet", + ValidationSeverity.ERROR, + ) + } + knownNodePaths += "." + } + parent == null -> issues += issue( + CODE_BAD_PARENT, + "Node '${name ?: "?"}' at line $lineNumber has no parent attribute", + ValidationSeverity.ERROR, + ) + parent !in knownNodePaths -> issues += issue( + CODE_BAD_PARENT, + "Node '${name ?: "?"}' at line $lineNumber references undeclared parent \"$parent\"", + ValidationSeverity.ERROR, + ) + name != null -> knownNodePaths += if (parent == ".") name else "$parent/$name" + } + } + + private fun issue(code: String, message: String, severity: ValidationSeverity) = + ValidationIssue(code = code, message = message, severity = severity) + + private data class Header(val type: String, val attributes: Map) + + /** + * Parses a section header line into its type and `key=value` attributes. Returns `null` when the + * line is not a balanced, parseable header (e.g. unclosed `[`, missing type token). + */ + private fun parseHeader(line: String): Header? { + // The header must be a single balanced [...] pair. + val balanced = line.startsWith("[") && line.endsWith("]") && + line.count { it == '[' } == 1 && line.count { it == ']' } == 1 + val tokens = line.takeIf { balanced } + ?.substring(1, line.length - 1) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { tokenize(it) } + ?: return null + + // The section type token must be a bare identifier, not a key=value pair. + return tokens.firstOrNull() + ?.takeIf { !it.contains('=') && it.all { ch -> ch.isLetterOrDigit() || ch == '_' } } + ?.let { type -> parseAttributes(tokens.drop(1))?.let { Header(type, it) } } + } + + /** Parses `key=value` attribute tokens. Returns `null` if any token is not a valid pair. */ + private fun parseAttributes(tokens: List): Map? { + val pairs = tokens.map { token -> + val eq = token.indexOf('=') + val key = if (eq > 0) token.substring(0, eq) else "" + if (key.isBlank()) { + null + } else { + key to token.substring(eq + 1).trim().removeSurrounding("\"") + } + } + return if (pairs.any { it == null }) null else pairs.filterNotNull().toMap() + } + + /** + * Splits header inner text into whitespace-separated tokens while respecting double-quoted + * values (which may themselves contain spaces). Returns `null` if a quote is left unterminated. + */ + private fun tokenize(inner: String): List? { + val tokens = mutableListOf() + val current = StringBuilder() + var inQuotes = false + for (ch in inner) { + when { + ch == '"' -> { + inQuotes = !inQuotes + current.append(ch) + } + ch.isWhitespace() && !inQuotes -> { + if (current.isNotEmpty()) { + tokens += current.toString() + current.clear() + } + } + else -> current.append(ch) + } + } + if (inQuotes) return null + if (current.isNotEmpty()) tokens += current.toString() + return tokens + } +} diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt new file mode 100644 index 00000000..2db8de04 --- /dev/null +++ b/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt @@ -0,0 +1,156 @@ +package com.correx.core.validation.scene + +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSeverity +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SceneValidatorTest { + + private val validator = SceneValidator() + + private fun ValidationReport.issues(): List = + sections.flatMap { it.issues } + + private fun ValidationReport.hasIssue(code: String, severity: ValidationSeverity): Boolean = + issues().any { it.code == code && it.severity == severity } + + @Test + fun `valid tscn with root and two child nodes and a referenced ext_resource has no issues`() { + val content = """ + [gd_scene load_steps=3 format=3 uid="uid://abc123"] + + [ext_resource type="Texture2D" path="res://icon.png" id="1"] + + [node name="Root" type="Node2D"] + + [node name="Sprite" type="Sprite2D" parent="."] + texture = ExtResource("1") + + [node name="Label" type="Label" parent="Sprite"] + text = "hello" + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.issues().isEmpty(), "expected no issues, got ${report.issues()}") + assertFalse(report.hasErrors()) + } + + @Test + fun `malformed unclosed header is flagged as error`() { + val content = """ + [gd_scene format=3] + + [node name="X" + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.malformed_header", ValidationSeverity.ERROR)) + assertTrue(report.hasErrors()) + } + + @Test + fun `ExtResource referencing an undeclared id is an unresolved resource error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + texture = ExtResource("99") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unresolved_resource", ValidationSeverity.ERROR)) + } + + @Test + fun `SubResource referencing an undeclared id is an unresolved resource error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + material = SubResource("MissingMat") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unresolved_resource", ValidationSeverity.ERROR)) + } + + @Test + fun `node with non-existent parent is a bad parent error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + + [node name="Child" type="Node2D" parent="DoesNotExist"] + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.bad_parent", ValidationSeverity.ERROR)) + } + + @Test + fun `valid tres resource file with ext and sub resources has no issues`() { + val content = """ + [gd_resource type="StyleBoxFlat" load_steps=2 format=3 uid="uid://xyz"] + + [ext_resource type="Texture2D" path="res://tex.png" id="1"] + + [sub_resource type="Gradient" id="grad_1"] + colors = PackedColorArray(1, 1, 1, 1) + + [resource] + texture = ExtResource("1") + gradient = SubResource("grad_1") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.issues().isEmpty(), "expected no issues, got ${report.issues()}") + assertFalse(report.hasErrors()) + } + + @Test + fun `unknown section type is a warning`() { + val content = """ + [gd_scene format=3] + + [bogus_section] + + [node name="Root" type="Node2D"] + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unknown_section", ValidationSeverity.WARNING)) + // An unknown section is a warning, not an error. + assertFalse(report.hasErrors()) + } + + @Test + fun `deeper parent path resolves when the intermediate node was declared`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + + [node name="A" type="Node2D" parent="."] + + [node name="B" type="Node2D" parent="A"] + + [node name="C" type="Node2D" parent="A/B"] + """.trimIndent() + + val report = validator.validate(content) + + assertFalse(report.hasIssue("scene.bad_parent", ValidationSeverity.ERROR)) + assertTrue(report.issues().isEmpty()) + } +} diff --git a/docs/decisions/adr-0012-task-tracking.md b/docs/decisions/adr-0012-task-tracking.md new file mode 100644 index 00000000..36a75314 --- /dev/null +++ b/docs/decisions/adr-0012-task-tracking.md @@ -0,0 +1,112 @@ +--- +name: "Adr 0012 Task Tracking" +description: "Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node" +depth: 2 +links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0005-persistence-choice.md", "./adr-0006-event-store-append-only.md"] +--- + +# ADR 0012: native task tracking as an event-sourced aggregate + +**status:** accepted +**date:** 23.06.2026 (June) +**deciders:** correx design team + +--- + +## context + +Correx is to gain **native task tracking** — a durable "task" (work item / issue) entity, +agent-first. Today the system has no concept of a task; it has `sessions` (agent runs) and +session/stage-scoped `artifacts`. A task is conceptually different: a durable unit of intent +that outlives any single session and *references* the artifacts and sessions that act on it. + +An originating pitch proposed storing tasks as YAML/markdown files as the **source of truth** +with Postgres as a cache, plus a `GET /tasks/next` assignment scheduler. Those technical +choices are **rejected** here: they contradict correx's foundational invariants — +"the event log is the only source of truth; projections are disposable and rebuilt from +events" (`.correx/project.toml`, ADR-0001, ADR-0006). Adopting hand-edited files as truth +would create two masters (files vs. log) and forfeit replay/idempotency. We keep the *goal* +(native tasks) and realise it with correx's actual architecture. + +This ADR covers the **foundational slice**: the aggregate, its events, and the work-graph +edge model. REST/CLI/agent-context-bundle/search are deliberately later phases. + +## decision + +**A task is a first-class event-sourced aggregate**, modelled on the existing `core:sessions` +aggregate (Status / State / Reducer / Projector / Repository / Counter), living in a new +`core:tasks` module. It is *not* an `ArtifactKind` — artifacts are session/stage-scoped LLM +outputs; a task is durable and cross-session. + +### 1. event log is the source of truth +Tasks are rebuilt by folding events. Any future markdown/file representation is a *disposable +projection* (export), never the master copy. + +### 2. per-project task stream +The `EventStore` is partitioned by `sessionId`. Since `SessionId` and `TaskId` are both +`TypeId`, all task events for a project live in one stream keyed `tasks:` +(`TaskStreams.forProject`). One read rebuilds the whole board (`TaskBoard = Map`). The `tasks:` prefix keeps these streams distinguishable from real agent +sessions; when task streams are later wired into the live server, session-listing consumers +filter the prefix. + +### 3. lifecycle as facts; status is derived +Mirroring sessions (whose events never carry `SessionStatus`), task events are facts +(`TaskClaimed`, `TaskBlocked`, `TaskCompleted`, …) and `DefaultTaskReducer` derives +`TaskStatus`. This keeps the `TaskStatus` enum out of the lowest module (`core:events`). A +`sealed interface TaskEvent { val taskId }` (a marker, *not* part of the serialized +`EventPayload` hierarchy) lets projections route any payload to its task without a giant `when`. + +### 4. status workflow +`TODO → IN_PROGRESS → IN_REVIEW → DONE`, with `BLOCKED` (reversible; unblock returns to +IN_PROGRESS if claimed, else TODO) and `CANCELLED` (terminal, reopenable). Illegal +transitions are ignored and counted in `TaskState.invalidTransitions` (as `SessionState` does). + +### 5. work-graph edges +`TaskLinkType` (sibling to the existing `ArtifactRelationshipType`): +`DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT`. A link target is a plain id +string — another task, an `ArtifactId`, or a `SessionId` — plus a type. This is the work-graph +backbone the agent-context bundle (later phase) will traverse. + +### 6. human-friendly ids +`TaskCounterProjection(projectId)` counts `TaskCreatedEvent`s in the project stream; the +numeric suffix of a key (`-`, e.g. `auth-142`) is the count of *created* tasks, so +ids never collide even after cancellation. The creating caller composes the key and stores it +in `TaskCreatedEvent.key`. (The creating service is a later phase; the projection and `key` +field land now.) + +## consequences + +**positive:** + +* reuses the entire event-sourcing backbone (sequencing, idempotency, replay, projections); + no new persistence model, no second source of truth +* the work graph (tasks ↔ tasks/artifacts/sessions) is native, enabling later + "everything touching X" queries and the agent-context bundle +* deterministic, replayable task state; status derivation is unit-testable in isolation + +**negative:** + +* overloading the `sessionId`-partitioned stream key with `tasks:` means + session-enumerating consumers must learn to filter the prefix once tasks go live + (acceptable; isolated to the live-wiring phase) +* per-project (not per-task) stream means rebuilding one task replays the project's task + events — fine at expected volumes; revisit with snapshots if a project's task log grows large + +## alternatives considered + +* **Task as an `ArtifactKind`:** rejected — artifacts are session/stage-scoped outputs; + tasks are durable, cross-session, and carry their own lifecycle/claiming. +* **YAML/markdown files as source of truth + Postgres cache (the pitch):** rejected — + violates ADR-0001/0006; creates two masters and loses replay/idempotency. +* **`GET /tasks/next` assignment scheduler (the pitch):** dropped — scheduling/prioritisation + is a separate hard problem; v1 offers `claim` (pull), not `assign` (push). +* **One stream per task:** cleaner per-task sequencing but pollutes session enumeration with + one stream per task and makes board/list queries fan out across many streams; the + per-project stream is the better default for a tracker that lists/boards constantly. + +## status + +Governs the foundational task-tracking slice. Re-evaluation expected when the REST surface, +the `GET /tasks/{id}/context` agent bundle, search, and git-driven auto status updates are +designed (those phases build on, and must not contradict, this ADR). diff --git a/docs/qa/ENV.md b/docs/qa/ENV.md new file mode 100644 index 00000000..eb87b882 --- /dev/null +++ b/docs/qa/ENV.md @@ -0,0 +1,126 @@ +# Correx live-QA environment — bring-up runbook + +The plans in `docs/qa/QA-*.md` cite **observable signals** (events, logs, files, TUI +renders). This runbook stands up the stack that produces them. Run it on a capable box +(JDK 21, Go ≥ 1.24, Docker, a GPU + GGUF model for any model-dependent plan). + +Helper scripts live in `scripts/qa/` (see `scripts/qa/README.md`). + +> **Low-RAM note:** a machine-local `~/.gradle/gradle.properties` on the dev box caps the +> Gradle/Kotlin heap (`-Xmx768m`, `parallel=false`) for a 1.9 GB machine. It is **not +> committed**. On a QA box with real RAM, delete it (or ignore it) so builds aren't throttled. + +--- + +## Prerequisites + +- **JDK 21** (`java -version` → 21). +- **Go ≥ 1.24** — the TUI builds via `GOTOOLCHAIN=auto` (go.mod pins `go 1.24.2`); the + toolchain auto-downloads if your `go` is older. +- **Docker** — only for SearXNG (research-workflow QA). +- **llama-server** binary + at least one **GGUF model** — for any model-dependent plan. +- (optional) an **embedder** model/endpoint — required for the architect-contradiction plan + and any L3-retrieval-dependent behavior (`[router.embedder] backend = "llamacpp"`). + +--- + +## 1. Config + +Correx reads `~/.config/correx/config.toml` plus `workflows/`, `prompts/`, `schemas/` +alongside it. Stale copies cause silent bugs (BACKLOG ops note), so **sync from the repo**: + +```bash +scripts/qa/sync-config.sh # copies examples/workflows + prompts + docs/schemas + # and seeds config.toml from docs/sample-config.toml IF ABSENT +``` + +Then edit `~/.config/correx/config.toml` for the plan you're running: + +- **Model** — pick ONE: + - *Managed* (`[[models]]` + `[models]`): correx spawns llama-server at `[models].host:port` + (default `127.0.0.1:10000`) and kills it on shutdown. Simplest. + - *Static* (`[[providers]]`, type `llamacpp`, `url`): you run llama-server yourself. **Use + this for the llama-health plan** so you can stop the server independently of correx. +- **`[project] enabled = true`** — required for project memory + the **architect-contradiction** + plan (it queries the `project:` L3 namespace written at session end). +- **`[router.embedder] backend = "llamacpp"`** (not `noop`) — required wherever embeddings + matter (architect contradiction, L3 retrieval). `noop` makes every distance meaningless. +- **`[tools.research] enabled = true` + `searxng_url`** — for the research/egress plan. +- **`[health] enabled = true`** — for the llama-health plan; note `interval_ms` (probe cadence). + +A `[[artifacts]]` entry must exist for every LLM-emitted artifact kind a workflow `produces` +(schemas under `~/.config/correx/schemas/`). `sync-config.sh` copies the schemas; confirm the +`[[artifacts]]` table in `config.toml` references the kinds your workflow uses. + +## 2. llama-server + +- **Managed:** nothing to start — correx launches it. The health probe pings + `http://<[models].host>:<[models].port>/health`. +- **External/static:** start your llama-server on the `url` host:port; its `/health` must 2xx. + ```bash + llama-server -m ~/models/.gguf --host 127.0.0.1 --port 10000 + ``` + +## 3. SearXNG (research-workflow QA only) + +```bash +scripts/qa/searxng-up.sh # http://localhost:8888, JSON format enabled +# ... run research QA ... +scripts/qa/searxng-down.sh +``` + +> **Gotcha:** SearXNG ships with only the `html` output format; the research `web_search` +> needs the **JSON API**. `searxng-up.sh` writes a `settings.yml` enabling `formats: [html, json]` +> and mounts it. Without that, `web_search` returns nothing and the workflow stalls. + +## 4. Start the server + +```bash +./gradlew :apps:server:run # mainClass com.correx.apps.server.MainKt, listens on :8080 +# or build a runnable dist once and reuse it: +./gradlew :apps:server:installDist +apps/server/build/install/server/bin/server +``` + +> **Gotcha:** do **not** rebuild jars while the server JVM is running — lazy classloading +> breaks (`NoClassDefFoundError`). Stop the server first, rebuild, restart. + +## 5. Start the TUI + +```bash +cd apps/tui-go +GOTOOLCHAIN=auto go build -o correx-tui . +./correx-tui -host localhost -port 8080 # flags default to localhost:8080 +``` + +## 6. Evidence tools (what the plans cite) + +| Source | Command / where | +|--------|-----------------| +| Event log (the truth) | `./gradlew :apps:cli:run --args="events "` or `GET /sessions/{id}/events` | +| Deterministic replay | `./gradlew :apps:cli:run --args="replay "` | +| System health | `./gradlew :apps:cli:run --args="health"` | +| Server logs | stdout of `:apps:server:run` — MDC carries `sessionId` | +| Files on disk | e.g. `/.correx/project.toml` (idea-promotion plan) | +| TUI render | the running `correx-tui` | + +(Once `:apps:cli:installDist` is built, `apps/cli/build/install/cli/bin/cli events ` works too.) + +## Teardown + +1. Stop the server (Ctrl-C) — a managed `[[models]]` llama-server is killed by the shutdown hook. +2. `scripts/qa/searxng-down.sh` if you started it. +3. External llama-server: stop it yourself. + +--- + +## Per-plan env matrix + +| QA plan | model | embedder=llamacpp | SearXNG | `project.enabled` | workspace `.correx/` | +|---------|:-----:|:-----------------:|:-------:|:-----------------:|:--------------------:| +| `QA-research-egress` | yes (tool-calling) | no | **yes** | no | no | +| `QA-architect-contradiction` | yes | **yes** | no | **yes** | repo root, 2 sessions same process | +| `QA-llama-health-probe` | yes (static path to kill) | no | no | no | no | +| `QA-idea-promotion` | router only (or seed event) | no | no | no | **yes** (bound workspace) | +| `QA-reviewer-static-first` | yes | no | no | no | seed `StaticFindingsRecordedEvent` | +| `QA-brief-echo-gate` | yes (the prod candidate) | no | no | no | analyst brief with criteria | diff --git a/docs/qa/QA-architect-contradiction.md b/docs/qa/QA-architect-contradiction.md new file mode 100644 index 00000000..81ec854a --- /dev/null +++ b/docs/qa/QA-architect-contradiction.md @@ -0,0 +1,42 @@ +# QA Plan: architect contradiction-check (display-only) — `4e08510` `eae0a0c` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §B-§4 "architect contradiction-check" — checker `eae0a0c`, live server hook `4e08510`. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a model that runs the `role_pipeline` (analyst→architect→…) and emits a `design` artifact with a populated `approach` field (see `docs/schemas/design.json`). +- [ ] **external deps:** none beyond the model. +- [ ] **config synced:** `[project] enabled = true` (the checker queries the `project:` L3 namespace that `ProjectMemoryService.persist` writes); **`[router.embedder] backend = "llamacpp"`** with a real embedder URL — `noop` embeddings make every distance meaningless and the gate never fires. `[router.l3] backend = "in_memory"` is fine for a single run, but **note**: in-memory L3 is process-lifetime, so the two sessions below must run against the **same** server process. +- [ ] **fixtures/seed:** the same workspace/repo root for both sessions (the namespace is `project:`). + +## Acceptance gate (one sentence) + +> When a later session's architect `design` decision is semantically near a **prior** session's recorded decision, a `PossibleContradictionFlaggedEvent` is appended — and it never halts the stage. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Run **session A** end-to-end on a repo; architect commits a clear decision (e.g. "use Postgres for persistence"). Let the session **complete** (distillation runs on completion). | Session A completes; `correx events {A}` shows the architect `design` `ArtifactCreatedEvent`. The decision is now distilled into L3 under `project:` (ProjectMemoryService.persist on completion). | | +| 2 | Run **session B** (same server process, same repo) whose architect decision **reverses** A (e.g. "use SQLite / drop Postgres"). | When B's architect `design` artifact is created, `correx events {B}` contains a `PossibleContradictionFlaggedEvent` — `decisionSummary` = B's `approach`, `related[]` lists A's decision with a `score ≥ 0.75` and `source` = the `project:` turnId. | | +| 3 | Confirm non-interference. | Session B's architect stage **still passes** and proceeds to the planner — the flag is display-only (no `WorkflowFailedEvent`, no stage halt around the flag). | | +| 4 | Run **session C** with an architect decision unrelated to A (different domain). | **No** `PossibleContradictionFlaggedEvent` for C (nothing clears the 0.75 threshold). Absence is the evidence. | | +| 5 | Confirm self-exclusion. | The flag in check 2 never lists **B's own** in-flight decision as `related` (checker filters `entry.sessionId != current`). | | + +Evidence sources: `correx events {id}`, server logs (the `[Orchestrator]`/checker warn line on a near hit is informative but the **event** is the gate). + +## Out of scope (explicitly NOT covered this pass) + +- In-session contradiction (two decisions inside one session) — by design the checker filters to PRIOR sessions; not built and not required. +- LLM-judged contradiction — v1 is similarity-only, display-only. +- ADR `alternativesConsidered` shape (BACKLOG marks this deliberately not taken). + +## Disposition + +- **PASS** → move the §B-§4 entry to `RETRO.md` with this run date + the `PossibleContradictionFlaggedEvent` evidence from check 2. Status: PASSED. +- **FAIL** → file numbered findings in `BACKLOG.md`. Most likely failure = embedder is `noop` or the two sessions ran against different processes (in-memory L3 lost) — fix the env, not the code, then re-run. Status: FAILED until green. diff --git a/docs/qa/QA-grants.md b/docs/qa/QA-grants.md new file mode 100644 index 00000000..807fc676 --- /dev/null +++ b/docs/qa/QA-grants.md @@ -0,0 +1,55 @@ +# QA Plan: cross-session grants (PROJECT/GLOBAL) + revoke — `c36d41b` `8df0ec7` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** operator-requested wider grants — see `RETRO.md` 2026-06-21 "wider grants + revoke" (the cross-session live-QA gate noted there). On PASS the gate clears and the entry stays in RETRO with the run date; on FAIL the misses refile into `BACKLOG.md`. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown` — _rebuild only with the server stopped (lazy classloading breaks otherwise)_ +- [ ] **llama-server + model:** a tool-calling-capable model that drives a **T2+** tool call reproducibly across runs. The `healthcheck` workflow's `write_script` → `file_write` (T3) is the reference fixture (used by the other QA demos); any workflow with a deterministic write/shell call works. +- [ ] **external deps:** none (no SearXNG / network needed — this exercises the approval gate, not research). +- [ ] **config synced:** `~/.config/correx` matches `examples/*` + `docs/schemas/*` (`scripts/qa/sync-config.sh`). +- [ ] **fixtures/seed:** two distinct workspace dirs — **W1** (e.g. `/home/kami/qa/repoA`) and **W2** (`/home/kami/qa/repoB`) — each launchable as a session workspace, for the PROJECT-isolation check. The TUI binds its cwd as the workspace (the `Hello` frame), so run the TUI from W1 vs W2 to switch projects. + +## Acceptance gate (one sentence) + +> A GLOBAL/PROJECT grant created in one session auto-clears its **bound tool** in a *different* session — recorded as `ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:")` with **no** `APPROVAL_PENDING` pause — scoped correctly (PROJECT only on the same workspace; tool-bound; T3/T4 honoured), and **revoking** it makes that tool prompt again — all visible in the grant ledger. + +## Checks + +The decisive signal throughout: a grant match emits `ApprovalDecisionResolvedEvent` with +`outcome = AUTO_APPROVED` and `reason = "grant:"` and **falls through to execute** +(no `OrchestrationPausedEvent("APPROVAL_PENDING")`, no `ApprovalRequestedEvent` for that +invocation). The human path emits both of those. "It didn't prompt" must be backed by the +*absence* of `APPROVAL_PENDING` in the log, not just a TUI impression. + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | **Baseline.** Run a session (W1) to a T2+ tool call with no grant in force. | The call pauses for approval: `OrchestrationPausedEvent(reason="APPROVAL_PENDING")` + `ApprovalRequestedEvent(toolName="file_write", tier=T3)` in `correx events {id}`; the TUI shows the approval band. | | +| 2 | **Create GLOBAL grant.** At that prompt press `A`, then `g`. | Exactly one `ApprovalGrantCreatedEvent` in the **grant ledger** (`GET /sessions/__grant_ledger__/events`) with `scope` = `GLOBAL`, `toolName="file_write"`, `permittedTiers` including T3. The current call then resolves `ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:")` and executes. The TUI grants viewer (`G`) lists the grant (GLOBAL · file_write · ≤T3). | | +| 3 | **Cross-session auto-clear (headline).** Start a **second, separate** session that makes the same `file_write` call. | That session records `ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:")` and **no** `OrchestrationPausedEvent("APPROVAL_PENDING")` / **no** `ApprovalRequestedEvent` for the invocation; the tool executes. Proves the ledger is unioned across sessions. | | +| 4 | **Tool-binding.** In a session have the agent call a *different* T2+ tool (e.g. `shell`). | `shell` still raises `OrchestrationPausedEvent("APPROVAL_PENDING")` + `ApprovalRequestedEvent(toolName="shell")` — the GLOBAL grant is bound to `file_write` only, never a blanket open. | | +| 5 | **No tier cap (T3/T4).** Confirm the auto-clear in #3 was a **T3** call. (Optional: at a prompt, grant a tool whose permitted tiers reach T4 and drive a T4 call.) | The ≥T3 call resolves `AUTO_APPROVED` — the removed T2 ceiling no longer blocks high tiers. (The old build rejected `CreateGrant` tiers > T2.) | | +| 6a | **PROJECT scope — same repo.** Revoke the GLOBAL grant (#8). From the TUI in **W1**, drive `file_write`, press `A` then `p`. Then start another session whose cwd is **W1**. | Ledger `ApprovalGrantCreatedEvent` `scope = PROJECT`, `projectId` = canonical absolute path of W1 (e.g. `/home/kami/qa/repoA`). The new W1 session auto-clears `file_write` (`AUTO_APPROVED, reason="grant:"`). | | +| 6b | **PROJECT scope — different repo.** Start a session whose cwd is **W2**, same `file_write` call. | The W2 session **prompts** (`APPROVAL_PENDING` + `ApprovalRequestedEvent`) — the PROJECT grant does not match a different workspace's derived `projectId`. | | +| 7 | **Persistence across restart.** Stop and restart the server; reopen the TUI grants viewer (`G`) / re-read `GET /sessions/__grant_ledger__/events`. | The standing grant is still listed (the ledger stream replays); a fresh session still auto-clears its tool. | | +| 8 | **Revoke.** In the grants viewer press `x` on the grant (or send `RevokeGrant`). | An `ApprovalGrantExpiredEvent(grantId=)` is appended to the **ledger**; the viewer's next `grant.list` no longer lists it. A new session calling that tool now **prompts again** (`APPROVAL_PENDING` + `ApprovalRequestedEvent` reappear). | | +| 9 | **Session-grant does not leak (regression).** At a prompt press `A` then `s` (or Enter); then start a *different* session with the same call. | The SESSION grant is written to the **session's own** stream (not `__grant_ledger__`); the other session still **prompts** (`APPROVAL_PENDING`). Confirms SESSION scope stayed per-session. | | +| 10 | **Audit + replay.** `GET /sessions/__grant_ledger__/events`; `correx replay {consuming-session-id}`. | The ledger shows the create + expire pair (nothing deleted — invariant #9). Replay reproduces the same AUTO_APPROVED / PENDING decisions deterministically from the recorded grants (no re-prompt). | | + +Evidence sources: the TUI grants viewer (`G`, backed by `ListGrants`→`grant.list`), `GET /sessions/__grant_ledger__/events` (the ledger is a real event stream — `correx events __grant_ledger__` if the CLI accepts the raw stream id), `correx events {id}` / `GET /sessions/{id}/events` for the consuming sessions, server logs (MDC sessionId), and `correx replay {id}` for determinism. + +## Out of scope (explicitly NOT covered this pass) + +- The §D standalone web-approval client (third client) — separate track, not built. +- **STAGE**-scope grants — internal, unchanged by this work. +- Time-based expiry (`expiresAt`): the TUI does not set one today, so a grant is standing-until-revoked; the engine *does* honour `expiresAt` if a future client sets it, but that path is not exercised here. +- The LLM-annotation command-card layer and other unrelated TUI overlays. + +## Disposition + +- **PASS** → the cross-session gate in `RETRO.md` (2026-06-21) clears; set this plan's Status: PASSED with the run date and the cited `ApprovalGrantCreatedEvent` / `AUTO_APPROVED reason="grant:"` / `ApprovalGrantExpiredEvent` evidence. +- **FAIL** → file each failure as a numbered finding in `BACKLOG.md` (action + exact repro + the wrong/missing signal — e.g. "step 3: session B still emitted APPROVAL_PENDING → ledger union not consulted"), fix, then re-run only the failed checks. Status: FAILED until green. diff --git a/docs/qa/QA-idea-promotion.md b/docs/qa/QA-idea-promotion.md new file mode 100644 index 00000000..fd3f3f2b --- /dev/null +++ b/docs/qa/QA-idea-promotion.md @@ -0,0 +1,44 @@ +# QA Plan: idea-board → project-profile promotion — `f107ff5` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §E "Idea board → profile promotion". + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a router model only if you capture the idea via the rubber-duck path (it must emit a ```` ```json {"ideas":[...]} ``` ```` block). To avoid model-dependence you may instead **seed an `IdeaCapturedEvent`** directly (see note below). +- [ ] **external deps:** none. +- [ ] **config synced:** standard. +- [ ] **fixtures/seed:** a session bound to a **workspace** (a `SessionWorkspaceBoundEvent` must exist for the capturing session — promotion writes into `/.correx/project.toml` and no-ops without a bound workspace). Start the TUI/session from inside the target repo dir so the workspace resolves. + +## Acceptance gate (one sentence) + +> Promoting a captured idea appends its text as a `conventions` entry in `/.correx/project.toml`, records an `IdeaPromotedEvent`, and removes the idea from the board. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Capture an idea (rubber-duck `{"ideas":[...]}`, or seed an `IdeaCapturedEvent`). List the board (TUI `R`/idea overlay, or `ListIdeas` WS). | The idea shows on the board with a stable `ideaId`; `IdeaCapturedEvent` in the log. | | +| 2 | Note the pre-state of `/.correx/project.toml` (may be absent). | File absent or its current `conventions = [...]` recorded. | | +| 3 | Send `PromoteIdea(ideaId)` (TUI promote key, or the WS `PromoteIdea` message). | `/.correx/project.toml` now exists and its `conventions` list contains the idea text **verbatim** (escaped if it had quotes). Re-reading via `ProjectProfileLoader` round-trips it (no corruption). | | +| 4 | Re-read the log and the board. | An `IdeaPromotedEvent(ideaId, sessionId, text, timestampMs)` is appended; the idea **no longer appears** on the board (`activeIdeas` tombstones promoted ids like discards). | | +| 5 | Promote the **same** idea text again from a second idea (dedup). | `withConvention` does not duplicate an identical convention — the list has it once. | | +| 6 | Promote with **no bound workspace** (start a session with an unresolved working dir). | No file write, no `IdeaPromotedEvent` — the handler no-ops and just refreshes the board (safe). | | + +> **Seeding without a model:** append an `IdeaCapturedEvent(ideaId, sessionId, text, timestampMs)` for a workspace-bound session via the event store, then drive `PromoteIdea`. This isolates the promotion path from router-prompt behavior. + +Evidence sources: the file `/.correx/project.toml` on disk, `correx events {id}`, the TUI board render. + +## Out of scope (explicitly NOT covered this pass) + +- The rubber-duck capture quality itself (covered by the separate idea-board live-QA, §F). +- Promotion into `about`/`commands` — v1 promotes into `conventions` only. + +## Disposition + +- **PASS** → move the §E "Idea board → profile promotion" bullet to `RETRO.md` with this run date + the on-disk `.correx/project.toml` evidence. Status: PASSED. +- **FAIL** → numbered findings (e.g. quote-escaping corruption, board not clearing). Status: FAILED until green. diff --git a/docs/qa/QA-research-egress.md b/docs/qa/QA-research-egress.md new file mode 100644 index 00000000..000932ef --- /dev/null +++ b/docs/qa/QA-research-egress.md @@ -0,0 +1,42 @@ +# QA Plan: research egress allowlist + batch source-list approval — `ab7e4be` `4b0024f` `b098d87` `027ff1f` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §D — "Dynamic per-session egress allowlist", "§3 batch fetch-approval at source-list level", "Dedicated `SourceFetched` / `LowQualityExtraction` events", plus the §D "Live-QA the full run" gate. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown` — _rebuild only with the server stopped (lazy classloading breaks otherwise)_ +- [ ] **llama-server + model:** a tool-calling-capable model (the research workflow drives `web_search`/`web_fetch` tool calls). Managed `[[models]]` or static `[[providers]]`. +- [ ] **external deps:** **SearXNG** at `[tools.research] searxng_url` with the JSON format enabled (`scripts/qa/searxng-up.sh`; see `docs/qa/ENV.md` §3). Outbound network for the fetched hosts. +- [ ] **config synced:** `~/.config/correx` matches `examples/*` + `docs/schemas/*` (`scripts/qa/sync-config.sh`); `research.toml` present; `[tools.research] enabled = true`. +- [ ] **fixtures/seed:** a research prompt that yields ≥2 distinct source hosts (e.g. "summarize the latest on citing official docs"). + +## Acceptance gate (one sentence) + +> Approving a source list once clears every subsequent `web_fetch` to those hosts **without a per-URL T2 prompt**, and the fetch quality lands as dedicated `SourceFetched` / `LowQualityExtraction` events — while a host NOT on the list still raises T2. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Start a research session; let it reach a state with candidate source URLs. | `web_search` T1 ran; the candidate URLs appear in the session (search result / dossier event). | | +| 2 | `POST /sessions/{id}/approve-sources` with the candidate URL list (2+ hosts). | Exactly **one** `EgressHostsGrantedEvent` in `correx events {id}` — `hosts` = the distinct lower-cased hostnames (ports stripped), `reason = "batch source-list approval"`. | | +| 3 | Let the workflow `web_fetch` each approved URL. | Each fetch to an approved host proceeds with **no `ApprovalRequestedEvent` (T2)** — `NetworkHostRule` PROCEEDs via the per-session union. A `SourceFetchedEvent` (with `content_sha256` + quality) is recorded per successful fetch. | | +| 4 | Force a low-quality extraction (e.g. a near-empty / boilerplate page among the sources). | A `LowQualityExtractionEvent` is recorded (not just metadata on `ToolExecutionCompletedEvent`). | | +| 5 | Have the agent attempt a `web_fetch` to a host **not** in the approved list. | A T2 `ApprovalRequestedEvent` IS raised for that host (the allowlist is a union, not a blanket open). | | +| 6 | `correx replay {id}`. | Replay reproduces the same allowlist decisions deterministically (the grant + fetch events are the only inputs; no re-fetch). | | + +Evidence sources: `correx events {id}` / `GET /sessions/{id}/events`, server logs (MDC sessionId), `correx replay {id}`. + +## Out of scope (explicitly NOT covered this pass) + +- The standalone §D web-approval client (third client) — separate track, not built. +- Headless-browser / LLM-based extraction (non-responsibilities). + +## Disposition + +- **PASS** → move the §D egress/batch/source-event bullets to `RETRO.md` with this run date + the cited `EgressHostsGrantedEvent` / `SourceFetchedEvent` evidence. Status: PASSED. +- **FAIL** → file each failure as a numbered finding in `BACKLOG.md` (action + repro + the wrong/missing signal). Status: FAILED until green. diff --git a/docs/qa/QA-reviewer-static-first.md b/docs/qa/QA-reviewer-static-first.md new file mode 100644 index 00000000..cd096c4b --- /dev/null +++ b/docs/qa/QA-reviewer-static-first.md @@ -0,0 +1,44 @@ +# QA Plan: reviewer static-first (findings exclusion) — `447fc7a` + +**Status:** DRAFT (PARTIAL — see gap) +**Run date / operator:** +**BACKLOG item:** §B-§5 "reviewer static-first" — runner + event + context-exclusion `447fc7a`; deterministic `static_check` stage seam + emitter `e46777e`. + +--- + +## Gap up front (read first) + +The exclusion **filter** is live-wired into reviewer context assembly; `StaticAnalysisRunner` + `StaticFindingsRecordedEvent` are built; and the deterministic **`static_check` stage seam + emitter now exist** (`e46777e`): the orchestrator runs any `stage_type = "static_check"` stage through `StaticCheckStageExecutor`, and `role_pipeline.toml` carries one between implementer and reviewer. The remaining gap is **activation**: that stage is a **no-op until** a sandboxed, workspace-scoped `CommandRunner` is wired into the orchestrator AND the stage sets `static_argv`. So checks 1–3 verify the **filter by injecting the event** (unchanged); check 4 is now an **activation** check (configure `static_argv` + wire a runner) rather than "blocked on an unbuilt seam". + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a model that runs `review_loop` / `role_pipeline` reviewer stage. +- [ ] **external deps:** none. +- [ ] **config synced:** `review_loop.toml` (or role_pipeline) present. +- [ ] **fixtures/seed:** a way to append a `StaticFindingsRecordedEvent` for the session (manual event-store seed), carrying a finding that matches a compile/lint issue the code actually has. + +## Acceptance gate (one sentence) + +> When a `StaticFindingsRecordedEvent` has already recorded a static finding this session, the reviewer's assembled context **mechanically omits** any feedback entry that merely restates that finding. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Seed a `StaticFindingsRecordedEvent` for the session with a known finding (e.g. an unused-import lint at `Foo.kt:12`). | The event is in `correx events {id}`. | | +| 2 | Drive the implementer→reviewer step so reviewer context is assembled. | The reviewer's input context (server log of the assembled prompt / context pack) **does not** contain a feedback entry restating the seeded static finding — `excludeStaticFindingsFromReview` stripped it. | | +| 3 | Seed a finding that does **not** match any retry-feedback entry. | Non-matching entries are untouched; only restatements of recorded static findings are dropped. | | +| 4 | **Activate** the `static_check` stage: wire a sandboxed `CommandRunner` into the orchestrator and set `static_argv` (e.g. `./gradlew detekt --console=plain`) on the role_pipeline stage, then run a workflow that produces a patch with a real lint/compile issue. | A `StaticFindingsRecordedEvent` with the tool's parsed findings appears in `correx events {id}`, and the reviewer never re-raises those issues. _(Activation is the open §B-§5 follow-up — the seam + emitter are built.)_ | | + +Evidence sources: server logs of the assembled reviewer context / context pack, `correx events {id}`. + +## Out of scope (explicitly NOT covered this pass) + +- **Production runner activation** (open BACKLOG §B-§5 follow-up): wiring a sandboxed, workspace-scoped `CommandRunner` is itself live-QA-gated. The seam + emitter are built (`e46777e`) and unit-verified (`StaticCheckStageExecutorTest`, `StaticCheckStageTest`); check 4 exercises the activation once a runner is wired. +- The reviewer prompt half (shipped earlier, `3467826`). + +## Disposition + +- **PASS (partial)** → record in `RETRO.md` that the **filter** is live-verified (checks 1–3) and keep the §B-§5 *production-runner activation* follow-up open in BACKLOG. Do not claim the full static-first loop until check 4 passes. +- **FAIL** → numbered findings. Status: FAILED until green. diff --git a/docs/qa/QA-tui-v2.md b/docs/qa/QA-tui-v2.md new file mode 100644 index 00000000..2db04a36 --- /dev/null +++ b/docs/qa/QA-tui-v2.md @@ -0,0 +1,52 @@ +# QA Plan: TUI Bubble Tea v2 + native cursor — `d247b19` `85af2c6` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** none — operator-requested TUI work, already archived in `RETRO.md` +(2026-06-22). This plan does not gate a BACKLOG→RETRO move; it gates the *behavioral* +claims that `go test` can't prove (Shift+Enter, the real cursor, no render regressions), +which need a real kitty-protocol terminal. + +--- + +## Preconditions + +- [ ] **terminal:** kitty (or any kitty-keyboard-protocol terminal: ghostty / foot / wezterm / recent iTerm2) — Shift+Enter disambiguation depends on it. +- [ ] **tui build/branch:** `feat/backlog-burndown` at `85af2c6` — `cd apps/tui-go && GOTOOLCHAIN=auto go build -o /tmp/correx-tui .` +- [ ] **server up:** a correx server reachable at `--host/--port` (default `localhost:8080`) with at least one resumable session, so in-session views render. _Rebuild the server only while it's stopped (lazy classloading)._ +- [ ] **config synced:** `~/.config/correx` matches `examples/*` (stale copies cause silent bugs). +- [ ] _(optional)_ `CORREX_TUI_LOG=/tmp/tui.log` to capture the `KEY type=… alt=… ` debug lines as corroborating evidence for the key checks. + +## Acceptance gate (one sentence) + +> The TUI is correct on v2 **iff** Shift+Enter inserts a newline in the composer without +> sending, a real blinking cursor tracks the caret in insert mode (and vanishes in normal +> mode / behind modals), and nothing in the render layer regressed from the v1 build. + +## Checks + +Evidence is the live TUI render (an allowed source) plus, where noted, the `CORREX_TUI_LOG` key lines. + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Enter a session, press `i`, type a word | A blinking vertical-bar cursor sits at the caret; typing extends it. Log shows `KEY … type=keyRunes`. | | +| 2 | With text in the composer, press **Shift+Enter** | A newline is inserted; the input grows to a second row; nothing is submitted. Log line shows the Enter key with `shift` (no `submit`). | | +| 3 | Press **Enter** (bare) | The message submits (composer clears / turn appears). Confirms bare Enter still sends. | | +| 4 | Press **Ctrl+J**, then **Alt+Enter** (fallback paths) | Each inserts a newline without sending (parity with Shift+Enter). | | +| 5 | From insert mode press `esc` to normal mode | The blinking cursor disappears entirely (no stray bar left on screen). | | +| 6 | In insert mode, open the palette (`/` or `p`) | The modal's own filter caret blinks; **no** second cursor shows behind/around the modal. Close it → composer cursor returns. | | +| 7 | Leave the composer in insert mode and idle ~5s, then drag-select text with the mouse | Selection holds (screen isn't being redrawn out from under it) — confirms insert mode no longer forces the frame loop. | | +| 8 | Observe the terminal tab/title while in a session vs. idle | Title reads `correx — ` in-session; `correx — ` (or `correx`) when idle. | | +| 9 | Visual regression sweep: open `?` help, stats, an `e` event inspector, a `^x` diff, the `t` artifacts viewer; resize the terminal narrow↔wide | Colors/borders/opaque panels look as before; modals scroll (`PgUp/PgDn`, `g/G`); diff viewer pages; no garbled ANSI, no off-by-one panel widths, alt-screen restores the shell cleanly on `q`. | | +| 10 | Walk the approval band (trigger a gate), `↑/↓` the queue, approve/reject/steer | Band behaves exactly as on v1 (single-key low tiers, arm→confirm T3+); steering caret still draws. | | + +## Out of scope (explicitly NOT covered this pass) + +- Mouse-wheel scroll (deliberately not added — would fight native text selection). +- Native cursor for the palette/files/steering/config carets (still frame-drawn `▏`; only the main composer moved to `View.Cursor`). +- Behavior on non-kitty terminals beyond the documented Ctrl+J/Alt+Enter fallback. + +## Disposition + +- **PASS** → set Status: PASSED with the run date; the RETRO 2026-06-22 rows for `d247b19`/`85af2c6` stand as live-verified. +- **FAIL** → file each failure as a numbered finding in `BACKLOG.md` (action + exact repro + the render/log signal that was wrong), fix, then re-run only the failed checks. Set Status: FAILED until green. diff --git a/docs/qa/README.md b/docs/qa/README.md new file mode 100644 index 00000000..ba6da693 --- /dev/null +++ b/docs/qa/README.md @@ -0,0 +1,37 @@ +# Correx live-QA plans + +Each `QA-.md` is the artifact that authorizes a `BACKLOG.md` → `RETRO.md` move: +it pins **observable evidence** (events, logs, files, TUI renders) for one shipped feature. +Template: `TEMPLATE.md`. Environment bring-up: **`ENV.md`** (+ `scripts/qa/`). + +A feature is **not** "resolved" until its plan passes live. On PASS → move the BACKLOG entry +to RETRO with the run date + cited evidence; on FAIL → refile each miss as a numbered finding. + +## Plans (this burndown) + +| Plan | Feature | Commits | Needs | +|------|---------|---------|-------| +| `QA-research-egress.md` | per-session egress allowlist + batch source-list approval + dedicated source events | `ab7e4be` `4b0024f` `b098d87` `027ff1f` | model + **SearXNG** | +| `QA-architect-contradiction.md` | architect contradiction-check (display-only) | `4e08510` `eae0a0c` | model + **llamacpp embedder** + `project.enabled`, 2 sessions/1 process | +| `QA-llama-health-probe.md` | LLAMA_SERVER liveness probe | `64a90e7` `9eef936` `266bbf0` | static llama-server you can stop | +| `QA-idea-promotion.md` | idea-board → `.correx/project.toml` promotion | `f107ff5` | bound workspace (router model optional — can seed the event) | +| `QA-reviewer-static-first.md` | reviewer static-findings exclusion (PARTIAL — stage seam+emitter built `e46777e`; runner activation pending) | `447fc7a` `e46777e` | seed `StaticFindingsRecordedEvent` | +| `QA-brief-echo-gate.md` | **ARM-IT** plan: brief echo-back gate (off by default; risky to arm blind) | `1df7af5` | prod-candidate model | +| `QA-grants.md` | cross-session grants (PROJECT/GLOBAL) + revoke | `c36d41b` `8df0ec7` | model + a T2+ tool call, 2 sessions, 2 workspaces | +| `QA-tui-v2.md` | Bubble Tea v2 + Shift+Enter + native cursor + window title | `d247b19` `85af2c6` | **kitty-protocol terminal** + a server with a resumable session | + +## Order of attack (suggested) + +0. **`QA-tui-v2`** — cheapest of all: no model / network / GPU, just a kitty terminal + a server with a session to enter. Pure render/keyboard verification. +1. **`QA-llama-health-probe`** + **`QA-idea-promotion`** — cheapest, least model-dependent (health is liveness; promotion can be seeded). Quick wins. +2. **`QA-grants`** — no network/embedder; just a model that drives one repeatable T2+ tool call. The headline check (grant in session A → auto-clear in session B) is a strong, cheap signal. +3. **`QA-research-egress`** — once SearXNG is up. +4. **`QA-architect-contradiction`** — needs a real embedder + two sessions. +5. **`QA-brief-echo-gate`** — run this **before** arming the gate anywhere real; check 2 (100% parseable-echo across runs) is the go/no-go for production. +6. **`QA-reviewer-static-first`** — partial until the static-check stage seam lands. + +## Still-open §F live-QA gates (no dedicated plan yet — same env applies) + +These predate the burndown and remain in `BACKLOG.md` §F; author a plan from `TEMPLATE.md` +when you tackle one: Epic 15 (events/replay + MDC sessionIds), idea-board capture, clarification +loop + workflow-propose panel, the full research-workflow run, TUI kernel-steering + AMD gauge. diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index f97603fd..a58457ce 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -1,11 +1,16 @@ id = "freestyle_planning" start = "analyst" +# analyst writes no files, but it owns task framing: task_search/task_context (read-only) find +# existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write) +# opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with +# dependency seams into parent + DEPENDS_ON-linked children. Either way the analysis names the task +# id the run will work, so the architect threads it into the plan's implementation stages. [[stages]] id = "analyst" prompt = "prompts/analyst_freestyle.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"] token_budget = 16384 max_retries = 2 diff --git a/examples/workflows/prompts/analyst.md b/examples/workflows/prompts/analyst.md index 477a18a7..7fb48c14 100644 --- a/examples/workflows/prompts/analyst.md +++ b/examples/workflows/prompts/analyst.md @@ -8,7 +8,12 @@ Steps: 1. Read the user's request carefully. Restate what is actually being asked. 2. Use `file_read` and shell (read-only: `ls`, `grep`, `cat`, `find`) to locate the relevant code. Identify the files, modules, and subsystems involved. Do not modify anything. -3. Derive concrete, checkable requirements and acceptance criteria. +3. Check for existing work: `task_search` for related, duplicate, or blocking tasks, and + `task_context` to load any the request names. Fold what you find into the analysis rather + than re-deriving it; flag a duplicate instead of restating it. If this work warrants tracking + (per the task policy) and no task covers it, `task_create` one and name its id in the analysis + so the implementer claims it and the reviewer completes it. +4. Derive concrete, checkable requirements and acceptance criteria. The decision history above (steering, approvals, prior verdicts) is ground truth — honour it. diff --git a/examples/workflows/prompts/analyst_freestyle.md b/examples/workflows/prompts/analyst_freestyle.md index c0eb7b0f..7772014c 100644 --- a/examples/workflows/prompts/analyst_freestyle.md +++ b/examples/workflows/prompts/analyst_freestyle.md @@ -2,6 +2,23 @@ You are the **Analyst** in freestyle mode. Understand the user's goal (in the de above) and the code it touches. Read-only: `file_read` (also lists a directory's entries when given a directory path), `ls`, `grep`, `cat`, `find`. +Before deriving requirements, check for existing work: `task_search` for related, duplicate, or +blocking tasks and `task_context` to load any the goal names. Fold what you find into the +analysis rather than re-deriving it; flag a duplicate instead of restating it. + +Then frame the work as a task (per the task policy): +- If a task already covers this work, name its id (e.g. `auth-142`) in the analysis. +- If the goal is a single coherent unit one run can carry to review, `task_create` one and name its + id. +- If the goal has **dependency seams** (a thing that must land before another) or **independent + review/handoff points** (a piece worth shipping or reviewing on its own), `task_decompose` it into + a parent epic + `DEPENDS_ON`-linked children — one approval for the whole graph. A session works + one task at a time, so the children are claimed by *later* runs as they unblock; don't over-split. +- After decomposing, **name in the analysis the single task this run will work** — the one already + ready (no unmet dependency, e.g. the scaffold). Leave the blocked siblings for future runs. + +Either way later stages thread the named task through the plan; the rest wait to be claimed. + Emit the `analysis` artifact (JSON, schema provided): - `summary`: the goal in your own words. - `requirements`: concrete, checkable requirements, one per line. diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 2487e8e8..51790137 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -57,9 +57,23 @@ Emit a JSON object that validates against the `execution_plan` schema: llm-emitted kind. - Declare `needs`: every upstream artifact id the stage's prompt references. Every id in `needs` must be `produces`d by a strictly earlier stage. -- Include `tools` only for stages that write or edit files: - `["file_read", "file_write", "file_edit", "ShellTool"]`. Do not invent tool names - beyond this set. +- Include `tools` per stage as it needs them, using only names from this set: + `file_read`, `file_write`, `file_edit`, `ShellTool`, `task_context`, `task_update`, + `task_search`. Stages that write or edit files take the file set + (`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond + this set. +- **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that + the analyst found, opened with `task_create`, or named as the ready task of a + `task_decompose` graph; if none is referenced there is no task to track). Thread **only that + one task** — this run works a single task; any sibling tasks the analyst decomposed are for + later runs to claim, so do not plan or reference them here. When one is referenced: + - Give the stage that does the work `task_context` and `task_update`, and have its + `prompt` `task_update action=claim` the task before starting and + `action=submit_for_review` when its output is ready. + - Give the final or review stage `task_context` and `task_update`, and have its `prompt` + `task_update action=complete` the task once the work is accepted. + - If the `analysis` references no task, omit the task tools entirely. Do not create or + decompose tasks here — task creation is out of scope for the plan. - Keep stages small and single-responsibility. Prefer more stages over large monolithic prompts. diff --git a/examples/workflows/prompts/implementer.md b/examples/workflows/prompts/implementer.md index d639ba0e..54f4b63c 100644 --- a/examples/workflows/prompts/implementer.md +++ b/examples/workflows/prompts/implementer.md @@ -4,12 +4,16 @@ You receive the `impl_plan` artifact (above). Execute it using the tools availab (`file_read`, `file_write`, `file_edit`, and shell). File writes land in the bound workspace. Steps: -1. Work through the plan `steps` in order. Read before you edit. -2. Make the change with `file_write` / `file_edit`. Keep new code consistent with the +1. If the analysis opened or referenced a task, `task_context` to load it and `task_update + action=claim` before you start (`task_create` one only if the work warrants tracking and none + exists). Skip this for a self-contained change. +2. Work through the plan `steps` in order. Read before you edit. +3. Make the change with `file_write` / `file_edit`. Keep new code consistent with the surrounding style, naming, and patterns. -3. Run the plan's `verification` commands (build/tests) via shell and fix what fails. Do not +4. Run the plan's `verification` commands (build/tests) via shell and fix what fails. Do not leave a step in a broken state. -4. When every step is done and verification passes, call the `stage_complete` tool. +5. When every step is done and verification passes, `task_update action=submit_for_review` on the + task (if any), then call the `stage_complete` tool. The decision history above is ground truth. **If the reviewer requested changes** in a prior round, you will see that verdict and its notes above — address those specific points; do not diff --git a/examples/workflows/prompts/reviewer.md b/examples/workflows/prompts/reviewer.md index 29bd1f74..04d87e18 100644 --- a/examples/workflows/prompts/reviewer.md +++ b/examples/workflows/prompts/reviewer.md @@ -29,5 +29,10 @@ Emit your result as the `review_report` artifact (JSON, schema provided): line), each tied to the requirement or plan step it violates, so the implementer knows exactly what to fix. If `approved`, briefly state which requirements the patch satisfies. +If the work is tracked as a task, reflect your verdict on it (use `task_context` first if you +need its own acceptance criteria): on `approved`, `task_update action=complete`; on +`changes_requested`, leave it claimed for the implementer — optionally add a `note` summarising +what's needed. + Use `changes_requested` only for real problems — the loop is capped and will escalate to a human if it runs too long. Be decisive. diff --git a/examples/workflows/review_loop.toml b/examples/workflows/review_loop.toml index 002029af..0bbd554a 100644 --- a/examples/workflows/review_loop.toml +++ b/examples/workflows/review_loop.toml @@ -14,19 +14,24 @@ id = "review_loop" start = "implement" +# implement owns the work item across the loop: claim before starting, submit_for_review once +# the patch is ready, add notes (task_create one first if the work warrants tracking). [[stages]] id = "implement" prompt = "prompts/implement.md" produces = [{ name = "patch", kind = "file_written" }] -allowed_tools = ["file_write"] +allowed_tools = ["file_write", "task_create", "task_update", "task_context", "task_search"] token_budget = 8192 max_retries = 3 +# review reads the task's own acceptance criteria (task_context) and completes it on an approved +# verdict (task_update action=complete); on changes_requested it leaves the task claimed. [[stages]] id = "review" prompt = "prompts/review.md" needs = ["patch"] produces = [{ name = "review_report", kind = "review_report" }] +allowed_tools = ["task_context", "task_update"] token_budget = 8192 max_retries = 3 diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index d2289279..f4e41883 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -33,7 +33,7 @@ start = "analyst" id = "analyst" prompt = "prompts/analyst.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create"] ground_references = true token_budget = 16384 max_retries = 2 @@ -87,7 +87,10 @@ id = "implementer" prompt = "prompts/implementer.md" needs = ["impl_plan"] produces = [{ name = "patch", kind = "file_written" }] -allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"] +allowed_tools = [ + "file_read", "file_write", "file_edit", "ShellTool", + "task_create", "task_update", "task_context", "task_search", +] # static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"] token_budget = 32768 max_retries = 3 @@ -100,6 +103,7 @@ id = "reviewer" prompt = "prompts/reviewer.md" needs = ["patch", "impl_plan", "analysis"] produces = [{ name = "review_report", kind = "review_report" }] +allowed_tools = ["task_context", "task_update"] token_budget = 32768 max_retries = 2 diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 475965d6..8b65bc9e 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -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 = emptyList(), + ): ToolRegistry = + DefaultToolRegistry.build(config.buildTools() + extraTools) fun createLlamaCppProvider( modelId: String, diff --git a/infrastructure/tools/build.gradle b/infrastructure/tools/build.gradle index 923bf246..c9dc23c3 100644 --- a/infrastructure/tools/build.gradle +++ b/infrastructure/tools/build.gradle @@ -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") diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 6aca46fa..526d8a7b 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -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 -> diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt index 33927af4..f4d8cfd8 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt @@ -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, backupMap: Map): String? { val diffs = mutableListOf() @@ -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" + } } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt new file mode 100644 index 00000000..ab64119b --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt @@ -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) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt new file mode 100644 index 00000000..6d82e833 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt @@ -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 +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt new file mode 100644 index 00000000..cdb121a9 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt @@ -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 = 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), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt new file mode 100644 index 00000000..e5691dcb --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -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 '-'.") + } + 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 = 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) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt new file mode 100644 index 00000000..0dae9869 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt @@ -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 '-'.") + } + 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 = 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>, children: List, 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) { + 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, 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, parentTask: Task?, readyIds: List): 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, + val affectedPaths: List, + val deps: List, + ) + + private fun parseSpecs(element: JsonArray?): List? = + 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 = + (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>) : Edges + data class Err(val message: String) : Edges + } + + private fun buildEdges(specs: List): Edges { + val refToIndex = HashMap() + specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } } + val adjacency = ArrayList>() + specs.forEachIndexed { i, s -> + val deps = ArrayList() + 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? { + val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done + val stack = ArrayList() + fun dfs(u: Int): List? { + 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.") + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt new file mode 100644 index 00000000..b47eded1 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt @@ -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 = 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) + } + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt new file mode 100644 index 00000000..66e61887 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt @@ -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 = 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()), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt new file mode 100644 index 00000000..dc287d4e --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt @@ -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 = 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()), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt new file mode 100644 index 00000000..150490e3 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -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 = + listOf( + TaskCreateTool(service), + TaskDecomposeTool(service), + TaskUpdateTool(service, sessionFacts, sessionWrites), + TaskDeleteTool(service), + TaskSearchTool(service), + TaskReadyTool(service), + TaskContextTool( + TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), + ), + ) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt new file mode 100644 index 00000000..3710fef9 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -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 = 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, globs: List): 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) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt new file mode 100644 index 00000000..b422223c --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -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 = + (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) +} diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt new file mode 100644 index 00000000..3e1e7195 --- /dev/null +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt @@ -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() + 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): List = events.map { append(it) } + override fun read(sessionId: SessionId): List = emptyList() + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = emptyList() + override fun lastSequence(sessionId: SessionId): Long? = null + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + override fun subscribeAll(): Flow = emptyFlow() + override suspend fun lastGlobalSequence(): Long = payloads.size.toLong() + override fun allEvents(): Sequence = emptySequence() + override fun allSessionIds(): Set = 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 = listOf(tool) + } + + /** Stands in for a research fetch: returns the same metadata shape WebFetchTool emits. */ + private class FakeFetchTool(private val metadata: Map) : Tool, ToolExecutor { + override val name: String = "web_fetch" + override val description: String = "fake" + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = setOf(ToolCapability.NETWORK_ACCESS) + override val paramRoles: Map = 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): 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().single() + assertEquals("https://example.com/a", fetched.url) + assertEquals("deadbeef", fetched.contentSha256) + assertEquals("OK", fetched.quality) + assertEquals(4096, fetched.byteCount) + assertTrue(events.payloads.filterIsInstance().isEmpty()) + } + + @Test + fun `low-quality fetch also emits LowQualityExtractionEvent`() { + val events = runFetch(fetchMetadata("LOW_QUALITY")) + + assertEquals(1, events.payloads.filterIsInstance().size) + val low = events.payloads.filterIsInstance().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().isEmpty()) + assertTrue(events.payloads.filterIsInstance().isEmpty()) + } +} diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt new file mode 100644 index 00000000..49a38b99 --- /dev/null +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -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) = 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>>() + 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() + private var global = 0L + private val perSession = mutableMapOf() + + 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): List = events.map { append(it) } + + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sessionSequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence } + + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + + override fun subscribeAll(): Flow = emptyFlow() + + override suspend fun lastGlobalSequence(): Long = global + + override fun allEvents(): Sequence = events.asSequence() + + override fun allSessionIds(): Set = events.map { it.metadata.sessionId }.toSet() +} diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 5991750b..0e1177cd 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -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 = emptySet(), ) { private val mapper = JsonMapper.builder() .addModule(kotlinModule()) @@ -27,6 +33,7 @@ class ExecutionPlanCompiler( val plan = runCatching { mapper.readValue(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 diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt index 225d1dc7..fbeeb742 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt @@ -15,6 +15,12 @@ data class PlanStage( val kind: String? = null, val needs: List = emptyList(), val tools: List = 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 = emptyList(), ) data class PlanEdge( diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt new file mode 100644 index 00000000..828b5c12 --- /dev/null +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt @@ -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 = + 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, planDerived: Set): Set = + (staticManifest + planDerived) + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() +} diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 8e2163a9..c0f9ed18 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -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 { 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 { 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) + } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt index bd238835..2fe406d9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt @@ -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, + ) } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt new file mode 100644 index 00000000..dd6afaa0 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt @@ -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") + } +} diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt index 0172cc3e..b2d4c577 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt @@ -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, + ) + } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt index 253bfeff..52d58de9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt @@ -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, + ) + } } diff --git a/settings.gradle b/settings.gradle index f10a760e..8026899a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,8 +9,6 @@ rootProject.name = 'correx' include ':apps:cli' include ':apps:server' -include ':apps:worker' -include ':apps:desktop' include ':core:kernel' include ':core:events' @@ -25,9 +23,11 @@ include ':core:tools' include ':core:toolintent' include ':core:router' include ':core:sessions' +include ':core:tasks' include ':core:config' include ':core:risk' include ':core:journal' +include ':core:critique' include ':infrastructure:persistence' include ':infrastructure:inference' diff --git a/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt b/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt new file mode 100644 index 00000000..51248dd0 --- /dev/null +++ b/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt @@ -0,0 +1,161 @@ +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.DefaultApprovalRepository +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.artifacts.DefaultArtifactReducer +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.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceState +import com.correx.core.journal.DecisionJournalProjector +import com.correx.core.journal.DefaultDecisionJournalReducer +import com.correx.core.journal.DefaultDecisionJournalRepository +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository +import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore +import com.correx.testing.fixtures.InferenceFixtures +import com.correx.testing.fixtures.context.ContextFixtures +import com.correx.testing.fixtures.cyclePolicyMissingValidator +import com.correx.testing.fixtures.transitions.TransitionFixtures +import com.correx.testing.kernel.MockSessionEventReplayer +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * End-to-end proof that the reviewer-loop runtime correlates recorded critique findings into + * outcome events at loop resolution (BACKLOG §B-§6), and that those events feed the + * `:core:critique` calibration projection. The LLM-side emission of findings is mocked here by + * seeding [CritiqueFindingsRecordedEvent]s directly (that half is a separate model-gated step). + */ +class CritiqueCalibrationWiringTest { + + private val eventStore = InMemoryEventStore() + private val sessionReplayer = MockSessionEventReplayer() + private val sessionRepository = DefaultSessionRepository(sessionReplayer) + private val orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ) + private val inferenceRepository = InferenceRepository( + object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + }, + ) + private val approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ) + private val decisionJournalRepository = DefaultDecisionJournalRepository( + DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), + ) + private val retryCoordinator = DefaultRetryCoordinator(eventStore) + + private val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = inferenceRepository, + orchestrationRepository = orchestrationRepository, + sessionRepository = sessionRepository, + artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()), + approvalRepository = approvalRepository, + ) + + private val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = OrchestratorEngines( + transitionResolver = TransitionFixtures.simpleResolver(), + contextPackBuilder = ContextFixtures.simpleBuilder(), + inferenceRouter = InferenceFixtures.fixedRouter(), + validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), + approvalEngine = DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + ), + retryCoordinator = retryCoordinator, + artifactStore = NoopArtifactStore(), + decisionJournalRepository = decisionJournalRepository, + ) + + private val defaultConfig = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + + private fun finding(id: String) = CritiqueFinding( + id = id, + role = CritiqueRole.CODE_REVIEWER, + severity = CritiqueSeverity.MAJOR, + category = "correctness", + message = "msg-$id", + ) + + private suspend fun seedReview(sessionId: SessionId, iteration: Int, verdict: CritiqueVerdict, findings: List) { + eventStore.append( + NewEvent( + EventMetadata(EventId("rev-$iteration"), sessionId, Clock.System.now(), 1, null, null), + CritiqueFindingsRecordedEvent( + sessionId = sessionId, + stageId = StageId("reviewer"), + role = CritiqueRole.CODE_REVIEWER, + modelHash = "model-A", + iteration = iteration, + verdict = verdict, + findings = findings, + ), + ), + ) + } + + @Test + fun `recorded findings are correlated at completion and feed the calibration projection`(): Unit = runBlocking { + val sessionId = SessionId("calib-1") + + // Two review rounds: f1 fixed between them (→ UPHELD); f2 persists to an approved final (→ DISMISSED). + seedReview(sessionId, 1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))) + seedReview(sessionId, 2, CritiqueVerdict.APPROVED, listOf(finding("f2"))) + + val stageA = StageId("A") + val done = StageId("done") + val graph = WorkflowGraph( + id = "calib-wiring", + stages = mapOf(stageA to StageConfig(), done to StageConfig()), + transitions = setOf(TransitionEdge(id = TransitionId("t1"), from = stageA, to = done, condition = { true })), + start = stageA, + ) + + orchestrator.run(sessionId, graph, defaultConfig) + + // The orchestrator emits one outcome per recorded finding at completion — the events the + // :core:critique calibration projection consumes (its folding is covered by that module's + // DefaultCriticCalibrationReducerTest). + val outcomes = eventStore.read(sessionId).mapNotNull { it.payload as? CritiqueOutcomeCorrelatedEvent } + assertEquals(2, outcomes.size, "one outcome per recorded finding") + assertEquals(CritiqueOutcome.UPHELD, outcomes.single { it.findingId == "f1" }.outcome) + assertEquals(CritiqueOutcome.DISMISSED, outcomes.single { it.findingId == "f2" }.outcome) + assertTrue(outcomes.all { it.modelHash == "model-A" && it.role == CritiqueRole.CODE_REVIEWER }) + } +} diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index 4de53ce9..85b9ccae 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -11,11 +11,13 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId import com.correx.core.events.events.RepoKnowledgeHit +import com.correx.core.kernel.orchestration.buildAgentInstructionsEntry import com.correx.core.kernel.orchestration.buildArtifactKindVocabularyEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildRelevantFilesEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.criticArtifactIds +import com.correx.core.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.transitions.graph.StageConfig import com.correx.core.transitions.graph.TransitionEdge @@ -87,6 +89,21 @@ class ContextFeedbackTest { assertEquals("projectProfile", entry.sourceType) } + @Test + fun `agent instructions render as single L0 entry`() { + val entry = buildAgentInstructionsEntry( + BoundAgentInstructions( + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools", + ), + ) + assertEquals(ContextLayer.L0, entry.layer) + assertEquals(EntryRole.SYSTEM, entry.role) + assertEquals("agentInstructions", entry.sourceType) + assertTrue(entry.content.contains("# CLAUDE.md"), "content: ${entry.content}") + assertTrue(entry.content.contains("# AGENTS.md"), "content: ${entry.content}") + } + @Test fun `criticArtifactIds resolves the from-stage produces on a back-edge`() { val graph = graphWith( diff --git a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt index 878d367c..0b4ac707 100644 --- a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt +++ b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt @@ -10,7 +10,9 @@ import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId +import com.correx.core.events.events.AgentInstructionsBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent +import com.correx.core.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.DefaultSessionReducer @@ -327,6 +329,50 @@ class DefaultSessionReducerTest { assertEquals("kernel project", result.boundProjectProfile?.about) } + @Test + fun `AgentInstructionsBoundEvent reduces into boundAgentInstructions`() { + val result = reducer.reduce( + state = initialState(), + event = stored( + sessionId = sessionId, + payload = AgentInstructionsBoundEvent( + sessionId = sessionId, + workspaceRoot = "/repo", + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful", + ), + ), + ) + + assertEquals( + BoundAgentInstructions( + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful", + ), + result.boundAgentInstructions, + ) + } + + @Test + fun `boundAgentInstructions is preserved by subsequent unrelated events`() { + val withInstructions = initialState().copy( + boundAgentInstructions = BoundAgentInstructions( + sources = listOf("CLAUDE.md"), + content = "# CLAUDE.md\n\nrules", + ), + ) + + val result = reducer.reduce( + state = withInstructions, + event = stored( + sessionId = sessionId, + payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")), + ), + ) + + assertEquals(listOf("CLAUDE.md"), result.boundAgentInstructions?.sources) + } + private fun initialState() = SessionState( status = SessionStatus.CREATED