Skip to main content

chant/mcp/tools/
work.rs

1//! MCP tools for work execution and management
2
3use anyhow::{Context, Result};
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7
8use crate::paths::LOGS_DIR;
9use crate::spec::{load_all_specs, resolve_spec, SpecStatus};
10
11use super::super::handlers::{
12    check_for_running_work_processes, find_project_root, mcp_ensure_initialized,
13};
14
15pub fn tool_chant_work_start(arguments: Option<&Value>) -> Result<Value> {
16    let specs_dir = match mcp_ensure_initialized() {
17        Ok(dir) => dir,
18        Err(err_response) => return Ok(err_response),
19    };
20
21    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
22
23    let id = args
24        .get("id")
25        .and_then(|v| v.as_str())
26        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
27
28    let chain = args.get("chain").and_then(|v| v.as_bool()).unwrap_or(false);
29    let parallel = args.get("parallel").and_then(|v| v.as_u64());
30    let skip_criteria = args
31        .get("skip_criteria")
32        .and_then(|v| v.as_bool())
33        .unwrap_or(false);
34
35    // Guard: prevent concurrent single/chain execution when not in parallel mode
36    if parallel.is_none() {
37        match check_for_running_work_processes() {
38            Ok(Some((running_spec, pid))) => {
39                return Ok(json!({
40                    "content": [
41                        {
42                            "type": "text",
43                            "text": format!(
44                                "Another work process is already running (spec: {}, PID: {}).\n\
45                                 Only one single or chain work process can run at a time.\n\
46                                 To run specs concurrently, use the parallel parameter:\n  \
47                                 chant_work_start(id=\"<spec>\", parallel=<N>)",
48                                running_spec, pid
49                            )
50                        }
51                    ],
52                    "isError": true
53                }));
54            }
55            Ok(None) => {
56                // No running processes, proceed
57            }
58            Err(e) => {
59                // Log warning but don't block - fail open if we can't check
60                eprintln!("Warning: failed to check for running processes: {}", e);
61            }
62        }
63    }
64
65    // Resolve spec to get full ID
66    let spec = match resolve_spec(&specs_dir, id) {
67        Ok(s) => s,
68        Err(e) => {
69            return Ok(json!({
70                "content": [
71                    {
72                        "type": "text",
73                        "text": e.to_string()
74                    }
75                ],
76                "isError": true
77            }));
78        }
79    };
80
81    let spec_id = spec.id.clone();
82
83    // Gate: reject specs in invalid states
84    match spec.frontmatter.status {
85        SpecStatus::Paused => {
86            return Ok(json!({
87                "content": [
88                    {
89                        "type": "text",
90                        "text": format!(
91                            "Spec '{}' is paused. Cannot start work on a paused spec.\n\
92                             Resume the spec first or use `chant reset {}` to reset it to pending.",
93                            spec_id, spec_id
94                        )
95                    }
96                ],
97                "isError": true
98            }));
99        }
100        SpecStatus::InProgress => {
101            return Ok(json!({
102                "content": [
103                    {
104                        "type": "text",
105                        "text": format!(
106                            "Spec '{}' is already in progress. Cannot start work on a spec that is already being worked on.\n\
107                             Use `chant takeover {}` to take over the running work.",
108                            spec_id, spec_id
109                        )
110                    }
111                ],
112                "isError": true
113            }));
114        }
115        SpecStatus::Completed => {
116            return Ok(json!({
117                "content": [
118                    {
119                        "type": "text",
120                        "text": format!(
121                            "Spec '{}' is already completed. Cannot start work on a completed spec.",
122                            spec_id
123                        )
124                    }
125                ],
126                "isError": true
127            }));
128        }
129        SpecStatus::Cancelled => {
130            return Ok(json!({
131                "content": [
132                    {
133                        "type": "text",
134                        "text": format!(
135                            "Spec '{}' is cancelled. Cannot start work on a cancelled spec.",
136                            spec_id
137                        )
138                    }
139                ],
140                "isError": true
141            }));
142        }
143        _ => {
144            // Valid states: Pending, Ready, Failed, NeedsAttention, Blocked
145        }
146    }
147
148    // Calculate spec quality for advisory feedback (not a gate)
149    let quality_warning = if !skip_criteria {
150        use crate::config::Config;
151        use crate::scoring::{calculate_spec_score, TrafficLight};
152
153        let config = match Config::load() {
154            Ok(c) => c,
155            Err(e) => {
156                return Ok(json!({
157                    "content": [
158                        {
159                            "type": "text",
160                            "text": format!("Failed to load config: {}", e)
161                        }
162                    ],
163                    "isError": true
164                }));
165            }
166        };
167
168        let all_specs = match load_all_specs(&specs_dir) {
169            Ok(specs) => specs,
170            Err(e) => {
171                return Ok(json!({
172                    "content": [
173                        {
174                            "type": "text",
175                            "text": format!("Failed to load specs: {}", e)
176                        }
177                    ],
178                    "isError": true
179                }));
180            }
181        };
182
183        let quality_score = calculate_spec_score(&spec, &all_specs, &config);
184
185        if quality_score.traffic_light == TrafficLight::Refine {
186            use crate::score::traffic_light;
187
188            let suggestions = traffic_light::generate_suggestions(&quality_score);
189            let mut warning_message =
190                "Quality advisory (Red/Refine) - work will start but spec may need improvement:\n\n"
191                    .to_string();
192
193            warning_message.push_str("Quality Assessment:\n");
194            warning_message.push_str(&format!("  Complexity:    {}\n", quality_score.complexity));
195            warning_message.push_str(&format!("  Confidence:    {}\n", quality_score.confidence));
196            warning_message.push_str(&format!(
197                "  Splittability: {}\n",
198                quality_score.splittability
199            ));
200            warning_message.push_str(&format!("  AC Quality:    {}\n", quality_score.ac_quality));
201            if let Some(iso) = quality_score.isolation {
202                warning_message.push_str(&format!("  Isolation:     {}\n", iso));
203            }
204
205            if !suggestions.is_empty() {
206                warning_message.push_str("\nSuggestions:\n");
207                for suggestion in &suggestions {
208                    warning_message.push_str(&format!("  • {}\n", suggestion));
209                }
210            }
211
212            Some(json!({
213                "status": "refine",
214                "scores": {
215                    "complexity": quality_score.complexity.to_string(),
216                    "confidence": quality_score.confidence.to_string(),
217                    "splittability": quality_score.splittability.to_string(),
218                    "ac_quality": quality_score.ac_quality.to_string(),
219                    "isolation": quality_score.isolation.map(|i| i.to_string())
220                },
221                "suggestions": suggestions,
222                "message": warning_message
223            }))
224        } else {
225            None
226        }
227    } else {
228        None
229    };
230
231    // Note: Do NOT transition to InProgress here. The spawned `chant work`
232    // handles that transition itself (single.rs line 179). Setting it here
233    // causes `chant work` to reject the spec with "already in progress",
234    // leaving it stuck as in_progress forever (the error goes to /dev/null).
235
236    // Build command based on mode
237    let mut cmd = Command::new("chant");
238    cmd.arg("work");
239
240    if skip_criteria {
241        cmd.arg("--skip-criteria");
242    }
243
244    let mode = if let Some(p) = parallel {
245        cmd.arg("--parallel").arg(p.to_string());
246        format!("parallel({})", p)
247    } else if chain {
248        cmd.arg("--chain").arg(&spec_id);
249        "chain".to_string()
250    } else {
251        cmd.arg(&spec_id);
252        "single".to_string()
253    };
254
255    // Spawn as background process
256    cmd.stdin(Stdio::null())
257        .stdout(Stdio::null())
258        .stderr(Stdio::null());
259
260    let mut child = cmd.spawn().context("Failed to spawn chant work process")?;
261
262    let pid = child.id();
263    let started_at = chrono::Local::now().to_rfc3339();
264    let process_id = format!("{}-{}", spec_id, pid);
265
266    // Spawn a thread to reap the process when it exits (prevents zombies)
267    std::thread::spawn(move || {
268        let _ = child.wait();
269    });
270
271    // Store process info
272    let project_root =
273        find_project_root().ok_or_else(|| anyhow::anyhow!("Project root not found"))?;
274    let processes_dir = project_root.join(".chant/processes");
275    std::fs::create_dir_all(&processes_dir)?;
276
277    let mut process_info = json!({
278        "process_id": process_id,
279        "spec_id": spec_id,
280        "pid": pid,
281        "started_at": started_at,
282        "mode": mode,
283        "started": true,
284        "monitor": {
285            "instruction": "Poll spec status every 60 seconds using chant_spec_get until status changes to completed or failed. Use chant_log with the since parameter to check progress.",
286            "poll_tool": "chant_spec_get",
287            "poll_interval_seconds": 60,
288            "done_statuses": ["completed", "failed"]
289        }
290    });
291
292    // Include quality warning if present
293    if let Some(warning) = quality_warning {
294        process_info["quality_warning"] = warning;
295    }
296
297    let process_file = processes_dir.join(format!("{}.json", process_id));
298    std::fs::write(&process_file, serde_json::to_string_pretty(&process_info)?)?;
299
300    Ok(json!({
301        "content": [
302            {
303                "type": "text",
304                "text": serde_json::to_string_pretty(&process_info)?
305            }
306        ]
307    }))
308}
309
310pub fn tool_chant_work_list(arguments: Option<&Value>) -> Result<Value> {
311    let specs_dir = match mcp_ensure_initialized() {
312        Ok(dir) => dir,
313        Err(err_response) => return Ok(err_response),
314    };
315
316    let include_completed = arguments
317        .and_then(|a| a.get("include_completed"))
318        .and_then(|v| v.as_bool())
319        .unwrap_or(false);
320
321    // Use PID files to determine running processes (reliable source of truth)
322    let active_pids = crate::pid::list_active_pids()?;
323
324    // Load all specs to get metadata
325    let all_specs = load_all_specs(&specs_dir)?;
326    let spec_map: std::collections::HashMap<String, &crate::spec::Spec> =
327        all_specs.iter().map(|s| (s.id.clone(), s)).collect();
328
329    let mut processes: Vec<Value> = Vec::new();
330    let mut running = 0;
331    let mut stale_count = 0;
332
333    let logs_dir = PathBuf::from(LOGS_DIR);
334
335    // Report processes with active PIDs
336    for (spec_id, pid, is_running) in &active_pids {
337        if !is_running {
338            // Self-healing: clean up stale PID and process files
339            let _ = crate::pid::remove_pid_file(spec_id);
340            let _ = crate::pid::remove_process_files(spec_id);
341            stale_count += 1;
342            if !include_completed {
343                continue;
344            }
345        } else {
346            running += 1;
347        }
348
349        let spec = spec_map.get(spec_id);
350        let title = spec.and_then(|s| s.title.as_deref());
351        let branch = spec.and_then(|s| s.frontmatter.branch.as_deref());
352        let spec_status = spec.map(|s| &s.frontmatter.status);
353
354        let log_path = logs_dir.join(format!("{}.log", spec_id));
355        let log_mtime = if log_path.exists() {
356            std::fs::metadata(&log_path)
357                .and_then(|m| m.modified())
358                .ok()
359                .map(|t| {
360                    chrono::DateTime::<chrono::Local>::from(t)
361                        .format("%Y-%m-%d %H:%M:%S")
362                        .to_string()
363                })
364        } else {
365            None
366        };
367
368        let is_dead_worker = !is_running && matches!(spec_status, Some(SpecStatus::InProgress));
369
370        let mut entry = json!({
371            "spec_id": spec_id,
372            "title": title,
373            "pid": pid,
374            "status": if *is_running { "running" } else { "stale" },
375            "log_modified": log_mtime,
376            "branch": branch
377        });
378
379        if is_dead_worker {
380            entry["warning"] = json!("process_dead");
381        }
382
383        processes.push(entry);
384    }
385
386    let summary = json!({
387        "running": running,
388        "stale": stale_count
389    });
390
391    let response = json!({
392        "processes": processes,
393        "summary": summary
394    });
395
396    Ok(json!({
397        "content": [
398            {
399                "type": "text",
400                "text": serde_json::to_string_pretty(&response)?
401            }
402        ]
403    }))
404}
405
406pub fn tool_chant_pause(arguments: Option<&Value>) -> Result<Value> {
407    let specs_dir = match mcp_ensure_initialized() {
408        Ok(dir) => dir,
409        Err(err_response) => return Ok(err_response),
410    };
411
412    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
413
414    let id = args
415        .get("id")
416        .and_then(|v| v.as_str())
417        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
418
419    // Resolve spec to get full ID
420    let mut spec = match resolve_spec(&specs_dir, id) {
421        Ok(s) => s,
422        Err(e) => {
423            return Ok(json!({
424                "content": [
425                    {
426                        "type": "text",
427                        "text": e.to_string()
428                    }
429                ],
430                "isError": true
431            }));
432        }
433    };
434
435    let spec_id = spec.id.clone();
436    let spec_path = specs_dir.join(format!("{}.md", spec_id));
437
438    // Use operations layer (MCP always forces pause)
439    let options = crate::operations::PauseOptions { force: true };
440    crate::operations::pause_spec(&mut spec, &spec_path, options)?;
441
442    Ok(json!({
443        "content": [
444            {
445                "type": "text",
446                "text": format!("Successfully paused work for spec '{}'", spec_id)
447            }
448        ]
449    }))
450}
451
452pub fn tool_chant_takeover(arguments: Option<&Value>) -> Result<Value> {
453    let specs_dir = match mcp_ensure_initialized() {
454        Ok(dir) => dir,
455        Err(err_response) => return Ok(err_response),
456    };
457
458    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
459
460    let id = args
461        .get("id")
462        .and_then(|v| v.as_str())
463        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
464
465    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
466
467    // Resolve spec to get full ID
468    let spec = match resolve_spec(&specs_dir, id) {
469        Ok(s) => s,
470        Err(e) => {
471            return Ok(json!({
472                "content": [
473                    {
474                        "type": "text",
475                        "text": e.to_string()
476                    }
477                ],
478                "isError": true
479            }));
480        }
481    };
482
483    // Execute takeover
484    match crate::takeover::cmd_takeover(&spec.id, force) {
485        Ok(result) => {
486            let response = json!({
487                "spec_id": result.spec_id,
488                "analysis": result.analysis,
489                "log_tail": result.log_tail,
490                "suggestion": result.suggestion,
491                "worktree_path": result.worktree_path
492            });
493
494            Ok(json!({
495                "content": [
496                    {
497                        "type": "text",
498                        "text": serde_json::to_string_pretty(&response)?
499                    }
500                ]
501            }))
502        }
503        Err(e) => Ok(json!({
504            "content": [
505                {
506                    "type": "text",
507                    "text": format!("Failed to take over spec '{}': {}", spec.id, e)
508                }
509            ],
510            "isError": true
511        })),
512    }
513}
514
515pub fn tool_chant_split(arguments: Option<&Value>) -> Result<Value> {
516    let specs_dir = match mcp_ensure_initialized() {
517        Ok(dir) => dir,
518        Err(err_response) => return Ok(err_response),
519    };
520
521    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
522
523    let id = args
524        .get("id")
525        .and_then(|v| v.as_str())
526        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
527
528    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
529    let recursive = args
530        .get("recursive")
531        .and_then(|v| v.as_bool())
532        .unwrap_or(false);
533    let max_depth = args.get("max_depth").and_then(|v| v.as_u64());
534
535    // Resolve spec to validate it exists
536    let spec = match resolve_spec(&specs_dir, id) {
537        Ok(s) => s,
538        Err(e) => {
539            return Ok(json!({
540                "content": [
541                    {
542                        "type": "text",
543                        "text": e.to_string()
544                    }
545                ],
546                "isError": true
547            }));
548        }
549    };
550
551    let spec_id = spec.id.clone();
552
553    // Check if spec is in valid state for splitting
554    match spec.frontmatter.status {
555        SpecStatus::Pending => {
556            // Valid for splitting
557        }
558        _ => {
559            return Ok(json!({
560                "content": [
561                    {
562                        "type": "text",
563                        "text": format!("Spec '{}' must be in pending status to split. Current status: {:?}", spec_id, spec.frontmatter.status)
564                    }
565                ],
566                "isError": true
567            }));
568        }
569    }
570
571    // Build command
572    let mut cmd = Command::new("chant");
573    cmd.arg("split");
574    cmd.arg(&spec_id);
575
576    if force {
577        cmd.arg("--force");
578    }
579    if recursive {
580        cmd.arg("--recursive");
581    }
582    if let Some(depth) = max_depth {
583        cmd.arg("--max-depth").arg(depth.to_string());
584    }
585
586    // Spawn as background process
587    cmd.stdin(Stdio::null())
588        .stdout(Stdio::null())
589        .stderr(Stdio::null());
590
591    let mut child = cmd.spawn().context("Failed to spawn chant split process")?;
592
593    let pid = child.id();
594    let started_at = chrono::Local::now().to_rfc3339();
595    let process_id = format!("split-{}-{}", spec_id, pid);
596
597    // Spawn a thread to reap the process when it exits (prevents zombies)
598    std::thread::spawn(move || {
599        let _ = child.wait();
600    });
601
602    // Store process info
603    let project_root =
604        find_project_root().ok_or_else(|| anyhow::anyhow!("Project root not found"))?;
605    let processes_dir = project_root.join(".chant/processes");
606    std::fs::create_dir_all(&processes_dir)?;
607
608    let process_info = json!({
609        "process_id": process_id,
610        "spec_id": spec_id,
611        "pid": pid,
612        "started_at": started_at,
613        "mode": "split",
614        "options": {
615            "force": force,
616            "recursive": recursive,
617            "max_depth": max_depth
618        }
619    });
620
621    let process_file = processes_dir.join(format!("{}.json", process_id));
622    std::fs::write(&process_file, serde_json::to_string_pretty(&process_info)?)?;
623
624    Ok(json!({
625        "content": [
626            {
627                "type": "text",
628                "text": serde_json::to_string_pretty(&process_info)?
629            }
630        ]
631    }))
632}