1use 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
25pub struct UpdateOptions<'a> {
27 pub db: &'a Database,
28 pub config: &'a AppConfig,
29 pub workflows: &'a crate::config::workflows::WorkflowsConfig,
31}
32
33pub fn get_tools(prompts: &Prompts, states_config: &StatesConfig) -> Vec<Tool> {
34 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 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 if title.is_none() && description.is_none() {
460 return Err(ToolError::missing_field("title or description").into());
461 }
462
463 let effective_title = title.unwrap_or_else(|| {
465 crate::format::truncate_title(description.as_deref().unwrap_or("")).into_owned()
466 });
467
468 let phase_warning = if let Some(ref p) = phase {
470 phases_config.check_phase(p)?
471 } else {
472 None
473 };
474
475 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 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 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 let attachments = db.get_attachments(&task_id)?;
604
605 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 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 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 let tags_any = get_string_array(&args, "tags_any");
686 let tags_all = get_string_array(&args, "tags_all");
687
688 let agent_id = get_string(&args, "agent");
690
691 let sort_by = get_string(&args, "sort_by");
693 let sort_order = get_string(&args, "sort_order");
694
695 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 let pid = parent_id_str.as_deref().unwrap();
702 let mut descendants = db.get_descendants(pid, -1)?;
703
704 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 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 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 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 db.get_claimed_tasks(None)?
738 } else {
739 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), Some(pid_str) => Some(Some(pid_str.as_str())),
745 None => None,
746 };
747
748 let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
750
751 if has_tag_filters {
752 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 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 if let Some(ref p) = phase {
792 tasks.retain(|t| t.phase.as_deref() == Some(p.as_str()));
793 }
794
795 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 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 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 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 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 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 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 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 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 if mode == "replace" {
977 let _ = db.delete_attachments_by_type(&task_id, attachment_type);
978 }
979
980 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 let phase_warning = if let Some(ref p) = phase {
1009 phases_config.check_phase(p)?
1010 } else {
1011 None
1012 };
1013
1014 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 let mut gate_warnings: Vec<String> = Vec::new();
1028 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 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 ¤t_task.status != new_status {
1038 let exit_gates = workflows.get_status_exit_gates(¤t_task.status);
1040
1041 if !exit_gates.is_empty() {
1042 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 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 ¤t_task.status,
1058 &gate_names,
1059 )
1060 .into());
1061 }
1062 "warn" => {
1063 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 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 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 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 if let Some(ref new_phase) = phase {
1138 let current_task = db.get_task(&task_id)?.ok_or_else(|| {
1143 ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
1144 })?;
1145
1146 if let Some(ref current_phase) = current_task.phase
1148 && current_phase != new_phase
1149 {
1150 let exit_gates = workflows.get_phase_exit_gates(current_phase);
1152
1153 if !exit_gates.is_empty() {
1154 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 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 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 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 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 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 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 ¤t_task.status != new_status {
1278 let mut tag_gates: Vec<crate::config::GateDefinition> = Vec::new();
1280 for tag in ¤t_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 ¤t_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 let audit_reason = {
1374 let mut parts: Vec<String> = Vec::new();
1375
1376 if let Some(ref r) = reason {
1378 parts.push(r.clone());
1379 }
1380
1381 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 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 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 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 let mut cascaded: Vec<Value> = Vec::new();
1436 if cascade && states_config.is_terminal_state(&task.status) && task.status == "cancelled" {
1437 if let Ok(descendants) = db.get_descendants(&task.id, -1) {
1439 for descendant in descendants {
1440 if states_config.is_terminal_state(&descendant.status) {
1442 continue;
1443 }
1444 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 match db.update_task_unified(
1454 &descendant.id,
1455 &worker_id,
1456 None, None, None, Some("cancelled".to_string()),
1460 None, None, None, None, None, None, None, Some(format!("Cascade cancelled from parent task '{}'", task_id)),
1468 true, 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 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 let mut transition_prompt_list: Vec<AttributedPrompt> = {
1500 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 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 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 let task_level_ref = task_level_str.as_deref();
1526 ctx = ctx.with_level(task_level_ref, child_count);
1527
1528 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 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![], }
1549 };
1550
1551 let mut response = serde_json::to_value(&task)?;
1553 if let Value::Object(ref mut map) = response {
1554 if !unblocked.is_empty() {
1556 map.insert("unblocked".to_string(), json!(unblocked));
1557 }
1558 if !auto_advanced.is_empty() {
1560 map.insert("auto_advanced".to_string(), json!(auto_advanced));
1561 }
1562 if !cascaded.is_empty() {
1564 map.insert("cascaded".to_string(), json!(cascaded));
1565 }
1566 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 if !attachment_results.is_empty() {
1576 map.insert("attachments_added".to_string(), json!(attachment_results));
1577 }
1578 if !attachment_warnings.is_empty() {
1580 map.insert(
1581 "attachment_warnings".to_string(),
1582 json!(attachment_warnings),
1583 );
1584 }
1585 if let Some(ref warning) = phase_warning {
1587 map.insert("phase_warning".to_string(), json!(warning));
1588 }
1589 if !tag_warnings.is_empty() {
1591 map.insert("tag_warnings".to_string(), json!(tag_warnings));
1592 }
1593 if !gate_warnings.is_empty() {
1595 map.insert("gate_warnings".to_string(), json!(gate_warnings));
1596 }
1597 let include_prompts = match prompts_mode.as_str() {
1603 "none" => false,
1604 "caller" => assignee.is_none(),
1605 _ => true, };
1607
1608 if include_prompts {
1609 if let Some(ref role_name) = worker_role_for_prompts {
1612 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 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 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 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 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 let root_task = db
1762 .get_task(&task_id)?
1763 .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
1764
1765 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}