1use std::path::Path;
7
8use anyhow::{Context, Result};
9use chrono::Utc;
10use serde_json::{json, Value};
11
12use crate::blocking::check_blocked;
13use crate::config::Config;
14use crate::discovery::find_unit_file;
15use crate::index::{Index, IndexEntry};
16use crate::mcp::protocol::ToolDefinition;
17use crate::unit::{Status, Unit};
18use crate::util::{natural_cmp, title_to_slug};
19
20pub fn tool_definitions() -> Vec<ToolDefinition> {
22 vec![
23 ToolDefinition {
24 name: "list_units".to_string(),
25 description: "List units with optional status and priority filters".to_string(),
26 input_schema: json!({
27 "type": "object",
28 "properties": {
29 "status": {
30 "type": "string",
31 "enum": ["open", "in_progress", "closed"],
32 "description": "Filter by status"
33 },
34 "priority": {
35 "type": "integer",
36 "minimum": 0,
37 "maximum": 4,
38 "description": "Filter by priority (0-4, where P0 is highest)"
39 },
40 "parent": {
41 "type": "string",
42 "description": "Filter by parent unit ID"
43 }
44 }
45 }),
46 },
47 ToolDefinition {
48 name: "show_unit".to_string(),
49 description: "Get full unit details including description, acceptance criteria, verify command, and history".to_string(),
50 input_schema: json!({
51 "type": "object",
52 "properties": {
53 "id": {
54 "type": "string",
55 "description": "Unit ID"
56 }
57 },
58 "required": ["id"]
59 }),
60 },
61 ToolDefinition {
62 name: "ready_units".to_string(),
63 description: "Get units ready to work on (open, has verify command, all dependencies resolved)".to_string(),
64 input_schema: json!({
65 "type": "object",
66 "properties": {}
67 }),
68 },
69 ToolDefinition {
70 name: "create_unit".to_string(),
71 description: "Create a new unit (task/spec for agents)".to_string(),
72 input_schema: json!({
73 "type": "object",
74 "properties": {
75 "title": {
76 "type": "string",
77 "description": "Unit title"
78 },
79 "description": {
80 "type": "string",
81 "description": "Full description / agent context (markdown)"
82 },
83 "verify": {
84 "type": "string",
85 "description": "Shell command that must exit 0 to close the unit"
86 },
87 "parent": {
88 "type": "string",
89 "description": "Parent unit ID (creates a child unit)"
90 },
91 "priority": {
92 "type": "integer",
93 "minimum": 0,
94 "maximum": 4,
95 "description": "Priority 0-4 (P0 highest, default P2)"
96 },
97 "acceptance": {
98 "type": "string",
99 "description": "Acceptance criteria"
100 },
101 "deps": {
102 "type": "string",
103 "description": "Comma-separated dependency unit IDs"
104 }
105 },
106 "required": ["title"]
107 }),
108 },
109 ToolDefinition {
110 name: "claim_unit".to_string(),
111 description: "Claim a unit for work (sets status to in_progress)".to_string(),
112 input_schema: json!({
113 "type": "object",
114 "properties": {
115 "id": {
116 "type": "string",
117 "description": "Unit ID to claim"
118 },
119 "by": {
120 "type": "string",
121 "description": "Who is claiming (agent name or user)"
122 }
123 },
124 "required": ["id"]
125 }),
126 },
127 ToolDefinition {
128 name: "close_unit".to_string(),
129 description: "Close a unit (runs verify gate first if configured). Returns error if verify fails.".to_string(),
130 input_schema: json!({
131 "type": "object",
132 "properties": {
133 "id": {
134 "type": "string",
135 "description": "Unit ID to close"
136 },
137 "force": {
138 "type": "boolean",
139 "description": "Skip verify command (force close)",
140 "default": false
141 },
142 "reason": {
143 "type": "string",
144 "description": "Close reason"
145 }
146 },
147 "required": ["id"]
148 }),
149 },
150 ToolDefinition {
151 name: "verify_unit".to_string(),
152 description: "Run a unit's verify command without closing it. Returns pass/fail and output.".to_string(),
153 input_schema: json!({
154 "type": "object",
155 "properties": {
156 "id": {
157 "type": "string",
158 "description": "Unit ID to verify"
159 }
160 },
161 "required": ["id"]
162 }),
163 },
164 ToolDefinition {
165 name: "context_unit".to_string(),
166 description: "Get assembled context for a unit (reads files referenced in description)".to_string(),
167 input_schema: json!({
168 "type": "object",
169 "properties": {
170 "id": {
171 "type": "string",
172 "description": "Unit ID"
173 }
174 },
175 "required": ["id"]
176 }),
177 },
178 ToolDefinition {
179 name: "status".to_string(),
180 description: "Project status overview: claimed, ready, goals, and blocked units".to_string(),
181 input_schema: json!({
182 "type": "object",
183 "properties": {}
184 }),
185 },
186 ToolDefinition {
187 name: "tree".to_string(),
188 description: "Hierarchical unit tree showing parent-child relationships and status".to_string(),
189 input_schema: json!({
190 "type": "object",
191 "properties": {
192 "id": {
193 "type": "string",
194 "description": "Root unit ID (shows full tree if omitted)"
195 }
196 }
197 }),
198 },
199 ]
200}
201
202pub fn handle_tool_call(name: &str, args: &Value, mana_dir: &Path) -> Value {
208 let result = match name {
209 "list_units" => handle_list_units(args, mana_dir),
210 "show_unit" => handle_show_unit(args, mana_dir),
211 "ready_units" => handle_ready_units(mana_dir),
212 "create_unit" => handle_create_unit(args, mana_dir),
213 "claim_unit" => handle_claim_unit(args, mana_dir),
214 "close_unit" => handle_close_unit(args, mana_dir),
215 "verify_unit" => handle_verify_unit(args, mana_dir),
216 "context_unit" => handle_context_unit(args, mana_dir),
217 "status" => handle_status(mana_dir),
218 "tree" => handle_tree(args, mana_dir),
219 _ => Err(anyhow::anyhow!("Unknown tool: {}", name)),
220 };
221
222 match result {
223 Ok(text) => json!({
224 "content": [{ "type": "text", "text": text }]
225 }),
226 Err(e) => json!({
227 "content": [{ "type": "text", "text": format!("Error: {}", e) }],
228 "isError": true
229 }),
230 }
231}
232
233fn handle_list_units(args: &Value, mana_dir: &Path) -> Result<String> {
238 let index = Index::load_or_rebuild(mana_dir)?;
239
240 let status_filter = args
241 .get("status")
242 .and_then(|v| v.as_str())
243 .and_then(crate::util::parse_status);
244
245 let priority_filter = args
246 .get("priority")
247 .and_then(|v| v.as_u64())
248 .map(|v| v as u8);
249
250 let parent_filter = args.get("parent").and_then(|v| v.as_str());
251
252 let filtered: Vec<&IndexEntry> = index
253 .units
254 .iter()
255 .filter(|entry| {
256 if let Some(status) = status_filter {
257 if entry.status != status {
258 return false;
259 }
260 } else if entry.status == Status::Closed {
261 return false;
263 }
264 if let Some(priority) = priority_filter {
265 if entry.priority != priority {
266 return false;
267 }
268 }
269 if let Some(parent) = parent_filter {
270 if entry.parent.as_deref() != Some(parent) {
271 return false;
272 }
273 }
274 true
275 })
276 .collect();
277
278 let entries: Vec<Value> = filtered
279 .iter()
280 .map(|e| {
281 json!({
282 "id": e.id,
283 "title": e.title,
284 "status": format!("{}", e.status),
285 "priority": format!("P{}", e.priority),
286 "parent": e.parent,
287 "has_verify": e.has_verify,
288 "claimed_by": e.claimed_by,
289 })
290 })
291 .collect();
292
293 serde_json::to_string_pretty(&json!({ "units": entries, "count": entries.len() }))
294 .context("Failed to serialize unit list")
295}
296
297fn handle_show_unit(args: &Value, mana_dir: &Path) -> Result<String> {
298 let id = args
299 .get("id")
300 .and_then(|v| v.as_str())
301 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
302
303 crate::util::validate_unit_id(id)?;
304 let unit_path = find_unit_file(mana_dir, id)?;
305 let unit = Unit::from_file(&unit_path)?;
306
307 serde_json::to_string_pretty(&unit).context("Failed to serialize unit")
308}
309
310fn handle_ready_units(mana_dir: &Path) -> Result<String> {
311 let index = Index::load_or_rebuild(mana_dir)?;
312
313 let mut ready: Vec<&IndexEntry> = index
314 .units
315 .iter()
316 .filter(|entry| {
317 entry.has_verify
318 && entry.status == Status::Open
319 && check_blocked(entry, &index).is_none()
320 })
321 .collect();
322
323 ready.sort_by(|a, b| match a.priority.cmp(&b.priority) {
324 std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
325 other => other,
326 });
327
328 let entries: Vec<Value> = ready
329 .iter()
330 .map(|e| {
331 json!({
332 "id": e.id,
333 "title": e.title,
334 "priority": format!("P{}", e.priority),
335 })
336 })
337 .collect();
338
339 serde_json::to_string_pretty(&json!({ "ready": entries, "count": entries.len() }))
340 .context("Failed to serialize ready units")
341}
342
343fn handle_create_unit(args: &Value, mana_dir: &Path) -> Result<String> {
344 let title = args
345 .get("title")
346 .and_then(|v| v.as_str())
347 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: title"))?;
348
349 let description = args.get("description").and_then(|v| v.as_str());
350 let verify = args.get("verify").and_then(|v| v.as_str());
351 let parent = args.get("parent").and_then(|v| v.as_str());
352 let priority = args
353 .get("priority")
354 .and_then(|v| v.as_u64())
355 .map(|v| v as u8);
356 let acceptance = args.get("acceptance").and_then(|v| v.as_str());
357 let deps = args.get("deps").and_then(|v| v.as_str());
358
359 if let Some(p) = priority {
360 crate::unit::validate_priority(p)?;
361 }
362
363 let mut config = Config::load(mana_dir)?;
365 let unit_id = if let Some(parent_id) = parent {
366 crate::util::validate_unit_id(parent_id)?;
367 crate::commands::create::assign_child_id(mana_dir, parent_id)?
368 } else {
369 let id = config.increment_id();
370 config.save(mana_dir)?;
371 id.to_string()
372 };
373
374 let slug = title_to_slug(title);
375 let mut unit = Unit::try_new(&unit_id, title)?;
376 unit.slug = Some(slug.clone());
377
378 if let Some(desc) = description {
379 unit.description = Some(desc.to_string());
380 }
381 if let Some(v) = verify {
382 unit.verify = Some(v.to_string());
383 }
384 if let Some(p) = parent {
385 unit.parent = Some(p.to_string());
386 }
387 if let Some(p) = priority {
388 unit.priority = p;
389 }
390 if let Some(a) = acceptance {
391 unit.acceptance = Some(a.to_string());
392 }
393 if let Some(d) = deps {
394 unit.dependencies = d.split(',').map(|s| s.trim().to_string()).collect();
395 }
396
397 let unit_path = mana_dir.join(format!("{}-{}.md", unit_id, slug));
399 unit.to_file(&unit_path)?;
400
401 let index = Index::build(mana_dir)?;
403 index.save(mana_dir)?;
404
405 Ok(format!("Created unit {}: {}", unit_id, title))
406}
407
408fn handle_claim_unit(args: &Value, mana_dir: &Path) -> Result<String> {
409 let id = args
410 .get("id")
411 .and_then(|v| v.as_str())
412 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
413 let by = args.get("by").and_then(|v| v.as_str());
414
415 crate::util::validate_unit_id(id)?;
416 let unit_path = find_unit_file(mana_dir, id)?;
417 let mut unit = Unit::from_file(&unit_path)?;
418
419 if unit.status != Status::Open {
420 anyhow::bail!(
421 "Unit {} is {} — only open units can be claimed",
422 id,
423 unit.status
424 );
425 }
426
427 let now = Utc::now();
428 unit.status = Status::InProgress;
429 unit.claimed_by = by.map(|s| s.to_string());
430 unit.claimed_at = Some(now);
431 unit.updated_at = now;
432
433 unit.to_file(&unit_path)?;
434
435 let index = Index::build(mana_dir)?;
437 index.save(mana_dir)?;
438
439 let claimer = by.unwrap_or("anonymous");
440 Ok(format!(
441 "Claimed unit {}: {} (by {})",
442 id, unit.title, claimer
443 ))
444}
445
446fn handle_close_unit(args: &Value, mana_dir: &Path) -> Result<String> {
447 let id = args
448 .get("id")
449 .and_then(|v| v.as_str())
450 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
451 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
452 let reason = args.get("reason").and_then(|v| v.as_str());
453
454 crate::util::validate_unit_id(id)?;
455 let unit_path = find_unit_file(mana_dir, id)?;
456 let mut unit = Unit::from_file(&unit_path)?;
457
458 if let Some(ref verify_cmd) = unit.verify {
460 if !force {
461 let project_root = mana_dir
462 .parent()
463 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
464
465 let output = std::process::Command::new("sh")
466 .args(["-c", verify_cmd])
467 .current_dir(project_root)
468 .output()
469 .with_context(|| format!("Failed to execute verify: {}", verify_cmd))?;
470
471 if !output.status.success() {
472 let stderr = String::from_utf8_lossy(&output.stderr);
473 let stdout = String::from_utf8_lossy(&output.stdout);
474 let combined = format!("{}{}", stdout, stderr);
475 let snippet = if combined.len() > 2000 {
476 format!("...{}", &combined[combined.len() - 2000..])
477 } else {
478 combined.to_string()
479 };
480
481 unit.attempts += 1;
482 unit.updated_at = Utc::now();
483 unit.to_file(&unit_path)?;
484
485 let index = Index::build(mana_dir)?;
487 index.save(mana_dir)?;
488
489 anyhow::bail!(
490 "Verify failed for unit {} (attempt {})\nCommand: {}\nOutput:\n{}",
491 id,
492 unit.attempts,
493 verify_cmd,
494 snippet.trim()
495 );
496 }
497 }
498 }
499
500 let now = Utc::now();
502 unit.status = Status::Closed;
503 unit.closed_at = Some(now);
504 unit.close_reason = reason.map(|s| s.to_string());
505 unit.updated_at = now;
506
507 unit.to_file(&unit_path)?;
508
509 let slug = unit
511 .slug
512 .clone()
513 .unwrap_or_else(|| title_to_slug(&unit.title));
514 let ext = unit_path
515 .extension()
516 .and_then(|e| e.to_str())
517 .unwrap_or("md");
518 let today = chrono::Local::now().naive_local().date();
519 let archive_path = crate::discovery::archive_path_for_unit(mana_dir, id, &slug, ext, today);
520
521 if let Some(parent) = archive_path.parent() {
522 std::fs::create_dir_all(parent)?;
523 }
524 std::fs::rename(&unit_path, &archive_path)?;
525
526 unit.is_archived = true;
527 unit.to_file(&archive_path)?;
528
529 let index = Index::build(mana_dir)?;
531 index.save(mana_dir)?;
532
533 if let Some(parent_id) = &unit.parent {
535 let auto_close = Config::load(mana_dir)
536 .map(|c| c.auto_close_parent)
537 .unwrap_or(true);
538 if auto_close {
539 if let Ok(true) = all_children_closed(mana_dir, parent_id) {
540 let _ = auto_close_parent(mana_dir, parent_id);
541 }
542 }
543 }
544
545 Ok(format!("Closed unit {}: {}", id, unit.title))
546}
547
548fn handle_verify_unit(args: &Value, mana_dir: &Path) -> Result<String> {
549 let id = args
550 .get("id")
551 .and_then(|v| v.as_str())
552 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
553
554 crate::util::validate_unit_id(id)?;
555 let unit_path = find_unit_file(mana_dir, id)?;
556 let unit = Unit::from_file(&unit_path)?;
557
558 let verify_cmd = match &unit.verify {
559 Some(cmd) => cmd.clone(),
560 None => return Ok(format!("Unit {} has no verify command", id)),
561 };
562
563 let project_root = mana_dir
564 .parent()
565 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
566
567 let output = std::process::Command::new("sh")
568 .args(["-c", &verify_cmd])
569 .current_dir(project_root)
570 .output()
571 .with_context(|| format!("Failed to execute verify: {}", verify_cmd))?;
572
573 let stdout = String::from_utf8_lossy(&output.stdout);
574 let stderr = String::from_utf8_lossy(&output.stderr);
575 let passed = output.status.success();
576
577 Ok(serde_json::to_string_pretty(&json!({
578 "id": id,
579 "passed": passed,
580 "command": verify_cmd,
581 "exit_code": output.status.code(),
582 "stdout": truncate_str(&stdout, 2000),
583 "stderr": truncate_str(&stderr, 2000),
584 }))?)
585}
586
587fn handle_context_unit(args: &Value, mana_dir: &Path) -> Result<String> {
588 let id = args
589 .get("id")
590 .and_then(|v| v.as_str())
591 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
592
593 crate::util::validate_unit_id(id)?;
594 let unit_path = find_unit_file(mana_dir, id)?;
595 let unit = Unit::from_file(&unit_path)?;
596
597 let project_dir = mana_dir
598 .parent()
599 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
600
601 let description = unit.description.as_deref().unwrap_or("");
602 let paths = crate::ctx_assembler::extract_paths(description);
603
604 if paths.is_empty() {
605 return Ok(format!("Unit {}: no file paths found in description", id));
606 }
607
608 let context = crate::ctx_assembler::assemble_context(paths, project_dir)
609 .context("Failed to assemble context")?;
610
611 Ok(context)
612}
613
614fn handle_status(mana_dir: &Path) -> Result<String> {
615 let index = Index::load_or_rebuild(mana_dir)?;
616
617 let mut claimed = Vec::new();
618 let mut ready = Vec::new();
619 let mut goals = Vec::new();
620 let mut blocked: Vec<(&IndexEntry, String)> = Vec::new();
621
622 for entry in &index.units {
623 match entry.status {
624 Status::InProgress | Status::AwaitingVerify => claimed.push(entry),
625 Status::Open => {
626 if let Some(reason) = check_blocked(entry, &index) {
627 blocked.push((entry, reason.to_string()));
628 } else if entry.has_verify {
629 ready.push(entry);
630 } else {
631 goals.push(entry);
632 }
633 }
634 Status::Closed => {}
635 }
636 }
637
638 let format_entries = |entries: &[&IndexEntry]| -> Vec<Value> {
639 entries
640 .iter()
641 .map(|e| {
642 json!({
643 "id": e.id,
644 "title": e.title,
645 "priority": format!("P{}", e.priority),
646 "claimed_by": e.claimed_by,
647 })
648 })
649 .collect()
650 };
651
652 let blocked_entries: Vec<Value> = blocked
653 .iter()
654 .map(|(e, reason)| {
655 json!({
656 "id": e.id,
657 "title": e.title,
658 "priority": format!("P{}", e.priority),
659 "claimed_by": e.claimed_by,
660 "block_reason": reason,
661 })
662 })
663 .collect();
664
665 serde_json::to_string_pretty(&json!({
666 "claimed": format_entries(&claimed),
667 "ready": format_entries(&ready),
668 "goals": format_entries(&goals),
669 "blocked": blocked_entries,
670 "summary": format!(
671 "{} claimed, {} ready, {} goals, {} blocked",
672 claimed.len(), ready.len(), goals.len(), blocked.len()
673 )
674 }))
675 .context("Failed to serialize status")
676}
677
678fn handle_tree(args: &Value, mana_dir: &Path) -> Result<String> {
679 let index = Index::load_or_rebuild(mana_dir)?;
680 let root_id = args.get("id").and_then(|v| v.as_str());
681
682 let mut output = String::new();
683
684 if let Some(root) = root_id {
685 render_subtree(&index, root, "", true, &mut output);
686 } else {
687 let roots: Vec<&IndexEntry> = index.units.iter().filter(|e| e.parent.is_none()).collect();
689
690 for (i, root) in roots.iter().enumerate() {
691 let is_last = i == roots.len() - 1;
692 let status_icon = status_icon(root.status);
693 output.push_str(&format!("{} {} {}\n", status_icon, root.id, root.title));
694 render_children(&index, &root.id, " ", &mut output);
695 if !is_last {
696 output.push('\n');
697 }
698 }
699 }
700
701 if output.is_empty() {
702 Ok("No units found.".to_string())
703 } else {
704 Ok(output)
705 }
706}
707
708fn all_children_closed(mana_dir: &Path, parent_id: &str) -> Result<bool> {
714 let index = Index::load_or_rebuild(mana_dir)?;
715 let children: Vec<&IndexEntry> = index
716 .units
717 .iter()
718 .filter(|e| e.parent.as_deref() == Some(parent_id))
719 .collect();
720
721 if children.is_empty() {
722 return Ok(false);
723 }
724
725 Ok(children.iter().all(|c| c.status == Status::Closed))
726}
727
728fn auto_close_parent(mana_dir: &Path, parent_id: &str) -> Result<()> {
730 let unit_path = find_unit_file(mana_dir, parent_id)?;
731 let mut unit = Unit::from_file(&unit_path)?;
732
733 if unit.status == Status::Closed {
734 return Ok(());
735 }
736
737 let now = Utc::now();
738 unit.status = Status::Closed;
739 unit.closed_at = Some(now);
740 unit.close_reason = Some("All children closed".to_string());
741 unit.updated_at = now;
742 unit.to_file(&unit_path)?;
743
744 let slug = unit
746 .slug
747 .clone()
748 .unwrap_or_else(|| title_to_slug(&unit.title));
749 let ext = unit_path
750 .extension()
751 .and_then(|e| e.to_str())
752 .unwrap_or("md");
753 let today = chrono::Local::now().naive_local().date();
754 let archive_path =
755 crate::discovery::archive_path_for_unit(mana_dir, parent_id, &slug, ext, today);
756 if let Some(parent) = archive_path.parent() {
757 std::fs::create_dir_all(parent)?;
758 }
759 std::fs::rename(&unit_path, &archive_path)?;
760 unit.is_archived = true;
761 unit.to_file(&archive_path)?;
762
763 let index = Index::build(mana_dir)?;
765 index.save(mana_dir)?;
766
767 Ok(())
768}
769
770fn status_icon(status: Status) -> &'static str {
771 match status {
772 Status::Open => "[ ]",
773 Status::InProgress | Status::AwaitingVerify => "[-]",
774 Status::Closed => "[x]",
775 }
776}
777
778fn render_subtree(index: &Index, id: &str, prefix: &str, _is_last: bool, output: &mut String) {
779 if let Some(entry) = index.units.iter().find(|e| e.id == id) {
780 let icon = status_icon(entry.status);
781 output.push_str(&format!(
782 "{}{} {} {}\n",
783 prefix, icon, entry.id, entry.title
784 ));
785 render_children(index, id, &format!("{} ", prefix), output);
786 }
787}
788
789fn render_children(index: &Index, parent_id: &str, prefix: &str, output: &mut String) {
790 let children: Vec<&IndexEntry> = index
791 .units
792 .iter()
793 .filter(|e| e.parent.as_deref() == Some(parent_id))
794 .collect();
795
796 for child in &children {
797 let icon = status_icon(child.status);
798 output.push_str(&format!(
799 "{}{} {} {}\n",
800 prefix, icon, child.id, child.title
801 ));
802 render_children(index, &child.id, &format!("{} ", prefix), output);
803 }
804}
805
806fn truncate_str(s: &str, max: usize) -> String {
807 if s.len() > max {
808 format!("...{}", &s[s.len() - max..])
809 } else {
810 s.to_string()
811 }
812}