Skip to main content

task_graph_mcp/tools/
tasks.rs

1//! Task CRUD tools.
2
3use super::{get_bool, get_i32, get_i64, get_string, get_string_array, make_tool_with_prompts};
4use crate::config::{
5    AttachmentsConfig, AutoAdvanceConfig, DependenciesConfig, Prompts, StatesConfig,
6    UnknownKeyBehavior,
7};
8use crate::db::Database;
9use crate::error::ToolError;
10use crate::format::{
11    OutputFormat, format_scan_result_markdown, format_task_markdown, format_tasks_markdown,
12    markdown_to_json,
13};
14use crate::types::{ScanResult, TaskTreeInput, parse_priority};
15use anyhow::Result;
16use rmcp::model::Tool;
17use serde_json::{Value, json};
18
19pub fn get_tools(prompts: &Prompts, states_config: &StatesConfig) -> Vec<Tool> {
20    // Generate state enum from config
21    let state_names: Vec<&str> = states_config.state_names();
22    let state_enum: Vec<Value> = state_names.iter().map(|s| json!(s)).collect();
23
24    vec![
25        make_tool_with_prompts(
26            "create",
27            "Create a new task. Use parent for subtasks. Use the link system (block tool) for dependencies.",
28            json!({
29                "id": {
30                    "type": "string",
31                    "description": "Custom task ID (optional, UUID7 generated if not provided)"
32                },
33                "description": {
34                    "type": "string",
35                    "description": "Task description (required)"
36                },
37                "parent": {
38                    "type": "string",
39                    "description": "Parent task ID for nesting"
40                },
41                "priority": {
42                    "type": "integer",
43                    "description": "Task priority 0-10 (higher = more important, default 5)"
44                },
45                "points": {
46                    "type": "integer",
47                    "description": "Story points / complexity estimate"
48                },
49                "time_estimate_ms": {
50                    "type": "integer",
51                    "description": "Estimated duration in milliseconds"
52                },
53                "tags": {
54                    "type": "array",
55                    "items": { "type": "string" },
56                    "description": "Categorization/discovery tags (what the task IS, for querying)"
57                }
58            }),
59            vec!["description"],
60            prompts,
61        ),
62        make_tool_with_prompts(
63            "create_tree",
64            "Create a task tree from nested structure. child_type (default 'contains') links parent→children, sibling_type ('follows' or null) links siblings. Use 'ref' in nodes to include existing tasks.",
65            json!({
66                "tree": {
67                    "type": "object",
68                    "description": "Nested tree structure with title, children[], etc. Use 'ref' to reference existing tasks.",
69                    "properties": {
70                        "ref": { "type": "string", "description": "Reference to an existing task ID (other fields ignored when set)" },
71                        "id": { "type": "string", "description": "Custom task ID (optional, UUID7 generated if not provided)" },
72                        "title": { "type": "string", "description": "Task title (required for new tasks)" },
73                        "description": { "type": "string", "description": "Task description" },
74                        "priority": { "type": "integer", "description": "Task priority 0-10 (default 5)" },
75                        "points": { "type": "integer", "description": "Story points / complexity estimate" },
76                        "time_estimate_ms": { "type": "integer", "description": "Estimated duration in milliseconds" },
77                        "tags": { "type": "array", "items": { "type": "string" }, "description": "Categorization/discovery tags" },
78                        "needed_tags": { "type": "array", "items": { "type": "string" }, "description": "Tags agent must have ALL of to claim (AND)" },
79                        "wanted_tags": { "type": "array", "items": { "type": "string" }, "description": "Tags agent must have AT LEAST ONE of to claim (OR)" },
80                        "children": { "type": "array", "description": "Child nodes (same structure, recursive)" }
81                    }
82                },
83                "parent": {
84                    "type": "string",
85                    "description": "Optional parent task ID for the tree root"
86                },
87                "child_type": {
88                    "type": "string",
89                    "description": "Dependency type from parent to children (default: 'contains'). Set to null for no parent-child deps."
90                },
91                "sibling_type": {
92                    "type": "string",
93                    "description": "Dependency type between consecutive siblings (default: null/parallel). Use 'follows' for sequential."
94                }
95            }),
96            vec!["tree"],
97            prompts,
98        ),
99        make_tool_with_prompts(
100            "get",
101            "Get a single task by ID. Returns detailed task with attachment metadata list and counts by type.",
102            json!({
103                "task": {
104                    "type": "string",
105                    "description": "Task ID"
106                }
107            }),
108            vec!["task"],
109            prompts,
110        ),
111        make_tool_with_prompts(
112            "list_tasks",
113            "Query tasks with flexible filters.",
114            json!({
115                "status": {
116                    "oneOf": [
117                        { "type": "string", "enum": state_enum },
118                        { "type": "array", "items": { "type": "string" } }
119                    ],
120                    "description": "Filter by status (single or array)"
121                },
122                "ready": {
123                    "type": "boolean",
124                    "description": "Filter for claimable tasks: in initial status, unclaimed, all start-blocking deps satisfied. When combined with 'agent', also filters by agent's tag qualifications."
125                },
126                "blocked": {
127                    "type": "boolean",
128                    "description": "Filter for blocked tasks: have unsatisfied start-blocking dependencies"
129                },
130                "claimed": {
131                    "type": "boolean",
132                    "description": "Filter for claimed tasks: currently owned by any agent (owner_agent IS NOT NULL)"
133                },
134                "owner": {
135                    "type": "string",
136                    "description": "Filter by owner agent ID (tasks currently claimed by this specific agent)"
137                },
138                "parent": {
139                    "type": "string",
140                    "description": "Filter by parent task ID (use 'null' for root tasks)"
141                },
142                "agent": {
143                    "type": "string",
144                    "description": "Agent ID for filtering. With ready=true, filters tasks the agent is qualified to claim based on agent_tags_all/agent_tags_any requirements."
145                },
146                "tags_any": {
147                    "type": "array",
148                    "items": { "type": "string" },
149                    "description": "Filter tasks that have ANY of these tags (OR)"
150                },
151                "tags_all": {
152                    "type": "array",
153                    "items": { "type": "string" },
154                    "description": "Filter tasks that have ALL of these tags (AND)"
155                },
156                "sort_by": {
157                    "type": "string",
158                    "enum": ["priority", "created_at", "updated_at"],
159                    "description": "Field to sort by (default: created_at for general queries, priority then created_at for ready queries)"
160                },
161                "sort_order": {
162                    "type": "string",
163                    "enum": ["asc", "desc"],
164                    "description": "Sort order: 'asc' for ascending, 'desc' for descending (default: desc for created_at/updated_at, priority always high-to-low)"
165                },
166                "limit": {
167                    "type": "integer",
168                    "description": "Maximum number of tasks to return"
169                }
170            }),
171            vec![],
172            prompts,
173        ),
174        make_tool_with_prompts(
175            "update",
176            "Update a task's properties. Status changes handle ownership automatically: transitioning to a timed status (e.g., in_progress) claims the task, transitioning to non-timed releases it, transitioning to terminal (e.g., completed) completes it. For push coordination: use assignee to assign a task to another agent (sets owner and transitions to 'assigned' status). Only the owner can update a claimed task unless force=true.",
177            json!({
178                "worker_id": {
179                    "type": "string",
180                    "description": "Worker ID making the update"
181                },
182                "task": {
183                    "type": "string",
184                    "description": "Task ID"
185                },
186                "assignee": {
187                    "type": "string",
188                    "description": "Agent ID to assign the task to (push coordination). Sets owner_agent to assignee and transitions to 'assigned' status. The assignee can then claim (transition to in_progress) when ready."
189                },
190                "status": {
191                    "type": "string",
192                    "enum": state_enum,
193                    "description": "New status"
194                },
195                "title": {
196                    "type": "string",
197                    "description": "New title"
198                },
199                "description": {
200                    "type": "string",
201                    "description": "New description"
202                },
203                "priority": {
204                    "type": "integer",
205                    "description": "New priority 0-10 (higher = more important)"
206                },
207                "points": {
208                    "type": "integer",
209                    "description": "New points estimate"
210                },
211                "tags": {
212                    "type": "array",
213                    "items": { "type": "string" },
214                    "description": "New categorization/discovery tags"
215                },
216                "needed_tags": {
217                    "type": "array",
218                    "items": { "type": "string" },
219                    "description": "Tags agent must have ALL of to claim (AND)"
220                },
221                "wanted_tags": {
222                    "type": "array",
223                    "items": { "type": "string" },
224                    "description": "Tags agent must have AT LEAST ONE of to claim (OR)"
225                },
226                "time_estimate_ms": {
227                    "type": "integer",
228                    "description": "Estimated duration in milliseconds"
229                },
230                "reason": {
231                    "type": "string",
232                    "description": "Reason for the update (stored in audit trail for state transitions)"
233                },
234                "force": {
235                    "type": "boolean",
236                    "description": "Force ownership changes even if owned by another worker (default: false)"
237                },
238                "attachments": {
239                    "type": "array",
240                    "description": "List of attachments to add to the task (e.g., commit hashes, changelists, notes)",
241                    "items": {
242                        "type": "object",
243                        "properties": {
244                            "name": {
245                                "type": "string",
246                                "description": "Attachment name/key (e.g., 'commit', 'changelist', 'note')"
247                            },
248                            "content": {
249                                "type": "string",
250                                "description": "Attachment content (text)"
251                            },
252                            "mime": {
253                                "type": "string",
254                                "description": "MIME type (uses configured default if omitted)"
255                            },
256                            "mode": {
257                                "type": "string",
258                                "enum": ["append", "replace"],
259                                "description": "How to handle existing attachment with same name (uses configured default if omitted)"
260                            }
261                        },
262                        "required": ["name", "content"]
263                    }
264                }
265            }),
266            vec!["worker_id", "task"],
267            prompts,
268        ),
269        make_tool_with_prompts(
270            "delete",
271            "Delete a task. Soft deletes by default (sets deleted_at), use obliterate=true to permanently remove. Rejects if task is claimed by another worker unless force=true.",
272            json!({
273                "worker_id": {
274                    "type": "string",
275                    "description": "Worker ID attempting to delete"
276                },
277                "task": {
278                    "type": "string",
279                    "description": "Task ID"
280                },
281                "cascade": {
282                    "type": "boolean",
283                    "description": "Whether to delete children (default: false)"
284                },
285                "reason": {
286                    "type": "string",
287                    "description": "Optional reason for deletion"
288                },
289                "obliterate": {
290                    "type": "boolean",
291                    "description": "If true, permanently deletes the task from the database. If false (default), soft deletes by setting deleted_at timestamp."
292                },
293                "force": {
294                    "type": "boolean",
295                    "description": "Force deletion even if claimed by another worker (default: false)"
296                }
297            }),
298            vec!["worker_id", "task"],
299            prompts,
300        ),
301        make_tool_with_prompts(
302            "scan",
303            "Scan the task graph from a starting task in multiple directions. Returns related tasks organized by direction: before (predecessors via blocks/follows), after (successors), above (ancestors via contains), below (descendants). Each direction has depth control: 0=none, N=levels, -1=all.",
304            json!({
305                "task": {
306                    "type": "string",
307                    "description": "Task ID to scan from"
308                },
309                "before": {
310                    "type": "integer",
311                    "description": "Depth for predecessors (tasks that block this one): 0=none, N=levels, -1=all (default: 0)"
312                },
313                "after": {
314                    "type": "integer",
315                    "description": "Depth for successors (tasks this one blocks): 0=none, N=levels, -1=all (default: 0)"
316                },
317                "above": {
318                    "type": "integer",
319                    "description": "Depth for ancestors (parent chain): 0=none, N=levels, -1=all (default: 0)"
320                },
321                "below": {
322                    "type": "integer",
323                    "description": "Depth for descendants (children tree): 0=none, N=levels, -1=all (default: 0)"
324                },
325                "format": {
326                    "type": "string",
327                    "enum": ["json", "markdown"],
328                    "description": "Output format (default: json)"
329                }
330            }),
331            vec!["task"],
332            prompts,
333        ),
334    ]
335}
336
337pub fn create(db: &Database, states_config: &StatesConfig, args: Value) -> Result<Value> {
338    let id = get_string(&args, "id");
339    let description =
340        get_string(&args, "description").ok_or_else(|| ToolError::missing_field("description"))?;
341    let parent_id = get_string(&args, "parent");
342    // Support both integer and string priority
343    let priority = get_i32(&args, "priority")
344        .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
345    let points = get_i32(&args, "points");
346    let time_estimate_ms = get_i64(&args, "time_estimate_ms");
347    let tags = get_string_array(&args, "tags");
348    let needed_tags = get_string_array(&args, "needed_tags");
349    let wanted_tags = get_string_array(&args, "wanted_tags");
350
351    let task = db.create_task(
352        id,
353        description,
354        parent_id,
355        priority,
356        points,
357        time_estimate_ms,
358        needed_tags,
359        wanted_tags,
360        tags,
361        states_config,
362    )?;
363
364    Ok(json!({
365        "id": &task.id,
366        "description": task.description,
367        "status": task.status,
368        "priority": task.priority,
369        "created_at": task.created_at
370    }))
371}
372
373pub fn create_tree(db: &Database, states_config: &StatesConfig, args: Value) -> Result<Value> {
374    let tree: TaskTreeInput = serde_json::from_value(
375        args.get("tree")
376            .cloned()
377            .ok_or_else(|| ToolError::missing_field("tree"))?,
378    )?;
379    let parent_id = get_string(&args, "parent");
380    let child_type = get_string(&args, "child_type");
381    let sibling_type = get_string(&args, "sibling_type");
382
383    let (root_id, all_ids) =
384        db.create_task_tree(tree, parent_id, child_type, sibling_type, states_config)?;
385
386    // Fetch the root task to return full details
387    let root_task = db.get_task(&root_id)?.ok_or_else(|| {
388        ToolError::new(
389            crate::error::ErrorCode::TaskNotFound,
390            "Root task not found after creation",
391        )
392    })?;
393
394    Ok(json!({
395        "root": {
396            "id": root_task.id,
397            "title": root_task.title,
398            "description": root_task.description,
399            "status": root_task.status,
400            "priority": root_task.priority,
401            "created_at": root_task.created_at
402        },
403        "all_ids": all_ids,
404        "count": all_ids.len()
405    }))
406}
407
408pub fn get(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
409    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
410    let format = get_string(&args, "format")
411        .and_then(|s| OutputFormat::parse(&s))
412        .unwrap_or(default_format);
413
414    let task = db
415        .get_task(&task_id)?
416        .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
417
418    let blocked_by = db.get_blockers(&task_id)?;
419
420    // Get attachment metadata
421    let attachments = db.get_attachments(&task_id)?;
422
423    // Calculate attachment counts by MIME type
424    let mut attachment_counts: std::collections::HashMap<String, i32> =
425        std::collections::HashMap::new();
426    for att in &attachments {
427        *attachment_counts.entry(att.mime_type.clone()).or_insert(0) += 1;
428    }
429
430    match format {
431        OutputFormat::Markdown => {
432            let mut md = format_task_markdown(&task, &blocked_by);
433
434            // Add attachment section if there are attachments
435            if !attachments.is_empty() {
436                md.push_str("\n### Attachments\n");
437                for att in &attachments {
438                    let file_indicator = if att.file_path.is_some() {
439                        " (file)"
440                    } else {
441                        ""
442                    };
443                    md.push_str(&format!(
444                        "- **{}** [{}]{}\n",
445                        att.name, att.mime_type, file_indicator
446                    ));
447                }
448
449                // Add counts by type
450                md.push_str("\n**Counts by type:**\n");
451                for (mime_type, count) in &attachment_counts {
452                    md.push_str(&format!("- {}: {}\n", mime_type, count));
453                }
454            }
455
456            Ok(markdown_to_json(md))
457        }
458        OutputFormat::Json => {
459            let mut task_json = serde_json::to_value(&task)?;
460            if let Some(obj) = task_json.as_object_mut() {
461                obj.insert("blocked_by".to_string(), json!(blocked_by));
462                obj.insert(
463                    "attachments".to_string(),
464                    serde_json::to_value(&attachments)?,
465                );
466                obj.insert(
467                    "attachment_counts".to_string(),
468                    serde_json::to_value(&attachment_counts)?,
469                );
470            }
471            Ok(task_json)
472        }
473    }
474}
475
476pub fn list_tasks(
477    db: &Database,
478    states_config: &StatesConfig,
479    deps_config: &DependenciesConfig,
480    default_format: OutputFormat,
481    args: Value,
482) -> Result<Value> {
483    let format = get_string(&args, "format")
484        .and_then(|s| OutputFormat::parse(&s))
485        .unwrap_or(default_format);
486
487    let ready = get_bool(&args, "ready").unwrap_or(false);
488    let blocked = get_bool(&args, "blocked").unwrap_or(false);
489    let claimed = get_bool(&args, "claimed").unwrap_or(false);
490    let limit = get_i32(&args, "limit");
491
492    // Extract tag filtering parameters
493    let tags_any = get_string_array(&args, "tags_any");
494    let tags_all = get_string_array(&args, "tags_all");
495
496    // 'agent' replaces both 'worker_id' and 'qualified_for' - single param for agent-related filtering
497    let agent_id = get_string(&args, "agent");
498
499    // Sorting parameters
500    let sort_by = get_string(&args, "sort_by");
501    let sort_order = get_string(&args, "sort_order");
502
503    // Get tasks based on filters
504    let mut tasks = if ready {
505        // Ready tasks: in initial state, unclaimed, all deps satisfied
506        // If agent is provided, also filter by agent's tag qualifications
507        db.get_ready_tasks(
508            agent_id.as_deref(),
509            states_config,
510            deps_config,
511            sort_by.as_deref(),
512            sort_order.as_deref(),
513        )?
514    } else if blocked {
515        // Blocked tasks: have unsatisfied deps
516        db.get_blocked_tasks(
517            states_config,
518            deps_config,
519            sort_by.as_deref(),
520            sort_order.as_deref(),
521        )?
522    } else if claimed {
523        // Claimed tasks: currently owned by any agent
524        db.get_claimed_tasks(None)?
525    } else {
526        // General query with filters
527        // Handle status which can be string or array
528        let status_vec: Option<Vec<String>> = if let Some(status_val) = args.get("status") {
529            if let Some(s) = status_val.as_str() {
530                Some(vec![s.to_string()])
531            } else { status_val.as_array().map(|arr| arr.iter()
532                        .filter_map(|v| v.as_str().map(String::from))
533                        .collect()) }
534        } else {
535            None
536        };
537        let owner = get_string(&args, "owner");
538        let parent_id_str = get_string(&args, "parent");
539        let parent_id: Option<Option<&str>> = match &parent_id_str {
540            Some(pid_str) if pid_str == "null" => Some(None), // Root tasks
541            Some(pid_str) => Some(Some(pid_str.as_str())),
542            None => None,
543        };
544
545        // Check if tag filtering or agent qualification filtering is needed
546        let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
547
548        if has_tag_filters {
549            // Use the tag-filtered query
550            // When agent is provided without ready=true, filter by agent's qualification
551            let qualified_agent_tags = if let Some(aid) = &agent_id {
552                Some(db.get_agent_tags(aid)?)
553            } else {
554                None
555            };
556
557            db.list_tasks_with_tag_filters(
558                status_vec,
559                owner.as_deref(),
560                parent_id,
561                tags_any,
562                tags_all,
563                qualified_agent_tags,
564                limit,
565                sort_by.as_deref(),
566                sort_order.as_deref(),
567            )?
568        } else {
569            // Use list_tasks which returns full Task objects (only supports single status)
570            let status = status_vec
571                .as_ref()
572                .and_then(|v| v.first().map(|s| s.as_str()));
573            db.list_tasks(
574                status,
575                owner.as_deref(),
576                parent_id,
577                limit,
578                sort_by.as_deref(),
579                sort_order.as_deref(),
580            )?
581        }
582    };
583
584    // Apply limit (some paths may already have limit applied, but this ensures consistency)
585    if let Some(l) = limit {
586        tasks.truncate(l as usize);
587    }
588
589    // Get blockers for each task
590    let tasks_with_blockers: Vec<_> = tasks
591        .into_iter()
592        .map(|task| {
593            let blockers = db.get_blockers(&task.id).unwrap_or_default();
594            (task, blockers)
595        })
596        .collect();
597
598    match format {
599        OutputFormat::Markdown => Ok(markdown_to_json(format_tasks_markdown(
600            &tasks_with_blockers,
601            states_config,
602        ))),
603        OutputFormat::Json => Ok(json!({
604            "tasks": tasks_with_blockers.iter().map(|(task, blockers)| {
605                let mut task_json = serde_json::to_value(task).unwrap();
606                if let Some(obj) = task_json.as_object_mut() {
607                    obj.insert("blocked_by".to_string(), json!(blockers));
608                }
609                task_json
610            }).collect::<Vec<_>>()
611        })),
612    }
613}
614
615pub fn update(
616    db: &Database,
617    attachments_config: &AttachmentsConfig,
618    states_config: &StatesConfig,
619    deps_config: &DependenciesConfig,
620    auto_advance: &AutoAdvanceConfig,
621    args: Value,
622) -> Result<Value> {
623    let worker_id =
624        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
625    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
626    let assignee = get_string(&args, "assignee");
627    let title = get_string(&args, "title");
628    let description = if args.get("description").is_some() {
629        Some(get_string(&args, "description"))
630    } else {
631        None
632    };
633    let status = get_string(&args, "status");
634    // Support both integer and string priority
635    let priority = get_i32(&args, "priority")
636        .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
637    let points = if args.get("points").is_some() {
638        Some(get_i32(&args, "points"))
639    } else {
640        None
641    };
642    let tags = if args.get("tags").is_some() {
643        Some(get_string_array(&args, "tags").unwrap_or_default())
644    } else {
645        None
646    };
647    let needed_tags = if args.get("needed_tags").is_some() {
648        Some(get_string_array(&args, "needed_tags").unwrap_or_default())
649    } else {
650        None
651    };
652    let wanted_tags = if args.get("wanted_tags").is_some() {
653        Some(get_string_array(&args, "wanted_tags").unwrap_or_default())
654    } else {
655        None
656    };
657    let time_estimate_ms = get_i64(&args, "time_estimate_ms");
658    let reason = get_string(&args, "reason");
659    let force = get_bool(&args, "force").unwrap_or(false);
660
661    // Process attachments first (before the update)
662    let mut attachment_results: Vec<Value> = Vec::new();
663    let mut attachment_warnings: Vec<String> = Vec::new();
664
665    if let Some(attachments_arr) = args.get("attachments").and_then(|v| v.as_array()) {
666        for att_value in attachments_arr {
667            let name = att_value.get("name").and_then(|v| v.as_str());
668            let content = att_value.get("content").and_then(|v| v.as_str());
669            let mime_override = att_value.get("mime").and_then(|v| v.as_str());
670            let mode_override = att_value.get("mode").and_then(|v| v.as_str());
671
672            let name = match name {
673                Some(n) => n,
674                None => {
675                    attachment_warnings
676                        .push("Skipped attachment: missing 'name' field".to_string());
677                    continue;
678                }
679            };
680
681            let content = match content {
682                Some(c) => c,
683                None => {
684                    attachment_warnings.push(format!(
685                        "Skipped attachment '{}': missing 'content' field",
686                        name
687                    ));
688                    continue;
689                }
690            };
691
692            // Check unknown key behavior
693            if !attachments_config.is_known_key(name) {
694                match attachments_config.unknown_key {
695                    UnknownKeyBehavior::Reject => {
696                        attachment_warnings.push(format!(
697                            "Rejected attachment '{}': unknown key (configure in attachments.definitions or set unknown_key to 'allow')",
698                            name
699                        ));
700                        continue;
701                    }
702                    UnknownKeyBehavior::Warn => {
703                        attachment_warnings.push(format!("Unknown attachment key '{}'", name));
704                    }
705                    UnknownKeyBehavior::Allow => {}
706                }
707            }
708
709            // Use config defaults for mime/mode, but allow explicit overrides
710            let mime_type = mime_override
711                .map(String::from)
712                .unwrap_or_else(|| attachments_config.get_mime_default(name).to_string());
713            let mode = mode_override.unwrap_or_else(|| attachments_config.get_mode_default(name));
714
715            // Validate mode
716            if mode != "append" && mode != "replace" {
717                attachment_warnings.push(format!(
718                    "Skipped attachment '{}': mode must be 'append' or 'replace'",
719                    name
720                ));
721                continue;
722            }
723
724            // Handle replace mode - delete existing attachment with same name
725            if mode == "replace" {
726                let _ = db.delete_attachment_by_name(&task_id, name);
727            }
728
729            // Add the attachment
730            match db.add_attachment(
731                &task_id,
732                name.to_string(),
733                content.to_string(),
734                Some(mime_type.clone()),
735                None,
736            ) {
737                Ok(order_index) => {
738                    attachment_results.push(json!({
739                        "name": name,
740                        "order_index": order_index,
741                        "mime_type": mime_type
742                    }));
743                }
744                Err(e) => {
745                    attachment_warnings.push(format!("Failed to add attachment '{}': {}", name, e));
746                }
747            }
748        }
749    }
750
751    // Perform the task update
752    let (task, unblocked, auto_advanced) = db.update_task_unified(
753        &task_id,
754        &worker_id,
755        assignee.as_deref(),
756        title,
757        description,
758        status,
759        priority,
760        points,
761        tags,
762        needed_tags,
763        wanted_tags,
764        time_estimate_ms,
765        reason,
766        force,
767        states_config,
768        deps_config,
769        auto_advance,
770    )?;
771
772    // Build response with task and unblocked/auto_advanced lists
773    let mut response = serde_json::to_value(&task)?;
774    if let Value::Object(ref mut map) = response {
775        // Always include unblocked if non-empty (tasks now ready to claim)
776        if !unblocked.is_empty() {
777            map.insert("unblocked".to_string(), json!(unblocked));
778        }
779        // Include auto_advanced if non-empty (tasks that were actually transitioned)
780        if !auto_advanced.is_empty() {
781            map.insert("auto_advanced".to_string(), json!(auto_advanced));
782        }
783        // Include attachment results if any were added
784        if !attachment_results.is_empty() {
785            map.insert("attachments_added".to_string(), json!(attachment_results));
786        }
787        // Include warnings if any
788        if !attachment_warnings.is_empty() {
789            map.insert(
790                "attachment_warnings".to_string(),
791                json!(attachment_warnings),
792            );
793        }
794    }
795
796    Ok(response)
797}
798
799pub fn delete(db: &Database, args: Value) -> Result<Value> {
800    let worker_id =
801        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
802    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
803    let cascade = get_bool(&args, "cascade").unwrap_or(false);
804    let reason = get_string(&args, "reason");
805    let obliterate = get_bool(&args, "obliterate").unwrap_or(false);
806    let force = get_bool(&args, "force").unwrap_or(false);
807
808    db.delete_task(&task_id, &worker_id, cascade, reason, obliterate, force)?;
809
810    Ok(json!({
811        "success": true,
812        "soft_deleted": !obliterate
813    }))
814}
815
816pub fn scan(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
817    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
818    let format = get_string(&args, "format")
819        .and_then(|s| OutputFormat::parse(&s))
820        .unwrap_or(default_format);
821
822    // Depth parameters: 0=none, N=levels, -1=all
823    let before_depth = get_i32(&args, "before").unwrap_or(0);
824    let after_depth = get_i32(&args, "after").unwrap_or(0);
825    let above_depth = get_i32(&args, "above").unwrap_or(0);
826    let below_depth = get_i32(&args, "below").unwrap_or(0);
827
828    // Verify the task exists
829    let root_task = db
830        .get_task(&task_id)?
831        .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
832
833    // Traverse in each direction
834    let before = db.get_predecessors(&task_id, before_depth)?;
835    let after = db.get_successors(&task_id, after_depth)?;
836    let above = db.get_ancestors(&task_id, above_depth)?;
837    let below = db.get_descendants(&task_id, below_depth)?;
838
839    let result = ScanResult {
840        root: root_task,
841        before,
842        after,
843        above,
844        below,
845    };
846
847    match format {
848        OutputFormat::Markdown => Ok(markdown_to_json(format_scan_result_markdown(&result))),
849        OutputFormat::Json => Ok(serde_json::to_value(&result)?),
850    }
851}