use super::{
get_bool, get_i32, get_i64, get_string, get_string_array, get_string_or_array,
make_tool_with_prompts,
};
use crate::config::{
AppConfig, DependenciesConfig, GateEnforcement, Prompts, StatesConfig, UnknownKeyBehavior,
};
use crate::db::Database;
use crate::db::tasks::{CreateTreeOptions, ListTasksQuery};
use crate::error::ToolError;
use crate::format::{
OutputFormat, ToolResult, format_scan_result_markdown, format_task_markdown,
format_tasks_markdown,
};
use crate::gates::evaluate_gates;
use crate::prompts::{AttributedPrompt, PromptContext};
use crate::types::{ScanResult, TaskTreeInput, parse_priority};
use anyhow::Result;
use rmcp::model::Tool;
use serde_json::{Value, json};
use tracing::warn;
pub struct UpdateOptions<'a> {
pub db: &'a Database,
pub config: &'a AppConfig,
pub workflows: &'a crate::config::workflows::WorkflowsConfig,
}
pub fn get_tools(prompts: &Prompts, states_config: &StatesConfig) -> Vec<Tool> {
let state_names: Vec<&str> = states_config.state_names();
let state_enum: Vec<Value> = state_names.iter().map(|s| json!(s)).collect();
vec![
make_tool_with_prompts(
"create",
"Create a new task. Use parent for subtasks. Use the link system (block tool) for dependencies.",
json!({
"id": {
"type": "string",
"description": "Custom task ID (optional, petname ID generated if not provided)"
},
"title": {
"type": "string",
"description": "Short task title (derived from description if omitted)"
},
"description": {
"type": "string",
"description": "Task description (optional if title provided)"
},
"parent": {
"type": "string",
"description": "Parent task ID for nesting"
},
"priority": {
"type": "integer",
"description": "Task priority 0-10 (higher = more important, default 5)"
},
"points": {
"type": "integer",
"description": "Story points / complexity estimate"
},
"time_estimate_ms": {
"type": "integer",
"description": "Estimated duration in milliseconds"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Categorization/discovery tags (what the task IS, for querying)"
}
}),
vec![],
prompts,
),
make_tool_with_prompts(
"create_tree",
"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.",
json!({
"tree": {
"type": "object",
"description": "Nested tree structure with title, children[], etc. Use 'ref' to reference existing tasks.",
"properties": {
"ref": { "type": "string", "description": "Reference to an existing task ID (other fields ignored when set)" },
"id": { "type": "string", "description": "Custom task ID (optional, petname ID generated if not provided)" },
"title": { "type": "string", "description": "Task title (required for new tasks)" },
"description": { "type": "string", "description": "Task description" },
"priority": { "type": "integer", "description": "Task priority 0-10 (default 5)" },
"points": { "type": "integer", "description": "Story points / complexity estimate" },
"time_estimate_ms": { "type": "integer", "description": "Estimated duration in milliseconds" },
"tags": { "type": "array", "items": { "type": "string" }, "description": "Categorization/discovery tags" },
"needed_tags": { "type": "array", "items": { "type": "string" }, "description": "Worker tags required (ALL must match) for claiming this task" },
"wanted_tags": { "type": "array", "items": { "type": "string" }, "description": "Worker tags preferred (at least ONE must match) for claiming this task" },
"blocked_by": { "type": "array", "items": { "type": "string" }, "description": "Task IDs that block this task. Creates 'blocks' deps. Can reference IDs from earlier nodes in this tree or existing tasks." },
"children": { "type": "array", "description": "Child nodes (same structure, recursive)" }
}
},
"parent": {
"type": "string",
"description": "Optional parent task ID for the tree root"
},
"child_type": {
"type": "string",
"description": "Dependency type from parent to children (default: 'contains'). Set to null for no parent-child deps."
},
"sibling_type": {
"type": "string",
"description": "Dependency type between consecutive siblings (default: null/parallel). Use 'follows' for sequential."
}
}),
vec!["tree"],
prompts,
),
make_tool_with_prompts(
"get",
"Get a single task by ID. Returns detailed task with attachment metadata list and counts by type.",
json!({
"task": {
"type": "string",
"description": "Task ID"
}
}),
vec!["task"],
prompts,
),
make_tool_with_prompts(
"list_tasks",
"Query tasks with flexible filters.",
json!({
"status": {
"oneOf": [
{ "type": "string", "enum": state_enum },
{ "type": "array", "items": { "type": "string" } }
],
"description": "Filter by status (single or array)"
},
"ready": {
"type": "boolean",
"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."
},
"blocked": {
"type": "boolean",
"description": "Filter for blocked tasks: have unsatisfied start-blocking dependencies"
},
"claimed": {
"type": "boolean",
"description": "Filter for claimed tasks: currently owned by any agent (owner_agent IS NOT NULL)"
},
"owner": {
"type": "string",
"description": "Filter by owner agent ID (tasks currently claimed by this specific agent)"
},
"parent": {
"type": "string",
"description": "Filter by parent task ID (use 'null' for root tasks)"
},
"recursive": {
"type": "boolean",
"description": "When true with parent, returns all descendants (subtree) instead of just direct children. Uses contains-dependency traversal."
},
"agent": {
"type": "string",
"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."
},
"tags_any": {
"type": "array",
"items": { "type": "string" },
"description": "Filter tasks that have ANY of these tags (OR)"
},
"tags_all": {
"type": "array",
"items": { "type": "string" },
"description": "Filter tasks that have ALL of these tags (AND)"
},
"sort_by": {
"type": "string",
"enum": ["priority", "created_at", "updated_at"],
"description": "Field to sort by (default: created_at for general queries, priority then created_at for ready queries)"
},
"sort_order": {
"type": "string",
"enum": ["asc", "desc"],
"description": "Sort order: 'asc' for ascending, 'desc' for descending (default: desc for created_at/updated_at, priority always high-to-low)"
},
"limit": {
"type": "integer",
"description": "Maximum number of tasks to return"
},
"offset": {
"type": "integer",
"description": "Number of tasks to skip for pagination (default: 0)"
}
}),
vec![],
prompts,
),
make_tool_with_prompts(
"update",
"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.",
json!({
"worker_id": {
"type": "string",
"description": "Worker ID making the update"
},
"task": {
"type": "string",
"description": "Task ID"
},
"assignee": {
"type": "string",
"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."
},
"status": {
"type": "string",
"enum": state_enum,
"description": "New status"
},
"title": {
"type": "string",
"description": "New title"
},
"description": {
"type": "string",
"description": "New description"
},
"priority": {
"type": "integer",
"description": "New priority 0-10 (higher = more important)"
},
"points": {
"type": "integer",
"description": "New points estimate"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "New categorization/discovery tags"
},
"needed_tags": {
"type": "array",
"items": { "type": "string" },
"description": "Worker tags required (ALL must match) for claiming this task"
},
"wanted_tags": {
"type": "array",
"items": { "type": "string" },
"description": "Worker tags preferred (at least ONE must match) for claiming this task"
},
"time_estimate_ms": {
"type": "integer",
"description": "Estimated duration in milliseconds"
},
"reason": {
"type": "string",
"description": "Reason for the update (stored in audit trail for state transitions)"
},
"force": {
"type": "boolean",
"description": "Force ownership changes even if owned by another worker (default: false)"
},
"cascade": {
"type": "boolean",
"description": "When true and status is being set to cancelled, also cancel all non-terminal descendants (default: false)"
},
"prompts": {
"type": "string",
"enum": ["all", "none", "caller"],
"description": "Control which transition prompts are returned. 'all' (default): all prompts. 'none': suppress all prompts. 'caller': only prompts relevant to the caller, suppressing assignee-targeted prompts when using push coordination."
},
"attachments": {
"type": "array",
"description": "List of attachments to add to the task (e.g., commit hashes, changelists, notes)",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Attachment type/category (e.g., 'commit', 'changelist', 'note'). Used for indexing and replace operations."
},
"name": {
"type": "string",
"description": "Optional label/name for the attachment (arbitrary string)"
},
"content": {
"type": "string",
"description": "Attachment content (text)"
},
"mime": {
"type": "string",
"description": "MIME type (uses configured default if omitted)"
},
"mode": {
"type": "string",
"enum": ["append", "replace"],
"description": "How to handle existing attachments of this type: 'append' adds new, 'replace' deletes all of this type first"
}
},
"required": ["type", "content"]
}
}
}),
vec!["worker_id", "task"],
prompts,
),
make_tool_with_prompts(
"delete",
"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.",
json!({
"worker_id": {
"type": "string",
"description": "Worker ID attempting to delete"
},
"task": {
"type": "string",
"description": "Task ID"
},
"cascade": {
"type": "boolean",
"description": "Whether to delete children (default: false)"
},
"reason": {
"type": "string",
"description": "Optional reason for deletion"
},
"obliterate": {
"type": "boolean",
"description": "If true, permanently deletes the task from the database. If false (default), soft deletes by setting deleted_at timestamp."
},
"force": {
"type": "boolean",
"description": "Force deletion even if claimed by another worker (default: false)"
}
}),
vec!["worker_id", "task"],
prompts,
),
make_tool_with_prompts(
"rename",
"Change a task's ID. Updates all references (dependencies, attachments, file marks, tags, etc.) atomically.",
json!({
"worker_id": {
"type": "string",
"description": "Worker ID (for audit)"
},
"task": {
"type": "string",
"description": "Current task ID"
},
"new_id": {
"type": "string",
"description": "New task ID"
}
}),
vec!["worker_id", "task", "new_id"],
prompts,
),
make_tool_with_prompts(
"scan",
"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.",
json!({
"task": {
"type": "string",
"description": "Task ID to scan from"
},
"before": {
"type": "integer",
"description": "Depth for predecessors (tasks that block this one): 0=none, N=levels, -1=all (default: 0)"
},
"after": {
"type": "integer",
"description": "Depth for successors (tasks this one blocks): 0=none, N=levels, -1=all (default: 0)"
},
"above": {
"type": "integer",
"description": "Depth for ancestors (parent chain): 0=none, N=levels, -1=all (default: 0)"
},
"below": {
"type": "integer",
"description": "Depth for descendants (children tree): 0=none, N=levels, -1=all (default: 0)"
},
"format": {
"type": "string",
"enum": ["json", "markdown"],
"description": "Output format (default: json)"
}
}),
vec!["task"],
prompts,
),
make_tool_with_prompts(
"status_summary",
"Get task counts grouped by status. Returns a counts object and total. Optionally scope to a subtree.",
json!({
"parent": {
"type": "string",
"description": "Optional parent task ID to scope the summary to its subtree (all descendants). When omitted, counts all tasks."
}
}),
vec![],
prompts,
),
make_tool_with_prompts(
"bulk_update",
"Update multiple tasks' status in one call. Each transition validates individually (state machine, ownership). Returns per-task success/failure.",
json!({
"worker_id": {
"type": "string",
"description": "Agent making the updates"
},
"tasks": {
"type": "array",
"items": { "type": "string" },
"description": "Task IDs to update"
},
"status": {
"type": "string",
"enum": state_enum,
"description": "Target status for all tasks"
},
"reason": {
"type": "string",
"description": "Reason for the update (stored in audit trail)"
},
"force": {
"type": "boolean",
"description": "Force ownership changes even if owned by another worker (default: false)"
}
}),
vec!["worker_id", "tasks", "status"],
prompts,
),
]
}
pub fn create(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
let states_config = &config.states;
let phases_config = &config.phases;
let tags_config = &config.tags;
let ids_config = &config.ids;
let id = get_string(&args, "id");
let title = get_string(&args, "title");
let description = get_string(&args, "description");
let parent_id = get_string(&args, "parent");
let phase = get_string(&args, "phase");
let priority = get_i32(&args, "priority")
.or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
let points = get_i32(&args, "points");
let time_estimate_ms = get_i64(&args, "time_estimate_ms");
let tags = get_string_array(&args, "tags");
let needed_tags = get_string_array(&args, "needed_tags");
let wanted_tags = get_string_array(&args, "wanted_tags");
if title.is_none() && description.is_none() {
return Err(ToolError::missing_field("title or description").into());
}
let effective_title = title.unwrap_or_else(|| {
crate::format::truncate_title(description.as_deref().unwrap_or("")).into_owned()
});
let phase_warning = if let Some(ref p) = phase {
phases_config.check_phase(p)?
} else {
None
};
let mut tag_warnings = Vec::new();
if let Some(ref t) = tags {
tag_warnings.extend(tags_config.validate_tags(t)?);
}
if let Some(ref t) = needed_tags {
tag_warnings.extend(tags_config.validate_tags(t)?);
}
if let Some(ref t) = wanted_tags {
tag_warnings.extend(tags_config.validate_tags(t)?);
}
let task = db.create_task(
id,
effective_title,
description,
parent_id,
phase,
priority,
points,
time_estimate_ms,
needed_tags,
wanted_tags,
tags,
states_config,
ids_config,
)?;
let mut response = json!({
"id": &task.id,
"title": task.title,
"description": task.description,
"status": task.status,
"phase": task.phase,
"priority": task.priority,
"created_at": task.created_at
});
if let Some(warning) = phase_warning {
response["phase_warning"] = json!(warning);
}
if !tag_warnings.is_empty() {
response["tag_warnings"] = json!(tag_warnings);
}
if task.title.len() > crate::format::MAX_TITLE_DISPLAY_LEN || task.title.contains('\n') {
response["title_warning"] = json!(
"Title exceeds 80 chars or is multi-line. Consider using a short title and keeping detail in the description."
);
}
Ok(response)
}
pub fn create_tree(db: &Database, config: &AppConfig, args: Value) -> Result<Value> {
let states_config = &config.states;
let phases_config = &config.phases;
let tags_config = &config.tags;
let ids_config = &config.ids;
let tree: TaskTreeInput = serde_json::from_value(
args.get("tree")
.cloned()
.ok_or_else(|| ToolError::missing_field("tree"))?,
)?;
let parent_id = get_string(&args, "parent");
let child_type = get_string(&args, "child_type");
let sibling_type = get_string(&args, "sibling_type");
let (root_id, all_ids, phase_warnings, tag_warnings) =
db.create_task_tree(CreateTreeOptions {
input: tree,
parent_id,
child_type,
sibling_type,
states_config,
phases_config,
tags_config,
ids_config,
})?;
let root_task = db.get_task(&root_id)?.ok_or_else(|| {
ToolError::new(
crate::error::ErrorCode::TaskNotFound,
"Root task not found after creation",
)
})?;
let mut response = json!({
"root": {
"id": root_task.id,
"title": root_task.title,
"description": root_task.description,
"status": root_task.status,
"phase": root_task.phase,
"priority": root_task.priority,
"created_at": root_task.created_at
},
"all_ids": all_ids,
"count": all_ids.len()
});
if !phase_warnings.is_empty() {
response["phase_warnings"] = json!(phase_warnings);
}
if !tag_warnings.is_empty() {
response["tag_warnings"] = json!(tag_warnings);
}
Ok(response)
}
pub fn get(db: &Database, default_format: OutputFormat, args: Value) -> Result<ToolResult> {
let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
let format = get_string(&args, "format")
.and_then(|s| OutputFormat::parse(&s))
.unwrap_or(default_format);
let task = db
.get_task(&task_id)?
.ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
let blocked_by = db.get_blockers(&task_id)?;
let attachments = db.get_attachments(&task_id)?;
let mut attachment_counts: std::collections::HashMap<String, i32> =
std::collections::HashMap::new();
for att in &attachments {
*attachment_counts.entry(att.mime_type.clone()).or_insert(0) += 1;
}
match format {
OutputFormat::Markdown => {
let mut md = format_task_markdown(&task, &blocked_by);
if !attachments.is_empty() {
md.push_str("\n### Attachments\n");
for att in &attachments {
let file_indicator = if att.file_path.is_some() {
" (file)"
} else {
""
};
md.push_str(&format!(
"- **{}** [{}]{}\n",
att.name, att.mime_type, file_indicator
));
}
md.push_str("\n**Counts by type:**\n");
for (mime_type, count) in &attachment_counts {
md.push_str(&format!("- {}: {}\n", mime_type, count));
}
}
Ok(ToolResult::Raw(md))
}
OutputFormat::Json => {
let mut task_json = serde_json::to_value(&task)?;
if let Some(obj) = task_json.as_object_mut() {
if !blocked_by.is_empty() {
obj.insert("blocked_by".to_string(), json!(blocked_by));
}
if !attachments.is_empty() {
obj.insert(
"attachments".to_string(),
serde_json::to_value(&attachments)?,
);
}
if !attachment_counts.is_empty() {
obj.insert(
"attachment_counts".to_string(),
serde_json::to_value(&attachment_counts)?,
);
}
}
Ok(ToolResult::Json(task_json))
}
}
}
pub fn list_tasks(
db: &Database,
states_config: &StatesConfig,
deps_config: &DependenciesConfig,
default_format: OutputFormat,
args: Value,
) -> Result<ToolResult> {
let format = get_string(&args, "format")
.and_then(|s| OutputFormat::parse(&s))
.unwrap_or(default_format);
let ready = get_bool(&args, "ready").unwrap_or(false);
let blocked = get_bool(&args, "blocked").unwrap_or(false);
let claimed = get_bool(&args, "claimed").unwrap_or(false);
let recursive = get_bool(&args, "recursive").unwrap_or(false);
let limit = get_i32(&args, "limit");
let offset = get_i32(&args, "offset").unwrap_or(0).max(0);
let fetch_limit = limit.map(|l| l + 1);
let phase = get_string(&args, "phase");
let tags_any = get_string_array(&args, "tags_any");
let tags_all = get_string_array(&args, "tags_all");
let agent_id = get_string(&args, "agent");
let sort_by = get_string(&args, "sort_by");
let sort_order = get_string(&args, "sort_order");
let parent_id_str = get_string(&args, "parent");
let mut tasks =
if recursive && parent_id_str.is_some() && parent_id_str.as_deref() != Some("null") {
let pid = parent_id_str.as_deref().unwrap();
let mut descendants = db.get_descendants(pid, -1)?;
if let Some(status_set) = get_string_or_array(&args, "status")
&& !status_set.is_empty()
{
descendants.retain(|t| status_set.contains(&t.status));
}
if let Some(ref owner) = get_string(&args, "owner") {
descendants.retain(|t| t.worker_id.as_deref() == Some(owner.as_str()));
}
descendants
} else if ready {
db.get_ready_tasks(
agent_id.as_deref(),
states_config,
deps_config,
sort_by.as_deref(),
sort_order.as_deref(),
)?
} else if blocked {
db.get_blocked_tasks(
states_config,
deps_config,
sort_by.as_deref(),
sort_order.as_deref(),
)?
} else if claimed {
db.get_claimed_tasks(None)?
} else {
let status_vec = get_string_or_array(&args, "status");
let owner = get_string(&args, "owner");
let parent_id: Option<Option<&str>> = match &parent_id_str {
Some(pid_str) if pid_str == "null" => Some(None), Some(pid_str) => Some(Some(pid_str.as_str())),
None => None,
};
let has_tag_filters = tags_any.is_some() || tags_all.is_some() || agent_id.is_some();
if has_tag_filters {
let qualified_agent_tags = if let Some(aid) = &agent_id {
Some(db.get_agent_tags(aid)?)
} else {
None
};
db.list_tasks_with_tag_filters(
status_vec,
owner.as_deref(),
parent_id,
tags_any,
tags_all,
qualified_agent_tags,
fetch_limit,
offset,
sort_by.as_deref(),
sort_order.as_deref(),
)?
} else {
let status = status_vec
.as_ref()
.and_then(|v| v.first().map(|s| s.as_str()));
db.list_tasks(ListTasksQuery {
status,
phase: phase.as_deref(),
owner: owner.as_deref(),
parent_id,
limit: fetch_limit,
offset,
sort_by: sort_by.as_deref(),
sort_order: sort_order.as_deref(),
})?
}
};
if let Some(ref p) = phase {
tasks.retain(|t| t.phase.as_deref() == Some(p.as_str()));
}
if offset > 0 && (ready || blocked || claimed || recursive) {
if (offset as usize) < tasks.len() {
tasks = tasks.split_off(offset as usize);
} else {
tasks.clear();
}
}
let has_more = limit.is_some_and(|l| tasks.len() > l as usize);
if let Some(l) = limit {
tasks.truncate(l as usize);
}
let tasks_with_blockers: Vec<_> = tasks
.into_iter()
.map(|task| {
let blockers = db
.get_unsatisfied_blockers(&task.id, states_config)
.unwrap_or_default();
(task, blockers)
})
.collect();
match format {
OutputFormat::Markdown => {
let mut md = format_tasks_markdown(&tasks_with_blockers, states_config);
if has_more {
let next_offset = offset + limit.unwrap_or(0);
md.push_str(&format!(
"\n\n*More results available. Use offset={} to see next page.*",
next_offset
));
}
Ok(ToolResult::Raw(md))
}
OutputFormat::Json => Ok(ToolResult::Json(json!({
"tasks": tasks_with_blockers.iter().map(|(task, blockers)| {
let mut task_json = serde_json::to_value(task).unwrap();
if let Some(obj) = task_json.as_object_mut() {
obj.insert("blocked_by".to_string(), json!(blockers));
obj.insert("blocked".to_string(), json!(!blockers.is_empty()));
}
task_json
}).collect::<Vec<_>>(),
"has_more": has_more,
"offset": offset,
"limit": limit,
}))),
}
}
pub fn update(opts: UpdateOptions<'_>, args: Value) -> Result<Value> {
let UpdateOptions {
db,
config,
workflows,
} = opts;
let attachments_config = &config.attachments;
let states_config_owned: StatesConfig = workflows.into();
let states_config = &states_config_owned;
let phases_config = &config.phases;
let deps_config = &config.deps;
let auto_advance = &config.auto_advance;
let tags_config = &config.tags;
let worker_id =
get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
let assignee = get_string(&args, "assignee");
let title = get_string(&args, "title");
let description = if args.get("description").is_some() {
Some(get_string(&args, "description"))
} else {
None
};
let status = get_string(&args, "status");
let phase = get_string(&args, "phase");
let priority = get_i32(&args, "priority")
.or_else(|| get_string(&args, "priority").map(|s| parse_priority(&s)));
let points = if args.get("points").is_some() {
Some(get_i32(&args, "points"))
} else {
None
};
let tags = if args.get("tags").is_some() {
Some(get_string_array(&args, "tags").unwrap_or_default())
} else {
None
};
let needed_tags = if args.get("needed_tags").is_some() {
Some(get_string_array(&args, "needed_tags").unwrap_or_default())
} else {
None
};
let wanted_tags = if args.get("wanted_tags").is_some() {
Some(get_string_array(&args, "wanted_tags").unwrap_or_default())
} else {
None
};
let time_estimate_ms = get_i64(&args, "time_estimate_ms");
let reason = get_string(&args, "reason");
let force = get_bool(&args, "force").unwrap_or(false);
let cascade = get_bool(&args, "cascade").unwrap_or(false);
let prompts_mode = get_string(&args, "prompts").unwrap_or_else(|| "all".to_string());
let mut attachment_results: Vec<Value> = Vec::new();
let mut attachment_warnings: Vec<String> = Vec::new();
if let Some(attachments_arr) = args.get("attachments").and_then(|v| v.as_array()) {
for att_value in attachments_arr {
let attachment_type = att_value.get("type").and_then(|v| v.as_str());
let name = att_value.get("name").and_then(|v| v.as_str()).unwrap_or("");
let content = att_value.get("content").and_then(|v| v.as_str());
let mime_override = att_value.get("mime").and_then(|v| v.as_str());
let mode_override = att_value.get("mode").and_then(|v| v.as_str());
let attachment_type = match attachment_type {
Some(t) => t,
None => {
attachment_warnings
.push("Skipped attachment: missing 'type' field".to_string());
continue;
}
};
let content = match content {
Some(c) => c,
None => {
attachment_warnings.push(format!(
"Skipped attachment type '{}': missing 'content' field",
attachment_type
));
continue;
}
};
if !attachments_config.is_known_key(attachment_type) {
match attachments_config.unknown_key {
UnknownKeyBehavior::Reject => {
attachment_warnings.push(format!(
"Rejected attachment type '{}': unknown type (configure in attachments.definitions or set unknown_key to 'allow')",
attachment_type
));
continue;
}
UnknownKeyBehavior::Warn => {
attachment_warnings
.push(format!("Unknown attachment type '{}'", attachment_type));
}
UnknownKeyBehavior::Allow => {}
}
}
let mime_type = mime_override.map(String::from).unwrap_or_else(|| {
attachments_config
.get_mime_default(attachment_type)
.to_string()
});
let mode = mode_override
.unwrap_or_else(|| attachments_config.get_mode_default(attachment_type));
if mode != "append" && mode != "replace" {
attachment_warnings.push(format!(
"Skipped attachment type '{}': mode must be 'append' or 'replace'",
attachment_type
));
continue;
}
if mode == "replace" {
let _ = db.delete_attachments_by_type(&task_id, attachment_type);
}
match db.add_attachment(
&task_id,
attachment_type.to_string(),
name.to_string(),
content.to_string(),
Some(mime_type.clone()),
None,
) {
Ok(sequence) => {
attachment_results.push(json!({
"type": attachment_type,
"sequence": sequence,
"name": name,
"mime_type": mime_type
}));
}
Err(e) => {
attachment_warnings.push(format!(
"Failed to add attachment type '{}': {}",
attachment_type, e
));
}
}
}
}
let phase_warning = if let Some(ref p) = phase {
phases_config.check_phase(p)?
} else {
None
};
let mut tag_warnings = Vec::new();
if let Some(ref t) = tags {
tag_warnings.extend(tags_config.validate_tags(t)?);
}
if let Some(ref t) = needed_tags {
tag_warnings.extend(tags_config.validate_tags(t)?);
}
if let Some(ref t) = wanted_tags {
tag_warnings.extend(tags_config.validate_tags(t)?);
}
let mut gate_warnings: Vec<String> = Vec::new();
let mut skipped_status_gates: Vec<String> = Vec::new();
let mut skipped_phase_gates: Vec<String> = Vec::new();
if let Some(ref new_status) = status {
let current_task = db.get_task(&task_id)?.ok_or_else(|| {
ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
})?;
if ¤t_task.status != new_status {
let exit_gates = workflows.get_status_exit_gates(¤t_task.status);
if !exit_gates.is_empty() {
let gates_owned: Vec<crate::config::GateDefinition> =
exit_gates.iter().map(|g| (*g).clone()).collect();
let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
match gate_result.status.as_str() {
"fail" => {
let gate_names: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Reject)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
return Err(ToolError::gates_not_satisfied(
¤t_task.status,
&gate_names,
)
.into());
}
"warn" => {
let warn_gates: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Warn)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
if !force {
let how_to_fix: Vec<String> = warn_gates
.iter()
.map(|g| {
let gate_type = g.split(" (").next().unwrap_or(g);
format!(
" - attach(task=\"{}\", type=\"{}\", content=\"...\")",
task_id, gate_type
)
})
.collect();
return Err(ToolError::new(
crate::error::ErrorCode::GatesNotSatisfied,
format!(
"Cannot exit '{}' without force=true: unsatisfied gates: {}",
current_task.status,
warn_gates.join(", ")
),
)
.with_details(format!(
"Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
how_to_fix.join("\n")
))
.with_suggestion(
"Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
)
.into());
}
warn!(
task_id = %task_id,
agent = %worker_id,
from_status = %current_task.status,
to_status = %new_status,
skipped_gates = ?warn_gates,
"Status transition with skipped warn gates (force=true)"
);
skipped_status_gates = warn_gates.clone();
gate_warnings.push(format!(
"Proceeding despite unsatisfied gates (force=true): {}",
warn_gates.join(", ")
));
}
"pass" => {
let allow_gates: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Allow)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
if !allow_gates.is_empty() {
gate_warnings.push(format!(
"Optional gates not satisfied: {}",
allow_gates.join(", ")
));
}
}
_ => {}
}
}
}
}
if let Some(ref new_phase) = phase {
let current_task = db.get_task(&task_id)?.ok_or_else(|| {
ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
})?;
if let Some(ref current_phase) = current_task.phase
&& current_phase != new_phase
{
let exit_gates = workflows.get_phase_exit_gates(current_phase);
if !exit_gates.is_empty() {
let gates_owned: Vec<crate::config::GateDefinition> =
exit_gates.iter().map(|g| (*g).clone()).collect();
let gate_result = evaluate_gates(db, &task_id, &gates_owned)?;
match gate_result.status.as_str() {
"fail" => {
let gate_names: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Reject)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
let how_to_fix: Vec<String> = gate_names
.iter()
.map(|g| {
let gate_type = g.split(" (").next().unwrap_or(g);
format!(
" - attach(task=\"{}\", type=\"{}\", content=\"...\")",
task_id, gate_type
)
})
.collect();
return Err(ToolError::new(
crate::error::ErrorCode::GatesNotSatisfied,
format!(
"Cannot exit phase '{}': unsatisfied gates: {}",
current_phase,
gate_names.join(", ")
),
)
.with_details(format!(
"These are reject-level gates and cannot be skipped. Satisfy them:\n{}",
how_to_fix.join("\n")
))
.with_suggestion(
"Attach the required gate artifacts, then retry the phase transition."
.to_string(),
)
.into());
}
"warn" => {
let warn_gates: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Warn)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
if !force {
let how_to_fix: Vec<String> = warn_gates
.iter()
.map(|g| {
let gate_type = g.split(" (").next().unwrap_or(g);
format!(
" - attach(task=\"{}\", type=\"{}\", content=\"...\")",
task_id, gate_type
)
})
.collect();
return Err(ToolError::new(
crate::error::ErrorCode::GatesNotSatisfied,
format!(
"Cannot exit phase '{}' without force=true: unsatisfied gates: {}",
current_phase,
warn_gates.join(", ")
),
)
.with_details(format!(
"Satisfy these gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
how_to_fix.join("\n")
))
.with_suggestion(
"Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
)
.into());
}
warn!(
task_id = %task_id,
agent = %worker_id,
from_phase = %current_phase,
to_phase = %new_phase,
skipped_gates = ?warn_gates,
"Phase transition with skipped warn gates (force=true)"
);
skipped_phase_gates = warn_gates.clone();
gate_warnings.push(format!(
"Proceeding despite unsatisfied phase gates (force=true): {}",
warn_gates.join(", ")
));
}
"pass" => {
let allow_gates: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Allow)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
if !allow_gates.is_empty() {
gate_warnings.push(format!(
"Optional phase gates not satisfied: {}",
allow_gates.join(", ")
));
}
}
_ => {}
}
}
}
}
let mut skipped_tag_gates: Vec<String> = Vec::new();
if let Some(ref new_status) = status {
let current_task = db.get_task(&task_id)?.ok_or_else(|| {
ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found")
})?;
if ¤t_task.status != new_status {
let mut tag_gates: Vec<crate::config::GateDefinition> = Vec::new();
for tag in ¤t_task.tags {
let gates = workflows.get_tag_exit_gates(tag);
tag_gates.extend(gates.into_iter().cloned());
}
if !tag_gates.is_empty() {
let gate_result = evaluate_gates(db, &task_id, &tag_gates)?;
match gate_result.status.as_str() {
"fail" => {
let gate_names: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Reject)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
return Err(ToolError::gates_not_satisfied(
¤t_task.status,
&gate_names,
)
.into());
}
"warn" => {
let warn_gates: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Warn)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
if !force {
let how_to_fix: Vec<String> = warn_gates
.iter()
.map(|g| {
let gate_type = g.split(" (").next().unwrap_or(g);
format!(
" - attach(task=\"{}\", type=\"{}\", content=\"...\")",
task_id, gate_type
)
})
.collect();
return Err(ToolError::new(
crate::error::ErrorCode::GatesNotSatisfied,
format!(
"Cannot exit '{}' without force=true: unsatisfied tag gates: {}",
current_task.status,
warn_gates.join(", ")
),
)
.with_details(format!(
"Satisfy these tag-based gates by attaching the required artifacts:\n{}\n\nOr pass force=true with a reason to skip warn-level gates.",
how_to_fix.join("\n")
))
.with_suggestion(
"Attach the required gate artifacts and retry, or use update(..., force=true, reason=\"why skipping\") to proceed.".to_string(),
)
.into());
}
warn!(
task_id = %task_id,
agent = %worker_id,
from_status = %current_task.status,
to_status = %new_status,
skipped_gates = ?warn_gates,
"Status transition with skipped tag gates (force=true)"
);
skipped_tag_gates = warn_gates.clone();
gate_warnings.push(format!(
"Proceeding despite unsatisfied tag gates (force=true): {}",
warn_gates.join(", ")
));
}
"pass" => {
let allow_gates: Vec<String> = gate_result
.unsatisfied_gates
.iter()
.filter(|g| g.enforcement == GateEnforcement::Allow)
.map(|g| format!("{} ({})", g.gate_type, g.description))
.collect();
if !allow_gates.is_empty() {
gate_warnings.push(format!(
"Optional tag gates not satisfied: {}",
allow_gates.join(", ")
));
}
}
_ => {}
}
}
}
}
let audit_reason = {
let mut parts: Vec<String> = Vec::new();
if let Some(ref r) = reason {
parts.push(r.clone());
}
if !skipped_status_gates.is_empty() {
parts.push(format!(
"Skipped status exit gates (force=true): {}",
skipped_status_gates.join(", ")
));
}
if !skipped_phase_gates.is_empty() {
parts.push(format!(
"Skipped phase exit gates (force=true): {}",
skipped_phase_gates.join(", ")
));
}
if !skipped_tag_gates.is_empty() {
parts.push(format!(
"Skipped tag exit gates (force=true): {}",
skipped_tag_gates.join(", ")
));
}
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
};
let (task, unblocked, auto_advanced, auto_completed) = db.update_task_unified(
&task_id,
&worker_id,
assignee.as_deref(),
title,
description,
status,
phase,
priority,
points,
tags,
needed_tags,
wanted_tags,
time_estimate_ms,
audit_reason,
force,
states_config,
deps_config,
auto_advance,
)?;
let mut cascaded: Vec<Value> = Vec::new();
if cascade && states_config.is_terminal_state(&task.status) && task.status == "cancelled" {
if let Ok(descendants) = db.get_descendants(&task.id, -1) {
for descendant in descendants {
if states_config.is_terminal_state(&descendant.status) {
continue;
}
if !states_config.is_valid_transition(&descendant.status, "cancelled") {
warn!(
"Cannot cascade cancel to task '{}': no valid transition from '{}' to 'cancelled'",
descendant.id, descendant.status
);
continue;
}
match db.update_task_unified(
&descendant.id,
&worker_id,
None, None, None, Some("cancelled".to_string()),
None, None, None, None, None, None, None, Some(format!("Cascade cancelled from parent task '{}'", task_id)),
true, states_config,
deps_config,
auto_advance,
) {
Ok((cancelled_task, _, _, _)) => {
cascaded.push(json!({
"id": cancelled_task.id,
"title": cancelled_task.title,
}));
}
Err(e) => {
warn!(
"Failed to cascade cancel to task '{}': {}",
descendant.id, e
);
}
}
}
}
}
let worker_info_for_prompts = db.get_worker(&worker_id).ok().flatten();
let worker_role_for_prompts = worker_info_for_prompts
.as_ref()
.map(|w| workflows.match_role(&w.tags))
.unwrap_or(None);
let mut transition_prompt_list: Vec<AttributedPrompt> = {
match db.update_worker_state(
&worker_id,
Some(&task.status),
task.phase.as_deref(),
Some(&task.id),
) {
Ok((old_status, old_phase)) => {
let mut ctx = PromptContext::new(
&task.status,
task.phase.as_deref(),
states_config,
phases_config,
)
.with_task(&task.id, &task.title, task.priority, &task.tags);
let task_level_str: Option<String> = task
.tags
.iter()
.find(|t| t.starts_with("level:"))
.map(|t| t.strip_prefix("level:").unwrap_or(t).to_string());
let child_count = db.get_children_ids(&task.id).ok().map(|ids| ids.len());
let task_level_ref = task_level_str.as_deref();
ctx = ctx.with_level(task_level_ref, child_count);
if let Some(ref worker) = worker_info_for_prompts {
ctx = ctx.with_agent(
&worker_id,
worker_role_for_prompts.as_deref(),
&worker.tags,
);
}
crate::prompts::get_transition_prompts_attributed(
old_status.as_deref().unwrap_or(""),
old_phase.as_deref(),
&task.status,
task.phase.as_deref(),
workflows,
&ctx,
)
}
Err(_) => vec![], }
};
let mut response = serde_json::to_value(&task)?;
if let Value::Object(ref mut map) = response {
if !unblocked.is_empty() {
map.insert("unblocked".to_string(), json!(unblocked));
}
if !auto_advanced.is_empty() {
map.insert("auto_advanced".to_string(), json!(auto_advanced));
}
if !cascaded.is_empty() {
map.insert("cascaded".to_string(), json!(cascaded));
}
if !auto_completed.is_empty() {
let completed_info: Vec<serde_json::Value> = auto_completed
.iter()
.map(|(id, title)| json!({"id": id, "title": title}))
.collect();
map.insert("auto_completed".to_string(), json!(completed_info));
}
if !attachment_results.is_empty() {
map.insert("attachments_added".to_string(), json!(attachment_results));
}
if !attachment_warnings.is_empty() {
map.insert(
"attachment_warnings".to_string(),
json!(attachment_warnings),
);
}
if let Some(ref warning) = phase_warning {
map.insert("phase_warning".to_string(), json!(warning));
}
if !tag_warnings.is_empty() {
map.insert("tag_warnings".to_string(), json!(tag_warnings));
}
if !gate_warnings.is_empty() {
map.insert("gate_warnings".to_string(), json!(gate_warnings));
}
let include_prompts = match prompts_mode.as_str() {
"none" => false,
"caller" => assignee.is_none(),
_ => true, };
if include_prompts {
if let Some(ref role_name) = worker_role_for_prompts {
let prompt_key = match task.status.as_str() {
"completed" => Some("completing"),
_ => None,
};
if let Some(key) = prompt_key
&& let Some(prompt) = workflows.get_role_prompt(role_name, key)
{
transition_prompt_list.push(AttributedPrompt {
text: prompt.to_string(),
source: format!("role:{}", role_name),
});
}
}
if !transition_prompt_list.is_empty() {
let prompt_objects: Vec<Value> = transition_prompt_list
.iter()
.map(|p| json!({"text": p.text, "source": p.source}))
.collect();
map.insert("prompts".to_string(), json!(prompt_objects));
}
}
let advisory_hints = super::advisories::relevant_advisory_topics(
workflows,
&task.tags,
task.phase.as_deref(),
worker_role_for_prompts.as_deref(),
);
if !advisory_hints.is_empty() {
map.insert("advisory_hints".to_string(), json!(advisory_hints));
}
}
Ok(response)
}
pub fn bulk_update(opts: UpdateOptions<'_>, args: Value) -> Result<Value> {
let worker_id =
get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
let task_ids =
get_string_array(&args, "tasks").ok_or_else(|| ToolError::missing_field("tasks"))?;
let status = get_string(&args, "status").ok_or_else(|| ToolError::missing_field("status"))?;
let reason = get_string(&args, "reason");
let force = get_bool(&args, "force").unwrap_or(false);
let total = task_ids.len();
let mut succeeded: Vec<Value> = Vec::new();
let mut failed: Vec<Value> = Vec::new();
for task_id in &task_ids {
let per_task_args = json!({
"worker_id": worker_id,
"task": task_id,
"status": status,
"reason": reason,
"force": force
});
match update(
UpdateOptions {
db: opts.db,
config: opts.config,
workflows: opts.workflows,
},
per_task_args,
) {
Ok(result) => {
let task_title = result
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let task_status = result
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
succeeded.push(json!({
"id": task_id,
"title": task_title,
"status": task_status
}));
}
Err(e) => {
failed.push(json!({
"id": task_id,
"error": e.to_string()
}));
}
}
}
Ok(json!({
"succeeded": succeeded,
"failed": failed,
"total": total
}))
}
pub fn delete(db: &Database, args: Value) -> Result<Value> {
let worker_id =
get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
let cascade = get_bool(&args, "cascade").unwrap_or(false);
let reason = get_string(&args, "reason");
let obliterate = get_bool(&args, "obliterate").unwrap_or(false);
let force = get_bool(&args, "force").unwrap_or(false);
db.delete_task(&task_id, &worker_id, cascade, reason, obliterate, force)?;
Ok(json!({
"success": true,
"soft_deleted": !obliterate
}))
}
pub fn rename(db: &Database, args: Value) -> Result<Value> {
let _worker_id =
get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
let new_id = get_string(&args, "new_id").ok_or_else(|| ToolError::missing_field("new_id"))?;
db.rename_task(&task_id, &new_id)?;
Ok(json!({
"success": true,
"old_id": task_id,
"new_id": new_id
}))
}
pub fn scan(db: &Database, default_format: OutputFormat, args: Value) -> Result<ToolResult> {
let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
let format = get_string(&args, "format")
.and_then(|s| OutputFormat::parse(&s))
.unwrap_or(default_format);
let before_depth = get_i32(&args, "before").unwrap_or(0);
let after_depth = get_i32(&args, "after").unwrap_or(0);
let above_depth = get_i32(&args, "above").unwrap_or(0);
let below_depth = get_i32(&args, "below").unwrap_or(0);
let root_task = db
.get_task(&task_id)?
.ok_or_else(|| ToolError::new(crate::error::ErrorCode::TaskNotFound, "Task not found"))?;
let before = db.get_predecessors(&task_id, before_depth)?;
let after = db.get_successors(&task_id, after_depth)?;
let above = db.get_ancestors(&task_id, above_depth)?;
let below = db.get_descendants(&task_id, below_depth)?;
let result = ScanResult {
root: root_task,
before,
after,
above,
below,
};
match format {
OutputFormat::Markdown => Ok(ToolResult::Raw(format_scan_result_markdown(&result))),
OutputFormat::Json => Ok(ToolResult::Json(serde_json::to_value(&result)?)),
}
}
pub fn status_summary(db: &Database, states_config: &StatesConfig, args: Value) -> Result<Value> {
let parent = get_string(&args, "parent");
let (counts, total) = db.get_status_summary(parent.as_deref(), states_config)?;
Ok(json!({
"counts": counts,
"total": total,
}))
}