1use 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};
14use super::super::response::{mcp_error_response, mcp_text_response};
15
16pub fn tool_chant_work_start(arguments: Option<&Value>) -> Result<Value> {
17 let specs_dir = match mcp_ensure_initialized() {
18 Ok(dir) => dir,
19 Err(err_response) => return Ok(err_response),
20 };
21
22 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
23
24 let id = args
25 .get("id")
26 .and_then(|v| v.as_str())
27 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
28
29 let chain = args.get("chain").and_then(|v| v.as_bool()).unwrap_or(false);
30 let parallel = args.get("parallel").and_then(|v| v.as_u64());
31 let skip_criteria = args
32 .get("skip_criteria")
33 .and_then(|v| v.as_bool())
34 .unwrap_or(false);
35
36 if parallel.is_none() {
38 match check_for_running_work_processes() {
39 Ok(Some((running_spec, pid))) => {
40 return Ok(mcp_error_response(format!(
41 "Another work process is already running (spec: {}, PID: {}).\n\
42 Only one single or chain work process can run at a time.\n\
43 To run specs concurrently, use the parallel parameter:\n \
44 chant_work_start(id=\"<spec>\", parallel=<N>)",
45 running_spec, pid
46 )));
47 }
48 Ok(None) => {
49 }
51 Err(e) => {
52 eprintln!("Warning: failed to check for running processes: {}", e);
54 }
55 }
56 }
57
58 let spec = match resolve_spec(&specs_dir, id) {
60 Ok(s) => s,
61 Err(e) => {
62 return Ok(mcp_error_response(e.to_string()));
63 }
64 };
65
66 let spec_id = spec.id.clone();
67
68 match spec.frontmatter.status {
70 SpecStatus::Paused => {
71 return Ok(mcp_error_response(format!(
72 "Spec '{}' is paused. Cannot start work on a paused spec.\n\
73 Resume the spec first or use `chant reset {}` to reset it to pending.",
74 spec_id, spec_id
75 )));
76 }
77 SpecStatus::InProgress => {
78 return Ok(mcp_error_response(format!(
79 "Spec '{}' is already in progress. Cannot start work on a spec that is already being worked on.\n\
80 Use `chant takeover {}` to take over the running work.",
81 spec_id, spec_id
82 )));
83 }
84 SpecStatus::Completed => {
85 return Ok(mcp_error_response(format!(
86 "Spec '{}' is already completed. Cannot start work on a completed spec.",
87 spec_id
88 )));
89 }
90 SpecStatus::Cancelled => {
91 return Ok(mcp_error_response(format!(
92 "Spec '{}' is cancelled. Cannot start work on a cancelled spec.",
93 spec_id
94 )));
95 }
96 _ => {
97 }
99 }
100
101 let quality_warning = if !skip_criteria {
103 use crate::config::Config;
104 use crate::scoring::{calculate_spec_score, TrafficLight};
105
106 let config = match Config::load() {
107 Ok(c) => c,
108 Err(e) => {
109 return Ok(mcp_error_response(format!("Failed to load config: {}", e)));
110 }
111 };
112
113 let all_specs = match load_all_specs(&specs_dir) {
114 Ok(specs) => specs,
115 Err(e) => {
116 return Ok(mcp_error_response(format!("Failed to load specs: {}", e)));
117 }
118 };
119
120 let quality_score = calculate_spec_score(&spec, &all_specs, &config);
121
122 if quality_score.traffic_light == TrafficLight::Refine {
123 use crate::score::traffic_light;
124
125 let suggestions = traffic_light::generate_suggestions(&quality_score);
126 let mut warning_message =
127 "Quality advisory (Red/Refine) - work will start but spec may need improvement:\n\n"
128 .to_string();
129
130 warning_message.push_str("Quality Assessment:\n");
131 warning_message.push_str(&format!(" Complexity: {}\n", quality_score.complexity));
132 warning_message.push_str(&format!(" Confidence: {}\n", quality_score.confidence));
133 warning_message.push_str(&format!(
134 " Splittability: {}\n",
135 quality_score.splittability
136 ));
137 warning_message.push_str(&format!(" AC Quality: {}\n", quality_score.ac_quality));
138 if let Some(iso) = quality_score.isolation {
139 warning_message.push_str(&format!(" Isolation: {}\n", iso));
140 }
141
142 if !suggestions.is_empty() {
143 warning_message.push_str("\nSuggestions:\n");
144 for suggestion in &suggestions {
145 warning_message.push_str(&format!(" • {}\n", suggestion));
146 }
147 }
148
149 Some(json!({
150 "status": "refine",
151 "scores": {
152 "complexity": quality_score.complexity.to_string(),
153 "confidence": quality_score.confidence.to_string(),
154 "splittability": quality_score.splittability.to_string(),
155 "ac_quality": quality_score.ac_quality.to_string(),
156 "isolation": quality_score.isolation.map(|i| i.to_string())
157 },
158 "suggestions": suggestions,
159 "message": warning_message
160 }))
161 } else {
162 None
163 }
164 } else {
165 None
166 };
167
168 let mut cmd = Command::new("chant");
175 cmd.arg("work");
176
177 if skip_criteria {
178 cmd.arg("--skip-criteria");
179 }
180
181 let mode = if let Some(p) = parallel {
182 cmd.arg("--parallel").arg(p.to_string());
183 format!("parallel({})", p)
184 } else if chain {
185 cmd.arg("--chain").arg(&spec_id);
186 "chain".to_string()
187 } else {
188 cmd.arg(&spec_id);
189 "single".to_string()
190 };
191
192 cmd.stdin(Stdio::null())
194 .stdout(Stdio::null())
195 .stderr(Stdio::null());
196
197 let mut child = cmd.spawn().context("Failed to spawn chant work process")?;
198
199 let pid = child.id();
200 let started_at = chrono::Local::now().to_rfc3339();
201 let process_id = format!("{}-{}", spec_id, pid);
202
203 std::thread::spawn(move || {
205 let _ = child.wait();
206 });
207
208 let project_root =
210 find_project_root().ok_or_else(|| anyhow::anyhow!("Project root not found"))?;
211 let processes_dir = project_root.join(".chant/processes");
212 std::fs::create_dir_all(&processes_dir)?;
213
214 let mut process_info = json!({
215 "process_id": process_id,
216 "spec_id": spec_id,
217 "pid": pid,
218 "started_at": started_at,
219 "mode": mode,
220 "started": true,
221 "monitor": {
222 "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.",
223 "poll_tool": "chant_spec_get",
224 "poll_interval_seconds": 60,
225 "done_statuses": ["completed", "failed"]
226 }
227 });
228
229 if let Some(warning) = quality_warning {
231 process_info["quality_warning"] = warning;
232 }
233
234 let process_file = processes_dir.join(format!("{}.json", process_id));
235 std::fs::write(&process_file, serde_json::to_string_pretty(&process_info)?)?;
236
237 Ok(mcp_text_response(serde_json::to_string_pretty(
238 &process_info,
239 )?))
240}
241
242pub fn tool_chant_work_list(arguments: Option<&Value>) -> Result<Value> {
243 let specs_dir = match mcp_ensure_initialized() {
244 Ok(dir) => dir,
245 Err(err_response) => return Ok(err_response),
246 };
247
248 let include_completed = arguments
249 .and_then(|a| a.get("include_completed"))
250 .and_then(|v| v.as_bool())
251 .unwrap_or(false);
252
253 let active_pids = crate::pid::list_active_pids()?;
255
256 let all_specs = load_all_specs(&specs_dir)?;
258 let spec_map: std::collections::HashMap<String, &crate::spec::Spec> =
259 all_specs.iter().map(|s| (s.id.clone(), s)).collect();
260
261 let mut processes: Vec<Value> = Vec::new();
262 let mut running = 0;
263 let mut stale_count = 0;
264
265 let logs_dir = PathBuf::from(LOGS_DIR);
266
267 for (spec_id, pid, is_running) in &active_pids {
269 if !is_running {
270 let _ = crate::pid::remove_pid_file(spec_id);
272 let _ = crate::pid::remove_process_files(spec_id);
273 stale_count += 1;
274 if !include_completed {
275 continue;
276 }
277 } else {
278 running += 1;
279 }
280
281 let spec = spec_map.get(spec_id);
282 let title = spec.and_then(|s| s.title.as_deref());
283 let branch = spec.and_then(|s| s.frontmatter.branch.as_deref());
284 let spec_status = spec.map(|s| &s.frontmatter.status);
285
286 let log_path = logs_dir.join(format!("{}.log", spec_id));
287 let log_mtime = if log_path.exists() {
288 std::fs::metadata(&log_path)
289 .and_then(|m| m.modified())
290 .ok()
291 .map(|t| {
292 chrono::DateTime::<chrono::Local>::from(t)
293 .format("%Y-%m-%d %H:%M:%S")
294 .to_string()
295 })
296 } else {
297 None
298 };
299
300 let is_dead_worker = !is_running && matches!(spec_status, Some(SpecStatus::InProgress));
301
302 let mut entry = json!({
303 "spec_id": spec_id,
304 "title": title,
305 "pid": pid,
306 "status": if *is_running { "running" } else { "stale" },
307 "log_modified": log_mtime,
308 "branch": branch
309 });
310
311 if is_dead_worker {
312 entry["warning"] = json!("process_dead");
313 }
314
315 processes.push(entry);
316 }
317
318 let summary = json!({
319 "running": running,
320 "stale": stale_count
321 });
322
323 let response = json!({
324 "processes": processes,
325 "summary": summary
326 });
327
328 Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
329}
330
331pub fn tool_chant_pause(arguments: Option<&Value>) -> Result<Value> {
332 let specs_dir = match mcp_ensure_initialized() {
333 Ok(dir) => dir,
334 Err(err_response) => return Ok(err_response),
335 };
336
337 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
338
339 let id = args
340 .get("id")
341 .and_then(|v| v.as_str())
342 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
343
344 let mut spec = match resolve_spec(&specs_dir, id) {
346 Ok(s) => s,
347 Err(e) => {
348 return Ok(mcp_error_response(e.to_string()));
349 }
350 };
351
352 let spec_id = spec.id.clone();
353 let spec_path = specs_dir.join(format!("{}.md", spec_id));
354
355 let options = crate::operations::PauseOptions { force: true };
357 crate::operations::pause_spec(&mut spec, &spec_path, options)?;
358
359 Ok(mcp_text_response(format!(
360 "Successfully paused work for spec '{}'",
361 spec_id
362 )))
363}
364
365pub fn tool_chant_takeover(arguments: Option<&Value>) -> Result<Value> {
366 let specs_dir = match mcp_ensure_initialized() {
367 Ok(dir) => dir,
368 Err(err_response) => return Ok(err_response),
369 };
370
371 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
372
373 let id = args
374 .get("id")
375 .and_then(|v| v.as_str())
376 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
377
378 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
379
380 let spec = match resolve_spec(&specs_dir, id) {
382 Ok(s) => s,
383 Err(e) => {
384 return Ok(mcp_error_response(e.to_string()));
385 }
386 };
387
388 match crate::takeover::cmd_takeover(&spec.id, force) {
390 Ok(result) => {
391 let response = json!({
392 "spec_id": result.spec_id,
393 "analysis": result.analysis,
394 "log_tail": result.log_tail,
395 "suggestion": result.suggestion,
396 "worktree_path": result.worktree_path
397 });
398
399 Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
400 }
401 Err(e) => Ok(mcp_error_response(format!(
402 "Failed to take over spec '{}': {}",
403 spec.id, e
404 ))),
405 }
406}
407
408pub fn tool_chant_split(arguments: Option<&Value>) -> Result<Value> {
409 let specs_dir = match mcp_ensure_initialized() {
410 Ok(dir) => dir,
411 Err(err_response) => return Ok(err_response),
412 };
413
414 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
415
416 let id = args
417 .get("id")
418 .and_then(|v| v.as_str())
419 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
420
421 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
422 let recursive = args
423 .get("recursive")
424 .and_then(|v| v.as_bool())
425 .unwrap_or(false);
426 let max_depth = args.get("max_depth").and_then(|v| v.as_u64());
427
428 let spec = match resolve_spec(&specs_dir, id) {
430 Ok(s) => s,
431 Err(e) => {
432 return Ok(mcp_error_response(e.to_string()));
433 }
434 };
435
436 let spec_id = spec.id.clone();
437
438 match spec.frontmatter.status {
440 SpecStatus::Pending => {
441 }
443 _ => {
444 return Ok(mcp_error_response(format!(
445 "Spec '{}' must be in pending status to split. Current status: {:?}",
446 spec_id, spec.frontmatter.status
447 )));
448 }
449 }
450
451 let mut cmd = Command::new("chant");
453 cmd.arg("split");
454 cmd.arg(&spec_id);
455
456 if force {
457 cmd.arg("--force");
458 }
459 if recursive {
460 cmd.arg("--recursive");
461 }
462 if let Some(depth) = max_depth {
463 cmd.arg("--max-depth").arg(depth.to_string());
464 }
465
466 cmd.stdin(Stdio::null())
468 .stdout(Stdio::null())
469 .stderr(Stdio::null());
470
471 let mut child = cmd.spawn().context("Failed to spawn chant split process")?;
472
473 let pid = child.id();
474 let started_at = chrono::Local::now().to_rfc3339();
475 let process_id = format!("split-{}-{}", spec_id, pid);
476
477 std::thread::spawn(move || {
479 let _ = child.wait();
480 });
481
482 let project_root =
484 find_project_root().ok_or_else(|| anyhow::anyhow!("Project root not found"))?;
485 let processes_dir = project_root.join(".chant/processes");
486 std::fs::create_dir_all(&processes_dir)?;
487
488 let process_info = json!({
489 "process_id": process_id,
490 "spec_id": spec_id,
491 "pid": pid,
492 "started_at": started_at,
493 "mode": "split",
494 "options": {
495 "force": force,
496 "recursive": recursive,
497 "max_depth": max_depth
498 }
499 });
500
501 let process_file = processes_dir.join(format!("{}.json", process_id));
502 std::fs::write(&process_file, serde_json::to_string_pretty(&process_info)?)?;
503
504 Ok(mcp_text_response(serde_json::to_string_pretty(
505 &process_info,
506 )?))
507}