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, 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
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": "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 "offset": {
193 "type": "integer",
194 "description": "Number of tasks to skip for pagination (default: 0)"
195 }
196 }),
197 vec![],
198 prompts,
199 ),
200 make_tool_with_prompts(
201 "update",
202 "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.",
203 json!({
204 "worker_id": {
205 "type": "string",
206 "description": "Worker ID making the update"
207 },
208 "task": {
209 "type": "string",
210 "description": "Task ID"
211 },
212 "assignee": {
213 "type": "string",
214 "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."
215 },
216 "status": {
217 "type": "string",
218 "enum": state_enum,
219 "description": "New status"
220 },
221 "title": {
222 "type": "string",
223 "description": "New title"
224 },
225 "description": {
226 "type": "string",
227 "description": "New description"
228 },
229 "priority": {
230 "type": "integer",
231 "description": "New priority 0-10 (higher = more important)"
232 },
233 "points": {
234 "type": "integer",
235 "description": "New points estimate"
236 },
237 "tags": {
238 "type": "array",
239 "items": { "type": "string" },
240 "description": "New categorization/discovery tags"
241 },
242 "needed_tags": {
243 "type": "array",
244 "items": { "type": "string" },
245 "description": "Tags agent must have ALL of to claim (AND)"
246 },
247 "wanted_tags": {
248 "type": "array",
249 "items": { "type": "string" },
250 "description": "Tags agent must have AT LEAST ONE of to claim (OR)"
251 },
252 "time_estimate_ms": {
253 "type": "integer",
254 "description": "Estimated duration in milliseconds"
255 },
256 "reason": {
257 "type": "string",
258 "description": "Reason for the update (stored in audit trail for state transitions)"
259 },
260 "force": {
261 "type": "boolean",
262 "description": "Force ownership changes even if owned by another worker (default: false)"
263 },
264 "attachments": {
265 "type": "array",
266 "description": "List of attachments to add to the task (e.g., commit hashes, changelists, notes)",
267 "items": {
268 "type": "object",
269 "properties": {
270 "type": {
271 "type": "string",
272 "description": "Attachment type/category (e.g., 'commit', 'changelist', 'note'). Used for indexing and replace operations."
273 },
274 "name": {
275 "type": "string",
276 "description": "Optional label/name for the attachment (arbitrary string)"
277 },
278 "content": {
279 "type": "string",
280 "description": "Attachment content (text)"
281 },
282 "mime": {
283 "type": "string",
284 "description": "MIME type (uses configured default if omitted)"
285 },
286 "mode": {
287 "type": "string",
288 "enum": ["append", "replace"],
289 "description": "How to handle existing attachments of this type: 'append' adds new, 'replace' deletes all of this type first"
290 }
291 },
292 "required": ["type", "content"]
293 }
294 }
295 }),
296 vec!["worker_id", "task"],
297 prompts,
298 ),
299 make_tool_with_prompts(
300 "delete",
301 "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.",
302 json!({
303 "worker_id": {
304 "type": "string",
305 "description": "Worker ID attempting to delete"
306 },
307 "task": {
308 "type": "string",
309 "description": "Task ID"
310 },
311 "cascade": {
312 "type": "boolean",
313 "description": "Whether to delete children (default: false)"
314 },
315 "reason": {
316 "type": "string",
317 "description": "Optional reason for deletion"
318 },
319 "obliterate": {
320 "type": "boolean",
321 "description": "If true, permanently deletes the task from the database. If false (default), soft deletes by setting deleted_at timestamp."
322 },
323 "force": {
324 "type": "boolean",
325 "description": "Force deletion even if claimed by another worker (default: false)"
326 }
327 }),
328 vec!["worker_id", "task"],
329 prompts,
330 ),
331 make_tool_with_prompts(
332 "rename",
333 "Change a task's ID. Updates all references (dependencies, attachments, file marks, tags, etc.) atomically.",
334 json!({
335 "worker_id": {
336 "type": "string",
337 "description": "Worker ID (for audit)"
338 },
339 "task": {
340 "type": "string",
341 "description": "Current task ID"
342 },
343 "new_id": {
344 "type": "string",
345 "description": "New task ID"
346 }
347 }),
348 vec!["worker_id", "task", "new_id"],
349 prompts,
350 ),
351 make_tool_with_prompts(
352 "scan",
353 "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.",
354 json!({
355 "task": {
356 "type": "string",
357 "description": "Task ID to scan from"
358 },
359 "before": {
360 "type": "integer",
361 "description": "Depth for predecessors (tasks that block this one): 0=none, N=levels, -1=all (default: 0)"
362 },
363 "after": {
364 "type": "integer",
365 "description": "Depth for successors (tasks this one blocks): 0=none, N=levels, -1=all (default: 0)"
366 },
367 "above": {
368 "type": "integer",
369 "description": "Depth for ancestors (parent chain): 0=none, N=levels, -1=all (default: 0)"
370 },
371 "below": {
372 "type": "integer",
373 "description": "Depth for descendants (children tree): 0=none, N=levels, -1=all (default: 0)"
374 },
375 "format": {
376 "type": "string",
377 "enum": ["json", "markdown"],
378 "description": "Output format (default: json)"
379 }
380 }),
381 vec!["task"],
382 prompts,
383 ),
384 ]
385}
386
387pub fn create(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
388 let states_config = &config.states;
389 let phases_config = &config.phases;
390 let tags_config = &config.tags;
391 let ids_config = &config.ids;
392 let id = get_string(&args, "id");
393 let title = get_string(&args, "title");
394 let description = get_string(&args, "description");
395 let parent_id = get_string(&args, "parent");
396 let phase = get_string(&args, "phase");
397 let priority = get_i32(&args, "priority")
399 .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
400 let points = get_i32(&args, "points");
401 let time_estimate_ms = get_i64(&args, "time_estimate_ms");
402 let tags = get_string_array(&args, "tags");
403 let needed_tags = get_string_array(&args, "needed_tags");
404 let wanted_tags = get_string_array(&args, "wanted_tags");
405
406 if title.is_none() && description.is_none() {
408 return Err(ToolError::missing_field("title or description").into());
409 }
410
411 let effective_title = title.unwrap_or_else(|| {
413 crate::format::truncate_title(description.as_deref().unwrap_or("")).into_owned()
414 });
415
416 let phase_warning = if let Some(ref p) = phase {
418 phases_config.check_phase(p)?
419 } else {
420 None
421 };
422
423 let mut tag_warnings = Vec::new();
425 if let Some(ref t) = tags {
426 tag_warnings.extend(tags_config.validate_tags(t)?);
427 }
428 if let Some(ref t) = needed_tags {
429 tag_warnings.extend(tags_config.validate_tags(t)?);
430 }
431 if let Some(ref t) = wanted_tags {
432 tag_warnings.extend(tags_config.validate_tags(t)?);
433 }
434
435 let task = db.create_task(
436 id,
437 effective_title,
438 description,
439 parent_id,
440 phase,
441 priority,
442 points,
443 time_estimate_ms,
444 needed_tags,
445 wanted_tags,
446 tags,
447 states_config,
448 ids_config,
449 )?;
450
451 let mut response = json!({
452 "id": &task.id,
453 "title": task.title,
454 "description": task.description,
455 "status": task.status,
456 "phase": task.phase,
457 "priority": task.priority,
458 "created_at": task.created_at
459 });
460
461 if let Some(warning) = phase_warning {
462 response["phase_warning"] = json!(warning);
463 }
464
465 if !tag_warnings.is_empty() {
466 response["tag_warnings"] = json!(tag_warnings);
467 }
468
469 if task.title.len() > crate::format::MAX_TITLE_DISPLAY_LEN || task.title.contains('\n') {
471 response["title_warning"] = json!(
472 "Title exceeds 80 chars or is multi-line. Consider using a short title and keeping detail in the description."
473 );
474 }
475
476 Ok(response)
477}
478
479pub fn create_tree(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
480 let states_config = &config.states;
481 let phases_config = &config.phases;
482 let tags_config = &config.tags;
483 let ids_config = &config.ids;
484 let tree: TaskTreeInput = serde_json::from_value(
485 args.get("tree")
486 .cloned()
487 .ok_or_else(|| ToolError::missing_field("tree"))?,
488 )?;
489 let parent_id = get_string(&args, "parent");
490 let child_type = get_string(&args, "child_type");
491 let sibling_type = get_string(&args, "sibling_type");
492
493 let (root_id, all_ids, phase_warnings, tag_warnings) =
494 db.create_task_tree(CreateTreeOptions {
495 input: tree,
496 parent_id,
497 child_type,
498 sibling_type,
499 states_config,
500 phases_config,
501 tags_config,
502 ids_config,
503 })?;
504
505 let root_task = db.get_task(&root_id)?.ok_or_else(|| {
507 ToolError::new(
508 crate::error::ErrorCode::TaskNotFound,
509 "Root task not found after creation",
510 )
511 })?;
512
513 let mut response = json!({
514 "root": {
515 "id": root_task.id,
516 "title": root_task.title,
517 "description": root_task.description,
518 "status": root_task.status,
519 "phase": root_task.phase,
520 "priority": root_task.priority,
521 "created_at": root_task.created_at
522 },
523 "all_ids": all_ids,
524 "count": all_ids.len()
525 });
526
527 if !phase_warnings.is_empty() {
528 response["phase_warnings"] = json!(phase_warnings);
529 }
530
531 if !tag_warnings.is_empty() {
532 response["tag_warnings"] = json!(tag_warnings);
533 }
534
535 Ok(response)
536}
537
538pub fn get(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
539 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
540 let format = get_string(&args, "format")
541 .and_then(|s| OutputFormat::parse(&s))
542 .unwrap_or(default_format);
543
544 let task = db
545 .get_task(&task_id)?
546 .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
547
548 let blocked_by = db.get_blockers(&task_id)?;
549
550 let attachments = db.get_attachments(&task_id)?;
552
553 let mut attachment_counts: std::collections::HashMap<String, i32> =
555 std::collections::HashMap::new();
556 for att in &attachments {
557 *attachment_counts.entry(att.mime_type.clone()).or_insert(0) += 1;
558 }
559
560 match format {
561 OutputFormat::Markdown => {
562 let mut md = format_task_markdown(&task, &blocked_by);
563
564 if !attachments.is_empty() {
566 md.push_str("\n### Attachments\n");
567 for att in &attachments {
568 let file_indicator = if att.file_path.is_some() {
569 " (file)"
570 } else {
571 ""
572 };
573 md.push_str(&format!(
574 "- **{}** [{}]{}\n",
575 att.name, att.mime_type, file_indicator
576 ));
577 }
578
579 md.push_str("\n**Counts by type:**\n");
581 for (mime_type, count) in &attachment_counts {
582 md.push_str(&format!("- {}: {}\n", mime_type, count));
583 }
584 }
585
586 Ok(markdown_to_json(md))
587 }
588 OutputFormat::Json => {
589 let mut task_json = serde_json::to_value(&task)?;
590 if let Some(obj) = task_json.as_object_mut() {
591 obj.insert("blocked_by".to_string(), json!(blocked_by));
592 obj.insert(
593 "attachments".to_string(),
594 serde_json::to_value(&attachments)?,
595 );
596 obj.insert(
597 "attachment_counts".to_string(),
598 serde_json::to_value(&attachment_counts)?,
599 );
600 }
601 Ok(task_json)
602 }
603 }
604}
605
606pub fn list_tasks(
607 db: &Database,
608 states_config: &StatesConfig,
609 deps_config: &DependenciesConfig,
610 default_format: OutputFormat,
611 args: Value,
612) -> Result<Value> {
613 let format = get_string(&args, "format")
614 .and_then(|s| OutputFormat::parse(&s))
615 .unwrap_or(default_format);
616
617 let ready = get_bool(&args, "ready").unwrap_or(false);
618 let blocked = get_bool(&args, "blocked").unwrap_or(false);
619 let claimed = get_bool(&args, "claimed").unwrap_or(false);
620 let recursive = get_bool(&args, "recursive").unwrap_or(false);
621 let limit = get_i32(&args, "limit");
622 let offset = get_i32(&args, "offset").unwrap_or(0).max(0);
623 let fetch_limit = limit.map(|l| l + 1);
624 let phase = get_string(&args, "phase");
625
626 let tags_any = get_string_array(&args, "tags_any");
628 let tags_all = get_string_array(&args, "tags_all");
629
630 let agent_id = get_string(&args, "agent");
632
633 let sort_by = get_string(&args, "sort_by");
635 let sort_order = get_string(&args, "sort_order");
636
637 let parent_id_str = get_string(&args, "parent");
639
640 let mut tasks =
641 if recursive && parent_id_str.is_some() && parent_id_str.as_deref() != Some("null") {
642 let pid = parent_id_str.as_deref().unwrap();
644 let mut descendants = db.get_descendants(pid, -1)?;
645
646 if let Some(status_set) = get_string_or_array(&args, "status")
648 && !status_set.is_empty()
649 {
650 descendants.retain(|t| status_set.contains(&t.status));
651 }
652
653 if let Some(ref owner) = get_string(&args, "owner") {
655 descendants.retain(|t| t.worker_id.as_deref() == Some(owner.as_str()));
656 }
657
658 descendants
659 } else if ready {
660 db.get_ready_tasks(
663 agent_id.as_deref(),
664 states_config,
665 deps_config,
666 sort_by.as_deref(),
667 sort_order.as_deref(),
668 )?
669 } else if blocked {
670 db.get_blocked_tasks(
672 states_config,
673 deps_config,
674 sort_by.as_deref(),
675 sort_order.as_deref(),
676 )?
677 } else if claimed {
678 db.get_claimed_tasks(None)?
680 } else {
681 let status_vec = get_string_or_array(&args, "status");
683 let owner = get_string(&args, "owner");
684 let parent_id: Option<Option<&str>> = match &parent_id_str {
685 Some(pid_str) if pid_str == "null" => Some(None), Some(pid_str) => Some(Some(pid_str.as_str())),
687 None => None,
688 };
689
690 let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
692
693 if has_tag_filters {
694 let qualified_agent_tags = if let Some(aid) = &agent_id {
697 Some(db.get_agent_tags(aid)?)
698 } else {
699 None
700 };
701
702 db.list_tasks_with_tag_filters(
703 status_vec,
704 owner.as_deref(),
705 parent_id,
706 tags_any,
707 tags_all,
708 qualified_agent_tags,
709 fetch_limit,
710 offset,
711 sort_by.as_deref(),
712 sort_order.as_deref(),
713 )?
714 } else {
715 let status = status_vec
717 .as_ref()
718 .and_then(|v| v.first().map(|s| s.as_str()));
719 db.list_tasks(ListTasksQuery {
720 status,
721 phase: phase.as_deref(),
722 owner: owner.as_deref(),
723 parent_id,
724 limit: fetch_limit,
725 offset,
726 sort_by: sort_by.as_deref(),
727 sort_order: sort_order.as_deref(),
728 })?
729 }
730 };
731
732 if let Some(ref p) = phase {
734 tasks.retain(|t| t.phase.as_deref() == Some(p.as_str()));
735 }
736
737 if offset > 0 && (ready || blocked || claimed || recursive) {
740 if (offset as usize) < tasks.len() {
741 tasks = tasks.split_off(offset as usize);
742 } else {
743 tasks.clear();
744 }
745 }
746
747 let has_more = limit.is_some_and(|l| tasks.len() > l as usize);
749 if let Some(l) = limit {
750 tasks.truncate(l as usize);
751 }
752
753 let tasks_with_blockers: Vec<_> = tasks
755 .into_iter()
756 .map(|task| {
757 let blockers = db.get_blockers(&task.id).unwrap_or_default();
758 (task, blockers)
759 })
760 .collect();
761
762 match format {
763 OutputFormat::Markdown => {
764 let mut md = format_tasks_markdown(&tasks_with_blockers, states_config);
765 if has_more {
766 let next_offset = offset + limit.unwrap_or(0);
767 md.push_str(&format!(
768 "\n\n*More results available. Use offset={} to see next page.*",
769 next_offset
770 ));
771 }
772 Ok(markdown_to_json(md))
773 }
774 OutputFormat::Json => Ok(json!({
775 "tasks": tasks_with_blockers.iter().map(|(task, blockers)| {
776 let mut task_json = serde_json::to_value(task).unwrap();
777 if let Some(obj) = task_json.as_object_mut() {
778 obj.insert("blocked_by".to_string(), json!(blockers));
779 }
780 task_json
781 }).collect::<Vec<_>>(),
782 "has_more": has_more,
783 "offset": offset,
784 "limit": limit,
785 })),
786 }
787}
788
789pub fn update(opts: UpdateOptions<'_>, args: Value) -> Result<Value> {
790 let UpdateOptions {
791 db,
792 config,
793 workflows,
794 } = opts;
795
796 let attachments_config = &config.attachments;
797 let states_config_owned: StatesConfig = workflows.into();
799 let states_config = &states_config_owned;
800 let phases_config = &config.phases;
801 let deps_config = &config.deps;
802 let auto_advance = &config.auto_advance;
803 let tags_config = &config.tags;
804
805 let worker_id =
806 get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
807 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
808 let assignee = get_string(&args, "assignee");
809 let title = get_string(&args, "title");
810 let description = if args.get("description").is_some() {
811 Some(get_string(&args, "description"))
812 } else {
813 None
814 };
815 let status = get_string(&args, "status");
816 let phase = get_string(&args, "phase");
817 let priority = get_i32(&args, "priority")
819 .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
820 let points = if args.get("points").is_some() {
821 Some(get_i32(&args, "points"))
822 } else {
823 None
824 };
825 let tags = if args.get("tags").is_some() {
826 Some(get_string_array(&args, "tags").unwrap_or_default())
827 } else {
828 None
829 };
830 let needed_tags = if args.get("needed_tags").is_some() {
831 Some(get_string_array(&args, "needed_tags").unwrap_or_default())
832 } else {
833 None
834 };
835 let wanted_tags = if args.get("wanted_tags").is_some() {
836 Some(get_string_array(&args, "wanted_tags").unwrap_or_default())
837 } else {
838 None
839 };
840 let time_estimate_ms = get_i64(&args, "time_estimate_ms");
841 let reason = get_string(&args, "reason");
842 let force = get_bool(&args, "force").unwrap_or(false);
843
844 let mut attachment_results: Vec<Value> = Vec::new();
846 let mut attachment_warnings: Vec<String> = Vec::new();
847
848 if let Some(attachments_arr) = args.get("attachments").and_then(|v| v.as_array()) {
849 for att_value in attachments_arr {
850 let attachment_type = att_value.get("type").and_then(|v| v.as_str());
851 let name = att_value.get("name").and_then(|v| v.as_str()).unwrap_or("");
852 let content = att_value.get("content").and_then(|v| v.as_str());
853 let mime_override = att_value.get("mime").and_then(|v| v.as_str());
854 let mode_override = att_value.get("mode").and_then(|v| v.as_str());
855
856 let attachment_type = match attachment_type {
857 Some(t) => t,
858 None => {
859 attachment_warnings
860 .push("Skipped attachment: missing 'type' field".to_string());
861 continue;
862 }
863 };
864
865 let content = match content {
866 Some(c) => c,
867 None => {
868 attachment_warnings.push(format!(
869 "Skipped attachment type '{}': missing 'content' field",
870 attachment_type
871 ));
872 continue;
873 }
874 };
875
876 if !attachments_config.is_known_key(attachment_type) {
878 match attachments_config.unknown_key {
879 UnknownKeyBehavior::Reject => {
880 attachment_warnings.push(format!(
881 "Rejected attachment type '{}': unknown type (configure in attachments.definitions or set unknown_key to 'allow')",
882 attachment_type
883 ));
884 continue;
885 }
886 UnknownKeyBehavior::Warn => {
887 attachment_warnings
888 .push(format!("Unknown attachment type '{}'", attachment_type));
889 }
890 UnknownKeyBehavior::Allow => {}
891 }
892 }
893
894 let mime_type = mime_override.map(String::from).unwrap_or_else(|| {
896 attachments_config
897 .get_mime_default(attachment_type)
898 .to_string()
899 });
900 let mode = mode_override
901 .unwrap_or_else(|| attachments_config.get_mode_default(attachment_type));
902
903 if mode != "append" && mode != "replace" {
905 attachment_warnings.push(format!(
906 "Skipped attachment type '{}': mode must be 'append' or 'replace'",
907 attachment_type
908 ));
909 continue;
910 }
911
912 if mode == "replace" {
914 let _ = db.delete_attachments_by_type(&task_id, attachment_type);
915 }
916
917 match db.add_attachment(
919 &task_id,
920 attachment_type.to_string(),
921 name.to_string(),
922 content.to_string(),
923 Some(mime_type.clone()),
924 None,
925 ) {
926 Ok(sequence) => {
927 attachment_results.push(json!({
928 "type": attachment_type,
929 "sequence": sequence,
930 "name": name,
931 "mime_type": mime_type
932 }));
933 }
934 Err(e) => {
935 attachment_warnings.push(format!(
936 "Failed to add attachment type '{}': {}",
937 attachment_type, e
938 ));
939 }
940 }
941 }
942 }
943
944 let phase_warning = if let Some(ref p) = phase {
946 phases_config.check_phase(p)?
947 } else {
948 None
949 };
950
951 let mut tag_warnings = Vec::new();
953 if let Some(ref t) = tags {
954 tag_warnings.extend(tags_config.validate_tags(t)?);
955 }
956 if let Some(ref t) = needed_tags {
957 tag_warnings.extend(tags_config.validate_tags(t)?);
958 }
959 if let Some(ref t) = wanted_tags {
960 tag_warnings.extend(tags_config.validate_tags(t)?);
961 }
962
963 let mut gate_warnings: Vec<String> = Vec::new();
965 let mut skipped_status_gates: Vec<String> = Vec::new();
967 let mut skipped_phase_gates: Vec<String> = Vec::new();
968 if let Some(ref new_status) = status {
969 let current_task = db.get_task(&task_id)?.ok_or_else(|| {
971 ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
972 })?;
973
974 if ¤t_task.status != new_status {
975 let exit_gates = workflows.get_status_exit_gates(¤t_task.status);
977
978 if !exit_gates.is_empty() {
979 let gates_owned: Vec<crate::config::GateDefinition> =
981 exit_gates.iter().map(|g| (*g).clone()).collect();
982 let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
983
984 match gate_result.status.as_str() {
985 "fail" => {
986 let gate_names: Vec<String> = gate_result
988 .unsatisfied_gates
989 .iter()
990 .filter(|g| g.enforcement == GateEnforcement::Reject)
991 .map(|g| format!("{} ({})", g.gate_type, g.description))
992 .collect();
993 return Err(ToolError::gates_not_satisfied(
994 ¤t_task.status,
995 &gate_names,
996 )
997 .into());
998 }
999 "warn" => {
1000 let warn_gates: Vec<String> = gate_result
1002 .unsatisfied_gates
1003 .iter()
1004 .filter(|g| g.enforcement == GateEnforcement::Warn)
1005 .map(|g| format!("{} ({})", g.gate_type, g.description))
1006 .collect();
1007
1008 if !force {
1009 let how_to_fix: Vec<String> = warn_gates
1011 .iter()
1012 .map(|g| {
1013 let gate_type = g.split(" (").next().unwrap_or(g);
1014 format!(
1015 " - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1016 task_id, gate_type
1017 )
1018 })
1019 .collect();
1020 return Err(ToolError::new(
1021 crate::error::ErrorCode::GatesNotSatisfied,
1022 format!(
1023 "Cannot exit '{}' without force=true: unsatisfied gates: {}",
1024 current_task.status,
1025 warn_gates.join(", ")
1026 ),
1027 )
1028 .with_details(format!(
1029 "Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1030 how_to_fix.join("\n")
1031 ))
1032 .with_suggestion(
1033 "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1034 )
1035 .into());
1036 }
1037 warn!(
1039 task_id = %task_id,
1040 agent = %worker_id,
1041 from_status = %current_task.status,
1042 to_status = %new_status,
1043 skipped_gates = ?warn_gates,
1044 "Status transition with skipped warn gates (force=true)"
1045 );
1046 skipped_status_gates = warn_gates.clone();
1047 gate_warnings.push(format!(
1048 "Proceeding despite unsatisfied gates (force=true): {}",
1049 warn_gates.join(", ")
1050 ));
1051 }
1052 "pass" => {
1053 let allow_gates: Vec<String> = gate_result
1055 .unsatisfied_gates
1056 .iter()
1057 .filter(|g| g.enforcement == GateEnforcement::Allow)
1058 .map(|g| format!("{} ({})", g.gate_type, g.description))
1059 .collect();
1060 if !allow_gates.is_empty() {
1061 gate_warnings.push(format!(
1062 "Optional gates not satisfied: {}",
1063 allow_gates.join(", ")
1064 ));
1065 }
1066 }
1067 _ => {}
1068 }
1069 }
1070 }
1071 }
1072
1073 if let Some(ref new_phase) = phase {
1075 let current_task = db.get_task(&task_id)?.ok_or_else(|| {
1080 ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
1081 })?;
1082
1083 if let Some(ref current_phase) = current_task.phase
1085 && current_phase != new_phase
1086 {
1087 let exit_gates = workflows.get_phase_exit_gates(current_phase);
1089
1090 if !exit_gates.is_empty() {
1091 let gates_owned: Vec<crate::config::GateDefinition> =
1093 exit_gates.iter().map(|g| (*g).clone()).collect();
1094 let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
1095
1096 match gate_result.status.as_str() {
1097 "fail" => {
1098 let gate_names: Vec<String> = gate_result
1100 .unsatisfied_gates
1101 .iter()
1102 .filter(|g| g.enforcement == GateEnforcement::Reject)
1103 .map(|g| format!("{} ({})", g.gate_type, g.description))
1104 .collect();
1105 let how_to_fix: Vec<String> = gate_names
1106 .iter()
1107 .map(|g| {
1108 let gate_type = g.split(" (").next().unwrap_or(g);
1109 format!(
1110 " - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1111 task_id, gate_type
1112 )
1113 })
1114 .collect();
1115 return Err(ToolError::new(
1116 crate::error::ErrorCode::GatesNotSatisfied,
1117 format!(
1118 "Cannot exit phase '{}': unsatisfied gates: {}",
1119 current_phase,
1120 gate_names.join(", ")
1121 ),
1122 )
1123 .with_details(format!(
1124 "These are reject-level gates and cannot be skipped. Satisfy them:\n{}",
1125 how_to_fix.join("\n")
1126 ))
1127 .with_suggestion(
1128 "Attach the required gate artifacts, then retry the phase transition."
1129 .to_string(),
1130 )
1131 .into());
1132 }
1133 "warn" => {
1134 let warn_gates: Vec<String> = gate_result
1136 .unsatisfied_gates
1137 .iter()
1138 .filter(|g| g.enforcement == GateEnforcement::Warn)
1139 .map(|g| format!("{} ({})", g.gate_type, g.description))
1140 .collect();
1141
1142 if !force {
1143 let how_to_fix: Vec<String> = warn_gates
1145 .iter()
1146 .map(|g| {
1147 let gate_type = g.split(" (").next().unwrap_or(g);
1148 format!(
1149 " - attach(task=\"{}\", type=\"{}\", content=\"...\")",
1150 task_id, gate_type
1151 )
1152 })
1153 .collect();
1154 return Err(ToolError::new(
1155 crate::error::ErrorCode::GatesNotSatisfied,
1156 format!(
1157 "Cannot exit phase '{}' without force=true: unsatisfied gates: {}",
1158 current_phase,
1159 warn_gates.join(", ")
1160 ),
1161 )
1162 .with_details(format!(
1163 "Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
1164 how_to_fix.join("\n")
1165 ))
1166 .with_suggestion(
1167 "Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
1168 )
1169 .into());
1170 }
1171 warn!(
1173 task_id = %task_id,
1174 agent = %worker_id,
1175 from_phase = %current_phase,
1176 to_phase = %new_phase,
1177 skipped_gates = ?warn_gates,
1178 "Phase transition with skipped warn gates (force=true)"
1179 );
1180 skipped_phase_gates = warn_gates.clone();
1181 gate_warnings.push(format!(
1182 "Proceeding despite unsatisfied phase gates (force=true): {}",
1183 warn_gates.join(", ")
1184 ));
1185 }
1186 "pass" => {
1187 let allow_gates: Vec<String> = gate_result
1189 .unsatisfied_gates
1190 .iter()
1191 .filter(|g| g.enforcement == GateEnforcement::Allow)
1192 .map(|g| format!("{} ({})", g.gate_type, g.description))
1193 .collect();
1194 if !allow_gates.is_empty() {
1195 gate_warnings.push(format!(
1196 "Optional phase gates not satisfied: {}",
1197 allow_gates.join(", ")
1198 ));
1199 }
1200 }
1201 _ => {}
1202 }
1203 }
1204 }
1205 }
1206
1207 let audit_reason = {
1209 let mut parts: Vec<String> = Vec::new();
1210
1211 if let Some(ref r) = reason {
1213 parts.push(r.clone());
1214 }
1215
1216 if !skipped_status_gates.is_empty() {
1218 parts.push(format!(
1219 "Skipped status exit gates (force=true): {}",
1220 skipped_status_gates.join(", ")
1221 ));
1222 }
1223
1224 if !skipped_phase_gates.is_empty() {
1226 parts.push(format!(
1227 "Skipped phase exit gates (force=true): {}",
1228 skipped_phase_gates.join(", ")
1229 ));
1230 }
1231
1232 if parts.is_empty() {
1233 None
1234 } else {
1235 Some(parts.join("; "))
1236 }
1237 };
1238
1239 let (task, unblocked, auto_advanced) = db.update_task_unified(
1241 &task_id,
1242 &worker_id,
1243 assignee.as_deref(),
1244 title,
1245 description,
1246 status,
1247 phase,
1248 priority,
1249 points,
1250 tags,
1251 needed_tags,
1252 wanted_tags,
1253 time_estimate_ms,
1254 audit_reason,
1255 force,
1256 states_config,
1257 deps_config,
1258 auto_advance,
1259 )?;
1260
1261 let worker_info_for_prompts = db.get_worker(&worker_id).ok().flatten();
1263 let worker_role_for_prompts = worker_info_for_prompts
1264 .as_ref()
1265 .map(|w| workflows.match_role(&w.tags))
1266 .unwrap_or(None);
1267
1268 let mut transition_prompt_list: Vec<String> = {
1271 match db.update_worker_state(&worker_id, Some(&task.status), task.phase.as_deref()) {
1273 Ok((old_status, old_phase)) => {
1274 let mut ctx = PromptContext::new(
1276 &task.status,
1277 task.phase.as_deref(),
1278 states_config,
1279 phases_config,
1280 )
1281 .with_task(&task.id, &task.title, task.priority, &task.tags);
1282
1283 if let Some(ref worker) = worker_info_for_prompts {
1285 ctx = ctx.with_agent(
1286 &worker_id,
1287 worker_role_for_prompts.as_deref(),
1288 &worker.tags,
1289 );
1290 }
1291
1292 crate::prompts::get_transition_prompts_with_context(
1294 old_status.as_deref().unwrap_or(""),
1295 old_phase.as_deref(),
1296 &task.status,
1297 task.phase.as_deref(),
1298 workflows,
1299 &ctx,
1300 )
1301 }
1302 Err(_) => vec![], }
1304 };
1305
1306 let mut response = serde_json::to_value(&task)?;
1308 if let Value::Object(ref mut map) = response {
1309 if !unblocked.is_empty() {
1311 map.insert("unblocked".to_string(), json!(unblocked));
1312 }
1313 if !auto_advanced.is_empty() {
1315 map.insert("auto_advanced".to_string(), json!(auto_advanced));
1316 }
1317 if !attachment_results.is_empty() {
1319 map.insert("attachments_added".to_string(), json!(attachment_results));
1320 }
1321 if !attachment_warnings.is_empty() {
1323 map.insert(
1324 "attachment_warnings".to_string(),
1325 json!(attachment_warnings),
1326 );
1327 }
1328 if let Some(ref warning) = phase_warning {
1330 map.insert("phase_warning".to_string(), json!(warning));
1331 }
1332 if !tag_warnings.is_empty() {
1334 map.insert("tag_warnings".to_string(), json!(tag_warnings));
1335 }
1336 if !gate_warnings.is_empty() {
1338 map.insert("gate_warnings".to_string(), json!(gate_warnings));
1339 }
1340 if let Some(ref role_name) = worker_role_for_prompts {
1343 let prompt_key = match task.status.as_str() {
1345 "completed" => Some("completing"),
1346 _ => None,
1347 };
1348 if let Some(key) = prompt_key
1349 && let Some(prompt) = workflows.get_role_prompt(role_name, key)
1350 {
1351 transition_prompt_list.push(prompt.to_string());
1352 }
1353 }
1354
1355 if !transition_prompt_list.is_empty() {
1357 map.insert("prompts".to_string(), json!(transition_prompt_list));
1358 }
1359 }
1360
1361 Ok(response)
1362}
1363
1364pub fn delete(db: &Database, args: Value) -> Result<Value> {
1365 let worker_id =
1366 get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1367 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1368 let cascade = get_bool(&args, "cascade").unwrap_or(false);
1369 let reason = get_string(&args, "reason");
1370 let obliterate = get_bool(&args, "obliterate").unwrap_or(false);
1371 let force = get_bool(&args, "force").unwrap_or(false);
1372
1373 db.delete_task(&task_id, &worker_id, cascade, reason, obliterate, force)?;
1374
1375 Ok(json!({
1376 "success": true,
1377 "soft_deleted": !obliterate
1378 }))
1379}
1380
1381pub fn rename(db: &Database, args: Value) -> Result<Value> {
1382 let _worker_id =
1383 get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1384 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1385 let new_id = get_string(&args, "new_id").ok_or_else(|| ToolError::missing_field("new_id"))?;
1386
1387 db.rename_task(&task_id, &new_id)?;
1388
1389 Ok(json!({
1390 "success": true,
1391 "old_id": task_id,
1392 "new_id": new_id
1393 }))
1394}
1395
1396pub fn scan(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
1397 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1398 let format = get_string(&args, "format")
1399 .and_then(|s| OutputFormat::parse(&s))
1400 .unwrap_or(default_format);
1401
1402 let before_depth = get_i32(&args, "before").unwrap_or(0);
1404 let after_depth = get_i32(&args, "after").unwrap_or(0);
1405 let above_depth = get_i32(&args, "above").unwrap_or(0);
1406 let below_depth = get_i32(&args, "below").unwrap_or(0);
1407
1408 let root_task = db
1410 .get_task(&task_id)?
1411 .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
1412
1413 let before = db.get_predecessors(&task_id, before_depth)?;
1415 let after = db.get_successors(&task_id, after_depth)?;
1416 let above = db.get_ancestors(&task_id, above_depth)?;
1417 let below = db.get_descendants(&task_id, below_depth)?;
1418
1419 let result = ScanResult {
1420 root: root_task,
1421 before,
1422 after,
1423 above,
1424 below,
1425 };
1426
1427 match format {
1428 OutputFormat::Markdown => Ok(markdown_to_json(format_scan_result_markdown(&result))),
1429 OutputFormat::Json => Ok(serde_json::to_value(&result)?),
1430 }
1431}