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