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