Skip to main content

task_graph_mcp/tools/
tasks.rs

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