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};
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 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 }
58 Err(e) => {
59 eprintln!("Warning: failed to check for running processes: {}", e);
61 }
62 }
63 }
64
65 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 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 }
146 }
147
148 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 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 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 std::thread::spawn(move || {
268 let _ = child.wait();
269 });
270
271 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 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 let active_pids = crate::pid::list_active_pids()?;
323
324 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 for (spec_id, pid, is_running) in &active_pids {
337 if !is_running {
338 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 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 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 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 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 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 match spec.frontmatter.status {
555 SpecStatus::Pending => {
556 }
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 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 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 std::thread::spawn(move || {
599 let _ = child.wait();
600 });
601
602 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}