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