Skip to main content

task_graph_mcp/tools/
tasks.rs

1//! Task CRUD tools.
2
3use super::{
4    get_bool, get_i32, get_i64, get_string, get_string_array, get_string_or_array,
5    make_tool_with_prompts,
6};
7use crate::config::{
8    AppConfig, DependenciesConfig, GateEnforcement, Prompts, StatesConfig, UnknownKeyBehavior,
9};
10use crate::db::Database;
11use crate::db::tasks::{CreateTreeOptions, ListTasksQuery};
12use crate::error::ToolError;
13use crate::format::{
14    OutputFormat, format_scan_result_markdown, format_task_markdown, format_tasks_markdown,
15    markdown_to_json,
16};
17use crate::gates::evaluate_gates;
18use crate::prompts::PromptContext;
19use crate::types::{ScanResult, TaskTreeInput, parse_priority};
20use anyhow::Result;
21use rmcp::model::Tool;
22use serde_json::{Value, json};
23use tracing::warn;
24
25/// Options for the task update tool, grouping config references.
26pub struct UpdateOptions<'a> {
27    pub db: &'a Database,
28    pub config: &'a AppConfig,
29    /// Per-update workflow (may differ from config.workflows for per-worker workflows).
30    pub workflows: &'a crate::config::workflows::WorkflowsConfig,
31}
32
33pub fn get_tools(prompts: &Prompts, states_config: &StatesConfig) -> Vec<Tool> {
34    // Generate state enum from config
35    let state_names: Vec<&str> = states_config.state_names();
36    let state_enum: Vec<Value> = state_names.iter().map(|s| json!(s)).collect();
37
38    vec![
39        make_tool_with_prompts(
40            "create",
41            "Create a new task. Use parent for subtasks. Use the link system (block tool) for dependencies.",
42            json!({
43                "id": {
44                    "type": "string",
45                    "description": "Custom task ID (optional, petname ID generated if not provided)"
46                },
47                "title": {
48                    "type": "string",
49                    "description": "Short task title (derived from description if omitted)"
50                },
51                "description": {
52                    "type": "string",
53                    "description": "Task description (optional if title provided)"
54                },
55                "parent": {
56                    "type": "string",
57                    "description": "Parent task ID for nesting"
58                },
59                "priority": {
60                    "type": "integer",
61                    "description": "Task priority 0-10 (higher = more important, default 5)"
62                },
63                "points": {
64                    "type": "integer",
65                    "description": "Story points / complexity estimate"
66                },
67                "time_estimate_ms": {
68                    "type": "integer",
69                    "description": "Estimated duration in milliseconds"
70                },
71                "tags": {
72                    "type": "array",
73                    "items": { "type": "string" },
74                    "description": "Categorization/discovery tags (what the task IS, for querying)"
75                }
76            }),
77            vec![],
78            prompts,
79        ),
80        make_tool_with_prompts(
81            "create_tree",
82            "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.",
83            json!({
84                "tree": {
85                    "type": "object",
86                    "description": "Nested tree structure with title, children[], etc. Use 'ref' to reference existing tasks.",
87                    "properties": {
88                        "ref": { "type": "string", "description": "Reference to an existing task ID (other fields ignored when set)" },
89                        "id": { "type": "string", "description": "Custom task ID (optional, petname ID generated if not provided)" },
90                        "title": { "type": "string", "description": "Task title (required for new tasks)" },
91                        "description": { "type": "string", "description": "Task description" },
92                        "priority": { "type": "integer", "description": "Task priority 0-10 (default 5)" },
93                        "points": { "type": "integer", "description": "Story points / complexity estimate" },
94                        "time_estimate_ms": { "type": "integer", "description": "Estimated duration in milliseconds" },
95                        "tags": { "type": "array", "items": { "type": "string" }, "description": "Categorization/discovery tags" },
96                        "needed_tags": { "type": "array", "items": { "type": "string" }, "description": "Tags agent must have ALL of to claim (AND)" },
97                        "wanted_tags": { "type": "array", "items": { "type": "string" }, "description": "Tags agent must have AT LEAST ONE of to claim (OR)" },
98                        "children": { "type": "array", "description": "Child nodes (same structure, recursive)" }
99                    }
100                },
101                "parent": {
102                    "type": "string",
103                    "description": "Optional parent task ID for the tree root"
104                },
105                "child_type": {
106                    "type": "string",
107                    "description": "Dependency type from parent to children (default: 'contains'). Set to null for no parent-child deps."
108                },
109                "sibling_type": {
110                    "type": "string",
111                    "description": "Dependency type between consecutive siblings (default: null/parallel). Use 'follows' for sequential."
112                }
113            }),
114            vec!["tree"],
115            prompts,
116        ),
117        make_tool_with_prompts(
118            "get",
119            "Get a single task by ID. Returns detailed task with attachment metadata list and counts by type.",
120            json!({
121                "task": {
122                    "type": "string",
123                    "description": "Task ID"
124                }
125            }),
126            vec!["task"],
127            prompts,
128        ),
129        make_tool_with_prompts(
130            "list_tasks",
131            "Query tasks with flexible filters.",
132            json!({
133                "status": {
134                    "oneOf": [
135                        { "type": "string", "enum": state_enum },
136                        { "type": "array", "items": { "type": "string" } }
137                    ],
138                    "description": "Filter by status (single or array)"
139                },
140                "ready": {
141                    "type": "boolean",
142                    "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."
143                },
144                "blocked": {
145                    "type": "boolean",
146                    "description": "Filter for blocked tasks: have unsatisfied start-blocking dependencies"
147                },
148                "claimed": {
149                    "type": "boolean",
150                    "description": "Filter for claimed tasks: currently owned by any agent (owner_agent IS NOT NULL)"
151                },
152                "owner": {
153                    "type": "string",
154                    "description": "Filter by owner agent ID (tasks currently claimed by this specific agent)"
155                },
156                "parent": {
157                    "type": "string",
158                    "description": "Filter by parent task ID (use 'null' for root tasks)"
159                },
160                "recursive": {
161                    "type": "boolean",
162                    "description": "When true with parent, returns all descendants (subtree) instead of just direct children. Uses contains-dependency traversal."
163                },
164                "agent": {
165                    "type": "string",
166                    "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."
167                },
168                "tags_any": {
169                    "type": "array",
170                    "items": { "type": "string" },
171                    "description": "Filter tasks that have ANY of these tags (OR)"
172                },
173                "tags_all": {
174                    "type": "array",
175                    "items": { "type": "string" },
176                    "description": "Filter tasks that have ALL of these tags (AND)"
177                },
178                "sort_by": {
179                    "type": "string",
180                    "enum": ["priority", "created_at", "updated_at"],
181                    "description": "Field to sort by (default: created_at for general queries, priority then created_at for ready queries)"
182                },
183                "sort_order": {
184                    "type": "string",
185                    "enum": ["asc", "desc"],
186                    "description": "Sort order: 'asc' for ascending, 'desc' for descending (default: desc for created_at/updated_at, priority always high-to-low)"
187                },
188                "limit": {
189                    "type": "integer",
190                    "description": "Maximum number of tasks to return"
191                },
192                "offset": {
193                    "type": "integer",
194                    "description": "Number of tasks to skip for pagination (default: 0)"
195                }
196            }),
197            vec![],
198            prompts,
199        ),
200        make_tool_with_prompts(
201            "update",
202            "Update a task's properties. Status changes handle ownership automatically: transitioning to a timed status (e.g., working) 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.",
203            json!({
204                "worker_id": {
205                    "type": "string",
206                    "description": "Worker ID making the update"
207                },
208                "task": {
209                    "type": "string",
210                    "description": "Task ID"
211                },
212                "assignee": {
213                    "type": "string",
214                    "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 working) when ready."
215                },
216                "status": {
217                    "type": "string",
218                    "enum": state_enum,
219                    "description": "New status"
220                },
221                "title": {
222                    "type": "string",
223                    "description": "New title"
224                },
225                "description": {
226                    "type": "string",
227                    "description": "New description"
228                },
229                "priority": {
230                    "type": "integer",
231                    "description": "New priority 0-10 (higher = more important)"
232                },
233                "points": {
234                    "type": "integer",
235                    "description": "New points estimate"
236                },
237                "tags": {
238                    "type": "array",
239                    "items": { "type": "string" },
240                    "description": "New categorization/discovery tags"
241                },
242                "needed_tags": {
243                    "type": "array",
244                    "items": { "type": "string" },
245                    "description": "Tags agent must have ALL of to claim (AND)"
246                },
247                "wanted_tags": {
248                    "type": "array",
249                    "items": { "type": "string" },
250                    "description": "Tags agent must have AT LEAST ONE of to claim (OR)"
251                },
252                "time_estimate_ms": {
253                    "type": "integer",
254                    "description": "Estimated duration in milliseconds"
255                },
256                "reason": {
257                    "type": "string",
258                    "description": "Reason for the update (stored in audit trail for state transitions)"
259                },
260                "force": {
261                    "type": "boolean",
262                    "description": "Force ownership changes even if owned by another worker (default: false)"
263                },
264                "attachments": {
265                    "type": "array",
266                    "description": "List of attachments to add to the task (e.g., commit hashes, changelists, notes)",
267                    "items": {
268                        "type": "object",
269                        "properties": {
270                            "type": {
271                                "type": "string",
272                                "description": "Attachment type/category (e.g., 'commit', 'changelist', 'note'). Used for indexing and replace operations."
273                            },
274                            "name": {
275                                "type": "string",
276                                "description": "Optional label/name for the attachment (arbitrary string)"
277                            },
278                            "content": {
279                                "type": "string",
280                                "description": "Attachment content (text)"
281                            },
282                            "mime": {
283                                "type": "string",
284                                "description": "MIME type (uses configured default if omitted)"
285                            },
286                            "mode": {
287                                "type": "string",
288                                "enum": ["append", "replace"],
289                                "description": "How to handle existing attachments of this type: 'append' adds new, 'replace' deletes all of this type first"
290                            }
291                        },
292                        "required": ["type", "content"]
293                    }
294                }
295            }),
296            vec!["worker_id", "task"],
297            prompts,
298        ),
299        make_tool_with_prompts(
300            "delete",
301            "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.",
302            json!({
303                "worker_id": {
304                    "type": "string",
305                    "description": "Worker ID attempting to delete"
306                },
307                "task": {
308                    "type": "string",
309                    "description": "Task ID"
310                },
311                "cascade": {
312                    "type": "boolean",
313                    "description": "Whether to delete children (default: false)"
314                },
315                "reason": {
316                    "type": "string",
317                    "description": "Optional reason for deletion"
318                },
319                "obliterate": {
320                    "type": "boolean",
321                    "description": "If true, permanently deletes the task from the database. If false (default), soft deletes by setting deleted_at timestamp."
322                },
323                "force": {
324                    "type": "boolean",
325                    "description": "Force deletion even if claimed by another worker (default: false)"
326                }
327            }),
328            vec!["worker_id", "task"],
329            prompts,
330        ),
331        make_tool_with_prompts(
332            "rename",
333            "Change a task's ID. Updates all references (dependencies, attachments, file marks, tags, etc.) atomically.",
334            json!({
335                "worker_id": {
336                    "type": "string",
337                    "description": "Worker ID (for audit)"
338                },
339                "task": {
340                    "type": "string",
341                    "description": "Current task ID"
342                },
343                "new_id": {
344                    "type": "string",
345                    "description": "New task ID"
346                }
347            }),
348            vec!["worker_id", "task", "new_id"],
349            prompts,
350        ),
351        make_tool_with_prompts(
352            "scan",
353            "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.",
354            json!({
355                "task": {
356                    "type": "string",
357                    "description": "Task ID to scan from"
358                },
359                "before": {
360                    "type": "integer",
361                    "description": "Depth for predecessors (tasks that block this one): 0=none, N=levels, -1=all (default: 0)"
362                },
363                "after": {
364                    "type": "integer",
365                    "description": "Depth for successors (tasks this one blocks): 0=none, N=levels, -1=all (default: 0)"
366                },
367                "above": {
368                    "type": "integer",
369                    "description": "Depth for ancestors (parent chain): 0=none, N=levels, -1=all (default: 0)"
370                },
371                "below": {
372                    "type": "integer",
373                    "description": "Depth for descendants (children tree): 0=none, N=levels, -1=all (default: 0)"
374                },
375                "format": {
376                    "type": "string",
377                    "enum": ["json", "markdown"],
378                    "description": "Output format (default: json)"
379                }
380            }),
381            vec!["task"],
382            prompts,
383        ),
384    ]
385}
386
387pub fn create(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
388    let states_config = &config.states;
389    let phases_config = &config.phases;
390    let tags_config = &config.tags;
391    let ids_config = &config.ids;
392    let id = get_string(&args, "id");
393    let title = get_string(&args, "title");
394    let description = get_string(&args, "description");
395    let parent_id = get_string(&args, "parent");
396    let phase = get_string(&args, "phase");
397    // Support both integer and string priority
398    let priority = get_i32(&args, "priority")
399        .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
400    let points = get_i32(&args, "points");
401    let time_estimate_ms = get_i64(&args, "time_estimate_ms");
402    let tags = get_string_array(&args, "tags");
403    let needed_tags = get_string_array(&args, "needed_tags");
404    let wanted_tags = get_string_array(&args, "wanted_tags");
405
406    // Require at least one of title or description
407    if title.is_none() && description.is_none() {
408        return Err(ToolError::missing_field("title or description").into());
409    }
410
411    // Derive effective title: explicit title, or truncated description
412    let effective_title = title.unwrap_or_else(|| {
413        crate::format::truncate_title(description.as_deref().unwrap_or("")).into_owned()
414    });
415
416    // Check phase validity (may return warning)
417    let phase_warning = if let Some(ref p) = phase {
418        phases_config.check_phase(p)?
419    } else {
420        None
421    };
422
423    // Check tag validity for all tag types
424    let mut tag_warnings = Vec::new();
425    if let Some(ref t) = tags {
426        tag_warnings.extend(tags_config.validate_tags(t)?);
427    }
428    if let Some(ref t) = needed_tags {
429        tag_warnings.extend(tags_config.validate_tags(t)?);
430    }
431    if let Some(ref t) = wanted_tags {
432        tag_warnings.extend(tags_config.validate_tags(t)?);
433    }
434
435    let task = db.create_task(
436        id,
437        effective_title,
438        description,
439        parent_id,
440        phase,
441        priority,
442        points,
443        time_estimate_ms,
444        needed_tags,
445        wanted_tags,
446        tags,
447        states_config,
448        ids_config,
449    )?;
450
451    let mut response = json!({
452        "id": &task.id,
453        "title": task.title,
454        "description": task.description,
455        "status": task.status,
456        "phase": task.phase,
457        "priority": task.priority,
458        "created_at": task.created_at
459    });
460
461    if let Some(warning) = phase_warning {
462        response["phase_warning"] = json!(warning);
463    }
464
465    if !tag_warnings.is_empty() {
466        response["tag_warnings"] = json!(tag_warnings);
467    }
468
469    // Warn if title is too long for scannable list output
470    if task.title.len() > crate::format::MAX_TITLE_DISPLAY_LEN || task.title.contains('\n') {
471        response["title_warning"] = json!(
472            "Title exceeds 80 chars or is multi-line. Consider using a short title and keeping detail in the description."
473        );
474    }
475
476    Ok(response)
477}
478
479pub fn create_tree(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
480    let states_config = &config.states;
481    let phases_config = &config.phases;
482    let tags_config = &config.tags;
483    let ids_config = &config.ids;
484    let tree: TaskTreeInput = serde_json::from_value(
485        args.get("tree")
486            .cloned()
487            .ok_or_else(|| ToolError::missing_field("tree"))?,
488    )?;
489    let parent_id = get_string(&args, "parent");
490    let child_type = get_string(&args, "child_type");
491    let sibling_type = get_string(&args, "sibling_type");
492
493    let (root_id, all_ids, phase_warnings, tag_warnings) =
494        db.create_task_tree(CreateTreeOptions {
495            input: tree,
496            parent_id,
497            child_type,
498            sibling_type,
499            states_config,
500            phases_config,
501            tags_config,
502            ids_config,
503        })?;
504
505    // Fetch the root task to return full details
506    let root_task = db.get_task(&root_id)?.ok_or_else(|| {
507        ToolError::new(
508            crate::error::ErrorCode::TaskNotFound,
509            "Root task not found after creation",
510        )
511    })?;
512
513    let mut response = json!({
514        "root": {
515            "id": root_task.id,
516            "title": root_task.title,
517            "description": root_task.description,
518            "status": root_task.status,
519            "phase": root_task.phase,
520            "priority": root_task.priority,
521            "created_at": root_task.created_at
522        },
523        "all_ids": all_ids,
524        "count": all_ids.len()
525    });
526
527    if !phase_warnings.is_empty() {
528        response["phase_warnings"] = json!(phase_warnings);
529    }
530
531    if !tag_warnings.is_empty() {
532        response["tag_warnings"] = json!(tag_warnings);
533    }
534
535    Ok(response)
536}
537
538pub fn get(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
539    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
540    let format = get_string(&args, "format")
541        .and_then(|s| OutputFormat::parse(&s))
542        .unwrap_or(default_format);
543
544    let task = db
545        .get_task(&task_id)?
546        .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
547
548    let blocked_by = db.get_blockers(&task_id)?;
549
550    // Get attachment metadata
551    let attachments = db.get_attachments(&task_id)?;
552
553    // Calculate attachment counts by MIME type
554    let mut attachment_counts: std::collections::HashMap<String, i32> =
555        std::collections::HashMap::new();
556    for att in &attachments {
557        *attachment_counts.entry(att.mime_type.clone()).or_insert(0) += 1;
558    }
559
560    match format {
561        OutputFormat::Markdown => {
562            let mut md = format_task_markdown(&task, &blocked_by);
563
564            // Add attachment section if there are attachments
565            if !attachments.is_empty() {
566                md.push_str("\n### Attachments\n");
567                for att in &attachments {
568                    let file_indicator = if att.file_path.is_some() {
569                        " (file)"
570                    } else {
571                        ""
572                    };
573                    md.push_str(&format!(
574                        "- **{}** [{}]{}\n",
575                        att.name, att.mime_type, file_indicator
576                    ));
577                }
578
579                // Add counts by type
580                md.push_str("\n**Counts by type:**\n");
581                for (mime_type, count) in &attachment_counts {
582                    md.push_str(&format!("- {}: {}\n", mime_type, count));
583                }
584            }
585
586            Ok(markdown_to_json(md))
587        }
588        OutputFormat::Json => {
589            let mut task_json = serde_json::to_value(&task)?;
590            if let Some(obj) = task_json.as_object_mut() {
591                obj.insert("blocked_by".to_string(), json!(blocked_by));
592                obj.insert(
593                    "attachments".to_string(),
594                    serde_json::to_value(&attachments)?,
595                );
596                obj.insert(
597                    "attachment_counts".to_string(),
598                    serde_json::to_value(&attachment_counts)?,
599                );
600            }
601            Ok(task_json)
602        }
603    }
604}
605
606pub fn list_tasks(
607    db: &Database,
608    states_config: &StatesConfig,
609    deps_config: &DependenciesConfig,
610    default_format: OutputFormat,
611    args: Value,
612) -> Result<Value> {
613    let format = get_string(&args, "format")
614        .and_then(|s| OutputFormat::parse(&s))
615        .unwrap_or(default_format);
616
617    let ready = get_bool(&args, "ready").unwrap_or(false);
618    let blocked = get_bool(&args, "blocked").unwrap_or(false);
619    let claimed = get_bool(&args, "claimed").unwrap_or(false);
620    let recursive = get_bool(&args, "recursive").unwrap_or(false);
621    let limit = get_i32(&args, "limit");
622    let offset = get_i32(&args, "offset").unwrap_or(0).max(0);
623    let fetch_limit = limit.map(|l| l + 1);
624    let phase = get_string(&args, "phase");
625
626    // Extract tag filtering parameters
627    let tags_any = get_string_array(&args, "tags_any");
628    let tags_all = get_string_array(&args, "tags_all");
629
630    // 'agent' replaces both 'worker_id' and 'qualified_for' - single param for agent-related filtering
631    let agent_id = get_string(&args, "agent");
632
633    // Sorting parameters
634    let sort_by = get_string(&args, "sort_by");
635    let sort_order = get_string(&args, "sort_order");
636
637    // Get tasks based on filters
638    let parent_id_str = get_string(&args, "parent");
639
640    let mut tasks =
641        if recursive && parent_id_str.is_some() && parent_id_str.as_deref() != Some("null") {
642            // Recursive descent: get all descendants of the parent via contains dependencies
643            let pid = parent_id_str.as_deref().unwrap();
644            let mut descendants = db.get_descendants(pid, -1)?;
645
646            // Apply status filter in memory
647            if let Some(status_set) = get_string_or_array(&args, "status")
648                && !status_set.is_empty()
649            {
650                descendants.retain(|t| status_set.contains(&t.status));
651            }
652
653            // Apply owner filter in memory
654            if let Some(ref owner) = get_string(&args, "owner") {
655                descendants.retain(|t| t.worker_id.as_deref() == Some(owner.as_str()));
656            }
657
658            descendants
659        } else if ready {
660            // Ready tasks: in initial state, unclaimed, all deps satisfied
661            // If agent is provided, also filter by agent's tag qualifications
662            db.get_ready_tasks(
663                agent_id.as_deref(),
664                states_config,
665                deps_config,
666                sort_by.as_deref(),
667                sort_order.as_deref(),
668            )?
669        } else if blocked {
670            // Blocked tasks: have unsatisfied deps
671            db.get_blocked_tasks(
672                states_config,
673                deps_config,
674                sort_by.as_deref(),
675                sort_order.as_deref(),
676            )?
677        } else if claimed {
678            // Claimed tasks: currently owned by any agent
679            db.get_claimed_tasks(None)?
680        } else {
681            // General query with filters
682            let status_vec = get_string_or_array(&args, "status");
683            let owner = get_string(&args, "owner");
684            let parent_id: Option<Option<&str>> = match &parent_id_str {
685                Some(pid_str) if pid_str == "null" => Some(None), // Root tasks
686                Some(pid_str) => Some(Some(pid_str.as_str())),
687                None => None,
688            };
689
690            // Check if tag filtering or agent qualification filtering is needed
691            let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
692
693            if has_tag_filters {
694                // Use the tag-filtered query
695                // When agent is provided without ready=true, filter by agent's qualification
696                let qualified_agent_tags = if let Some(aid) = &agent_id {
697                    Some(db.get_agent_tags(aid)?)
698                } else {
699                    None
700                };
701
702                db.list_tasks_with_tag_filters(
703                    status_vec,
704                    owner.as_deref(),
705                    parent_id,
706                    tags_any,
707                    tags_all,
708                    qualified_agent_tags,
709                    fetch_limit,
710                    offset,
711                    sort_by.as_deref(),
712                    sort_order.as_deref(),
713                )?
714            } else {
715                // Use list_tasks which returns full Task objects (only supports single status)
716                let status = status_vec
717                    .as_ref()
718                    .and_then(|v| v.first().map(|s| s.as_str()));
719                db.list_tasks(ListTasksQuery {
720                    status,
721                    phase: phase.as_deref(),
722                    owner: owner.as_deref(),
723                    parent_id,
724                    limit: fetch_limit,
725                    offset,
726                    sort_by: sort_by.as_deref(),
727                    sort_order: sort_order.as_deref(),
728                })?
729            }
730        };
731
732    // Apply phase filter for ready/blocked/claimed paths (list_tasks handles it internally)
733    if let Some(ref p) = phase {
734        tasks.retain(|t| t.phase.as_deref() == Some(p.as_str()));
735    }
736
737    // Apply offset for paths that don't go through paginated DB queries
738    // (ready, blocked, claimed, recursive paths fetch all matching tasks)
739    if offset > 0 && (ready || blocked || claimed || recursive) {
740        if (offset as usize) < tasks.len() {
741            tasks = tasks.split_off(offset as usize);
742        } else {
743            tasks.clear();
744        }
745    }
746
747    // Detect has_more using N+1 pattern, then truncate to actual limit
748    let has_more = limit.is_some_and(|l| tasks.len() > l as usize);
749    if let Some(l) = limit {
750        tasks.truncate(l as usize);
751    }
752
753    // Get blockers for each task
754    let tasks_with_blockers: Vec<_> = tasks
755        .into_iter()
756        .map(|task| {
757            let blockers = db.get_blockers(&task.id).unwrap_or_default();
758            (task, blockers)
759        })
760        .collect();
761
762    match format {
763        OutputFormat::Markdown => {
764            let mut md = format_tasks_markdown(&tasks_with_blockers, states_config);
765            if has_more {
766                let next_offset = offset + limit.unwrap_or(0);
767                md.push_str(&format!(
768                    "\n\n*More results available. Use offset={} to see next page.*",
769                    next_offset
770                ));
771            }
772            Ok(markdown_to_json(md))
773        }
774        OutputFormat::Json => Ok(json!({
775            "tasks": tasks_with_blockers.iter().map(|(task, blockers)| {
776                let mut task_json = serde_json::to_value(task).unwrap();
777                if let Some(obj) = task_json.as_object_mut() {
778                    obj.insert("blocked_by".to_string(), json!(blockers));
779                }
780                task_json
781            }).collect::<Vec<_>>(),
782            "has_more": has_more,
783            "offset": offset,
784            "limit": limit,
785        })),
786    }
787}
788
789pub fn update(opts: UpdateOptions<'_>, args: Value) -> Result<Value> {
790    let UpdateOptions {
791        db,
792        config,
793        workflows,
794    } = opts;
795
796    let attachments_config = &config.attachments;
797    // Derive states/phases from the per-worker workflow so overlay-added states are recognized
798    let states_config_owned: StatesConfig = workflows.into();
799    let states_config = &states_config_owned;
800    let phases_config = &config.phases;
801    let deps_config = &config.deps;
802    let auto_advance = &config.auto_advance;
803    let tags_config = &config.tags;
804
805    let worker_id =
806        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
807    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
808    let assignee = get_string(&args, "assignee");
809    let title = get_string(&args, "title");
810    let description = if args.get("description").is_some() {
811        Some(get_string(&args, "description"))
812    } else {
813        None
814    };
815    let status = get_string(&args, "status");
816    let phase = get_string(&args, "phase");
817    // Support both integer and string priority
818    let priority = get_i32(&args, "priority")
819        .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
820    let points = if args.get("points").is_some() {
821        Some(get_i32(&args, "points"))
822    } else {
823        None
824    };
825    let tags = if args.get("tags").is_some() {
826        Some(get_string_array(&args, "tags").unwrap_or_default())
827    } else {
828        None
829    };
830    let needed_tags = if args.get("needed_tags").is_some() {
831        Some(get_string_array(&args, "needed_tags").unwrap_or_default())
832    } else {
833        None
834    };
835    let wanted_tags = if args.get("wanted_tags").is_some() {
836        Some(get_string_array(&args, "wanted_tags").unwrap_or_default())
837    } else {
838        None
839    };
840    let time_estimate_ms = get_i64(&args, "time_estimate_ms");
841    let reason = get_string(&args, "reason");
842    let force = get_bool(&args, "force").unwrap_or(false);
843
844    // Process attachments first (before the update)
845    let mut attachment_results: Vec<Value> = Vec::new();
846    let mut attachment_warnings: Vec<String> = Vec::new();
847
848    if let Some(attachments_arr) = args.get("attachments").and_then(|v| v.as_array()) {
849        for att_value in attachments_arr {
850            let attachment_type = att_value.get("type").and_then(|v| v.as_str());
851            let name = att_value.get("name").and_then(|v| v.as_str()).unwrap_or("");
852            let content = att_value.get("content").and_then(|v| v.as_str());
853            let mime_override = att_value.get("mime").and_then(|v| v.as_str());
854            let mode_override = att_value.get("mode").and_then(|v| v.as_str());
855
856            let attachment_type = match attachment_type {
857                Some(t) => t,
858                None => {
859                    attachment_warnings
860                        .push("Skipped attachment: missing 'type' field".to_string());
861                    continue;
862                }
863            };
864
865            let content = match content {
866                Some(c) => c,
867                None => {
868                    attachment_warnings.push(format!(
869                        "Skipped attachment type '{}': missing 'content' field",
870                        attachment_type
871                    ));
872                    continue;
873                }
874            };
875
876            // Check unknown key behavior
877            if !attachments_config.is_known_key(attachment_type) {
878                match attachments_config.unknown_key {
879                    UnknownKeyBehavior::Reject => {
880                        attachment_warnings.push(format!(
881                            "Rejected attachment type '{}': unknown type (configure in attachments.definitions or set unknown_key to 'allow')",
882                            attachment_type
883                        ));
884                        continue;
885                    }
886                    UnknownKeyBehavior::Warn => {
887                        attachment_warnings
888                            .push(format!("Unknown attachment type '{}'", attachment_type));
889                    }
890                    UnknownKeyBehavior::Allow => {}
891                }
892            }
893
894            // Use config defaults for mime/mode, but allow explicit overrides
895            let mime_type = mime_override.map(String::from).unwrap_or_else(|| {
896                attachments_config
897                    .get_mime_default(attachment_type)
898                    .to_string()
899            });
900            let mode = mode_override
901                .unwrap_or_else(|| attachments_config.get_mode_default(attachment_type));
902
903            // Validate mode
904            if mode != "append" && mode != "replace" {
905                attachment_warnings.push(format!(
906                    "Skipped attachment type '{}': mode must be 'append' or 'replace'",
907                    attachment_type
908                ));
909                continue;
910            }
911
912            // Handle replace mode - delete all existing attachments of this type
913            if mode == "replace" {
914                let _ = db.delete_attachments_by_type(&task_id, attachment_type);
915            }
916
917            // Add the attachment
918            match db.add_attachment(
919                &task_id,
920                attachment_type.to_string(),
921                name.to_string(),
922                content.to_string(),
923                Some(mime_type.clone()),
924                None,
925            ) {
926                Ok(sequence) => {
927                    attachment_results.push(json!({
928                        "type": attachment_type,
929                        "sequence": sequence,
930                        "name": name,
931                        "mime_type": mime_type
932                    }));
933                }
934                Err(e) => {
935                    attachment_warnings.push(format!(
936                        "Failed to add attachment type '{}': {}",
937                        attachment_type, e
938                    ));
939                }
940            }
941        }
942    }
943
944    // Check phase validity (may return warning)
945    let phase_warning = if let Some(ref p) = phase {
946        phases_config.check_phase(p)?
947    } else {
948        None
949    };
950
951    // Check tag validity for all tag types
952    let mut tag_warnings = Vec::new();
953    if let Some(ref t) = tags {
954        tag_warnings.extend(tags_config.validate_tags(t)?);
955    }
956    if let Some(ref t) = needed_tags {
957        tag_warnings.extend(tags_config.validate_tags(t)?);
958    }
959    if let Some(ref t) = wanted_tags {
960        tag_warnings.extend(tags_config.validate_tags(t)?);
961    }
962
963    // Check exit gates for status transitions
964    let mut gate_warnings: Vec<String> = Vec::new();
965    // Track skipped gates for audit logging (separate from warnings for response)
966    let mut skipped_status_gates: Vec<String> = Vec::new();
967    let mut skipped_phase_gates: Vec<String> = Vec::new();
968    if let Some(ref new_status) = status {
969        // Get current task to check if status is actually changing
970        let current_task = db.get_task(&task_id)?.ok_or_else(|| {
971            ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
972        })?;
973
974        if &current_task.status != new_status {
975            // Status is changing - check exit gates for the CURRENT status
976            let exit_gates = workflows.get_status_exit_gates(&current_task.status);
977
978            if !exit_gates.is_empty() {
979                // Convert references to owned GateDefinitions for evaluate_gates
980                let gates_owned: Vec<crate::config::GateDefinition> =
981                    exit_gates.iter().map(|g| (*g).clone()).collect();
982                let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
983
984                match gate_result.status.as_str() {
985                    "fail" => {
986                        // Reject-level gates unsatisfied - cannot proceed
987                        let gate_names: Vec<String> = gate_result
988                            .unsatisfied_gates
989                            .iter()
990                            .filter(|g| g.enforcement == GateEnforcement::Reject)
991                            .map(|g| format!("{} ({})", g.gate_type, g.description))
992                            .collect();
993                        return Err(ToolError::gates_not_satisfied(
994                            &current_task.status,
995                            &gate_names,
996                        )
997                        .into());
998                    }
999                    "warn" => {
1000                        // Warn-level gates unsatisfied
1001                        let warn_gates: Vec<String> = gate_result
1002                            .unsatisfied_gates
1003                            .iter()
1004                            .filter(|g| g.enforcement == GateEnforcement::Warn)
1005                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1006                            .collect();
1007
1008                        if !force {
1009                            // Cannot proceed without force flag - include actionable guidance
1010                            let how_to_fix: Vec<String> = warn_gates
1011                                .iter()
1012                                .map(|g| {
1013                                    let gate_type = g.split(" (").next().unwrap_or(g);
1014                                    format!(
1015                                        "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1016                                        task_id, gate_type
1017                                    )
1018                                })
1019                                .collect();
1020                            return Err(ToolError::new(
1021                                crate::error::ErrorCode::GatesNotSatisfied,
1022                                format!(
1023                                    "Cannot exit '{}' without force=true: unsatisfied gates: {}",
1024                                    current_task.status,
1025                                    warn_gates.join(", ")
1026                                ),
1027                            )
1028                            .with_details(format!(
1029                                "Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1030                                how_to_fix.join("\n")
1031                            ))
1032                            .with_suggestion(
1033                                "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1034                            )
1035                            .into());
1036                        }
1037                        // force=true: proceed but include warning and log for audit
1038                        warn!(
1039                            task_id = %task_id,
1040                            agent = %worker_id,
1041                            from_status = %current_task.status,
1042                            to_status = %new_status,
1043                            skipped_gates = ?warn_gates,
1044                            "Status transition with skipped warn gates (force=true)"
1045                        );
1046                        skipped_status_gates = warn_gates.clone();
1047                        gate_warnings.push(format!(
1048                            "Proceeding despite unsatisfied gates (force=true): {}",
1049                            warn_gates.join(", ")
1050                        ));
1051                    }
1052                    "pass" => {
1053                        // All gates satisfied - check for allow-level warnings
1054                        let allow_gates: Vec<String> = gate_result
1055                            .unsatisfied_gates
1056                            .iter()
1057                            .filter(|g| g.enforcement == GateEnforcement::Allow)
1058                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1059                            .collect();
1060                        if !allow_gates.is_empty() {
1061                            gate_warnings.push(format!(
1062                                "Optional gates not satisfied: {}",
1063                                allow_gates.join(", ")
1064                            ));
1065                        }
1066                    }
1067                    _ => {}
1068                }
1069            }
1070        }
1071    }
1072
1073    // Check exit gates for phase transitions
1074    if let Some(ref new_phase) = phase {
1075        // Get current task to check if phase is actually changing
1076        // Note: We may have already fetched the task for status gate checking,
1077        // but we fetch again to ensure we have fresh data and to handle cases
1078        // where only phase is changing (not status)
1079        let current_task = db.get_task(&task_id)?.ok_or_else(|| {
1080            ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
1081        })?;
1082
1083        // Only check gates if there's a current phase AND it's different from new phase
1084        if let Some(ref current_phase) = current_task.phase
1085            && current_phase != new_phase
1086        {
1087            // Phase is changing - check exit gates for the CURRENT phase
1088            let exit_gates = workflows.get_phase_exit_gates(current_phase);
1089
1090            if !exit_gates.is_empty() {
1091                // Convert references to owned GateDefinitions for evaluate_gates
1092                let gates_owned: Vec<crate::config::GateDefinition> =
1093                    exit_gates.iter().map(|g| (*g).clone()).collect();
1094                let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
1095
1096                match gate_result.status.as_str() {
1097                    "fail" => {
1098                        // Reject-level gates unsatisfied - cannot proceed
1099                        let gate_names: Vec<String> = gate_result
1100                            .unsatisfied_gates
1101                            .iter()
1102                            .filter(|g| g.enforcement == GateEnforcement::Reject)
1103                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1104                            .collect();
1105                        let how_to_fix: Vec<String> = gate_names
1106                            .iter()
1107                            .map(|g| {
1108                                let gate_type = g.split(" (").next().unwrap_or(g);
1109                                format!(
1110                                    "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1111                                    task_id, gate_type
1112                                )
1113                            })
1114                            .collect();
1115                        return Err(ToolError::new(
1116                            crate::error::ErrorCode::GatesNotSatisfied,
1117                            format!(
1118                                "Cannot exit phase '{}': unsatisfied gates: {}",
1119                                current_phase,
1120                                gate_names.join(", ")
1121                            ),
1122                        )
1123                        .with_details(format!(
1124                            "These are reject-level gates and cannot be skipped. Satisfy them:\n{}",
1125                            how_to_fix.join("\n")
1126                        ))
1127                        .with_suggestion(
1128                            "Attach the required gate artifacts, then retry the phase transition."
1129                                .to_string(),
1130                        )
1131                        .into());
1132                    }
1133                    "warn" => {
1134                        // Warn-level gates unsatisfied
1135                        let warn_gates: Vec<String> = gate_result
1136                            .unsatisfied_gates
1137                            .iter()
1138                            .filter(|g| g.enforcement == GateEnforcement::Warn)
1139                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1140                            .collect();
1141
1142                        if !force {
1143                            // Cannot proceed without force flag - include actionable guidance
1144                            let how_to_fix: Vec<String> = warn_gates
1145                                .iter()
1146                                .map(|g| {
1147                                    let gate_type = g.split(" (").next().unwrap_or(g);
1148                                    format!(
1149                                        "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1150                                        task_id, gate_type
1151                                    )
1152                                })
1153                                .collect();
1154                            return Err(ToolError::new(
1155                                    crate::error::ErrorCode::GatesNotSatisfied,
1156                                    format!(
1157                                        "Cannot exit phase '{}' without force=true: unsatisfied gates: {}",
1158                                        current_phase,
1159                                        warn_gates.join(", ")
1160                                    ),
1161                                )
1162                                .with_details(format!(
1163                                    "Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1164                                    how_to_fix.join("\n")
1165                                ))
1166                                .with_suggestion(
1167                                    "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1168                                )
1169                                .into());
1170                        }
1171                        // force=true: proceed but include warning and log for audit
1172                        warn!(
1173                            task_id = %task_id,
1174                            agent = %worker_id,
1175                            from_phase = %current_phase,
1176                            to_phase = %new_phase,
1177                            skipped_gates = ?warn_gates,
1178                            "Phase transition with skipped warn gates (force=true)"
1179                        );
1180                        skipped_phase_gates = warn_gates.clone();
1181                        gate_warnings.push(format!(
1182                            "Proceeding despite unsatisfied phase gates (force=true): {}",
1183                            warn_gates.join(", ")
1184                        ));
1185                    }
1186                    "pass" => {
1187                        // All gates satisfied - check for allow-level warnings
1188                        let allow_gates: Vec<String> = gate_result
1189                            .unsatisfied_gates
1190                            .iter()
1191                            .filter(|g| g.enforcement == GateEnforcement::Allow)
1192                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1193                            .collect();
1194                        if !allow_gates.is_empty() {
1195                            gate_warnings.push(format!(
1196                                "Optional phase gates not satisfied: {}",
1197                                allow_gates.join(", ")
1198                            ));
1199                        }
1200                    }
1201                    _ => {}
1202                }
1203            }
1204        }
1205    }
1206
1207    // Build audit reason including any skipped gates
1208    let audit_reason = {
1209        let mut parts: Vec<String> = Vec::new();
1210
1211        // Include original reason if provided
1212        if let Some(ref r) = reason {
1213            parts.push(r.clone());
1214        }
1215
1216        // Include skipped status gates for audit
1217        if !skipped_status_gates.is_empty() {
1218            parts.push(format!(
1219                "Skipped status exit gates (force=true): {}",
1220                skipped_status_gates.join(", ")
1221            ));
1222        }
1223
1224        // Include skipped phase gates for audit
1225        if !skipped_phase_gates.is_empty() {
1226            parts.push(format!(
1227                "Skipped phase exit gates (force=true): {}",
1228                skipped_phase_gates.join(", ")
1229            ));
1230        }
1231
1232        if parts.is_empty() {
1233            None
1234        } else {
1235            Some(parts.join("; "))
1236        }
1237    };
1238
1239    // Perform the task update
1240    let (task, unblocked, auto_advanced) = db.update_task_unified(
1241        &task_id,
1242        &worker_id,
1243        assignee.as_deref(),
1244        title,
1245        description,
1246        status,
1247        phase,
1248        priority,
1249        points,
1250        tags,
1251        needed_tags,
1252        wanted_tags,
1253        time_estimate_ms,
1254        audit_reason,
1255        force,
1256        states_config,
1257        deps_config,
1258        auto_advance,
1259    )?;
1260
1261    // Pre-fetch worker info for context-sensitive prompts (must outlive ctx)
1262    let worker_info_for_prompts = db.get_worker(&worker_id).ok().flatten();
1263    let worker_role_for_prompts = worker_info_for_prompts
1264        .as_ref()
1265        .map(|w| workflows.match_role(&w.tags))
1266        .unwrap_or(None);
1267
1268    // Get transition prompts if status or phase may have changed
1269    // We update the worker's last seen state and get any matching prompts
1270    let mut transition_prompt_list: Vec<String> = {
1271        // Update worker state and get old state for prompt calculation
1272        match db.update_worker_state(&worker_id, Some(&task.status), task.phase.as_deref()) {
1273            Ok((old_status, old_phase)) => {
1274                // Create context with task and agent info for rich template expansion
1275                let mut ctx = PromptContext::new(
1276                    &task.status,
1277                    task.phase.as_deref(),
1278                    states_config,
1279                    phases_config,
1280                )
1281                .with_task(&task.id, &task.title, task.priority, &task.tags);
1282
1283                // Add agent context if worker info is available
1284                if let Some(ref worker) = worker_info_for_prompts {
1285                    ctx = ctx.with_agent(
1286                        &worker_id,
1287                        worker_role_for_prompts.as_deref(),
1288                        &worker.tags,
1289                    );
1290                }
1291
1292                // Get prompts for this transition with context-sensitive template expansion
1293                crate::prompts::get_transition_prompts_with_context(
1294                    old_status.as_deref().unwrap_or(""),
1295                    old_phase.as_deref(),
1296                    &task.status,
1297                    task.phase.as_deref(),
1298                    workflows,
1299                    &ctx,
1300                )
1301            }
1302            Err(_) => vec![], // Worker not found or other error - skip prompts
1303        }
1304    };
1305
1306    // Build response with task and unblocked/auto_advanced lists
1307    let mut response = serde_json::to_value(&task)?;
1308    if let Value::Object(ref mut map) = response {
1309        // Always include unblocked if non-empty (tasks now ready to claim)
1310        if !unblocked.is_empty() {
1311            map.insert("unblocked".to_string(), json!(unblocked));
1312        }
1313        // Include auto_advanced if non-empty (tasks that were actually transitioned)
1314        if !auto_advanced.is_empty() {
1315            map.insert("auto_advanced".to_string(), json!(auto_advanced));
1316        }
1317        // Include attachment results if any were added
1318        if !attachment_results.is_empty() {
1319            map.insert("attachments_added".to_string(), json!(attachment_results));
1320        }
1321        // Include warnings if any
1322        if !attachment_warnings.is_empty() {
1323            map.insert(
1324                "attachment_warnings".to_string(),
1325                json!(attachment_warnings),
1326            );
1327        }
1328        // Include phase warning if any
1329        if let Some(ref warning) = phase_warning {
1330            map.insert("phase_warning".to_string(), json!(warning));
1331        }
1332        // Include tag warnings if any
1333        if !tag_warnings.is_empty() {
1334            map.insert("tag_warnings".to_string(), json!(tag_warnings));
1335        }
1336        // Include gate warnings if any
1337        if !gate_warnings.is_empty() {
1338            map.insert("gate_warnings".to_string(), json!(gate_warnings));
1339        }
1340        // Add role-specific prompt based on the new status
1341        // Uses pre-fetched worker info to avoid redundant DB lookups
1342        if let Some(ref role_name) = worker_role_for_prompts {
1343            // Map status transitions to role prompt keys
1344            let prompt_key = match task.status.as_str() {
1345                "completed" => Some("completing"),
1346                _ => None,
1347            };
1348            if let Some(key) = prompt_key
1349                && let Some(prompt) = workflows.get_role_prompt(role_name, key)
1350            {
1351                transition_prompt_list.push(prompt.to_string());
1352            }
1353        }
1354
1355        // Include transition prompts if any
1356        if !transition_prompt_list.is_empty() {
1357            map.insert("prompts".to_string(), json!(transition_prompt_list));
1358        }
1359    }
1360
1361    Ok(response)
1362}
1363
1364pub fn delete(db: &Database, args: Value) -> Result<Value> {
1365    let worker_id =
1366        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1367    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1368    let cascade = get_bool(&args, "cascade").unwrap_or(false);
1369    let reason = get_string(&args, "reason");
1370    let obliterate = get_bool(&args, "obliterate").unwrap_or(false);
1371    let force = get_bool(&args, "force").unwrap_or(false);
1372
1373    db.delete_task(&task_id, &worker_id, cascade, reason, obliterate, force)?;
1374
1375    Ok(json!({
1376        "success": true,
1377        "soft_deleted": !obliterate
1378    }))
1379}
1380
1381pub fn rename(db: &Database, args: Value) -> Result<Value> {
1382    let _worker_id =
1383        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1384    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1385    let new_id = get_string(&args, "new_id").ok_or_else(|| ToolError::missing_field("new_id"))?;
1386
1387    db.rename_task(&task_id, &new_id)?;
1388
1389    Ok(json!({
1390        "success": true,
1391        "old_id": task_id,
1392        "new_id": new_id
1393    }))
1394}
1395
1396pub fn scan(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
1397    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1398    let format = get_string(&args, "format")
1399        .and_then(|s| OutputFormat::parse(&s))
1400        .unwrap_or(default_format);
1401
1402    // Depth parameters: 0=none, N=levels, -1=all
1403    let before_depth = get_i32(&args, "before").unwrap_or(0);
1404    let after_depth = get_i32(&args, "after").unwrap_or(0);
1405    let above_depth = get_i32(&args, "above").unwrap_or(0);
1406    let below_depth = get_i32(&args, "below").unwrap_or(0);
1407
1408    // Verify the task exists
1409    let root_task = db
1410        .get_task(&task_id)?
1411        .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
1412
1413    // Traverse in each direction
1414    let before = db.get_predecessors(&task_id, before_depth)?;
1415    let after = db.get_successors(&task_id, after_depth)?;
1416    let above = db.get_ancestors(&task_id, above_depth)?;
1417    let below = db.get_descendants(&task_id, below_depth)?;
1418
1419    let result = ScanResult {
1420        root: root_task,
1421        before,
1422        after,
1423        above,
1424        below,
1425    };
1426
1427    match format {
1428        OutputFormat::Markdown => Ok(markdown_to_json(format_scan_result_markdown(&result))),
1429        OutputFormat::Json => Ok(serde_json::to_value(&result)?),
1430    }
1431}