Skip to main content

task_graph_mcp/tools/
tasks.rs

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