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,46 @@
package com.correx.core.tasks
/**
* Exact/substring task search. A task matches when every whitespace-separated term in the query
* appears (case-insensitive) in some searchable field; results are ranked by the strongest field a
* term hits (title > key > goal > acceptance criteria > notes), ties broken by id for stability.
* Pure and store-free so it can be reused over [TaskService.list]/[TaskService.listAll] and unit
* tested directly.
*/
object TaskSearch {
fun search(tasks: List<Task>, query: String): List<Task> {
val terms = query.trim().lowercase().split(WHITESPACE).filter { it.isNotBlank() }
if (terms.isEmpty()) return tasks
return tasks
.mapNotNull { task -> score(task, terms)?.let { task to it } }
.sortedWith(compareByDescending<Pair<Task, Int>> { it.second }.thenBy { it.first.taskId.value })
.map { it.first }
}
/** Sum of the best per-term field weight, or null when any term matches no field. */
private fun score(task: Task, terms: List<String>): Int? {
val s = task.state
val fields = listOf(
(s.title ?: "") to TITLE_WEIGHT,
(s.key ?: "") to KEY_WEIGHT,
(s.goal ?: "") to GOAL_WEIGHT,
s.acceptanceCriteria.joinToString(" ") to CRITERIA_WEIGHT,
s.notes.joinToString(" ") { it.body } to NOTES_WEIGHT,
).map { (text, weight) -> text.lowercase() to weight }
var total = 0
for (term in terms) {
val best = fields.filter { it.first.contains(term) }.maxOfOrNull { it.second } ?: return null
total += best
}
return total
}
private val WHITESPACE = Regex("\\s+")
private const val TITLE_WEIGHT = 5
private const val KEY_WEIGHT = 4
private const val GOAL_WEIGHT = 3
private const val CRITERIA_WEIGHT = 2
private const val NOTES_WEIGHT = 1
}
@@ -70,6 +70,10 @@ class TaskService(
return if (state.deleted) null else Task(taskId, state)
}
/** Ranked text search over one project (when given) or the whole cross-project board. */
fun search(query: String, projectId: ProjectId? = null): List<Task> =
TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query)
suspend fun createTask(
projectId: ProjectId,
title: String,
@@ -0,0 +1,59 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TaskSearchTest {
private fun task(
id: String,
title: String?,
goal: String? = null,
criteria: List<String> = emptyList(),
notes: List<String> = emptyList(),
) = Task(
TaskId(id),
TaskState(
key = id,
title = title,
goal = goal,
acceptanceCriteria = criteria,
notes = notes.map { TaskNote(TaskNoteAuthor.AGENT, it, Instant.parse("2026-01-01T00:00:00Z")) },
),
)
@Test
fun `requires all terms and ranks title hits above body hits`() {
val titleHit = task("auth-1", title = "JWT refresh flow", goal = "stay authed")
val goalHit = task("auth-2", title = "Token rotation", goal = "rotate the jwt refresh token")
val noMatch = task("auth-3", title = "Logging", goal = "structured logs")
val results = TaskSearch.search(listOf(goalHit, noMatch, titleHit), "jwt refresh")
assertEquals(listOf("auth-1", "auth-2"), results.map { it.taskId.value })
}
@Test
fun `matches across acceptance criteria and notes`() {
val viaNotes = task("auth-1", title = "x", notes = listOf("the migration failed on rollback"))
val viaCriteria = task("auth-2", title = "y", criteria = listOf("rollback is idempotent"))
val results = TaskSearch.search(listOf(viaNotes, viaCriteria), "rollback")
assertEquals(setOf("auth-1", "auth-2"), results.map { it.taskId.value }.toSet())
}
@Test
fun `blank query returns the input unchanged`() {
val tasks = listOf(task("auth-1", "a"), task("auth-2", "b"))
assertEquals(tasks, TaskSearch.search(tasks, " "))
}
@Test
fun `no match yields empty`() {
assertEquals(emptyList<Task>(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra"))
}
}