feat(tasks): duplicate-title guard on task creation

The doctrine says "search before creating", but that leans on the LLM; this is
the backstop against an agent re-creating a task across runs. TaskService.
findDuplicates matches active (non-terminal) same-title tasks, case- and
whitespace-insensitively — a DONE/CANCELLED look-alike is not a duplicate, since
that work may legitimately recur.

- task_create rejects a duplicate, naming the look-alike and offering force=true.
- POST /tasks → 409 with the duplicates, or force:true to override;
  correx task create --force.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:34:24 +00:00
parent 215a951b52
commit 5d48c26ec8
7 changed files with 156 additions and 8 deletions
@@ -6,6 +6,7 @@ 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
@@ -206,6 +207,7 @@ class TaskCreateCommand : TaskHttpCommand("create") {
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 {
@@ -214,12 +216,19 @@ class TaskCreateCommand : TaskHttpCommand("create") {
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 raw = client.post("${baseUrl()}/tasks") {
val resp = client.post("${baseUrl()}/tasks") {
contentType(ContentType.Application.Json)
setBody(payload)
}.bodyAsText()
if (jsonOut()) println(raw) else println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
}
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<TaskRow>(raw).id}")
}
}
}