feat(tasks): task search across REST, tool, and TUI

Ranked exact/substring search: TaskSearch (all terms AND, ranked title >
key > goal > criteria > notes) over one project or the whole board via
TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool
for agents, and a `/` filter in the TUI board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 22:08:11 +00:00
parent 1d7fab4ee4
commit 99f781687f
11 changed files with 357 additions and 28 deletions
@@ -0,0 +1,70 @@
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 by text (matches id/title/goal/acceptance criteria/notes), ranked by " +
"relevance. Returns 'id [STATUS] title' lines. Use to find a task id, then task_context."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") }
putJsonObject("project") {
put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.")
}
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
}
put("required", buildJsonArray { add(JsonPrimitive("query")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("query") ?: return ValidationResult.Invalid("Missing 'query' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val query = request.stringParam("query")
?: return ToolResult.Failure(request.invocationId, "Missing 'query' (string).", recoverable = false)
val project = request.stringParam("project")?.let { ProjectId(it) }
val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT
val hits = service.search(query, project).take(limit)
val output = if (hits.isEmpty()) {
"no tasks match \"$query\""
} else {
hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
}
return ToolResult.Success(
invocationId = request.invocationId,
output = output,
metadata = mapOf("query" to query, "count" to hits.size.toString()),
)
}
}
@@ -26,6 +26,7 @@ object TaskTools {
TaskCreateTool(service),
TaskUpdateTool(service),
TaskDeleteTool(service),
TaskSearchTool(service),
TaskContextTool(
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
),
@@ -18,6 +18,7 @@ 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
@@ -28,6 +29,7 @@ class TaskToolsTest {
private val create = TaskCreateTool(service)
private val update = TaskUpdateTool(service)
private val delete = TaskDeleteTool(service)
private val search = TaskSearchTool(service)
private val context = TaskContextTool(TaskContextAssembler(service))
private var counter = 0
@@ -138,6 +140,21 @@ class TaskToolsTest {
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_delete tombstones the task`() = runBlocking {
val id = createTask()