Skip to main content

max / goingson

Add the go-mcp subtask tools add_subtask, set_subtask_completed, update_subtask, and delete_subtask, each behind its own write capability. A subtask is either plain checklist text or a link to an existing task, whose completion then tracks that task's status. get_task returns each subtask with the id the write tools take.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-26 13:35 UTC
Signed with PGP, not checked
Commit: 4fdb6d9a6ee30a2a31e96699f613a5f43aece436
Parent: bdbe42a
5 files changed, +453 insertions, -24 deletions
@@ -14,6 +14,9 @@
14 14 pub const TASK_BULK_IMPORT: &str = "go.task.bulk_import";
15 15 pub const TASK_UPDATE: &str = "go.task.update";
16 16 pub const TASK_COMPLETE: &str = "go.task.complete";
17 + pub const TASK_SUBTASK_ADD: &str = "go.task.subtask.add";
18 + pub const TASK_SUBTASK_UPDATE: &str = "go.task.subtask.update";
19 + pub const TASK_SUBTASK_DELETE: &str = "go.task.subtask.delete";
17 20
18 21 pub fn project_create() -> WriteCapability {
19 22 WriteCapability::new(PROJECT_CREATE, "create a project")
@@ -42,3 +45,12 @@
42 45 pub fn task_complete() -> WriteCapability {
43 46 WriteCapability::new(TASK_COMPLETE, "mark a task complete")
44 47 }
48 + pub fn task_subtask_add() -> WriteCapability {
49 + WriteCapability::new(TASK_SUBTASK_ADD, "add a subtask to a task")
50 + }
51 + pub fn task_subtask_update() -> WriteCapability {
52 + WriteCapability::new(TASK_SUBTASK_UPDATE, "edit or check off a subtask")
53 + }
54 + pub fn task_subtask_delete() -> WriteCapability {
55 + WriteCapability::new(TASK_SUBTASK_DELETE, "delete a subtask")
56 + }
@@ -5,7 +5,7 @@
5 5 //! [`Task`] back down to the compact JSON rows the read tools return.
6 6
7 7 use chrono::{DateTime, NaiveDate, TimeZone, Utc};
8 - use goingson_core::{Priority, ProjectId, Task, TaskId};
8 + use goingson_core::{Priority, ProjectId, Subtask, SubtaskId, Task, TaskId};
9 9 use kberg::Error;
10 10 use serde_json::{Value, json};
11 11 use uuid::Uuid;
@@ -31,6 +31,31 @@
31 31 })
32 32 }
33 33
34 + /// Parse a subtask id string into a [`SubtaskId`], or an `InvalidArgs`.
35 + pub fn parse_subtask_id(tool: &str, s: &str) -> Result<SubtaskId, Error> {
36 + Uuid::parse_str(s.trim())
37 + .map(SubtaskId::from_uuid)
38 + .map_err(|_| Error::InvalidArgs {
39 + tool: tool.to_string(),
40 + message: format!("`{s}` is not a valid subtask id (expected a UUID)"),
41 + })
42 + }
43 +
44 + /// Compact JSON projection of a subtask.
45 + ///
46 + /// Carries `id`, without which a caller that reads a task cannot address its
47 + /// subtasks to check one off. `linked_task_id` is present only for link
48 + /// subtasks, so its absence is the "plain text subtask" signal.
49 + pub fn subtask_row(s: &Subtask) -> Value {
50 + json!({
51 + "id": s.id.to_string(),
52 + "text": s.text,
53 + "completed": s.is_completed,
54 + "position": s.position,
55 + "linked_task_id": s.linked_task_id.map(|t: TaskId| t.to_string()),
56 + })
57 + }
58 +
34 59 /// Best-effort due-date parse. Accepts an RFC 3339 timestamp or a bare
35 60 /// `YYYY-MM-DD` (interpreted as midnight UTC), matching the CSV import
36 61 /// interchange semantics. Returns `None` for empty/absent, `Err` for garbage
@@ -576,3 +576,158 @@
576 576 other => panic!("expected InvalidArgs, got {other:?}"),
577 577 }
578 578 }
579 +
580 + #[tokio::test]
581 + async fn subtasks_round_trip_through_the_write_surface() {
582 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
583 +
584 + let parent = call(&reg, "create_task", json!({ "description": "batch" })).await;
585 + let task_id = parent["id"].as_str().unwrap().to_string();
586 +
587 + let a = call(
588 + &reg,
589 + "add_subtask",
590 + json!({ "task_id": task_id, "text": "first" }),
591 + )
592 + .await;
593 + call(
594 + &reg,
595 + "add_subtask",
596 + json!({ "task_id": task_id, "text": "second" }),
597 + )
598 + .await;
599 + let sub_id = a["id"].as_str().unwrap().to_string();
600 + assert_eq!(a["text"], "first");
601 + assert_eq!(a["completed"], false);
602 +
603 + // get_task must expose the ids, or nothing downstream can address a subtask.
604 + let got = call(&reg, "get_task", json!({ "id": task_id })).await;
605 + let subs = got["subtasks"].as_array().unwrap();
606 + assert_eq!(subs.len(), 2);
607 + assert_eq!(subs[0]["id"], sub_id.as_str());
608 + assert_eq!(subs[1]["text"], "second");
609 +
610 + let renamed = call(
611 + &reg,
612 + "update_subtask",
613 + json!({ "subtask_id": sub_id, "text": "first, revised" }),
614 + )
615 + .await;
616 + assert_eq!(renamed["text"], "first, revised");
617 + assert_eq!(renamed["completed"], false, "rename must not check it off");
618 +
619 + let done = call(
620 + &reg,
621 + "set_subtask_completed",
622 + json!({ "task_id": task_id, "subtask_id": sub_id, "completed": true }),
623 + )
624 + .await;
625 + assert_eq!(done["completed"], true);
626 +
627 + let deleted = call(&reg, "delete_subtask", json!({ "subtask_id": sub_id })).await;
628 + assert_eq!(deleted["deleted"], true);
629 + let after = call(&reg, "get_task", json!({ "id": task_id })).await;
630 + assert_eq!(after["subtasks"].as_array().unwrap().len(), 1);
631 + }
632 +
633 + #[tokio::test]
634 + async fn set_subtask_completed_is_idempotent() {
635 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
636 +
637 + let parent = call(&reg, "create_task", json!({ "description": "batch" })).await;
638 + let task_id = parent["id"].as_str().unwrap().to_string();
639 + let sub = call(
640 + &reg,
641 + "add_subtask",
642 + json!({ "task_id": task_id, "text": "item" }),
643 + )
644 + .await;
645 + let sub_id = sub["id"].as_str().unwrap().to_string();
646 +
647 + // The underlying repository call is a toggle. A retried tool call must not
648 + // flip it back, so the second `true` has to be a no-op rather than an undo.
649 + for _ in 0..2 {
650 + let out = call(
651 + &reg,
652 + "set_subtask_completed",
653 + json!({ "task_id": task_id, "subtask_id": sub_id, "completed": true }),
654 + )
655 + .await;
656 + assert_eq!(out["completed"], true);
657 + }
658 +
659 + let back = call(
660 + &reg,
661 + "set_subtask_completed",
662 + json!({ "task_id": task_id, "subtask_id": sub_id, "completed": false }),
663 + )
664 + .await;
665 + assert_eq!(back["completed"], false);
666 + }
667 +
668 + #[tokio::test]
669 + async fn add_subtask_links_an_existing_task() {
670 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
671 +
672 + let parent = call(&reg, "create_task", json!({ "description": "phase 1" })).await;
673 + let child = call(&reg, "create_task", json!({ "description": "phase 2" })).await;
674 + let parent_id = parent["id"].as_str().unwrap().to_string();
675 + let child_id = child["id"].as_str().unwrap().to_string();
676 +
677 + let linked = call(
678 + &reg,
679 + "add_subtask",
680 + json!({ "task_id": parent_id, "linked_task_id": child_id }),
681 + )
682 + .await;
683 + assert_eq!(linked["linked_task_id"], child_id.as_str());
684 + }
685 +
686 + #[tokio::test]
687 + async fn add_subtask_rejects_both_forms_and_neither() {
688 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
689 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
690 +
691 + let parent = call(&reg, "create_task", json!({ "description": "batch" })).await;
692 + let task_id = parent["id"].as_str().unwrap().to_string();
693 +
694 + for (args, want) in [
695 + (
696 + json!({ "task_id": task_id, "text": "x", "linked_task_id": task_id }),
697 + "not both",
698 + ),
699 + (json!({ "task_id": task_id }), "missing `text`"),
700 + ] {
701 + let err = reg
702 + .call("add_subtask", args, Some(&grants))
703 + .await
704 + .unwrap_err();
705 + match err {
706 + Error::InvalidArgs { message, .. } => {
707 + assert!(message.contains(want), "unhelpful message: {message}");
708 + }
709 + other => panic!("expected InvalidArgs, got {other:?}"),
710 + }
711 + }
712 + }
713 +
714 + #[tokio::test]
715 + async fn subtask_writes_are_capability_gated() {
716 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
717 +
718 + for (tool, capability) in [
719 + ("add_subtask", "go.task.subtask.add"),
720 + ("set_subtask_completed", "go.task.subtask.update"),
721 + ("update_subtask", "go.task.subtask.update"),
722 + ("delete_subtask", "go.task.subtask.delete"),
723 + ] {
724 + let err = reg
725 + .call(tool, json!({}), Some(&HashSet::new()))
726 + .await
727 + .unwrap_err();
728 + match err {
729 + Error::CapabilityDenied { capability: c, .. } => assert_eq!(c, capability),
730 + other => panic!("{tool}: expected CapabilityDenied, got {other:?}"),
731 + }
732 + }
733 + }
@@ -13,7 +13,10 @@
13 13
14 14 pub use problem::{ListProblems, PromoteProblem, ReportProblems, UpdateProblemTool};
15 15 pub use project::{CreateProject, ListProjects, UpdateProjectTool};
16 - pub use task::{BulkImportTasks, CompleteTask, CreateTask, GetTask, ListTasks, UpdateTaskTool};
16 + pub use task::{
17 + AddSubtask, BulkImportTasks, CompleteTask, CreateTask, DeleteSubtask, GetTask, ListTasks,
18 + SetSubtaskCompleted, UpdateSubtaskTool, UpdateTaskTool,
19 + };
17 20
18 21 /// Build the full go-mcp tool surface over a shared context.
19 22 pub fn registry(ctx: Arc<Ctx>) -> ToolRegistry {
@@ -30,6 +33,10 @@
30 33 r.register(BulkImportTasks(ctx.clone()));
31 34 r.register(UpdateTaskTool(ctx.clone()));
32 35 r.register(CompleteTask(ctx.clone()));
36 + r.register(AddSubtask(ctx.clone()));
37 + r.register(SetSubtaskCompleted(ctx.clone()));
38 + r.register(UpdateSubtaskTool(ctx.clone()));
39 + r.register(DeleteSubtask(ctx.clone()));
33 40 r.register(ReportProblems(ctx.clone()));
34 41 r.register(PromoteProblem(ctx.clone()));
35 42 r.register(UpdateProblemTool(ctx));
@@ -1,5 +1,7 @@
1 1 //! Task tools: the read surface (`list_tasks`, `get_task`) and the write
2 - //! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`).
2 + //! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`,
3 + //! plus the subtask writes `add_subtask`, `set_subtask_completed`,
4 + //! `update_subtask`, `delete_subtask`).
3 5 //!
4 6 //! `bulk_import_tasks` is the `/dellm` migration primitive: it takes a parsed
5 7 //! backlog and creates tasks in one capability-gated call, deduping on a
@@ -21,8 +23,8 @@
21 23 use crate::caps;
22 24 use crate::context::Ctx;
23 25 use crate::convert::{
24 - MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_tags, parse_task_id,
25 - req_str, source_tag, task_row, task_summary_row,
26 + MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_subtask_id, parse_tags,
27 + parse_task_id, req_str, source_tag, subtask_row, task_row, task_summary_row,
26 28 };
27 29
28 30 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
@@ -38,10 +40,10 @@
38 40
39 41 #[async_trait]
40 42 impl Tool for ListTasks {
41 - fn name(&self) -> &str {
43 + fn name(&self) -> &'static str {
42 44 "list_tasks"
43 45 }
44 - fn description(&self) -> &str {
46 + fn description(&self) -> &'static str {
45 47 "List tasks as compact rows. Optional filters: `project` (name), `status` (Pending|Started|Completed), `tag`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_task` for the full text. The reply carries `total` (rows matching the filters) and, when more remain, `next_offset`."
46 48 }
47 49 fn kind(&self) -> ToolKind {
@@ -115,11 +117,11 @@
115 117
116 118 #[async_trait]
117 119 impl Tool for GetTask {
118 - fn name(&self) -> &str {
120 + fn name(&self) -> &'static str {
119 121 "get_task"
120 122 }
121 - fn description(&self) -> &str {
122 - "Fetch one task by id, including its subtasks and annotations."
123 + fn description(&self) -> &'static str {
124 + "Fetch one task by id, including its subtasks and annotations. Each subtask carries its `id`, which the subtask write tools take."
123 125 }
124 126 fn kind(&self) -> ToolKind {
125 127 ToolKind::Read
@@ -156,12 +158,7 @@
156 158 .map_err(|e| fail(self.name(), e))?;
157 159
158 160 let mut row = task_row(&task);
159 - row["subtasks"] = json!(
160 - subtasks
161 - .iter()
162 - .map(|s| json!({ "text": s.text, "completed": s.is_completed }))
163 - .collect::<Vec<_>>()
164 - );
161 + row["subtasks"] = json!(subtasks.iter().map(subtask_row).collect::<Vec<_>>());
165 162 row["annotations"] = json!(
166 163 annotations
167 164 .iter()
@@ -178,10 +175,10 @@
178 175
179 176 #[async_trait]
180 177 impl Tool for CreateTask {
181 - fn name(&self) -> &str {
178 + fn name(&self) -> &'static str {
182 179 "create_task"
183 180 }
184 - fn description(&self) -> &str {
181 + fn description(&self) -> &'static str {
185 182 "Create one task. `project` (name) is resolved to a project, creating it if absent. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low."
186 183 }
187 184 fn kind(&self) -> ToolKind {
@@ -240,10 +237,10 @@
240 237
241 238 #[async_trait]
242 239 impl Tool for BulkImportTasks {
243 - fn name(&self) -> &str {
240 + fn name(&self) -> &'static str {
244 241 "bulk_import_tasks"
245 242 }
246 - fn description(&self) -> &str {
243 + fn description(&self) -> &'static str {
247 244 "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project?, tags?, due?, priority?, source?}. `source` is a provenance key (e.g. file:line); an item whose source tag already exists is skipped, so re-running is idempotent. Returns created/skipped counts and the new task ids."
248 245 }
249 246 fn kind(&self) -> ToolKind {
@@ -364,10 +361,10 @@
364 361
365 362 #[async_trait]
366 363 impl Tool for UpdateTaskTool {
367 - fn name(&self) -> &str {
364 + fn name(&self) -> &'static str {
368 365 "update_task"
369 366 }
370 - fn description(&self) -> &str {
367 + fn description(&self) -> &'static str {
371 368 "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project`, and `commit`. `commit` appends an advancing commit to the task's commit list, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); use `complete_task` to record the closing commit."
372 369 }
373 370 fn kind(&self) -> ToolKind {
@@ -478,10 +475,10 @@
478 475
479 476 #[async_trait]
480 477 impl Tool for CompleteTask {
481 - fn name(&self) -> &str {
478 + fn name(&self) -> &'static str {
482 479 "complete_task"
483 480 }
484 - fn description(&self) -> &str {
481 + fn description(&self) -> &'static str {
485 482 "Mark a task complete by id. Optional `commit` records the closing commit, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); it is appended to the task's commit list and flagged as the closer."
486 483 }
487 484 fn kind(&self) -> ToolKind {
@@ -543,6 +540,239 @@
543 540 }
544 541 }
545 542
543 + // subtask writes
544 +
545 + pub struct AddSubtask(pub Arc<Ctx>);
546 +
547 + #[async_trait]
548 + impl Tool for AddSubtask {
549 + fn name(&self) -> &'static str {
550 + "add_subtask"
551 + }
552 + fn description(&self) -> &'static str {
553 + "Add a subtask to a task. Pass `text` for a plain checklist item, or `linked_task_id` to link an existing task as a subtask (its completion then tracks that task's status) — exactly one of the two. Subtasks append in call order; read them back with `get_task`."
554 + }
555 + fn kind(&self) -> ToolKind {
556 + ToolKind::Write(caps::task_subtask_add())
557 + }
558 + fn input_schema(&self) -> Value {
559 + json!({
560 + "type": "object",
561 + "properties": {
562 + "task_id": { "type": "string" },
563 + "text": { "type": "string" },
564 + "linked_task_id": { "type": "string" }
565 + },
566 + "required": ["task_id"]
567 + })
568 + }
569 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
570 + let task_id = parse_task_id(self.name(), req_str(self.name(), &args, "task_id")?)?;
571 + let text = args
572 + .get("text")
573 + .and_then(Value::as_str)
574 + .map(str::trim)
575 + .filter(|s| !s.is_empty());
576 + let linked = args
577 + .get("linked_task_id")
578 + .and_then(Value::as_str)
579 + .map(str::trim)
580 + .filter(|s| !s.is_empty());
581 +
582 + // Exactly one form. Both would silently drop one; neither is a no-op
583 + // that would otherwise report success.
584 + let repo = self.0.tasks();
585 + let created = match (text, linked) {
586 + (Some(_), Some(_)) => {
587 + return Err(Error::InvalidArgs {
588 + tool: self.name().to_string(),
589 + message: "pass either `text` or `linked_task_id`, not both".into(),
590 + });
591 + }
592 + (None, None) => {
593 + return Err(Error::InvalidArgs {
594 + tool: self.name().to_string(),
595 + message: "missing `text` (or `linked_task_id`)".into(),
596 + });
597 + }
598 + (Some(t), None) => repo
599 + .add_subtask(task_id, self.0.user_id, t)
600 + .await
601 + .map_err(|e| fail(self.name(), e))?,
602 + (None, Some(l)) => {
603 + let linked_id = parse_task_id(self.name(), l)?;
604 + repo.add_subtask_link(task_id, self.0.user_id, linked_id)
605 + .await
606 + .map_err(|e| fail(self.name(), e))?
607 + }
608 + };
609 +
610 + let created = created.ok_or_else(|| Error::ToolFailed {
611 + tool: self.name().to_string(),
612 + message: format!("no task with id {task_id}"),
613 + })?;
614 + Ok(ToolCallResult::text(
615 + serde_json::to_string(&subtask_row(&created)).unwrap(),
616 + ))
617 + }
618 + }
619 +
620 + pub struct SetSubtaskCompleted(pub Arc<Ctx>);
621 +
622 + #[async_trait]
623 + impl Tool for SetSubtaskCompleted {
624 + fn name(&self) -> &'static str {
625 + "set_subtask_completed"
626 + }
627 + fn description(&self) -> &'static str {
628 + "Check or uncheck a subtask: pass `completed` true or false. Idempotent — setting a subtask to the state it already holds is a no-op, so a retry cannot flip it back. `task_id` is the parent task, `subtask_id` comes from `get_task`."
629 + }
630 + fn kind(&self) -> ToolKind {
631 + ToolKind::Write(caps::task_subtask_update())
632 + }
633 + fn input_schema(&self) -> Value {
634 + json!({
635 + "type": "object",
636 + "properties": {
637 + "task_id": { "type": "string" },
638 + "subtask_id": { "type": "string" },
639 + "completed": { "type": "boolean" }
640 + },
641 + "required": ["task_id", "subtask_id", "completed"]
642 + })
643 + }
644 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
645 + let task_id = parse_task_id(self.name(), req_str(self.name(), &args, "task_id")?)?;
646 + let subtask_id = parse_subtask_id(self.name(), req_str(self.name(), &args, "subtask_id")?)?;
647 + let want = args
648 + .get("completed")
649 + .and_then(Value::as_bool)
650 + .ok_or_else(|| Error::InvalidArgs {
651 + tool: self.name().to_string(),
652 + message: "missing boolean field `completed`".into(),
653 + })?;
654 +
655 + // The repository exposes a toggle, which is the wrong shape for a retryable
656 + // tool call: a repeat would undo the first. Read the current state and act
657 + // only on a mismatch, so the call is idempotent in the requested state.
658 + let repo = self.0.tasks();
659 + let current = repo
660 + .get_subtasks_for_task(task_id)
661 + .await
662 + .map_err(|e| fail(self.name(), e))?
663 + .into_iter()
664 + .find(|s| s.id == subtask_id)
665 + .ok_or_else(|| Error::ToolFailed {
666 + tool: self.name().to_string(),
667 + message: format!("task {task_id} has no subtask {subtask_id}"),
668 + })?;
669 +
670 + if current.is_completed == want {
671 + return Ok(ToolCallResult::text(
672 + serde_json::to_string(&subtask_row(&current)).unwrap(),
673 + ));
674 + }
675 +
676 + let updated = repo
677 + .toggle_subtask(subtask_id, self.0.user_id)
678 + .await
679 + .map_err(|e| fail(self.name(), e))?
680 + .ok_or_else(|| Error::ToolFailed {
681 + tool: self.name().to_string(),
682 + message: format!("subtask {subtask_id} vanished during update"),
683 + })?;
684 + Ok(ToolCallResult::text(
685 + serde_json::to_string(&subtask_row(&updated)).unwrap(),
686 + ))
687 + }
688 + }
689 +
690 + pub struct UpdateSubtaskTool(pub Arc<Ctx>);
691 +
692 + #[async_trait]
693 + impl Tool for UpdateSubtaskTool {
694 + fn name(&self) -> &'static str {
695 + "update_subtask"
696 + }
697 + fn description(&self) -> &'static str {
698 + "Rewrite a subtask's text. Completion state is untouched; use `set_subtask_completed` for that."
699 + }
700 + fn kind(&self) -> ToolKind {
701 + ToolKind::Write(caps::task_subtask_update())
702 + }
703 + fn input_schema(&self) -> Value {
704 + json!({
705 + "type": "object",
706 + "properties": {
707 + "subtask_id": { "type": "string" },
708 + "text": { "type": "string" }
709 + },
710 + "required": ["subtask_id", "text"]
711 + })
712 + }
713 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
714 + let subtask_id = parse_subtask_id(self.name(), req_str(self.name(), &args, "subtask_id")?)?;
715 + let text = req_str(self.name(), &args, "text")?;
716 +
717 + let updated = self
718 + .0
719 + .tasks()
720 + .update_subtask(subtask_id, self.0.user_id, text)
721 + .await
722 + .map_err(|e| fail(self.name(), e))?
723 + .ok_or_else(|| Error::ToolFailed {
724 + tool: self.name().to_string(),
725 + message: format!("no subtask with id {subtask_id}"),
726 + })?;
727 + Ok(ToolCallResult::text(
728 + serde_json::to_string(&subtask_row(&updated)).unwrap(),
729 + ))
730 + }
731 + }
732 +
733 + pub struct DeleteSubtask(pub Arc<Ctx>);
734 +
735 + #[async_trait]
736 + impl Tool for DeleteSubtask {
737 + fn name(&self) -> &'static str {
738 + "delete_subtask"
739 + }
740 + fn description(&self) -> &'static str {
741 + "Delete a subtask outright. Prefer `set_subtask_completed` for work that is done — deleting discards the record that it was ever tracked."
742 + }
743 + fn kind(&self) -> ToolKind {
744 + ToolKind::Write(caps::task_subtask_delete())
745 + }
746 + fn input_schema(&self) -> Value {
747 + json!({
748 + "type": "object",
749 + "properties": { "subtask_id": { "type": "string" } },
750 + "required": ["subtask_id"]
751 + })
752 + }
753 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
754 + let subtask_id = parse_subtask_id(self.name(), req_str(self.name(), &args, "subtask_id")?)?;
755 + let deleted = self
756 + .0
757 + .tasks()
758 + .delete_subtask(subtask_id, self.0.user_id)
759 + .await
760 + .map_err(|e| fail(self.name(), e))?;
761 + if !deleted {
762 + return Err(Error::ToolFailed {
763 + tool: self.name().to_string(),
764 + message: format!("no subtask with id {subtask_id}"),
765 + });
766 + }
767 + Ok(ToolCallResult::text(
768 + serde_json::to_string(
769 + &json!({ "deleted": true, "subtask_id": subtask_id.to_string() }),
770 + )
771 + .unwrap(),
772 + ))
773 + }
774 + }
775 +
546 776 // helpers
547 777
548 778 /// Resolve a project by name, creating a default `SideProject` if absent.