1use super::{get_bool, get_i32, get_i64, get_string, get_string_array, make_tool_with_prompts};
4use crate::config::{
5 AttachmentsConfig, AutoAdvanceConfig, DependenciesConfig, GateEnforcement, IdsConfig,
6 PhasesConfig, Prompts, StatesConfig, TagsConfig, UnknownKeyBehavior,
7};
8use crate::db::Database;
9use crate::error::ToolError;
10use crate::format::{
11 OutputFormat, format_scan_result_markdown, format_task_markdown, format_tasks_markdown,
12 markdown_to_json,
13};
14use crate::gates::evaluate_gates;
15use crate::prompts::PromptContext;
16use crate::types::{ScanResult, TaskTreeInput, parse_priority};
17use anyhow::Result;
18use rmcp::model::Tool;
19use serde_json::{Value, json};
20use tracing::warn;
21
22pub fn get_tools(prompts: &Prompts, states_config: &StatesConfig) -> Vec<Tool> {
23 let state_names: Vec<&str> = states_config.state_names();
25 let state_enum: Vec<Value> = state_names.iter().map(|s| json!(s)).collect();
26
27 vec![
28 make_tool_with_prompts(
29 "create",
30 "Create a new task. Use parent for subtasks. Use the link system (block tool) for dependencies.",
31 json!({
32 "id": {
33 "type": "string",
34 "description": "Custom task ID (optional, petname ID generated if not provided)"
35 },
36 "description": {
37 "type": "string",
38 "description": "Task description (required)"
39 },
40 "parent": {
41 "type": "string",
42 "description": "Parent task ID for nesting"
43 },
44 "priority": {
45 "type": "integer",
46 "description": "Task priority 0-10 (higher = more important, default 5)"
47 },
48 "points": {
49 "type": "integer",
50 "description": "Story points / complexity estimate"
51 },
52 "time_estimate_ms": {
53 "type": "integer",
54 "description": "Estimated duration in milliseconds"
55 },
56 "tags": {
57 "type": "array",
58 "items": { "type": "string" },
59 "description": "Categorization/discovery tags (what the task IS, for querying)"
60 }
61 }),
62 vec!["description"],
63 prompts,
64 ),
65 make_tool_with_prompts(
66 "create_tree",
67 "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.",
68 json!({
69 "tree": {
70 "type": "object",
71 "description": "Nested tree structure with title, children[], etc. Use 'ref' to reference existing tasks.",
72 "properties": {
73 "ref": { "type": "string", "description": "Reference to an existing task ID (other fields ignored when set)" },
74 "id": { "type": "string", "description": "Custom task ID (optional, petname ID generated if not provided)" },
75 "title": { "type": "string", "description": "Task title (required for new tasks)" },
76 "description": { "type": "string", "description": "Task description" },
77 "priority": { "type": "integer", "description": "Task priority 0-10 (default 5)" },
78 "points": { "type": "integer", "description": "Story points / complexity estimate" },
79 "time_estimate_ms": { "type": "integer", "description": "Estimated duration in milliseconds" },
80 "tags": { "type": "array", "items": { "type": "string" }, "description": "Categorization/discovery tags" },
81 "needed_tags": { "type": "array", "items": { "type": "string" }, "description": "Tags agent must have ALL of to claim (AND)" },
82 "wanted_tags": { "type": "array", "items": { "type": "string" }, "description": "Tags agent must have AT LEAST ONE of to claim (OR)" },
83 "children": { "type": "array", "description": "Child nodes (same structure, recursive)" }
84 }
85 },
86 "parent": {
87 "type": "string",
88 "description": "Optional parent task ID for the tree root"
89 },
90 "child_type": {
91 "type": "string",
92 "description": "Dependency type from parent to children (default: 'contains'). Set to null for no parent-child deps."
93 },
94 "sibling_type": {
95 "type": "string",
96 "description": "Dependency type between consecutive siblings (default: null/parallel). Use 'follows' for sequential."
97 }
98 }),
99 vec!["tree"],
100 prompts,
101 ),
102 make_tool_with_prompts(
103 "get",
104 "Get a single task by ID. Returns detailed task with attachment metadata list and counts by type.",
105 json!({
106 "task": {
107 "type": "string",
108 "description": "Task ID"
109 }
110 }),
111 vec!["task"],
112 prompts,
113 ),
114 make_tool_with_prompts(
115 "list_tasks",
116 "Query tasks with flexible filters.",
117 json!({
118 "status": {
119 "oneOf": [
120 { "type": "string", "enum": state_enum },
121 { "type": "array", "items": { "type": "string" } }
122 ],
123 "description": "Filter by status (single or array)"
124 },
125 "ready": {
126 "type": "boolean",
127 "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."
128 },
129 "blocked": {
130 "type": "boolean",
131 "description": "Filter for blocked tasks: have unsatisfied start-blocking dependencies"
132 },
133 "claimed": {
134 "type": "boolean",
135 "description": "Filter for claimed tasks: currently owned by any agent (owner_agent IS NOT NULL)"
136 },
137 "owner": {
138 "type": "string",
139 "description": "Filter by owner agent ID (tasks currently claimed by this specific agent)"
140 },
141 "parent": {
142 "type": "string",
143 "description": "Filter by parent task ID (use 'null' for root tasks)"
144 },
145 "agent": {
146 "type": "string",
147 "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."
148 },
149 "tags_any": {
150 "type": "array",
151 "items": { "type": "string" },
152 "description": "Filter tasks that have ANY of these tags (OR)"
153 },
154 "tags_all": {
155 "type": "array",
156 "items": { "type": "string" },
157 "description": "Filter tasks that have ALL of these tags (AND)"
158 },
159 "sort_by": {
160 "type": "string",
161 "enum": ["priority", "created_at", "updated_at"],
162 "description": "Field to sort by (default: created_at for general queries, priority then created_at for ready queries)"
163 },
164 "sort_order": {
165 "type": "string",
166 "enum": ["asc", "desc"],
167 "description": "Sort order: 'asc' for ascending, 'desc' for descending (default: desc for created_at/updated_at, priority always high-to-low)"
168 },
169 "limit": {
170 "type": "integer",
171 "description": "Maximum number of tasks to return"
172 }
173 }),
174 vec![],
175 prompts,
176 ),
177 make_tool_with_prompts(
178 "update",
179 "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.",
180 json!({
181 "worker_id": {
182 "type": "string",
183 "description": "Worker ID making the update"
184 },
185 "task": {
186 "type": "string",
187 "description": "Task ID"
188 },
189 "assignee": {
190 "type": "string",
191 "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."
192 },
193 "status": {
194 "type": "string",
195 "enum": state_enum,
196 "description": "New status"
197 },
198 "title": {
199 "type": "string",
200 "description": "New title"
201 },
202 "description": {
203 "type": "string",
204 "description": "New description"
205 },
206 "priority": {
207 "type": "integer",
208 "description": "New priority 0-10 (higher = more important)"
209 },
210 "points": {
211 "type": "integer",
212 "description": "New points estimate"
213 },
214 "tags": {
215 "type": "array",
216 "items": { "type": "string" },
217 "description": "New categorization/discovery tags"
218 },
219 "needed_tags": {
220 "type": "array",
221 "items": { "type": "string" },
222 "description": "Tags agent must have ALL of to claim (AND)"
223 },
224 "wanted_tags": {
225 "type": "array",
226 "items": { "type": "string" },
227 "description": "Tags agent must have AT LEAST ONE of to claim (OR)"
228 },
229 "time_estimate_ms": {
230 "type": "integer",
231 "description": "Estimated duration in milliseconds"
232 },
233 "reason": {
234 "type": "string",
235 "description": "Reason for the update (stored in audit trail for state transitions)"
236 },
237 "force": {
238 "type": "boolean",
239 "description": "Force ownership changes even if owned by another worker (default: false)"
240 },
241 "attachments": {
242 "type": "array",
243 "description": "List of attachments to add to the task (e.g., commit hashes, changelists, notes)",
244 "items": {
245 "type": "object",
246 "properties": {
247 "type": {
248 "type": "string",
249 "description": "Attachment type/category (e.g., 'commit', 'changelist', 'note'). Used for indexing and replace operations."
250 },
251 "name": {
252 "type": "string",
253 "description": "Optional label/name for the attachment (arbitrary string)"
254 },
255 "content": {
256 "type": "string",
257 "description": "Attachment content (text)"
258 },
259 "mime": {
260 "type": "string",
261 "description": "MIME type (uses configured default if omitted)"
262 },
263 "mode": {
264 "type": "string",
265 "enum": ["append", "replace"],
266 "description": "How to handle existing attachments of this type: 'append' adds new, 'replace' deletes all of this type first"
267 }
268 },
269 "required": ["type", "content"]
270 }
271 }
272 }),
273 vec!["worker_id", "task"],
274 prompts,
275 ),
276 make_tool_with_prompts(
277 "delete",
278 "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.",
279 json!({
280 "worker_id": {
281 "type": "string",
282 "description": "Worker ID attempting to delete"
283 },
284 "task": {
285 "type": "string",
286 "description": "Task ID"
287 },
288 "cascade": {
289 "type": "boolean",
290 "description": "Whether to delete children (default: false)"
291 },
292 "reason": {
293 "type": "string",
294 "description": "Optional reason for deletion"
295 },
296 "obliterate": {
297 "type": "boolean",
298 "description": "If true, permanently deletes the task from the database. If false (default), soft deletes by setting deleted_at timestamp."
299 },
300 "force": {
301 "type": "boolean",
302 "description": "Force deletion even if claimed by another worker (default: false)"
303 }
304 }),
305 vec!["worker_id", "task"],
306 prompts,
307 ),
308 make_tool_with_prompts(
309 "scan",
310 "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.",
311 json!({
312 "task": {
313 "type": "string",
314 "description": "Task ID to scan from"
315 },
316 "before": {
317 "type": "integer",
318 "description": "Depth for predecessors (tasks that block this one): 0=none, N=levels, -1=all (default: 0)"
319 },
320 "after": {
321 "type": "integer",
322 "description": "Depth for successors (tasks this one blocks): 0=none, N=levels, -1=all (default: 0)"
323 },
324 "above": {
325 "type": "integer",
326 "description": "Depth for ancestors (parent chain): 0=none, N=levels, -1=all (default: 0)"
327 },
328 "below": {
329 "type": "integer",
330 "description": "Depth for descendants (children tree): 0=none, N=levels, -1=all (default: 0)"
331 },
332 "format": {
333 "type": "string",
334 "enum": ["json", "markdown"],
335 "description": "Output format (default: json)"
336 }
337 }),
338 vec!["task"],
339 prompts,
340 ),
341 ]
342}
343
344pub fn create(
345 db: &Database,
346 states_config: &StatesConfig,
347 phases_config: &PhasesConfig,
348 tags_config: &TagsConfig,
349 ids_config: &IdsConfig,
350 args: Value,
351) -> Result<Value> {
352 let id = get_string(&args, "id");
353 let description =
354 get_string(&args, "description").ok_or_else(|| ToolError::missing_field("description"))?;
355 let parent_id = get_string(&args, "parent");
356 let phase = get_string(&args, "phase");
357 let priority = get_i32(&args, "priority")
359 .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
360 let points = get_i32(&args, "points");
361 let time_estimate_ms = get_i64(&args, "time_estimate_ms");
362 let tags = get_string_array(&args, "tags");
363 let needed_tags = get_string_array(&args, "needed_tags");
364 let wanted_tags = get_string_array(&args, "wanted_tags");
365
366 let phase_warning = if let Some(ref p) = phase {
368 phases_config.check_phase(p)?
369 } else {
370 None
371 };
372
373 let mut tag_warnings = Vec::new();
375 if let Some(ref t) = tags {
376 tag_warnings.extend(tags_config.validate_tags(t)?);
377 }
378 if let Some(ref t) = needed_tags {
379 tag_warnings.extend(tags_config.validate_tags(t)?);
380 }
381 if let Some(ref t) = wanted_tags {
382 tag_warnings.extend(tags_config.validate_tags(t)?);
383 }
384
385 let task = db.create_task(
386 id,
387 description,
388 parent_id,
389 phase,
390 priority,
391 points,
392 time_estimate_ms,
393 needed_tags,
394 wanted_tags,
395 tags,
396 states_config,
397 ids_config,
398 )?;
399
400 let mut response = json!({
401 "id": &task.id,
402 "description": task.description,
403 "status": task.status,
404 "phase": task.phase,
405 "priority": task.priority,
406 "created_at": task.created_at
407 });
408
409 if let Some(warning) = phase_warning {
410 response["phase_warning"] = json!(warning);
411 }
412
413 if !tag_warnings.is_empty() {
414 response["tag_warnings"] = json!(tag_warnings);
415 }
416
417 Ok(response)
418}
419
420pub fn create_tree(
421 db: &Database,
422 states_config: &StatesConfig,
423 phases_config: &PhasesConfig,
424 tags_config: &TagsConfig,
425 ids_config: &IdsConfig,
426 args: Value,
427) -> Result<Value> {
428 let tree: TaskTreeInput = serde_json::from_value(
429 args.get("tree")
430 .cloned()
431 .ok_or_else(|| ToolError::missing_field("tree"))?,
432 )?;
433 let parent_id = get_string(&args, "parent");
434 let child_type = get_string(&args, "child_type");
435 let sibling_type = get_string(&args, "sibling_type");
436
437 let (root_id, all_ids, phase_warnings, tag_warnings) = db.create_task_tree(
438 tree,
439 parent_id,
440 child_type,
441 sibling_type,
442 states_config,
443 phases_config,
444 tags_config,
445 ids_config,
446 )?;
447
448 let root_task = db.get_task(&root_id)?.ok_or_else(|| {
450 ToolError::new(
451 crate::error::ErrorCode::TaskNotFound,
452 "Root task not found after creation",
453 )
454 })?;
455
456 let mut response = json!({
457 "root": {
458 "id": root_task.id,
459 "title": root_task.title,
460 "description": root_task.description,
461 "status": root_task.status,
462 "phase": root_task.phase,
463 "priority": root_task.priority,
464 "created_at": root_task.created_at
465 },
466 "all_ids": all_ids,
467 "count": all_ids.len()
468 });
469
470 if !phase_warnings.is_empty() {
471 response["phase_warnings"] = json!(phase_warnings);
472 }
473
474 if !tag_warnings.is_empty() {
475 response["tag_warnings"] = json!(tag_warnings);
476 }
477
478 Ok(response)
479}
480
481pub fn get(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
482 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
483 let format = get_string(&args, "format")
484 .and_then(|s| OutputFormat::parse(&s))
485 .unwrap_or(default_format);
486
487 let task = db
488 .get_task(&task_id)?
489 .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
490
491 let blocked_by = db.get_blockers(&task_id)?;
492
493 let attachments = db.get_attachments(&task_id)?;
495
496 let mut attachment_counts: std::collections::HashMap<String, i32> =
498 std::collections::HashMap::new();
499 for att in &attachments {
500 *attachment_counts.entry(att.mime_type.clone()).or_insert(0) += 1;
501 }
502
503 match format {
504 OutputFormat::Markdown => {
505 let mut md = format_task_markdown(&task, &blocked_by);
506
507 if !attachments.is_empty() {
509 md.push_str("\n### Attachments\n");
510 for att in &attachments {
511 let file_indicator = if att.file_path.is_some() {
512 " (file)"
513 } else {
514 ""
515 };
516 md.push_str(&format!(
517 "- **{}** [{}]{}\n",
518 att.name, att.mime_type, file_indicator
519 ));
520 }
521
522 md.push_str("\n**Counts by type:**\n");
524 for (mime_type, count) in &attachment_counts {
525 md.push_str(&format!("- {}: {}\n", mime_type, count));
526 }
527 }
528
529 Ok(markdown_to_json(md))
530 }
531 OutputFormat::Json => {
532 let mut task_json = serde_json::to_value(&task)?;
533 if let Some(obj) = task_json.as_object_mut() {
534 obj.insert("blocked_by".to_string(), json!(blocked_by));
535 obj.insert(
536 "attachments".to_string(),
537 serde_json::to_value(&attachments)?,
538 );
539 obj.insert(
540 "attachment_counts".to_string(),
541 serde_json::to_value(&attachment_counts)?,
542 );
543 }
544 Ok(task_json)
545 }
546 }
547}
548
549pub fn list_tasks(
550 db: &Database,
551 states_config: &StatesConfig,
552 deps_config: &DependenciesConfig,
553 default_format: OutputFormat,
554 args: Value,
555) -> Result<Value> {
556 let format = get_string(&args, "format")
557 .and_then(|s| OutputFormat::parse(&s))
558 .unwrap_or(default_format);
559
560 let ready = get_bool(&args, "ready").unwrap_or(false);
561 let blocked = get_bool(&args, "blocked").unwrap_or(false);
562 let claimed = get_bool(&args, "claimed").unwrap_or(false);
563 let limit = get_i32(&args, "limit");
564 let phase = get_string(&args, "phase");
565
566 let tags_any = get_string_array(&args, "tags_any");
568 let tags_all = get_string_array(&args, "tags_all");
569
570 let agent_id = get_string(&args, "agent");
572
573 let sort_by = get_string(&args, "sort_by");
575 let sort_order = get_string(&args, "sort_order");
576
577 let mut tasks = if ready {
579 db.get_ready_tasks(
582 agent_id.as_deref(),
583 states_config,
584 deps_config,
585 sort_by.as_deref(),
586 sort_order.as_deref(),
587 )?
588 } else if blocked {
589 db.get_blocked_tasks(
591 states_config,
592 deps_config,
593 sort_by.as_deref(),
594 sort_order.as_deref(),
595 )?
596 } else if claimed {
597 db.get_claimed_tasks(None)?
599 } else {
600 let status_vec: Option<Vec<String>> = if let Some(status_val) = args.get("status") {
603 if let Some(s) = status_val.as_str() {
604 Some(vec![s.to_string()])
605 } else {
606 status_val.as_array().map(|arr| {
607 arr.iter()
608 .filter_map(|v| v.as_str().map(String::from))
609 .collect()
610 })
611 }
612 } else {
613 None
614 };
615 let owner = get_string(&args, "owner");
616 let parent_id_str = get_string(&args, "parent");
617 let parent_id: Option<Option<&str>> = match &parent_id_str {
618 Some(pid_str) if pid_str == "null" => Some(None), Some(pid_str) => Some(Some(pid_str.as_str())),
620 None => None,
621 };
622
623 let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
625
626 if has_tag_filters {
627 let qualified_agent_tags = if let Some(aid) = &agent_id {
630 Some(db.get_agent_tags(aid)?)
631 } else {
632 None
633 };
634
635 db.list_tasks_with_tag_filters(
636 status_vec,
637 owner.as_deref(),
638 parent_id,
639 tags_any,
640 tags_all,
641 qualified_agent_tags,
642 limit,
643 0, sort_by.as_deref(),
645 sort_order.as_deref(),
646 )?
647 } else {
648 let status = status_vec
650 .as_ref()
651 .and_then(|v| v.first().map(|s| s.as_str()));
652 db.list_tasks(
653 status,
654 phase.as_deref(),
655 owner.as_deref(),
656 parent_id,
657 limit,
658 0, sort_by.as_deref(),
660 sort_order.as_deref(),
661 )?
662 }
663 };
664
665 if let Some(ref p) = phase {
667 tasks.retain(|t| t.phase.as_deref() == Some(p.as_str()));
668 }
669
670 if let Some(l) = limit {
672 tasks.truncate(l as usize);
673 }
674
675 let tasks_with_blockers: Vec<_> = tasks
677 .into_iter()
678 .map(|task| {
679 let blockers = db.get_blockers(&task.id).unwrap_or_default();
680 (task, blockers)
681 })
682 .collect();
683
684 match format {
685 OutputFormat::Markdown => Ok(markdown_to_json(format_tasks_markdown(
686 &tasks_with_blockers,
687 states_config,
688 ))),
689 OutputFormat::Json => Ok(json!({
690 "tasks": tasks_with_blockers.iter().map(|(task, blockers)| {
691 let mut task_json = serde_json::to_value(task).unwrap();
692 if let Some(obj) = task_json.as_object_mut() {
693 obj.insert("blocked_by".to_string(), json!(blockers));
694 }
695 task_json
696 }).collect::<Vec<_>>()
697 })),
698 }
699}
700
701pub fn update(
702 db: &Database,
703 attachments_config: &AttachmentsConfig,
704 states_config: &StatesConfig,
705 phases_config: &PhasesConfig,
706 deps_config: &DependenciesConfig,
707 auto_advance: &AutoAdvanceConfig,
708 tags_config: &TagsConfig,
709 workflows: &crate::config::workflows::WorkflowsConfig,
710 args: Value,
711) -> Result<Value> {
712 let worker_id =
713 get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
714 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
715 let assignee = get_string(&args, "assignee");
716 let title = get_string(&args, "title");
717 let description = if args.get("description").is_some() {
718 Some(get_string(&args, "description"))
719 } else {
720 None
721 };
722 let status = get_string(&args, "status");
723 let phase = get_string(&args, "phase");
724 let priority = get_i32(&args, "priority")
726 .or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
727 let points = if args.get("points").is_some() {
728 Some(get_i32(&args, "points"))
729 } else {
730 None
731 };
732 let tags = if args.get("tags").is_some() {
733 Some(get_string_array(&args, "tags").unwrap_or_default())
734 } else {
735 None
736 };
737 let needed_tags = if args.get("needed_tags").is_some() {
738 Some(get_string_array(&args, "needed_tags").unwrap_or_default())
739 } else {
740 None
741 };
742 let wanted_tags = if args.get("wanted_tags").is_some() {
743 Some(get_string_array(&args, "wanted_tags").unwrap_or_default())
744 } else {
745 None
746 };
747 let time_estimate_ms = get_i64(&args, "time_estimate_ms");
748 let reason = get_string(&args, "reason");
749 let force = get_bool(&args, "force").unwrap_or(false);
750
751 let mut attachment_results: Vec<Value> = Vec::new();
753 let mut attachment_warnings: Vec<String> = Vec::new();
754
755 if let Some(attachments_arr) = args.get("attachments").and_then(|v| v.as_array()) {
756 for att_value in attachments_arr {
757 let attachment_type = att_value.get("type").and_then(|v| v.as_str());
758 let name = att_value.get("name").and_then(|v| v.as_str()).unwrap_or("");
759 let content = att_value.get("content").and_then(|v| v.as_str());
760 let mime_override = att_value.get("mime").and_then(|v| v.as_str());
761 let mode_override = att_value.get("mode").and_then(|v| v.as_str());
762
763 let attachment_type = match attachment_type {
764 Some(t) => t,
765 None => {
766 attachment_warnings
767 .push("Skipped attachment: missing 'type' field".to_string());
768 continue;
769 }
770 };
771
772 let content = match content {
773 Some(c) => c,
774 None => {
775 attachment_warnings.push(format!(
776 "Skipped attachment type '{}': missing 'content' field",
777 attachment_type
778 ));
779 continue;
780 }
781 };
782
783 if !attachments_config.is_known_key(attachment_type) {
785 match attachments_config.unknown_key {
786 UnknownKeyBehavior::Reject => {
787 attachment_warnings.push(format!(
788 "Rejected attachment type '{}': unknown type (configure in attachments.definitions or set unknown_key to 'allow')",
789 attachment_type
790 ));
791 continue;
792 }
793 UnknownKeyBehavior::Warn => {
794 attachment_warnings
795 .push(format!("Unknown attachment type '{}'", attachment_type));
796 }
797 UnknownKeyBehavior::Allow => {}
798 }
799 }
800
801 let mime_type = mime_override.map(String::from).unwrap_or_else(|| {
803 attachments_config
804 .get_mime_default(attachment_type)
805 .to_string()
806 });
807 let mode = mode_override
808 .unwrap_or_else(|| attachments_config.get_mode_default(attachment_type));
809
810 if mode != "append" && mode != "replace" {
812 attachment_warnings.push(format!(
813 "Skipped attachment type '{}': mode must be 'append' or 'replace'",
814 attachment_type
815 ));
816 continue;
817 }
818
819 if mode == "replace" {
821 let _ = db.delete_attachments_by_type(&task_id, attachment_type);
822 }
823
824 match db.add_attachment(
826 &task_id,
827 attachment_type.to_string(),
828 name.to_string(),
829 content.to_string(),
830 Some(mime_type.clone()),
831 None,
832 ) {
833 Ok(sequence) => {
834 attachment_results.push(json!({
835 "type": attachment_type,
836 "sequence": sequence,
837 "name": name,
838 "mime_type": mime_type
839 }));
840 }
841 Err(e) => {
842 attachment_warnings.push(format!(
843 "Failed to add attachment type '{}': {}",
844 attachment_type, e
845 ));
846 }
847 }
848 }
849 }
850
851 let phase_warning = if let Some(ref p) = phase {
853 phases_config.check_phase(p)?
854 } else {
855 None
856 };
857
858 let mut tag_warnings = Vec::new();
860 if let Some(ref t) = tags {
861 tag_warnings.extend(tags_config.validate_tags(t)?);
862 }
863 if let Some(ref t) = needed_tags {
864 tag_warnings.extend(tags_config.validate_tags(t)?);
865 }
866 if let Some(ref t) = wanted_tags {
867 tag_warnings.extend(tags_config.validate_tags(t)?);
868 }
869
870 let mut gate_warnings: Vec<String> = Vec::new();
872 let mut skipped_status_gates: Vec<String> = Vec::new();
874 let mut skipped_phase_gates: Vec<String> = Vec::new();
875 if let Some(ref new_status) = status {
876 let current_task = db.get_task(&task_id)?.ok_or_else(|| {
878 ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
879 })?;
880
881 if ¤t_task.status != new_status {
882 let exit_gates = workflows.get_status_exit_gates(¤t_task.status);
884
885 if !exit_gates.is_empty() {
886 let gates_owned: Vec<crate::config::GateDefinition> =
888 exit_gates.iter().map(|g| (*g).clone()).collect();
889 let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
890
891 match gate_result.status.as_str() {
892 "fail" => {
893 let gate_names: Vec<String> = gate_result
895 .unsatisfied_gates
896 .iter()
897 .filter(|g| g.enforcement == GateEnforcement::Reject)
898 .map(|g| format!("{} ({})", g.gate_type, g.description))
899 .collect();
900 return Err(ToolError::gates_not_satisfied(
901 ¤t_task.status,
902 &gate_names,
903 )
904 .into());
905 }
906 "warn" => {
907 let warn_gates: Vec<String> = gate_result
909 .unsatisfied_gates
910 .iter()
911 .filter(|g| g.enforcement == GateEnforcement::Warn)
912 .map(|g| format!("{} ({})", g.gate_type, g.description))
913 .collect();
914
915 if !force {
916 return Err(ToolError::new(
918 crate::error::ErrorCode::GatesNotSatisfied,
919 format!(
920 "Cannot exit '{}' without force=true: unsatisfied gates: {}",
921 current_task.status,
922 warn_gates.join(", ")
923 ),
924 )
925 .into());
926 }
927 warn!(
929 task_id = %task_id,
930 agent = %worker_id,
931 from_status = %current_task.status,
932 to_status = %new_status,
933 skipped_gates = ?warn_gates,
934 "Status transition with skipped warn gates (force=true)"
935 );
936 skipped_status_gates = warn_gates.clone();
937 gate_warnings.push(format!(
938 "Proceeding despite unsatisfied gates (force=true): {}",
939 warn_gates.join(", ")
940 ));
941 }
942 "pass" => {
943 let allow_gates: Vec<String> = gate_result
945 .unsatisfied_gates
946 .iter()
947 .filter(|g| g.enforcement == GateEnforcement::Allow)
948 .map(|g| format!("{} ({})", g.gate_type, g.description))
949 .collect();
950 if !allow_gates.is_empty() {
951 gate_warnings.push(format!(
952 "Optional gates not satisfied: {}",
953 allow_gates.join(", ")
954 ));
955 }
956 }
957 _ => {}
958 }
959 }
960 }
961 }
962
963 if let Some(ref new_phase) = phase {
965 let current_task = db.get_task(&task_id)?.ok_or_else(|| {
970 ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
971 })?;
972
973 if let Some(ref current_phase) = current_task.phase
975 && current_phase != new_phase
976 {
977 let exit_gates = workflows.get_phase_exit_gates(current_phase);
979
980 if !exit_gates.is_empty() {
981 let gates_owned: Vec<crate::config::GateDefinition> =
983 exit_gates.iter().map(|g| (*g).clone()).collect();
984 let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
985
986 match gate_result.status.as_str() {
987 "fail" => {
988 let gate_names: Vec<String> = gate_result
990 .unsatisfied_gates
991 .iter()
992 .filter(|g| g.enforcement == GateEnforcement::Reject)
993 .map(|g| format!("{} ({})", g.gate_type, g.description))
994 .collect();
995 return Err(ToolError::new(
996 crate::error::ErrorCode::GatesNotSatisfied,
997 format!(
998 "Cannot exit phase '{}': unsatisfied gates: {}",
999 current_phase,
1000 gate_names.join(", ")
1001 ),
1002 )
1003 .into());
1004 }
1005 "warn" => {
1006 let warn_gates: Vec<String> = gate_result
1008 .unsatisfied_gates
1009 .iter()
1010 .filter(|g| g.enforcement == GateEnforcement::Warn)
1011 .map(|g| format!("{} ({})", g.gate_type, g.description))
1012 .collect();
1013
1014 if !force {
1015 return Err(ToolError::new(
1017 crate::error::ErrorCode::GatesNotSatisfied,
1018 format!(
1019 "Cannot exit phase '{}' without force=true: unsatisfied gates: {}",
1020 current_phase,
1021 warn_gates.join(", ")
1022 ),
1023 )
1024 .into());
1025 }
1026 warn!(
1028 task_id = %task_id,
1029 agent = %worker_id,
1030 from_phase = %current_phase,
1031 to_phase = %new_phase,
1032 skipped_gates = ?warn_gates,
1033 "Phase transition with skipped warn gates (force=true)"
1034 );
1035 skipped_phase_gates = warn_gates.clone();
1036 gate_warnings.push(format!(
1037 "Proceeding despite unsatisfied phase gates (force=true): {}",
1038 warn_gates.join(", ")
1039 ));
1040 }
1041 "pass" => {
1042 let allow_gates: Vec<String> = gate_result
1044 .unsatisfied_gates
1045 .iter()
1046 .filter(|g| g.enforcement == GateEnforcement::Allow)
1047 .map(|g| format!("{} ({})", g.gate_type, g.description))
1048 .collect();
1049 if !allow_gates.is_empty() {
1050 gate_warnings.push(format!(
1051 "Optional phase gates not satisfied: {}",
1052 allow_gates.join(", ")
1053 ));
1054 }
1055 }
1056 _ => {}
1057 }
1058 }
1059 }
1060 }
1061
1062 let audit_reason = {
1064 let mut parts: Vec<String> = Vec::new();
1065
1066 if let Some(ref r) = reason {
1068 parts.push(r.clone());
1069 }
1070
1071 if !skipped_status_gates.is_empty() {
1073 parts.push(format!(
1074 "Skipped status exit gates (force=true): {}",
1075 skipped_status_gates.join(", ")
1076 ));
1077 }
1078
1079 if !skipped_phase_gates.is_empty() {
1081 parts.push(format!(
1082 "Skipped phase exit gates (force=true): {}",
1083 skipped_phase_gates.join(", ")
1084 ));
1085 }
1086
1087 if parts.is_empty() {
1088 None
1089 } else {
1090 Some(parts.join("; "))
1091 }
1092 };
1093
1094 let (task, unblocked, auto_advanced) = db.update_task_unified(
1096 &task_id,
1097 &worker_id,
1098 assignee.as_deref(),
1099 title,
1100 description,
1101 status,
1102 phase,
1103 priority,
1104 points,
1105 tags,
1106 needed_tags,
1107 wanted_tags,
1108 time_estimate_ms,
1109 audit_reason,
1110 force,
1111 states_config,
1112 deps_config,
1113 auto_advance,
1114 )?;
1115
1116 let transition_prompt_list: Vec<String> = {
1119 match db.update_worker_state(&worker_id, Some(&task.status), task.phase.as_deref()) {
1121 Ok((old_status, old_phase)) => {
1122 let ctx = PromptContext::new(
1124 &task.status,
1125 task.phase.as_deref(),
1126 states_config,
1127 phases_config,
1128 );
1129 crate::prompts::get_transition_prompts_with_context(
1131 old_status.as_deref().unwrap_or(""),
1132 old_phase.as_deref(),
1133 &task.status,
1134 task.phase.as_deref(),
1135 workflows,
1136 &ctx,
1137 )
1138 }
1139 Err(_) => vec![], }
1141 };
1142
1143 let mut response = serde_json::to_value(&task)?;
1145 if let Value::Object(ref mut map) = response {
1146 if !unblocked.is_empty() {
1148 map.insert("unblocked".to_string(), json!(unblocked));
1149 }
1150 if !auto_advanced.is_empty() {
1152 map.insert("auto_advanced".to_string(), json!(auto_advanced));
1153 }
1154 if !attachment_results.is_empty() {
1156 map.insert("attachments_added".to_string(), json!(attachment_results));
1157 }
1158 if !attachment_warnings.is_empty() {
1160 map.insert(
1161 "attachment_warnings".to_string(),
1162 json!(attachment_warnings),
1163 );
1164 }
1165 if let Some(ref warning) = phase_warning {
1167 map.insert("phase_warning".to_string(), json!(warning));
1168 }
1169 if !tag_warnings.is_empty() {
1171 map.insert("tag_warnings".to_string(), json!(tag_warnings));
1172 }
1173 if !gate_warnings.is_empty() {
1175 map.insert("gate_warnings".to_string(), json!(gate_warnings));
1176 }
1177 if !transition_prompt_list.is_empty() {
1179 map.insert("prompts".to_string(), json!(transition_prompt_list));
1180 }
1181 }
1182
1183 Ok(response)
1184}
1185
1186pub fn delete(db: &Database, args: Value) -> Result<Value> {
1187 let worker_id =
1188 get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
1189 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1190 let cascade = get_bool(&args, "cascade").unwrap_or(false);
1191 let reason = get_string(&args, "reason");
1192 let obliterate = get_bool(&args, "obliterate").unwrap_or(false);
1193 let force = get_bool(&args, "force").unwrap_or(false);
1194
1195 db.delete_task(&task_id, &worker_id, cascade, reason, obliterate, force)?;
1196
1197 Ok(json!({
1198 "success": true,
1199 "soft_deleted": !obliterate
1200 }))
1201}
1202
1203pub fn scan(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
1204 let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
1205 let format = get_string(&args, "format")
1206 .and_then(|s| OutputFormat::parse(&s))
1207 .unwrap_or(default_format);
1208
1209 let before_depth = get_i32(&args, "before").unwrap_or(0);
1211 let after_depth = get_i32(&args, "after").unwrap_or(0);
1212 let above_depth = get_i32(&args, "above").unwrap_or(0);
1213 let below_depth = get_i32(&args, "below").unwrap_or(0);
1214
1215 let root_task = db
1217 .get_task(&task_id)?
1218 .ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
1219
1220 let before = db.get_predecessors(&task_id, before_depth)?;
1222 let after = db.get_successors(&task_id, after_depth)?;
1223 let above = db.get_ancestors(&task_id, above_depth)?;
1224 let below = db.get_descendants(&task_id, below_depth)?;
1225
1226 let result = ScanResult {
1227 root: root_task,
1228 before,
1229 after,
1230 above,
1231 below,
1232 };
1233
1234 match format {
1235 OutputFormat::Markdown => Ok(markdown_to_json(format_scan_result_markdown(&result))),
1236 OutputFormat::Json => Ok(serde_json::to_value(&result)?),
1237 }
1238}