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, ToolResult, format_scan_result_markdown, format_task_markdown,
15    format_tasks_markdown,
16};
17use crate::gates::evaluate_gates;
18use crate::prompts::{AttributedPrompt, 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": "Worker tags required (ALL must match) for claiming this task" },
97                        "wanted_tags": { "type": "array", "items": { "type": "string" }, "description": "Worker tags preferred (at least ONE must match) for claiming this task" },
98                        "blocked_by": { "type": "array", "items": { "type": "string" }, "description": "Task IDs that block this task. Creates 'blocks' deps. Can reference IDs from earlier nodes in this tree or existing tasks." },
99                        "children": { "type": "array", "description": "Child nodes (same structure, recursive)" }
100                    }
101                },
102                "parent": {
103                    "type": "string",
104                    "description": "Optional parent task ID for the tree root"
105                },
106                "child_type": {
107                    "type": "string",
108                    "description": "Dependency type from parent to children (default: 'contains'). Set to null for no parent-child deps."
109                },
110                "sibling_type": {
111                    "type": "string",
112                    "description": "Dependency type between consecutive siblings (default: null/parallel). Use 'follows' for sequential."
113                }
114            }),
115            vec!["tree"],
116            prompts,
117        ),
118        make_tool_with_prompts(
119            "get",
120            "Get a single task by ID. Returns detailed task with attachment metadata list and counts by type.",
121            json!({
122                "task": {
123                    "type": "string",
124                    "description": "Task ID"
125                }
126            }),
127            vec!["task"],
128            prompts,
129        ),
130        make_tool_with_prompts(
131            "list_tasks",
132            "Query tasks with flexible filters.",
133            json!({
134                "status": {
135                    "oneOf": [
136                        { "type": "string", "enum": state_enum },
137                        { "type": "array", "items": { "type": "string" } }
138                    ],
139                    "description": "Filter by status (single or array)"
140                },
141                "ready": {
142                    "type": "boolean",
143                    "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."
144                },
145                "blocked": {
146                    "type": "boolean",
147                    "description": "Filter for blocked tasks: have unsatisfied start-blocking dependencies"
148                },
149                "claimed": {
150                    "type": "boolean",
151                    "description": "Filter for claimed tasks: currently owned by any agent (owner_agent IS NOT NULL)"
152                },
153                "owner": {
154                    "type": "string",
155                    "description": "Filter by owner agent ID (tasks currently claimed by this specific agent)"
156                },
157                "parent": {
158                    "type": "string",
159                    "description": "Filter by parent task ID (use 'null' for root tasks)"
160                },
161                "recursive": {
162                    "type": "boolean",
163                    "description": "When true with parent, returns all descendants (subtree) instead of just direct children. Uses contains-dependency traversal."
164                },
165                "agent": {
166                    "type": "string",
167                    "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."
168                },
169                "tags_any": {
170                    "type": "array",
171                    "items": { "type": "string" },
172                    "description": "Filter tasks that have ANY of these tags (OR)"
173                },
174                "tags_all": {
175                    "type": "array",
176                    "items": { "type": "string" },
177                    "description": "Filter tasks that have ALL of these tags (AND)"
178                },
179                "sort_by": {
180                    "type": "string",
181                    "enum": ["priority", "created_at", "updated_at"],
182                    "description": "Field to sort by (default: created_at for general queries, priority then created_at for ready queries)"
183                },
184                "sort_order": {
185                    "type": "string",
186                    "enum": ["asc", "desc"],
187                    "description": "Sort order: 'asc' for ascending, 'desc' for descending (default: desc for created_at/updated_at, priority always high-to-low)"
188                },
189                "limit": {
190                    "type": "integer",
191                    "description": "Maximum number of tasks to return"
192                },
193                "offset": {
194                    "type": "integer",
195                    "description": "Number of tasks to skip for pagination (default: 0)"
196                }
197            }),
198            vec![],
199            prompts,
200        ),
201        make_tool_with_prompts(
202            "update",
203            "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.",
204            json!({
205                "worker_id": {
206                    "type": "string",
207                    "description": "Worker ID making the update"
208                },
209                "task": {
210                    "type": "string",
211                    "description": "Task ID"
212                },
213                "assignee": {
214                    "type": "string",
215                    "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."
216                },
217                "status": {
218                    "type": "string",
219                    "enum": state_enum,
220                    "description": "New status"
221                },
222                "title": {
223                    "type": "string",
224                    "description": "New title"
225                },
226                "description": {
227                    "type": "string",
228                    "description": "New description"
229                },
230                "priority": {
231                    "type": "integer",
232                    "description": "New priority 0-10 (higher = more important)"
233                },
234                "points": {
235                    "type": "integer",
236                    "description": "New points estimate"
237                },
238                "tags": {
239                    "type": "array",
240                    "items": { "type": "string" },
241                    "description": "New categorization/discovery tags"
242                },
243                "needed_tags": {
244                    "type": "array",
245                    "items": { "type": "string" },
246                    "description": "Worker tags required (ALL must match) for claiming this task"
247                },
248                "wanted_tags": {
249                    "type": "array",
250                    "items": { "type": "string" },
251                    "description": "Worker tags preferred (at least ONE must match) for claiming this task"
252                },
253                "time_estimate_ms": {
254                    "type": "integer",
255                    "description": "Estimated duration in milliseconds"
256                },
257                "reason": {
258                    "type": "string",
259                    "description": "Reason for the update (stored in audit trail for state transitions)"
260                },
261                "force": {
262                    "type": "boolean",
263                    "description": "Force ownership changes even if owned by another worker (default: false)"
264                },
265                "cascade": {
266                    "type": "boolean",
267                    "description": "When true and status is being set to cancelled, also cancel all non-terminal descendants (default: false)"
268                },
269                "prompts": {
270                    "type": "string",
271                    "enum": ["all", "none", "caller"],
272                    "description": "Control which transition prompts are returned. 'all' (default): all prompts. 'none': suppress all prompts. 'caller': only prompts relevant to the caller, suppressing assignee-targeted prompts when using push coordination."
273                },
274                "attachments": {
275                    "type": "array",
276                    "description": "List of attachments to add to the task (e.g., commit hashes, changelists, notes)",
277                    "items": {
278                        "type": "object",
279                        "properties": {
280                            "type": {
281                                "type": "string",
282                                "description": "Attachment type/category (e.g., 'commit', 'changelist', 'note'). Used for indexing and replace operations."
283                            },
284                            "name": {
285                                "type": "string",
286                                "description": "Optional label/name for the attachment (arbitrary string)"
287                            },
288                            "content": {
289                                "type": "string",
290                                "description": "Attachment content (text)"
291                            },
292                            "mime": {
293                                "type": "string",
294                                "description": "MIME type (uses configured default if omitted)"
295                            },
296                            "mode": {
297                                "type": "string",
298                                "enum": ["append", "replace"],
299                                "description": "How to handle existing attachments of this type: 'append' adds new, 'replace' deletes all of this type first"
300                            }
301                        },
302                        "required": ["type", "content"]
303                    }
304                }
305            }),
306            vec!["worker_id", "task"],
307            prompts,
308        ),
309        make_tool_with_prompts(
310            "delete",
311            "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.",
312            json!({
313                "worker_id": {
314                    "type": "string",
315                    "description": "Worker ID attempting to delete"
316                },
317                "task": {
318                    "type": "string",
319                    "description": "Task ID"
320                },
321                "cascade": {
322                    "type": "boolean",
323                    "description": "Whether to delete children (default: false)"
324                },
325                "reason": {
326                    "type": "string",
327                    "description": "Optional reason for deletion"
328                },
329                "obliterate": {
330                    "type": "boolean",
331                    "description": "If true, permanently deletes the task from the database. If false (default), soft deletes by setting deleted_at timestamp."
332                },
333                "force": {
334                    "type": "boolean",
335                    "description": "Force deletion even if claimed by another worker (default: false)"
336                }
337            }),
338            vec!["worker_id", "task"],
339            prompts,
340        ),
341        make_tool_with_prompts(
342            "rename",
343            "Change a task's ID. Updates all references (dependencies, attachments, file marks, tags, etc.) atomically.",
344            json!({
345                "worker_id": {
346                    "type": "string",
347                    "description": "Worker ID (for audit)"
348                },
349                "task": {
350                    "type": "string",
351                    "description": "Current task ID"
352                },
353                "new_id": {
354                    "type": "string",
355                    "description": "New task ID"
356                }
357            }),
358            vec!["worker_id", "task", "new_id"],
359            prompts,
360        ),
361        make_tool_with_prompts(
362            "scan",
363            "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.",
364            json!({
365                "task": {
366                    "type": "string",
367                    "description": "Task ID to scan from"
368                },
369                "before": {
370                    "type": "integer",
371                    "description": "Depth for predecessors (tasks that block this one): 0=none, N=levels, -1=all (default: 0)"
372                },
373                "after": {
374                    "type": "integer",
375                    "description": "Depth for successors (tasks this one blocks): 0=none, N=levels, -1=all (default: 0)"
376                },
377                "above": {
378                    "type": "integer",
379                    "description": "Depth for ancestors (parent chain): 0=none, N=levels, -1=all (default: 0)"
380                },
381                "below": {
382                    "type": "integer",
383                    "description": "Depth for descendants (children tree): 0=none, N=levels, -1=all (default: 0)"
384                },
385                "format": {
386                    "type": "string",
387                    "enum": ["json", "markdown"],
388                    "description": "Output format (default: json)"
389                }
390            }),
391            vec!["task"],
392            prompts,
393        ),
394        make_tool_with_prompts(
395            "status_summary",
396            "Get task counts grouped by status. Returns a counts object and total. Optionally scope to a subtree.",
397            json!({
398                "parent": {
399                    "type": "string",
400                    "description": "Optional parent task ID to scope the summary to its subtree (all descendants). When omitted, counts all tasks."
401                }
402            }),
403            vec![],
404            prompts,
405        ),
406        make_tool_with_prompts(
407            "bulk_update",
408            "Update multiple tasks' status in one call. Each transition validates individually (state machine, ownership). Returns per-task success/failure.",
409            json!({
410                "worker_id": {
411                    "type": "string",
412                    "description": "Agent making the updates"
413                },
414                "tasks": {
415                    "type": "array",
416                    "items": { "type": "string" },
417                    "description": "Task IDs to update"
418                },
419                "status": {
420                    "type": "string",
421                    "enum": state_enum,
422                    "description": "Target status for all tasks"
423                },
424                "reason": {
425                    "type": "string",
426                    "description": "Reason for the update (stored in audit trail)"
427                },
428                "force": {
429                    "type": "boolean",
430                    "description": "Force ownership changes even if owned by another worker (default: false)"
431                }
432            }),
433            vec!["worker_id", "tasks", "status"],
434            prompts,
435        ),
436    ]
437}
438
439pub fn create(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
440    let states_config = &config.states;
441    let phases_config = &config.phases;
442    let tags_config = &config.tags;
443    let ids_config = &config.ids;
444    let id = get_string(&args, "id");
445    let title = get_string(&args, "title");
446    let description = get_string(&args, "description");
447    let parent_id = get_string(&args, "parent");
448    let phase = get_string(&args, "phase");
449    // Support both integer and string priority
450    let priority = get_i32(&args, "priority")
451        .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
452    let points = get_i32(&args, "points");
453    let time_estimate_ms = get_i64(&args, "time_estimate_ms");
454    let tags = get_string_array(&args, "tags");
455    let needed_tags = get_string_array(&args, "needed_tags");
456    let wanted_tags = get_string_array(&args, "wanted_tags");
457
458    // Require at least one of title or description
459    if title.is_none() && description.is_none() {
460        return Err(ToolError::missing_field("title or description").into());
461    }
462
463    // Derive effective title: explicit title, or truncated description
464    let effective_title = title.unwrap_or_else(|| {
465        crate::format::truncate_title(description.as_deref().unwrap_or("")).into_owned()
466    });
467
468    // Check phase validity (may return warning)
469    let phase_warning = if let Some(ref p) = phase {
470        phases_config.check_phase(p)?
471    } else {
472        None
473    };
474
475    // Check tag validity for all tag types
476    let mut tag_warnings = Vec::new();
477    if let Some(ref t) = tags {
478        tag_warnings.extend(tags_config.validate_tags(t)?);
479    }
480    if let Some(ref t) = needed_tags {
481        tag_warnings.extend(tags_config.validate_tags(t)?);
482    }
483    if let Some(ref t) = wanted_tags {
484        tag_warnings.extend(tags_config.validate_tags(t)?);
485    }
486
487    let task = db.create_task(
488        id,
489        effective_title,
490        description,
491        parent_id,
492        phase,
493        priority,
494        points,
495        time_estimate_ms,
496        needed_tags,
497        wanted_tags,
498        tags,
499        states_config,
500        ids_config,
501    )?;
502
503    let mut response = json!({
504        "id": &task.id,
505        "title": task.title,
506        "description": task.description,
507        "status": task.status,
508        "phase": task.phase,
509        "priority": task.priority,
510        "created_at": task.created_at
511    });
512
513    if let Some(warning) = phase_warning {
514        response["phase_warning"] = json!(warning);
515    }
516
517    if !tag_warnings.is_empty() {
518        response["tag_warnings"] = json!(tag_warnings);
519    }
520
521    // Warn if title is too long for scannable list output
522    if task.title.len() > crate::format::MAX_TITLE_DISPLAY_LEN || task.title.contains('\n') {
523        response["title_warning"] = json!(
524            "Title exceeds 80 chars or is multi-line. Consider using a short title and keeping detail in the description."
525        );
526    }
527
528    Ok(response)
529}
530
531pub fn create_tree(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
532    let states_config = &config.states;
533    let phases_config = &config.phases;
534    let tags_config = &config.tags;
535    let ids_config = &config.ids;
536    let tree: TaskTreeInput = serde_json::from_value(
537        args.get("tree")
538            .cloned()
539            .ok_or_else(|| ToolError::missing_field("tree"))?,
540    )?;
541    let parent_id = get_string(&args, "parent");
542    let child_type = get_string(&args, "child_type");
543    let sibling_type = get_string(&args, "sibling_type");
544
545    let (root_id, all_ids, phase_warnings, tag_warnings) =
546        db.create_task_tree(CreateTreeOptions {
547            input: tree,
548            parent_id,
549            child_type,
550            sibling_type,
551            states_config,
552            phases_config,
553            tags_config,
554            ids_config,
555        })?;
556
557    // Fetch the root task to return full details
558    let root_task = db.get_task(&root_id)?.ok_or_else(|| {
559        ToolError::new(
560            crate::error::ErrorCode::TaskNotFound,
561            "Root task not found after creation",
562        )
563    })?;
564
565    let mut response = json!({
566        "root": {
567            "id": root_task.id,
568            "title": root_task.title,
569            "description": root_task.description,
570            "status": root_task.status,
571            "phase": root_task.phase,
572            "priority": root_task.priority,
573            "created_at": root_task.created_at
574        },
575        "all_ids": all_ids,
576        "count": all_ids.len()
577    });
578
579    if !phase_warnings.is_empty() {
580        response["phase_warnings"] = json!(phase_warnings);
581    }
582
583    if !tag_warnings.is_empty() {
584        response["tag_warnings"] = json!(tag_warnings);
585    }
586
587    Ok(response)
588}
589
590pub fn get(db: &Database, default_format: OutputFormat, args: Value) -> Result<ToolResult> {
591    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
592    let format = get_string(&args, "format")
593        .and_then(|s| OutputFormat::parse(&s))
594        .unwrap_or(default_format);
595
596    let task = db
597        .get_task(&task_id)?
598        .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
599
600    let blocked_by = db.get_blockers(&task_id)?;
601
602    // Get attachment metadata
603    let attachments = db.get_attachments(&task_id)?;
604
605    // Calculate attachment counts by MIME type
606    let mut attachment_counts: std::collections::HashMap<String, i32> =
607        std::collections::HashMap::new();
608    for att in &attachments {
609        *attachment_counts.entry(att.mime_type.clone()).or_insert(0) += 1;
610    }
611
612    match format {
613        OutputFormat::Markdown => {
614            let mut md = format_task_markdown(&task, &blocked_by);
615
616            // Add attachment section if there are attachments
617            if !attachments.is_empty() {
618                md.push_str("\n### Attachments\n");
619                for att in &attachments {
620                    let file_indicator = if att.file_path.is_some() {
621                        " (file)"
622                    } else {
623                        ""
624                    };
625                    md.push_str(&format!(
626                        "- **{}** [{}]{}\n",
627                        att.name, att.mime_type, file_indicator
628                    ));
629                }
630
631                // Add counts by type
632                md.push_str("\n**Counts by type:**\n");
633                for (mime_type, count) in &attachment_counts {
634                    md.push_str(&format!("- {}: {}\n", mime_type, count));
635                }
636            }
637
638            Ok(ToolResult::Raw(md))
639        }
640        OutputFormat::Json => {
641            let mut task_json = serde_json::to_value(&task)?;
642            if let Some(obj) = task_json.as_object_mut() {
643                if !blocked_by.is_empty() {
644                    obj.insert("blocked_by".to_string(), json!(blocked_by));
645                }
646                if !attachments.is_empty() {
647                    obj.insert(
648                        "attachments".to_string(),
649                        serde_json::to_value(&attachments)?,
650                    );
651                }
652                if !attachment_counts.is_empty() {
653                    obj.insert(
654                        "attachment_counts".to_string(),
655                        serde_json::to_value(&attachment_counts)?,
656                    );
657                }
658            }
659            Ok(ToolResult::Json(task_json))
660        }
661    }
662}
663
664pub fn list_tasks(
665    db: &Database,
666    states_config: &StatesConfig,
667    deps_config: &DependenciesConfig,
668    default_format: OutputFormat,
669    args: Value,
670) -> Result<ToolResult> {
671    let format = get_string(&args, "format")
672        .and_then(|s| OutputFormat::parse(&s))
673        .unwrap_or(default_format);
674
675    let ready = get_bool(&args, "ready").unwrap_or(false);
676    let blocked = get_bool(&args, "blocked").unwrap_or(false);
677    let claimed = get_bool(&args, "claimed").unwrap_or(false);
678    let recursive = get_bool(&args, "recursive").unwrap_or(false);
679    let limit = get_i32(&args, "limit");
680    let offset = get_i32(&args, "offset").unwrap_or(0).max(0);
681    let fetch_limit = limit.map(|l| l + 1);
682    let phase = get_string(&args, "phase");
683
684    // Extract tag filtering parameters
685    let tags_any = get_string_array(&args, "tags_any");
686    let tags_all = get_string_array(&args, "tags_all");
687
688    // 'agent' replaces both 'worker_id' and 'qualified_for' - single param for agent-related filtering
689    let agent_id = get_string(&args, "agent");
690
691    // Sorting parameters
692    let sort_by = get_string(&args, "sort_by");
693    let sort_order = get_string(&args, "sort_order");
694
695    // Get tasks based on filters
696    let parent_id_str = get_string(&args, "parent");
697
698    let mut tasks =
699        if recursive && parent_id_str.is_some() && parent_id_str.as_deref() != Some("null") {
700            // Recursive descent: get all descendants of the parent via contains dependencies
701            let pid = parent_id_str.as_deref().unwrap();
702            let mut descendants = db.get_descendants(pid, -1)?;
703
704            // Apply status filter in memory
705            if let Some(status_set) = get_string_or_array(&args, "status")
706                && !status_set.is_empty()
707            {
708                descendants.retain(|t| status_set.contains(&t.status));
709            }
710
711            // Apply owner filter in memory
712            if let Some(ref owner) = get_string(&args, "owner") {
713                descendants.retain(|t| t.worker_id.as_deref() == Some(owner.as_str()));
714            }
715
716            descendants
717        } else if ready {
718            // Ready tasks: in initial state, unclaimed, all deps satisfied
719            // If agent is provided, also filter by agent's tag qualifications
720            db.get_ready_tasks(
721                agent_id.as_deref(),
722                states_config,
723                deps_config,
724                sort_by.as_deref(),
725                sort_order.as_deref(),
726            )?
727        } else if blocked {
728            // Blocked tasks: have unsatisfied deps
729            db.get_blocked_tasks(
730                states_config,
731                deps_config,
732                sort_by.as_deref(),
733                sort_order.as_deref(),
734            )?
735        } else if claimed {
736            // Claimed tasks: currently owned by any agent
737            db.get_claimed_tasks(None)?
738        } else {
739            // General query with filters
740            let status_vec = get_string_or_array(&args, "status");
741            let owner = get_string(&args, "owner");
742            let parent_id: Option<Option<&str>> = match &parent_id_str {
743                Some(pid_str) if pid_str == "null" => Some(None), // Root tasks
744                Some(pid_str) => Some(Some(pid_str.as_str())),
745                None => None,
746            };
747
748            // Check if tag filtering or agent qualification filtering is needed
749            let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
750
751            if has_tag_filters {
752                // Use the tag-filtered query
753                // When agent is provided without ready=true, filter by agent's qualification
754                let qualified_agent_tags = if let Some(aid) = &agent_id {
755                    Some(db.get_agent_tags(aid)?)
756                } else {
757                    None
758                };
759
760                db.list_tasks_with_tag_filters(
761                    status_vec,
762                    owner.as_deref(),
763                    parent_id,
764                    tags_any,
765                    tags_all,
766                    qualified_agent_tags,
767                    fetch_limit,
768                    offset,
769                    sort_by.as_deref(),
770                    sort_order.as_deref(),
771                )?
772            } else {
773                // Use list_tasks which returns full Task objects (only supports single status)
774                let status = status_vec
775                    .as_ref()
776                    .and_then(|v| v.first().map(|s| s.as_str()));
777                db.list_tasks(ListTasksQuery {
778                    status,
779                    phase: phase.as_deref(),
780                    owner: owner.as_deref(),
781                    parent_id,
782                    limit: fetch_limit,
783                    offset,
784                    sort_by: sort_by.as_deref(),
785                    sort_order: sort_order.as_deref(),
786                })?
787            }
788        };
789
790    // Apply phase filter for ready/blocked/claimed paths (list_tasks handles it internally)
791    if let Some(ref p) = phase {
792        tasks.retain(|t| t.phase.as_deref() == Some(p.as_str()));
793    }
794
795    // Apply offset for paths that don't go through paginated DB queries
796    // (ready, blocked, claimed, recursive paths fetch all matching tasks)
797    if offset > 0 && (ready || blocked || claimed || recursive) {
798        if (offset as usize) < tasks.len() {
799            tasks = tasks.split_off(offset as usize);
800        } else {
801            tasks.clear();
802        }
803    }
804
805    // Detect has_more using N+1 pattern, then truncate to actual limit
806    let has_more = limit.is_some_and(|l| tasks.len() > l as usize);
807    if let Some(l) = limit {
808        tasks.truncate(l as usize);
809    }
810
811    // Get unsatisfied blockers for each task (only blockers still in a blocking state)
812    let tasks_with_blockers: Vec<_> = tasks
813        .into_iter()
814        .map(|task| {
815            let blockers = db
816                .get_unsatisfied_blockers(&task.id, states_config)
817                .unwrap_or_default();
818            (task, blockers)
819        })
820        .collect();
821
822    match format {
823        OutputFormat::Markdown => {
824            let mut md = format_tasks_markdown(&tasks_with_blockers, states_config);
825            if has_more {
826                let next_offset = offset + limit.unwrap_or(0);
827                md.push_str(&format!(
828                    "\n\n*More results available. Use offset={} to see next page.*",
829                    next_offset
830                ));
831            }
832            Ok(ToolResult::Raw(md))
833        }
834        OutputFormat::Json => Ok(ToolResult::Json(json!({
835            "tasks": tasks_with_blockers.iter().map(|(task, blockers)| {
836                let mut task_json = serde_json::to_value(task).unwrap();
837                if let Some(obj) = task_json.as_object_mut() {
838                    obj.insert("blocked_by".to_string(), json!(blockers));
839                    obj.insert("blocked".to_string(), json!(!blockers.is_empty()));
840                }
841                task_json
842            }).collect::<Vec<_>>(),
843            "has_more": has_more,
844            "offset": offset,
845            "limit": limit,
846        }))),
847    }
848}
849
850pub fn update(opts: UpdateOptions<'_>, args: Value) -> Result<Value> {
851    let UpdateOptions {
852        db,
853        config,
854        workflows,
855    } = opts;
856
857    let attachments_config = &config.attachments;
858    // Derive states/phases from the per-worker workflow so overlay-added states are recognized
859    let states_config_owned: StatesConfig = workflows.into();
860    let states_config = &states_config_owned;
861    let phases_config = &config.phases;
862    let deps_config = &config.deps;
863    let auto_advance = &config.auto_advance;
864    let tags_config = &config.tags;
865
866    let worker_id =
867        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
868    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
869    let assignee = get_string(&args, "assignee");
870    let title = get_string(&args, "title");
871    let description = if args.get("description").is_some() {
872        Some(get_string(&args, "description"))
873    } else {
874        None
875    };
876    let status = get_string(&args, "status");
877    let phase = get_string(&args, "phase");
878    // Support both integer and string priority
879    let priority = get_i32(&args, "priority")
880        .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
881    let points = if args.get("points").is_some() {
882        Some(get_i32(&args, "points"))
883    } else {
884        None
885    };
886    let tags = if args.get("tags").is_some() {
887        Some(get_string_array(&args, "tags").unwrap_or_default())
888    } else {
889        None
890    };
891    let needed_tags = if args.get("needed_tags").is_some() {
892        Some(get_string_array(&args, "needed_tags").unwrap_or_default())
893    } else {
894        None
895    };
896    let wanted_tags = if args.get("wanted_tags").is_some() {
897        Some(get_string_array(&args, "wanted_tags").unwrap_or_default())
898    } else {
899        None
900    };
901    let time_estimate_ms = get_i64(&args, "time_estimate_ms");
902    let reason = get_string(&args, "reason");
903    let force = get_bool(&args, "force").unwrap_or(false);
904    let cascade = get_bool(&args, "cascade").unwrap_or(false);
905    let prompts_mode = get_string(&args, "prompts").unwrap_or_else(|| "all".to_string());
906
907    // Process attachments first (before the update)
908    let mut attachment_results: Vec<Value> = Vec::new();
909    let mut attachment_warnings: Vec<String> = Vec::new();
910
911    if let Some(attachments_arr) = args.get("attachments").and_then(|v| v.as_array()) {
912        for att_value in attachments_arr {
913            let attachment_type = att_value.get("type").and_then(|v| v.as_str());
914            let name = att_value.get("name").and_then(|v| v.as_str()).unwrap_or("");
915            let content = att_value.get("content").and_then(|v| v.as_str());
916            let mime_override = att_value.get("mime").and_then(|v| v.as_str());
917            let mode_override = att_value.get("mode").and_then(|v| v.as_str());
918
919            let attachment_type = match attachment_type {
920                Some(t) => t,
921                None => {
922                    attachment_warnings
923                        .push("Skipped attachment: missing 'type' field".to_string());
924                    continue;
925                }
926            };
927
928            let content = match content {
929                Some(c) => c,
930                None => {
931                    attachment_warnings.push(format!(
932                        "Skipped attachment type '{}': missing 'content' field",
933                        attachment_type
934                    ));
935                    continue;
936                }
937            };
938
939            // Check unknown key behavior
940            if !attachments_config.is_known_key(attachment_type) {
941                match attachments_config.unknown_key {
942                    UnknownKeyBehavior::Reject => {
943                        attachment_warnings.push(format!(
944                            "Rejected attachment type '{}': unknown type (configure in attachments.definitions or set unknown_key to 'allow')",
945                            attachment_type
946                        ));
947                        continue;
948                    }
949                    UnknownKeyBehavior::Warn => {
950                        attachment_warnings
951                            .push(format!("Unknown attachment type '{}'", attachment_type));
952                    }
953                    UnknownKeyBehavior::Allow => {}
954                }
955            }
956
957            // Use config defaults for mime/mode, but allow explicit overrides
958            let mime_type = mime_override.map(String::from).unwrap_or_else(|| {
959                attachments_config
960                    .get_mime_default(attachment_type)
961                    .to_string()
962            });
963            let mode = mode_override
964                .unwrap_or_else(|| attachments_config.get_mode_default(attachment_type));
965
966            // Validate mode
967            if mode != "append" && mode != "replace" {
968                attachment_warnings.push(format!(
969                    "Skipped attachment type '{}': mode must be 'append' or 'replace'",
970                    attachment_type
971                ));
972                continue;
973            }
974
975            // Handle replace mode - delete all existing attachments of this type
976            if mode == "replace" {
977                let _ = db.delete_attachments_by_type(&task_id, attachment_type);
978            }
979
980            // Add the attachment
981            match db.add_attachment(
982                &task_id,
983                attachment_type.to_string(),
984                name.to_string(),
985                content.to_string(),
986                Some(mime_type.clone()),
987                None,
988            ) {
989                Ok(sequence) => {
990                    attachment_results.push(json!({
991                        "type": attachment_type,
992                        "sequence": sequence,
993                        "name": name,
994                        "mime_type": mime_type
995                    }));
996                }
997                Err(e) => {
998                    attachment_warnings.push(format!(
999                        "Failed to add attachment type '{}': {}",
1000                        attachment_type, e
1001                    ));
1002                }
1003            }
1004        }
1005    }
1006
1007    // Check phase validity (may return warning)
1008    let phase_warning = if let Some(ref p) = phase {
1009        phases_config.check_phase(p)?
1010    } else {
1011        None
1012    };
1013
1014    // Check tag validity for all tag types
1015    let mut tag_warnings = Vec::new();
1016    if let Some(ref t) = tags {
1017        tag_warnings.extend(tags_config.validate_tags(t)?);
1018    }
1019    if let Some(ref t) = needed_tags {
1020        tag_warnings.extend(tags_config.validate_tags(t)?);
1021    }
1022    if let Some(ref t) = wanted_tags {
1023        tag_warnings.extend(tags_config.validate_tags(t)?);
1024    }
1025
1026    // Check exit gates for status transitions
1027    let mut gate_warnings: Vec<String> = Vec::new();
1028    // Track skipped gates for audit logging (separate from warnings for response)
1029    let mut skipped_status_gates: Vec<String> = Vec::new();
1030    let mut skipped_phase_gates: Vec<String> = Vec::new();
1031    if let Some(ref new_status) = status {
1032        // Get current task to check if status is actually changing
1033        let current_task = db.get_task(&task_id)?.ok_or_else(|| {
1034            ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
1035        })?;
1036
1037        if &current_task.status != new_status {
1038            // Status is changing - check exit gates for the CURRENT status
1039            let exit_gates = workflows.get_status_exit_gates(&current_task.status);
1040
1041            if !exit_gates.is_empty() {
1042                // Convert references to owned GateDefinitions for evaluate_gates
1043                let gates_owned: Vec<crate::config::GateDefinition> =
1044                    exit_gates.iter().map(|g| (*g).clone()).collect();
1045                let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
1046
1047                match gate_result.status.as_str() {
1048                    "fail" => {
1049                        // Reject-level gates unsatisfied - cannot proceed
1050                        let gate_names: Vec<String> = gate_result
1051                            .unsatisfied_gates
1052                            .iter()
1053                            .filter(|g| g.enforcement == GateEnforcement::Reject)
1054                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1055                            .collect();
1056                        return Err(ToolError::gates_not_satisfied(
1057                            &current_task.status,
1058                            &gate_names,
1059                        )
1060                        .into());
1061                    }
1062                    "warn" => {
1063                        // Warn-level gates unsatisfied
1064                        let warn_gates: Vec<String> = gate_result
1065                            .unsatisfied_gates
1066                            .iter()
1067                            .filter(|g| g.enforcement == GateEnforcement::Warn)
1068                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1069                            .collect();
1070
1071                        if !force {
1072                            // Cannot proceed without force flag - include actionable guidance
1073                            let how_to_fix: Vec<String> = warn_gates
1074                                .iter()
1075                                .map(|g| {
1076                                    let gate_type = g.split(" (").next().unwrap_or(g);
1077                                    format!(
1078                                        "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1079                                        task_id, gate_type
1080                                    )
1081                                })
1082                                .collect();
1083                            return Err(ToolError::new(
1084                                crate::error::ErrorCode::GatesNotSatisfied,
1085                                format!(
1086                                    "Cannot exit '{}' without force=true: unsatisfied gates: {}",
1087                                    current_task.status,
1088                                    warn_gates.join(", ")
1089                                ),
1090                            )
1091                            .with_details(format!(
1092                                "Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1093                                how_to_fix.join("\n")
1094                            ))
1095                            .with_suggestion(
1096                                "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1097                            )
1098                            .into());
1099                        }
1100                        // force=true: proceed but include warning and log for audit
1101                        warn!(
1102                            task_id = %task_id,
1103                            agent = %worker_id,
1104                            from_status = %current_task.status,
1105                            to_status = %new_status,
1106                            skipped_gates = ?warn_gates,
1107                            "Status transition with skipped warn gates (force=true)"
1108                        );
1109                        skipped_status_gates = warn_gates.clone();
1110                        gate_warnings.push(format!(
1111                            "Proceeding despite unsatisfied gates (force=true): {}",
1112                            warn_gates.join(", ")
1113                        ));
1114                    }
1115                    "pass" => {
1116                        // All gates satisfied - check for allow-level warnings
1117                        let allow_gates: Vec<String> = gate_result
1118                            .unsatisfied_gates
1119                            .iter()
1120                            .filter(|g| g.enforcement == GateEnforcement::Allow)
1121                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1122                            .collect();
1123                        if !allow_gates.is_empty() {
1124                            gate_warnings.push(format!(
1125                                "Optional gates not satisfied: {}",
1126                                allow_gates.join(", ")
1127                            ));
1128                        }
1129                    }
1130                    _ => {}
1131                }
1132            }
1133        }
1134    }
1135
1136    // Check exit gates for phase transitions
1137    if let Some(ref new_phase) = phase {
1138        // Get current task to check if phase is actually changing
1139        // Note: We may have already fetched the task for status gate checking,
1140        // but we fetch again to ensure we have fresh data and to handle cases
1141        // where only phase is changing (not status)
1142        let current_task = db.get_task(&task_id)?.ok_or_else(|| {
1143            ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
1144        })?;
1145
1146        // Only check gates if there's a current phase AND it's different from new phase
1147        if let Some(ref current_phase) = current_task.phase
1148            && current_phase != new_phase
1149        {
1150            // Phase is changing - check exit gates for the CURRENT phase
1151            let exit_gates = workflows.get_phase_exit_gates(current_phase);
1152
1153            if !exit_gates.is_empty() {
1154                // Convert references to owned GateDefinitions for evaluate_gates
1155                let gates_owned: Vec<crate::config::GateDefinition> =
1156                    exit_gates.iter().map(|g| (*g).clone()).collect();
1157                let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
1158
1159                match gate_result.status.as_str() {
1160                    "fail" => {
1161                        // Reject-level gates unsatisfied - cannot proceed
1162                        let gate_names: Vec<String> = gate_result
1163                            .unsatisfied_gates
1164                            .iter()
1165                            .filter(|g| g.enforcement == GateEnforcement::Reject)
1166                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1167                            .collect();
1168                        let how_to_fix: Vec<String> = gate_names
1169                            .iter()
1170                            .map(|g| {
1171                                let gate_type = g.split(" (").next().unwrap_or(g);
1172                                format!(
1173                                    "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1174                                    task_id, gate_type
1175                                )
1176                            })
1177                            .collect();
1178                        return Err(ToolError::new(
1179                            crate::error::ErrorCode::GatesNotSatisfied,
1180                            format!(
1181                                "Cannot exit phase '{}': unsatisfied gates: {}",
1182                                current_phase,
1183                                gate_names.join(", ")
1184                            ),
1185                        )
1186                        .with_details(format!(
1187                            "These are reject-level gates and cannot be skipped. Satisfy them:\n{}",
1188                            how_to_fix.join("\n")
1189                        ))
1190                        .with_suggestion(
1191                            "Attach the required gate artifacts, then retry the phase transition."
1192                                .to_string(),
1193                        )
1194                        .into());
1195                    }
1196                    "warn" => {
1197                        // Warn-level gates unsatisfied
1198                        let warn_gates: Vec<String> = gate_result
1199                            .unsatisfied_gates
1200                            .iter()
1201                            .filter(|g| g.enforcement == GateEnforcement::Warn)
1202                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1203                            .collect();
1204
1205                        if !force {
1206                            // Cannot proceed without force flag - include actionable guidance
1207                            let how_to_fix: Vec<String> = warn_gates
1208                                .iter()
1209                                .map(|g| {
1210                                    let gate_type = g.split(" (").next().unwrap_or(g);
1211                                    format!(
1212                                        "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1213                                        task_id, gate_type
1214                                    )
1215                                })
1216                                .collect();
1217                            return Err(ToolError::new(
1218                                    crate::error::ErrorCode::GatesNotSatisfied,
1219                                    format!(
1220                                        "Cannot exit phase '{}' without force=true: unsatisfied gates: {}",
1221                                        current_phase,
1222                                        warn_gates.join(", ")
1223                                    ),
1224                                )
1225                                .with_details(format!(
1226                                    "Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1227                                    how_to_fix.join("\n")
1228                                ))
1229                                .with_suggestion(
1230                                    "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1231                                )
1232                                .into());
1233                        }
1234                        // force=true: proceed but include warning and log for audit
1235                        warn!(
1236                            task_id = %task_id,
1237                            agent = %worker_id,
1238                            from_phase = %current_phase,
1239                            to_phase = %new_phase,
1240                            skipped_gates = ?warn_gates,
1241                            "Phase transition with skipped warn gates (force=true)"
1242                        );
1243                        skipped_phase_gates = warn_gates.clone();
1244                        gate_warnings.push(format!(
1245                            "Proceeding despite unsatisfied phase gates (force=true): {}",
1246                            warn_gates.join(", ")
1247                        ));
1248                    }
1249                    "pass" => {
1250                        // All gates satisfied - check for allow-level warnings
1251                        let allow_gates: Vec<String> = gate_result
1252                            .unsatisfied_gates
1253                            .iter()
1254                            .filter(|g| g.enforcement == GateEnforcement::Allow)
1255                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1256                            .collect();
1257                        if !allow_gates.is_empty() {
1258                            gate_warnings.push(format!(
1259                                "Optional phase gates not satisfied: {}",
1260                                allow_gates.join(", ")
1261                            ));
1262                        }
1263                    }
1264                    _ => {}
1265                }
1266            }
1267        }
1268    }
1269
1270    // Check tag-based exit gates (evaluated on status transitions)
1271    let mut skipped_tag_gates: Vec<String> = Vec::new();
1272    if let Some(ref new_status) = status {
1273        let current_task = db.get_task(&task_id)?.ok_or_else(|| {
1274            ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
1275        })?;
1276
1277        if &current_task.status != new_status {
1278            // Collect gates from all task tags
1279            let mut tag_gates: Vec<crate::config::GateDefinition> = Vec::new();
1280            for tag in &current_task.tags {
1281                let gates = workflows.get_tag_exit_gates(tag);
1282                tag_gates.extend(gates.into_iter().cloned());
1283            }
1284
1285            if !tag_gates.is_empty() {
1286                let gate_result = evaluate_gates(db, &task_id, &tag_gates)?;
1287
1288                match gate_result.status.as_str() {
1289                    "fail" => {
1290                        let gate_names: Vec<String> = gate_result
1291                            .unsatisfied_gates
1292                            .iter()
1293                            .filter(|g| g.enforcement == GateEnforcement::Reject)
1294                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1295                            .collect();
1296                        return Err(ToolError::gates_not_satisfied(
1297                            &current_task.status,
1298                            &gate_names,
1299                        )
1300                        .into());
1301                    }
1302                    "warn" => {
1303                        let warn_gates: Vec<String> = gate_result
1304                            .unsatisfied_gates
1305                            .iter()
1306                            .filter(|g| g.enforcement == GateEnforcement::Warn)
1307                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1308                            .collect();
1309
1310                        if !force {
1311                            let how_to_fix: Vec<String> = warn_gates
1312                                .iter()
1313                                .map(|g| {
1314                                    let gate_type = g.split(" (").next().unwrap_or(g);
1315                                    format!(
1316                                        "  - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1317                                        task_id, gate_type
1318                                    )
1319                                })
1320                                .collect();
1321                            return Err(ToolError::new(
1322                                crate::error::ErrorCode::GatesNotSatisfied,
1323                                format!(
1324                                    "Cannot exit '{}' without force=true: unsatisfied tag gates: {}",
1325                                    current_task.status,
1326                                    warn_gates.join(", ")
1327                                ),
1328                            )
1329                            .with_details(format!(
1330                                "Satisfy these tag-based gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1331                                how_to_fix.join("\n")
1332                            ))
1333                            .with_suggestion(
1334                                "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1335                            )
1336                            .into());
1337                        }
1338                        warn!(
1339                            task_id = %task_id,
1340                            agent = %worker_id,
1341                            from_status = %current_task.status,
1342                            to_status = %new_status,
1343                            skipped_gates = ?warn_gates,
1344                            "Status transition with skipped tag gates (force=true)"
1345                        );
1346                        skipped_tag_gates = warn_gates.clone();
1347                        gate_warnings.push(format!(
1348                            "Proceeding despite unsatisfied tag gates (force=true): {}",
1349                            warn_gates.join(", ")
1350                        ));
1351                    }
1352                    "pass" => {
1353                        let allow_gates: Vec<String> = gate_result
1354                            .unsatisfied_gates
1355                            .iter()
1356                            .filter(|g| g.enforcement == GateEnforcement::Allow)
1357                            .map(|g| format!("{} ({})", g.gate_type, g.description))
1358                            .collect();
1359                        if !allow_gates.is_empty() {
1360                            gate_warnings.push(format!(
1361                                "Optional tag gates not satisfied: {}",
1362                                allow_gates.join(", ")
1363                            ));
1364                        }
1365                    }
1366                    _ => {}
1367                }
1368            }
1369        }
1370    }
1371
1372    // Build audit reason including any skipped gates
1373    let audit_reason = {
1374        let mut parts: Vec<String> = Vec::new();
1375
1376        // Include original reason if provided
1377        if let Some(ref r) = reason {
1378            parts.push(r.clone());
1379        }
1380
1381        // Include skipped status gates for audit
1382        if !skipped_status_gates.is_empty() {
1383            parts.push(format!(
1384                "Skipped status exit gates (force=true): {}",
1385                skipped_status_gates.join(", ")
1386            ));
1387        }
1388
1389        // Include skipped phase gates for audit
1390        if !skipped_phase_gates.is_empty() {
1391            parts.push(format!(
1392                "Skipped phase exit gates (force=true): {}",
1393                skipped_phase_gates.join(", ")
1394            ));
1395        }
1396
1397        // Include skipped tag gates for audit
1398        if !skipped_tag_gates.is_empty() {
1399            parts.push(format!(
1400                "Skipped tag exit gates (force=true): {}",
1401                skipped_tag_gates.join(", ")
1402            ));
1403        }
1404
1405        if parts.is_empty() {
1406            None
1407        } else {
1408            Some(parts.join("; "))
1409        }
1410    };
1411
1412    // Perform the task update
1413    let (task, unblocked, auto_advanced, auto_completed) = db.update_task_unified(
1414        &task_id,
1415        &worker_id,
1416        assignee.as_deref(),
1417        title,
1418        description,
1419        status,
1420        phase,
1421        priority,
1422        points,
1423        tags,
1424        needed_tags,
1425        wanted_tags,
1426        time_estimate_ms,
1427        audit_reason,
1428        force,
1429        states_config,
1430        deps_config,
1431        auto_advance,
1432    )?;
1433
1434    // Cascading cancellation: if cascade=true and task was cancelled, cancel all non-terminal descendants
1435    let mut cascaded: Vec<Value> = Vec::new();
1436    if cascade && states_config.is_terminal_state(&task.status) && task.status == "cancelled" {
1437        // Get all descendants recursively (depth = -1 means unlimited)
1438        if let Ok(descendants) = db.get_descendants(&task.id, -1) {
1439            for descendant in descendants {
1440                // Skip tasks already in a terminal state
1441                if states_config.is_terminal_state(&descendant.status) {
1442                    continue;
1443                }
1444                // Check if transition to cancelled is valid for this descendant
1445                if !states_config.is_valid_transition(&descendant.status, "cancelled") {
1446                    warn!(
1447                        "Cannot cascade cancel to task '{}': no valid transition from '{}' to 'cancelled'",
1448                        descendant.id, descendant.status
1449                    );
1450                    continue;
1451                }
1452                // Cancel the descendant using update_task_unified
1453                match db.update_task_unified(
1454                    &descendant.id,
1455                    &worker_id,
1456                    None, // no assignee
1457                    None, // no title change
1458                    None, // no description change
1459                    Some("cancelled".to_string()),
1460                    None, // no phase change
1461                    None, // no priority change
1462                    None, // no points change
1463                    None, // no tags change
1464                    None, // no needed_tags change
1465                    None, // no wanted_tags change
1466                    None, // no time_estimate_ms change
1467                    Some(format!("Cascade cancelled from parent task '{}'", task_id)),
1468                    true, // force=true to bypass ownership checks
1469                    states_config,
1470                    deps_config,
1471                    auto_advance,
1472                ) {
1473                    Ok((cancelled_task, _, _, _)) => {
1474                        cascaded.push(json!({
1475                            "id": cancelled_task.id,
1476                            "title": cancelled_task.title,
1477                        }));
1478                    }
1479                    Err(e) => {
1480                        warn!(
1481                            "Failed to cascade cancel to task '{}': {}",
1482                            descendant.id, e
1483                        );
1484                    }
1485                }
1486            }
1487        }
1488    }
1489
1490    // Pre-fetch worker info for context-sensitive prompts (must outlive ctx)
1491    let worker_info_for_prompts = db.get_worker(&worker_id).ok().flatten();
1492    let worker_role_for_prompts = worker_info_for_prompts
1493        .as_ref()
1494        .map(|w| workflows.match_role(&w.tags))
1495        .unwrap_or(None);
1496
1497    // Get transition prompts if status or phase may have changed
1498    // We update the worker's last seen state and get any matching prompts
1499    let mut transition_prompt_list: Vec<AttributedPrompt> = {
1500        // Update worker state and get old state for prompt calculation
1501        match db.update_worker_state(
1502            &worker_id,
1503            Some(&task.status),
1504            task.phase.as_deref(),
1505            Some(&task.id),
1506        ) {
1507            Ok((old_status, old_phase)) => {
1508                // Create context with task and agent info for rich template expansion
1509                let mut ctx = PromptContext::new(
1510                    &task.status,
1511                    task.phase.as_deref(),
1512                    states_config,
1513                    phases_config,
1514                )
1515                .with_task(&task.id, &task.title, task.priority, &task.tags);
1516
1517                // Add hierarchy level context from level:* tags
1518                let task_level_str: Option<String> = task
1519                    .tags
1520                    .iter()
1521                    .find(|t| t.starts_with("level:"))
1522                    .map(|t| t.strip_prefix("level:").unwrap_or(t).to_string());
1523                let child_count = db.get_children_ids(&task.id).ok().map(|ids| ids.len());
1524                // We need a reference that outlives ctx, so bind to a variable
1525                let task_level_ref = task_level_str.as_deref();
1526                ctx = ctx.with_level(task_level_ref, child_count);
1527
1528                // Add agent context if worker info is available
1529                if let Some(ref worker) = worker_info_for_prompts {
1530                    ctx = ctx.with_agent(
1531                        &worker_id,
1532                        worker_role_for_prompts.as_deref(),
1533                        &worker.tags,
1534                    );
1535                }
1536
1537                // Get prompts for this transition with context-sensitive template expansion + attribution
1538                crate::prompts::get_transition_prompts_attributed(
1539                    old_status.as_deref().unwrap_or(""),
1540                    old_phase.as_deref(),
1541                    &task.status,
1542                    task.phase.as_deref(),
1543                    workflows,
1544                    &ctx,
1545                )
1546            }
1547            Err(_) => vec![], // Worker not found or other error - skip prompts
1548        }
1549    };
1550
1551    // Build response with task and unblocked/auto_advanced lists
1552    let mut response = serde_json::to_value(&task)?;
1553    if let Value::Object(ref mut map) = response {
1554        // Always include unblocked if non-empty (tasks now ready to claim)
1555        if !unblocked.is_empty() {
1556            map.insert("unblocked".to_string(), json!(unblocked));
1557        }
1558        // Include auto_advanced if non-empty (tasks that were actually transitioned)
1559        if !auto_advanced.is_empty() {
1560            map.insert("auto_advanced".to_string(), json!(auto_advanced));
1561        }
1562        // Include cascaded cancellations if any
1563        if !cascaded.is_empty() {
1564            map.insert("cascaded".to_string(), json!(cascaded));
1565        }
1566        // Include auto_completed if non-empty (parent tasks auto-completed via rollup)
1567        if !auto_completed.is_empty() {
1568            let completed_info: Vec<serde_json::Value> = auto_completed
1569                .iter()
1570                .map(|(id, title)| json!({"id": id, "title": title}))
1571                .collect();
1572            map.insert("auto_completed".to_string(), json!(completed_info));
1573        }
1574        // Include attachment results if any were added
1575        if !attachment_results.is_empty() {
1576            map.insert("attachments_added".to_string(), json!(attachment_results));
1577        }
1578        // Include warnings if any
1579        if !attachment_warnings.is_empty() {
1580            map.insert(
1581                "attachment_warnings".to_string(),
1582                json!(attachment_warnings),
1583            );
1584        }
1585        // Include phase warning if any
1586        if let Some(ref warning) = phase_warning {
1587            map.insert("phase_warning".to_string(), json!(warning));
1588        }
1589        // Include tag warnings if any
1590        if !tag_warnings.is_empty() {
1591            map.insert("tag_warnings".to_string(), json!(tag_warnings));
1592        }
1593        // Include gate warnings if any
1594        if !gate_warnings.is_empty() {
1595            map.insert("gate_warnings".to_string(), json!(gate_warnings));
1596        }
1597        // Apply prompts filter based on the `prompts` parameter.
1598        // "none" — suppress all prompts.
1599        // "caller" — suppress prompts when an assignee is set (push coordination),
1600        //            since those prompts target the assignee, not the caller.
1601        // "all" (default) — include all prompts.
1602        let include_prompts = match prompts_mode.as_str() {
1603            "none" => false,
1604            "caller" => assignee.is_none(),
1605            _ => true, // "all" or any unrecognized value
1606        };
1607
1608        if include_prompts {
1609            // Add role-specific prompt based on the new status
1610            // Uses pre-fetched worker info to avoid redundant DB lookups
1611            if let Some(ref role_name) = worker_role_for_prompts {
1612                // Map status transitions to role prompt keys
1613                let prompt_key = match task.status.as_str() {
1614                    "completed" => Some("completing"),
1615                    _ => None,
1616                };
1617                if let Some(key) = prompt_key
1618                    && let Some(prompt) = workflows.get_role_prompt(role_name, key)
1619                {
1620                    transition_prompt_list.push(AttributedPrompt {
1621                        text: prompt.to_string(),
1622                        source: format!("role:{}", role_name),
1623                    });
1624                }
1625            }
1626
1627            // Include transition prompts if any (with source attribution)
1628            if !transition_prompt_list.is_empty() {
1629                let prompt_objects: Vec<Value> = transition_prompt_list
1630                    .iter()
1631                    .map(|p| json!({"text": p.text, "source": p.source}))
1632                    .collect();
1633                map.insert("prompts".to_string(), json!(prompt_objects));
1634            }
1635        }
1636
1637        // Include relevant advisory hints based on task tags, phase, and worker role
1638        let advisory_hints = super::advisories::relevant_advisory_topics(
1639            workflows,
1640            &task.tags,
1641            task.phase.as_deref(),
1642            worker_role_for_prompts.as_deref(),
1643        );
1644        if !advisory_hints.is_empty() {
1645            map.insert("advisory_hints".to_string(), json!(advisory_hints));
1646        }
1647    }
1648
1649    Ok(response)
1650}
1651
1652pub fn bulk_update(opts: UpdateOptions<'_>, args: Value) -> Result<Value> {
1653    let worker_id =
1654        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1655    let task_ids =
1656        get_string_array(&args, "tasks").ok_or_else(|| ToolError::missing_field("tasks"))?;
1657    let status = get_string(&args, "status").ok_or_else(|| ToolError::missing_field("status"))?;
1658    let reason = get_string(&args, "reason");
1659    let force = get_bool(&args, "force").unwrap_or(false);
1660
1661    let total = task_ids.len();
1662    let mut succeeded: Vec<Value> = Vec::new();
1663    let mut failed: Vec<Value> = Vec::new();
1664
1665    for task_id in &task_ids {
1666        // Build per-task arguments reusing the existing update function
1667        let per_task_args = json!({
1668            "worker_id": worker_id,
1669            "task": task_id,
1670            "status": status,
1671            "reason": reason,
1672            "force": force
1673        });
1674
1675        match update(
1676            UpdateOptions {
1677                db: opts.db,
1678                config: opts.config,
1679                workflows: opts.workflows,
1680            },
1681            per_task_args,
1682        ) {
1683            Ok(result) => {
1684                let task_title = result
1685                    .get("title")
1686                    .and_then(|v| v.as_str())
1687                    .unwrap_or("")
1688                    .to_string();
1689                let task_status = result
1690                    .get("status")
1691                    .and_then(|v| v.as_str())
1692                    .unwrap_or("")
1693                    .to_string();
1694                succeeded.push(json!({
1695                    "id": task_id,
1696                    "title": task_title,
1697                    "status": task_status
1698                }));
1699            }
1700            Err(e) => {
1701                failed.push(json!({
1702                    "id": task_id,
1703                    "error": e.to_string()
1704                }));
1705            }
1706        }
1707    }
1708
1709    Ok(json!({
1710        "succeeded": succeeded,
1711        "failed": failed,
1712        "total": total
1713    }))
1714}
1715
1716pub fn delete(db: &Database, args: Value) -> Result<Value> {
1717    let worker_id =
1718        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1719    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1720    let cascade = get_bool(&args, "cascade").unwrap_or(false);
1721    let reason = get_string(&args, "reason");
1722    let obliterate = get_bool(&args, "obliterate").unwrap_or(false);
1723    let force = get_bool(&args, "force").unwrap_or(false);
1724
1725    db.delete_task(&task_id, &worker_id, cascade, reason, obliterate, force)?;
1726
1727    Ok(json!({
1728        "success": true,
1729        "soft_deleted": !obliterate
1730    }))
1731}
1732
1733pub fn rename(db: &Database, args: Value) -> Result<Value> {
1734    let _worker_id =
1735        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1736    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1737    let new_id = get_string(&args, "new_id").ok_or_else(|| ToolError::missing_field("new_id"))?;
1738
1739    db.rename_task(&task_id, &new_id)?;
1740
1741    Ok(json!({
1742        "success": true,
1743        "old_id": task_id,
1744        "new_id": new_id
1745    }))
1746}
1747
1748pub fn scan(db: &Database, default_format: OutputFormat, args: Value) -> Result<ToolResult> {
1749    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1750    let format = get_string(&args, "format")
1751        .and_then(|s| OutputFormat::parse(&s))
1752        .unwrap_or(default_format);
1753
1754    // Depth parameters: 0=none, N=levels, -1=all
1755    let before_depth = get_i32(&args, "before").unwrap_or(0);
1756    let after_depth = get_i32(&args, "after").unwrap_or(0);
1757    let above_depth = get_i32(&args, "above").unwrap_or(0);
1758    let below_depth = get_i32(&args, "below").unwrap_or(0);
1759
1760    // Verify the task exists
1761    let root_task = db
1762        .get_task(&task_id)?
1763        .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
1764
1765    // Traverse in each direction
1766    let before = db.get_predecessors(&task_id, before_depth)?;
1767    let after = db.get_successors(&task_id, after_depth)?;
1768    let above = db.get_ancestors(&task_id, above_depth)?;
1769    let below = db.get_descendants(&task_id, below_depth)?;
1770
1771    let result = ScanResult {
1772        root: root_task,
1773        before,
1774        after,
1775        above,
1776        below,
1777    };
1778
1779    match format {
1780        OutputFormat::Markdown => Ok(ToolResult::Raw(format_scan_result_markdown(&result))),
1781        OutputFormat::Json => Ok(ToolResult::Json(serde_json::to_value(&result)?)),
1782    }
1783}
1784
1785pub fn status_summary(db: &Database, states_config: &StatesConfig, args: Value) -> Result<Value> {
1786    let parent = get_string(&args, "parent");
1787    let (counts, total) = db.get_status_summary(parent.as_deref(), states_config)?;
1788    Ok(json!({
1789        "counts": counts,
1790        "total": total,
1791    }))
1792}