feat(tasks): task_decompose splits a goal into a dependency-linked graph in one approval

The freestyle analyst can now break a large goal with dependency seams or
independent review/handoff points into a parent epic + DEPENDS_ON-linked children
in a single T2 approval, instead of N separate task_create calls. Parent
DEPENDS_ON every child (completes last); each child IMPLEMENTS parent. Resolves
depends_on by ref or index; rejects cycles, unresolved refs, missing title/goal,
and empty batches; same batch dedup + force_reason convention as task_create.

A session works one active task, so multi-task work is multi-session by
construction: the analyst names the single ready task this run works, the architect
threads only that one, and siblings are claimed by later runs via task_ready
(claim-driven; no scheduler, /tasks/next stays rejected).

Doctrine: analyst_freestyle.md picks one-task-vs-decompose and names the ready
task; architect_freestyle.md threads only that one; plus the L0 policy line.
freestyle_planning.toml analyst gains task_decompose (pinned by
FreestylePlanningWorkflowTest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 10:39:03 +00:00
parent 67384691e0
commit 21e01da3ac
8 changed files with 446 additions and 15 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ conventions = [
"Dependency direction: apps -> core -> infrastructure; no cross-core imports", "Dependency direction: apps -> core -> infrastructure; no cross-core imports",
"No bare try-catch; use runCatching and sealed domain error types", "No bare try-catch; use runCatching and sealed domain error types",
"Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)", "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.", "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] [commands]
+5 -4
View File
@@ -2,14 +2,15 @@ id = "freestyle_planning"
start = "analyst" start = "analyst"
# analyst writes no files, but it owns task framing: task_search/task_context (read-only) find # analyst writes no files, but it owns task framing: task_search/task_context (read-only) find
# existing work, and task_create (T2, approval-gated — a task is an event-log entry, not a file # existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write)
# write) opens a task for this work and names its id in the analysis, so the architect can thread # opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with
# it into the plan's implementation stages. # 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]] [[stages]]
id = "analyst" id = "analyst"
prompt = "prompts/analyst_freestyle.md" prompt = "prompts/analyst_freestyle.md"
produces = [{ name = "analysis", kind = "analysis" }] produces = [{ name = "analysis", kind = "analysis" }]
allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create"] allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"]
token_budget = 16384 token_budget = 16384
max_retries = 2 max_retries = 2
@@ -4,10 +4,20 @@ given a directory path), `ls`, `grep`, `cat`, `find`.
Before deriving requirements, check for existing work: `task_search` for related, duplicate, or 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 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. If a task already analysis rather than re-deriving it; flag a duplicate instead of restating it.
covers this work, name its id (e.g. `auth-142`) in the analysis; if none does and the work
warrants tracking (per the task policy), `task_create` one and name its id — either way later Then frame the work as a task (per the task policy):
stages thread it through the plan. - 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): Emit the `analysis` artifact (JSON, schema provided):
- `summary`: the goal in your own words. - `summary`: the goal in your own words.
@@ -63,15 +63,17 @@ Emit a JSON object that validates against the `execution_plan` schema:
(`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond (`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond
this set. this set.
- **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that - **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that
the analyst found or opened with `task_create`; if none is referenced there is no task to the analyst found, opened with `task_create`, or named as the ready task of a
track). When one is referenced, thread it through the plan so the work stays tracked: `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 - 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 `prompt` `task_update action=claim` the task before starting and
`action=submit_for_review` when its output is ready. `action=submit_for_review` when its output is ready.
- Give the final or review stage `task_context` and `task_update`, and have its `prompt` - 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. `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 a - If the `analysis` references no task, omit the task tools entirely. Do not create or
new task here — creation is out of scope for the plan. decompose tasks here — task creation is out of scope for the plan.
- Keep stages small and single-responsibility. Prefer more stages over large monolithic - Keep stages small and single-responsibility. Prefer more stages over large monolithic
prompts. prompts.
@@ -0,0 +1,305 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.Task
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: break a large goal into a small task graph in ONE approval, instead of N
* separate [TaskCreateTool] calls. Use when the goal has dependency seams or independent
* review/handoff points (a thing that must land before another, or a piece worth reviewing on its
* own); for a single coherent unit you finish in one run, use task_create. Because a session works
* one task at a time, each node becomes a future claim — this tool decides the *shape*, claiming is
* still lazy (task_ready → claim), never scheduled.
*
* Creates the tasks and their `DEPENDS_ON` edges atomically (one dedup check, one approval) and
* returns the new ids plus which are ready to work now. An optional `parent` epic `DEPENDS_ON` every
* child (so it completes last) and each child `IMPLEMENTS` it (provenance).
*
* Nested args arrive flattened (the orchestrator stringifies non-primitive tool arguments), so the
* `tasks`/`parent` JSON is parsed here directly rather than via the list-param accessor.
*/
class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_decompose"
override val description: String =
"Break a large goal into a small task graph (parent epic + dependency-linked children) in " +
"one shot. Use when the goal has dependency seams or independent review/handoff points; " +
"for a single coherent unit you finish now, use task_create. Creates the tasks and their " +
"DEPENDS_ON links in a single approval; returns the new ids and which are ready to work now."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("project") {
put("type", "string")
put("description", "Project key shared by every task, e.g. 'webui'. Ids become '<project>-<n>'.")
}
putJsonObject("parent") {
put("type", "object")
put(
"description",
"Optional umbrella/epic. It DEPENDS_ON every child (completes last); each child IMPLEMENTS it.",
)
putJsonObject("properties") { unitProperties() }
}
putJsonObject("tasks") {
put("type", "array")
put("description", "The units of work, in order. Declare cross-task ordering with depends_on.")
putJsonObject("items") {
put("type", "object")
putJsonObject("properties") {
putJsonObject("ref") {
put("type", "string")
put("description", "Local handle other tasks cite in depends_on (e.g. 'scaffold').")
}
unitProperties()
putJsonObject("depends_on") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) })
}
}
putJsonObject("force") {
put("type", "boolean")
put("description", "Create even if a title duplicates an active task. Default false.")
}
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why duplicates are acceptable. Recorded on each task.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("project")); add(JsonPrimitive("tasks")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
if (elementOf(request, "tasks") !is JsonArray) {
return ValidationResult.Invalid("Missing 'tasks' (JSON array).")
}
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val project = request.stringParam("project") ?: return fail(request, "Missing 'project' (string).")
val specs = parseSpecs(elementOf(request, "tasks") as? JsonArray)
?: return fail(request, "'tasks' must be a JSON array of {title, goal, ...}.")
if (specs.isEmpty()) return fail(request, "'tasks' must contain at least one task.")
specs.withIndex().firstOrNull { it.value.title.isBlank() || it.value.goal.isBlank() }?.let {
return fail(request, "task #${it.index} is missing 'title' or 'goal'.")
}
val parent = parseSpec(elementOf(request, "parent") as? JsonObject)
if (parent != null && (parent.title.isBlank() || parent.goal.isBlank())) {
return fail(request, "'parent' needs both 'title' and 'goal'.")
}
val adjacency = when (val e = buildEdges(specs)) {
is Edges.Err -> return fail(request, e.message)
is Edges.Ok -> e.adjacency
}
dedupFailure(request, project, specs, parent)?.let { return it }
val pid = ProjectId(project)
val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
wireLinks(adjacency, children, parentTask)
recordProvenance(request, listOfNotNull(parentTask) + children)
val createdIds = (children + listOfNotNull(parentTask)).joinToString(",") { it.taskId.value }
val readyIds = children.filter { service.blockers(it.taskId).isEmpty() }.map { it.taskId.value }
return ToolResult.Success(
invocationId = request.invocationId,
output = render(children, parentTask, readyIds),
metadata = mapOf("taskIds" to createdIds, "readyIds" to readyIds.joinToString(","), "project" to project),
)
}
/** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */
private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) {
adjacency.forEachIndexed { i, deps ->
deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) }
}
if (parentTask != null) {
children.forEach { child ->
// Parent waits on every child; child records the epic it implements.
service.link(parentTask.taskId, child.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
service.link(child.taskId, parentTask.taskId.value, TaskLinkType.IMPLEMENTS, TaskTargetKind.TASK)
}
}
}
private suspend fun recordProvenance(request: ToolRequest, created: List<Task>) {
created.forEach { service.linkOriginSession(it.taskId, request) }
if (!request.boolParam("force")) return
val reason = request.stringParam("force_reason") ?: return
created.forEach {
service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason")
}
}
/** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */
private fun dedupFailure(request: ToolRequest, project: String, specs: List<TaskSpec>, parent: TaskSpec?): ToolResult.Failure? {
val titles = (listOfNotNull(parent?.title) + specs.map { it.title })
val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys
if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
if (request.boolParam("force")) {
return if (request.stringParam("force_reason") == null) {
fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
} else {
null
}
}
val pid = ProjectId(project)
val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value }
return clashes.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
}
}
private fun render(children: List<Task>, parentTask: Task?, readyIds: List<String>): String {
val lines = children.map { c ->
val unmet = service.blockers(c.taskId).map { it.taskId.value }
val state = if (unmet.isEmpty()) "READY" else "blocked by ${unmet.joinToString(", ")}"
"- ${c.taskId.value} '${c.state.title.orEmpty()}' [$state]"
} + listOfNotNull(
parentTask?.let { p ->
val unmet = service.blockers(p.taskId).map { it.taskId.value }
"- ${p.taskId.value} '${p.state.title.orEmpty()}' (epic) [blocked by ${unmet.joinToString(", ")}]"
},
)
val readyLine = if (readyIds.isEmpty()) {
"Nothing is ready yet — check the dependency graph."
} else {
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete."
}
return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" +
lines.joinToString("\n") + "\n" + readyLine
}
// --- parsing (flattened JSON args) ---
private data class TaskSpec(
val ref: String?,
val title: String,
val goal: String,
val acceptanceCriteria: List<String>,
val affectedPaths: List<String>,
val deps: List<String>,
)
private fun parseSpecs(element: JsonArray?): List<TaskSpec>? =
element?.map { parseSpec(it as? JsonObject) ?: return null }
private fun parseSpec(obj: JsonObject?): TaskSpec? {
if (obj == null) return null
return TaskSpec(
ref = str(obj, "ref"),
title = str(obj, "title").orEmpty(),
goal = str(obj, "goal").orEmpty(),
acceptanceCriteria = strList(obj, "acceptance_criteria"),
affectedPaths = strList(obj, "affected_paths"),
deps = strList(obj, "depends_on"),
)
}
private fun str(obj: JsonObject, key: String): String? =
(obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
private fun strList(obj: JsonObject, key: String): List<String> =
(obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList()
/** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */
private fun elementOf(request: ToolRequest, name: String) =
request.parameters[name]?.let { runCatching { Json.parseToJsonElement(it.toString()) }.getOrNull() }
private fun normalize(title: String): String = title.trim().lowercase().replace(Regex("\\s+"), " ")
// --- dependency edges + cycle check ---
private sealed interface Edges {
data class Ok(val adjacency: List<List<Int>>) : Edges
data class Err(val message: String) : Edges
}
private fun buildEdges(specs: List<TaskSpec>): Edges {
val refToIndex = HashMap<String, Int>()
specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } }
val adjacency = ArrayList<List<Int>>()
specs.forEachIndexed { i, s ->
val deps = ArrayList<Int>()
for (d in s.deps) {
val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices }
?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.")
if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.")
deps.add(j)
}
adjacency.add(deps.distinct())
}
cyclePath(adjacency)?.let { path ->
return Edges.Err("Dependency cycle: ${path.joinToString(" -> ") { specs[it].title }}.")
}
return Edges.Ok(adjacency)
}
/** Returns a back-edge cycle as a list of task indices, or null when the graph is acyclic. */
private fun cyclePath(adjacency: List<List<Int>>): List<Int>? {
val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done
val stack = ArrayList<Int>()
fun dfs(u: Int): List<Int>? {
color[u] = 1
stack.add(u)
for (v in adjacency[u]) {
if (color[v] == 1) return stack.subList(stack.indexOf(v), stack.size).toList() + v
if (color[v] == 0) dfs(v)?.let { return it }
}
color[u] = 2
stack.removeAt(stack.lastIndex)
return null
}
for (i in adjacency.indices) if (color[i] == 0) dfs(i)?.let { return it }
return null
}
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
/** The fields a task spec and the parent epic share, declared once for the schema. */
private fun kotlinx.serialization.json.JsonObjectBuilder.unitProperties() {
putJsonObject("title") { put("type", "string"); put("description", "Short imperative title.") }
putJsonObject("goal") { put("type", "string"); put("description", "What 'done' looks like.") }
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Concrete, verifiable criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Globs/paths this task is expected to touch.")
}
}
@@ -26,6 +26,7 @@ object TaskTools {
): List<Tool> = ): List<Tool> =
listOf( listOf(
TaskCreateTool(service), TaskCreateTool(service),
TaskDecomposeTool(service),
TaskUpdateTool(service, sessionFacts, sessionWrites), TaskUpdateTool(service, sessionFacts, sessionWrites),
TaskDeleteTool(service), TaskDeleteTool(service),
TaskSearchTool(service), TaskSearchTool(service),
@@ -27,6 +27,7 @@ class TaskToolsTest {
private val service = TaskService(InMemoryEventStore()) private val service = TaskService(InMemoryEventStore())
private val create = TaskCreateTool(service) private val create = TaskCreateTool(service)
private val decompose = TaskDecomposeTool(service)
private val update = TaskUpdateTool(service) private val update = TaskUpdateTool(service)
private val delete = TaskDeleteTool(service) private val delete = TaskDeleteTool(service)
private val search = TaskSearchTool(service) private val search = TaskSearchTool(service)
@@ -63,6 +64,116 @@ class TaskToolsTest {
assertTrue(result is ToolResult.Failure) 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 @Test
fun `task_update applies status action, edits, link and note`() = runBlocking { fun `task_update applies status action, edits, link and note`() = runBlocking {
val id = createTask() val id = createTask()
@@ -70,9 +70,10 @@ class FreestylePlanningWorkflowTest {
assertEquals(2, graph.transitions.size) assertEquals(2, graph.transitions.size)
// analyst frames the work: search + open a task (the architect threads it into the plan). // analyst frames the work: search + open one task or decompose into a graph (the architect
// threads the named task into the plan).
assertEquals( assertEquals(
setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"), setOf("file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"),
graph.stages[StageId("analyst")]!!.allowedTools, graph.stages[StageId("analyst")]!!.allowedTools,
) )
} }