1use anyhow::Result;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7use crate::diagnose;
8use crate::paths::LOGS_DIR;
9use crate::spec::{load_all_specs, resolve_spec, SpecStatus};
10use crate::spec_group;
11
12use super::super::handlers::mcp_ensure_initialized;
13
14pub fn tool_chant_spec_list(arguments: Option<&Value>) -> Result<Value> {
15 let specs_dir = match mcp_ensure_initialized() {
16 Ok(dir) => dir,
17 Err(err_response) => return Ok(err_response),
18 };
19
20 let mut specs = load_all_specs(&specs_dir)?;
21 specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
22
23 if let Some(args) = arguments {
25 if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
26 let filter_status = match status_str {
27 "pending" => Some(SpecStatus::Pending),
28 "in_progress" => Some(SpecStatus::InProgress),
29 "completed" => Some(SpecStatus::Completed),
30 "failed" => Some(SpecStatus::Failed),
31 "cancelled" => Some(SpecStatus::Cancelled),
32 _ => None,
33 };
34
35 if let Some(status) = filter_status {
36 specs.retain(|s| s.frontmatter.status == status);
37 }
38 } else {
39 specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
41 }
42 } else {
43 specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
45 }
46
47 let limit = arguments
49 .and_then(|a| a.get("limit"))
50 .and_then(|v| v.as_u64())
51 .unwrap_or(50) as usize;
52
53 let total = specs.len();
54 let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
55
56 let specs_json: Vec<Value> = limited_specs
57 .iter()
58 .map(|s| {
59 json!({
60 "id": s.id,
61 "title": s.title,
62 "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
63 "type": s.frontmatter.r#type,
64 "depends_on": s.frontmatter.depends_on,
65 "labels": s.frontmatter.labels
66 })
67 })
68 .collect();
69
70 let response = json!({
71 "specs": specs_json,
72 "total": total,
73 "limit": limit,
74 "returned": limited_specs.len()
75 });
76
77 Ok(json!({
78 "content": [
79 {
80 "type": "text",
81 "text": serde_json::to_string_pretty(&response)?
82 }
83 ]
84 }))
85}
86
87pub fn tool_chant_spec_get(arguments: Option<&Value>) -> Result<Value> {
88 let specs_dir = match mcp_ensure_initialized() {
89 Ok(dir) => dir,
90 Err(err_response) => return Ok(err_response),
91 };
92
93 let id = arguments
94 .and_then(|a| a.get("id"))
95 .and_then(|v| v.as_str())
96 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
97
98 let spec = match resolve_spec(&specs_dir, id) {
99 Ok(s) => s,
100 Err(e) => {
101 return Ok(json!({
102 "content": [
103 {
104 "type": "text",
105 "text": e.to_string()
106 }
107 ],
108 "isError": true
109 }));
110 }
111 };
112
113 let spec_json = json!({
114 "id": spec.id,
115 "title": spec.title,
116 "status": format!("{:?}", spec.frontmatter.status).to_lowercase(),
117 "type": spec.frontmatter.r#type,
118 "depends_on": spec.frontmatter.depends_on,
119 "labels": spec.frontmatter.labels,
120 "target_files": spec.frontmatter.target_files,
121 "context": spec.frontmatter.context,
122 "prompt": spec.frontmatter.prompt,
123 "branch": spec.frontmatter.branch,
124 "commits": spec.frontmatter.commits,
125 "completed_at": spec.frontmatter.completed_at,
126 "model": spec.frontmatter.model,
127 "body": spec.body
128 });
129
130 Ok(json!({
131 "content": [
132 {
133 "type": "text",
134 "text": serde_json::to_string_pretty(&spec_json)?
135 }
136 ]
137 }))
138}
139
140pub fn tool_chant_ready(arguments: Option<&Value>) -> Result<Value> {
141 let specs_dir = match mcp_ensure_initialized() {
142 Ok(dir) => dir,
143 Err(err_response) => return Ok(err_response),
144 };
145
146 let mut specs = load_all_specs(&specs_dir)?;
147 specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
148
149 let all_specs = specs.clone();
151 specs.retain(|s| s.is_ready(&all_specs));
152 specs.retain(|s| s.frontmatter.r#type != "group");
154
155 let limit = arguments
157 .and_then(|a| a.get("limit"))
158 .and_then(|v| v.as_u64())
159 .unwrap_or(50) as usize;
160
161 let total = specs.len();
162 let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
163
164 let specs_json: Vec<Value> = limited_specs
165 .iter()
166 .map(|s| {
167 json!({
168 "id": s.id,
169 "title": s.title,
170 "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
171 "type": s.frontmatter.r#type,
172 "depends_on": s.frontmatter.depends_on,
173 "labels": s.frontmatter.labels
174 })
175 })
176 .collect();
177
178 let response = json!({
179 "specs": specs_json,
180 "total": total,
181 "limit": limit,
182 "returned": limited_specs.len()
183 });
184
185 Ok(json!({
186 "content": [
187 {
188 "type": "text",
189 "text": serde_json::to_string_pretty(&response)?
190 }
191 ]
192 }))
193}
194
195pub fn tool_chant_spec_update(arguments: Option<&Value>) -> Result<Value> {
196 let specs_dir = match mcp_ensure_initialized() {
197 Ok(dir) => dir,
198 Err(err_response) => return Ok(err_response),
199 };
200
201 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
202
203 let id = args
204 .get("id")
205 .and_then(|v| v.as_str())
206 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
207
208 let mut spec = match resolve_spec(&specs_dir, id) {
209 Ok(s) => s,
210 Err(e) => {
211 return Ok(json!({
212 "content": [
213 {
214 "type": "text",
215 "text": e.to_string()
216 }
217 ],
218 "isError": true
219 }));
220 }
221 };
222
223 let spec_id = spec.id.clone();
224 let spec_path = specs_dir.join(format!("{}.md", spec.id));
225
226 let status = if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
228 let new_status = match status_str {
229 "pending" => SpecStatus::Pending,
230 "in_progress" => SpecStatus::InProgress,
231 "completed" => SpecStatus::Completed,
232 "failed" => SpecStatus::Failed,
233 _ => {
234 return Ok(json!({
235 "content": [
236 {
237 "type": "text",
238 "text": format!("Invalid status: {}. Must be one of: pending, in_progress, completed, failed", status_str)
239 }
240 ],
241 "isError": true
242 }));
243 }
244 };
245 Some(new_status)
246 } else {
247 None
248 };
249
250 let depends_on = args
252 .get("depends_on")
253 .and_then(|v| v.as_array())
254 .map(|arr| {
255 arr.iter()
256 .filter_map(|v| v.as_str().map(String::from))
257 .collect()
258 });
259
260 let labels = args.get("labels").and_then(|v| v.as_array()).map(|arr| {
261 arr.iter()
262 .filter_map(|v| v.as_str().map(String::from))
263 .collect()
264 });
265
266 let target_files = args
267 .get("target_files")
268 .and_then(|v| v.as_array())
269 .map(|arr| {
270 arr.iter()
271 .filter_map(|v| v.as_str().map(String::from))
272 .collect()
273 });
274
275 let model = args.get("model").and_then(|v| v.as_str()).map(String::from);
276
277 let output = args
278 .get("output")
279 .and_then(|v| v.as_str())
280 .map(String::from);
281
282 let replace_body = args
283 .get("replace_body")
284 .and_then(|v| v.as_bool())
285 .unwrap_or(false);
286
287 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
289
290 let options = crate::operations::update::UpdateOptions {
292 status,
293 depends_on,
294 labels,
295 target_files,
296 model,
297 output,
298 replace_body,
299 force,
300 };
301
302 match crate::operations::update::update_spec(&mut spec, &spec_path, options) {
303 Ok(_) => Ok(json!({
304 "content": [
305 {
306 "type": "text",
307 "text": format!("Updated spec: {}", spec_id)
308 }
309 ]
310 })),
311 Err(e) => Ok(json!({
312 "content": [
313 {
314 "type": "text",
315 "text": format!("Failed to update spec: {}", e)
316 }
317 ],
318 "isError": true
319 })),
320 }
321}
322
323pub fn tool_chant_status(arguments: Option<&Value>) -> Result<Value> {
324 let specs_dir = match mcp_ensure_initialized() {
325 Ok(dir) => dir,
326 Err(err_response) => return Ok(err_response),
327 };
328
329 let specs = load_all_specs(&specs_dir)?;
330
331 let brief = arguments
333 .and_then(|a| a.get("brief"))
334 .and_then(|v| v.as_bool())
335 .unwrap_or(false);
336 let include_activity = arguments
337 .and_then(|a| a.get("include_activity"))
338 .and_then(|v| v.as_bool())
339 .unwrap_or(false);
340
341 let mut pending = 0;
343 let mut in_progress = 0;
344 let mut completed = 0;
345 let mut failed = 0;
346 let mut blocked = 0;
347 let mut cancelled = 0;
348 let mut needs_attention = 0;
349
350 for spec in &specs {
351 match spec.frontmatter.status {
352 SpecStatus::Pending => pending += 1,
353 SpecStatus::InProgress => in_progress += 1,
354 SpecStatus::Paused => in_progress += 1, SpecStatus::Completed => completed += 1,
356 SpecStatus::Failed => failed += 1,
357 SpecStatus::Ready => pending += 1, SpecStatus::Blocked => blocked += 1,
359 SpecStatus::Cancelled => cancelled += 1,
360 SpecStatus::NeedsAttention => needs_attention += 1,
361 }
362 }
363
364 if brief {
366 let mut parts = vec![];
367 if pending > 0 {
368 parts.push(format!("{} pending", pending));
369 }
370 if in_progress > 0 {
371 parts.push(format!("{} in_progress", in_progress));
372 }
373 if completed > 0 {
374 parts.push(format!("{} completed", completed));
375 }
376 if failed > 0 {
377 parts.push(format!("{} failed", failed));
378 }
379 if blocked > 0 {
380 parts.push(format!("{} blocked", blocked));
381 }
382 if cancelled > 0 {
383 parts.push(format!("{} cancelled", cancelled));
384 }
385 if needs_attention > 0 {
386 parts.push(format!("{} needs_attention", needs_attention));
387 }
388 let brief_text = if parts.is_empty() {
389 "No specs".to_string()
390 } else {
391 parts.join(" | ")
392 };
393 return Ok(json!({
394 "content": [
395 {
396 "type": "text",
397 "text": brief_text
398 }
399 ]
400 }));
401 }
402
403 let mut status_json = json!({
405 "total": specs.len(),
406 "pending": pending,
407 "in_progress": in_progress,
408 "completed": completed,
409 "failed": failed,
410 "blocked": blocked,
411 "cancelled": cancelled,
412 "needs_attention": needs_attention
413 });
414
415 if include_activity {
417 let logs_dir = PathBuf::from(LOGS_DIR);
418 let mut activity: Vec<Value> = vec![];
419
420 for spec in &specs {
421 if spec.frontmatter.status != SpecStatus::InProgress {
422 continue;
423 }
424
425 let spec_path = specs_dir.join(format!("{}.md", spec.id));
426 let log_path = logs_dir.join(format!("{}.log", spec.id));
427
428 let spec_mtime = std::fs::metadata(&spec_path)
430 .and_then(|m| m.modified())
431 .ok()
432 .map(|t| {
433 chrono::DateTime::<chrono::Local>::from(t)
434 .format("%Y-%m-%d %H:%M:%S")
435 .to_string()
436 });
437
438 let log_mtime = std::fs::metadata(&log_path)
440 .and_then(|m| m.modified())
441 .ok()
442 .map(|t| {
443 chrono::DateTime::<chrono::Local>::from(t)
444 .format("%Y-%m-%d %H:%M:%S")
445 .to_string()
446 });
447
448 let has_log = log_path.exists();
450
451 activity.push(json!({
452 "id": spec.id,
453 "title": spec.title,
454 "spec_modified": spec_mtime,
455 "log_modified": log_mtime,
456 "has_log": has_log
457 }));
458 }
459
460 status_json["in_progress_activity"] = json!(activity);
461 }
462
463 Ok(json!({
464 "content": [
465 {
466 "type": "text",
467 "text": serde_json::to_string_pretty(&status_json)?
468 }
469 ]
470 }))
471}
472
473pub fn tool_chant_log(arguments: Option<&Value>) -> Result<Value> {
474 let specs_dir = match mcp_ensure_initialized() {
475 Ok(dir) => dir,
476 Err(err_response) => return Ok(err_response),
477 };
478
479 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
480
481 let id = args
482 .get("id")
483 .and_then(|v| v.as_str())
484 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
485
486 let lines = args
487 .get("lines")
488 .and_then(|v| v.as_u64())
489 .map(|v| v as usize);
490 let byte_offset = args.get("offset").and_then(|v| v.as_u64());
491 let since = args.get("since").and_then(|v| v.as_str());
492
493 let spec = match resolve_spec(&specs_dir, id) {
495 Ok(s) => s,
496 Err(e) => {
497 return Ok(json!({
498 "content": [
499 {
500 "type": "text",
501 "text": e.to_string()
502 }
503 ],
504 "isError": true
505 }));
506 }
507 };
508
509 let logs_dir = PathBuf::from(LOGS_DIR);
510 let log_path = logs_dir.join(format!("{}.log", spec.id));
511
512 if !log_path.exists() {
513 return Ok(json!({
514 "content": [
515 {
516 "type": "text",
517 "text": format!("No log file found for spec '{}'. Logs are created when a spec is executed with `chant work`.", spec.id)
518 }
519 ],
520 "isError": true
521 }));
522 }
523
524 let content = std::fs::read_to_string(&log_path)?;
526 let file_byte_len = content.len() as u64;
527
528 let content_after_offset = if let Some(offset) = byte_offset {
530 if offset >= file_byte_len {
531 String::new()
533 } else {
534 content[(offset as usize)..].to_string()
535 }
536 } else {
537 content.clone()
538 };
539
540 let content_after_since = if let Some(since_ts) = since {
542 if let Ok(since_time) = chrono::DateTime::parse_from_rfc3339(since_ts) {
543 content_after_offset
544 .lines()
545 .filter(|line| {
546 if line.len() >= 24 {
549 if let Ok(line_time) = chrono::DateTime::parse_from_rfc3339(&line[..24]) {
550 return line_time > since_time;
551 }
552 }
553 true })
555 .collect::<Vec<&str>>()
556 .join("\n")
557 } else {
558 content_after_offset
559 }
560 } else {
561 content_after_offset
562 };
563
564 let all_lines: Vec<&str> = content_after_since.lines().collect();
566 let lines_limit = lines.unwrap_or(100);
567 let start = if all_lines.len() > lines_limit {
568 all_lines.len() - lines_limit
569 } else {
570 0
571 };
572 let log_output = all_lines[start..].join("\n");
573
574 let new_byte_offset = if byte_offset.is_some() {
576 file_byte_len
577 } else {
578 content.len() as u64
579 };
580
581 let has_more = all_lines.len() > lines_limit;
582 let line_count = all_lines[start..].len();
583
584 let response = json!({
585 "content": log_output,
586 "byte_offset": new_byte_offset,
587 "line_count": line_count,
588 "has_more": has_more
589 });
590
591 Ok(json!({
592 "content": [
593 {
594 "type": "text",
595 "text": serde_json::to_string_pretty(&response)?
596 }
597 ]
598 }))
599}
600
601pub fn tool_chant_search(arguments: Option<&Value>) -> Result<Value> {
602 let specs_dir = match mcp_ensure_initialized() {
603 Ok(dir) => dir,
604 Err(err_response) => return Ok(err_response),
605 };
606
607 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
608
609 let query = args
610 .get("query")
611 .and_then(|v| v.as_str())
612 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?
613 .to_lowercase();
614
615 let status_filter = args.get("status").and_then(|v| v.as_str());
616
617 let mut specs = load_all_specs(&specs_dir)?;
618
619 specs.retain(|s| {
621 let title_match = s
622 .title
623 .as_ref()
624 .map(|t| t.to_lowercase().contains(&query))
625 .unwrap_or(false);
626 title_match || s.body.to_lowercase().contains(&query)
627 });
628
629 if let Some(status_str) = status_filter {
631 let filter_status = match status_str {
632 "pending" => Some(SpecStatus::Pending),
633 "in_progress" => Some(SpecStatus::InProgress),
634 "completed" => Some(SpecStatus::Completed),
635 "failed" => Some(SpecStatus::Failed),
636 "blocked" => Some(SpecStatus::Blocked),
637 "cancelled" => Some(SpecStatus::Cancelled),
638 _ => None,
639 };
640
641 if let Some(status) = filter_status {
642 specs.retain(|s| s.frontmatter.status == status);
643 }
644 }
645
646 specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
647
648 let specs_json: Vec<Value> = specs
649 .iter()
650 .map(|s| {
651 json!({
652 "id": s.id,
653 "title": s.title,
654 "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
655 "type": s.frontmatter.r#type
656 })
657 })
658 .collect();
659
660 Ok(json!({
661 "content": [
662 {
663 "type": "text",
664 "text": serde_json::to_string_pretty(&specs_json)?
665 }
666 ]
667 }))
668}
669
670pub fn tool_chant_diagnose(arguments: Option<&Value>) -> Result<Value> {
671 let specs_dir = match mcp_ensure_initialized() {
672 Ok(dir) => dir,
673 Err(err_response) => return Ok(err_response),
674 };
675
676 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
677
678 let id = args
679 .get("id")
680 .and_then(|v| v.as_str())
681 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
682
683 let spec = match resolve_spec(&specs_dir, id) {
685 Ok(s) => s,
686 Err(e) => {
687 return Ok(json!({
688 "content": [
689 {
690 "type": "text",
691 "text": e.to_string()
692 }
693 ],
694 "isError": true
695 }));
696 }
697 };
698
699 let report = match diagnose::diagnose_spec(&spec.id) {
701 Ok(r) => r,
702 Err(e) => {
703 return Ok(json!({
704 "content": [
705 {
706 "type": "text",
707 "text": format!("Failed to diagnose spec: {}", e)
708 }
709 ],
710 "isError": true
711 }));
712 }
713 };
714
715 let checks_json: Vec<Value> = report
717 .checks
718 .iter()
719 .map(|c| {
720 json!({
721 "name": c.name,
722 "passed": c.passed,
723 "details": c.details
724 })
725 })
726 .collect();
727
728 let report_json = json!({
729 "spec_id": report.spec_id,
730 "status": format!("{:?}", report.status).to_lowercase(),
731 "location": report.location,
732 "checks": checks_json,
733 "diagnosis": report.diagnosis,
734 "suggestion": report.suggestion
735 });
736
737 Ok(json!({
738 "content": [
739 {
740 "type": "text",
741 "text": serde_json::to_string_pretty(&report_json)?
742 }
743 ]
744 }))
745}
746
747pub fn tool_chant_lint(arguments: Option<&Value>) -> Result<Value> {
748 let specs_dir = match mcp_ensure_initialized() {
749 Ok(dir) => dir,
750 Err(err_response) => return Ok(err_response),
751 };
752
753 let spec_id = arguments
754 .and_then(|args| args.get("id"))
755 .and_then(|v| v.as_str());
756
757 use crate::config::Config;
758 use crate::score::traffic_light;
759 use crate::scoring::{calculate_spec_score, TrafficLight};
760 use crate::spec::Spec;
761
762 let config = match Config::load() {
764 Ok(c) => c,
765 Err(e) => {
766 return Ok(json!({
767 "content": [{ "type": "text", "text": format!("Failed to load config: {}", e) }],
768 "isError": true
769 }));
770 }
771 };
772
773 let all_specs = load_all_specs(&specs_dir)?;
775
776 let specs_to_check: Vec<Spec> = if let Some(id) = spec_id {
778 match resolve_spec(&specs_dir, id) {
779 Ok(spec) => vec![spec],
780 Err(e) => {
781 return Ok(json!({
782 "content": [{ "type": "text", "text": e.to_string() }],
783 "isError": true
784 }));
785 }
786 }
787 } else {
788 all_specs.clone()
789 };
790
791 let mut results: Vec<Value> = Vec::new();
792 let mut red_count = 0;
793 let mut yellow_count = 0;
794 let mut green_count = 0;
795
796 for spec in &specs_to_check {
798 let score = calculate_spec_score(spec, &all_specs, &config);
799 let suggestions = traffic_light::generate_suggestions(&score);
800
801 let traffic_light_str = match score.traffic_light {
802 TrafficLight::Ready => {
803 green_count += 1;
804 "green"
805 }
806 TrafficLight::Review => {
807 yellow_count += 1;
808 "yellow"
809 }
810 TrafficLight::Refine => {
811 red_count += 1;
812 "red"
813 }
814 };
815
816 results.push(json!({
817 "id": spec.id,
818 "title": spec.title,
819 "traffic_light": traffic_light_str,
820 "complexity": score.complexity.to_string(),
821 "confidence": score.confidence.to_string(),
822 "splittability": score.splittability.to_string(),
823 "ac_quality": score.ac_quality.to_string(),
824 "isolation": score.isolation.map(|i| i.to_string()),
825 "suggestions": suggestions
826 }));
827 }
828
829 let summary = json!({
830 "specs_checked": specs_to_check.len(),
831 "red": red_count,
832 "yellow": yellow_count,
833 "green": green_count,
834 "results": results
835 });
836
837 Ok(json!({
838 "content": [
839 {
840 "type": "text",
841 "text": serde_json::to_string_pretty(&summary)?
842 }
843 ]
844 }))
845}
846
847pub fn tool_chant_add(arguments: Option<&Value>) -> Result<Value> {
848 let specs_dir = match mcp_ensure_initialized() {
849 Ok(dir) => dir,
850 Err(err_response) => return Ok(err_response),
851 };
852
853 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
854
855 let description = args
856 .get("description")
857 .and_then(|v| v.as_str())
858 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: description"))?;
859
860 let prompt = args.get("prompt").and_then(|v| v.as_str());
861
862 let config = match crate::config::Config::load() {
864 Ok(c) => c,
865 Err(e) => {
866 return Ok(json!({
867 "content": [{ "type": "text", "text": format!("Failed to load config: {}", e) }],
868 "isError": true
869 }));
870 }
871 };
872
873 let options = crate::operations::create::CreateOptions {
875 prompt: prompt.map(String::from),
876 needs_approval: false,
877 auto_commit: false, };
879
880 let (spec, _filepath) = match crate::operations::create::create_spec(
881 description,
882 &specs_dir,
883 &config,
884 options,
885 ) {
886 Ok(result) => result,
887 Err(e) => {
888 return Ok(json!({
889 "content": [{ "type": "text", "text": format!("Failed to create spec: {}", e) }],
890 "isError": true
891 }));
892 }
893 };
894
895 let all_specs = match load_all_specs(&specs_dir) {
897 Ok(specs) => specs,
898 Err(e) => {
899 return Ok(json!({
900 "content": [{ "type": "text", "text": format!("Failed to load specs: {}", e) }],
901 "isError": true
902 }));
903 }
904 };
905
906 use crate::score::traffic_light;
908 use crate::scoring::calculate_spec_score;
909
910 let score = calculate_spec_score(&spec, &all_specs, &config);
911 let suggestions = traffic_light::generate_suggestions(&score);
912
913 let traffic_light_str = match score.traffic_light {
915 crate::scoring::TrafficLight::Ready => "green",
916 crate::scoring::TrafficLight::Review => "yellow",
917 crate::scoring::TrafficLight::Refine => "red",
918 };
919
920 let response = json!({
922 "spec_id": spec.id,
923 "message": format!("Created spec: {}", spec.id),
924 "lint": {
925 "traffic_light": traffic_light_str,
926 "complexity": score.complexity.to_string(),
927 "confidence": score.confidence.to_string(),
928 "splittability": score.splittability.to_string(),
929 "ac_quality": score.ac_quality.to_string(),
930 "isolation": score.isolation.map(|i| i.to_string()),
931 "suggestions": suggestions
932 }
933 });
934
935 Ok(json!({
936 "content": [
937 {
938 "type": "text",
939 "text": serde_json::to_string_pretty(&response)?
940 }
941 ]
942 }))
943}
944
945pub fn tool_chant_verify(arguments: Option<&Value>) -> Result<Value> {
946 let specs_dir = match mcp_ensure_initialized() {
947 Ok(dir) => dir,
948 Err(err_response) => return Ok(err_response),
949 };
950
951 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
952
953 let id = args
954 .get("id")
955 .and_then(|v| v.as_str())
956 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
957
958 let spec = match resolve_spec(&specs_dir, id) {
959 Ok(s) => s,
960 Err(e) => {
961 return Ok(json!({
962 "content": [
963 {
964 "type": "text",
965 "text": e.to_string()
966 }
967 ],
968 "isError": true
969 }));
970 }
971 };
972
973 let spec_id = spec.id.clone();
974
975 let unchecked_count = spec.count_unchecked_checkboxes();
977
978 let total_count: usize = {
980 let acceptance_criteria_marker = "## Acceptance Criteria";
981 let mut in_ac_section = false;
982 let mut in_code_fence = false;
983 let mut count = 0;
984
985 for line in spec.body.lines() {
986 let trimmed = line.trim_start();
987
988 if trimmed.starts_with("```") {
989 in_code_fence = !in_code_fence;
990 continue;
991 }
992
993 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
994 in_ac_section = true;
995 continue;
996 }
997
998 if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
999 break;
1000 }
1001
1002 if in_ac_section
1003 && !in_code_fence
1004 && (trimmed.starts_with("- [x]") || trimmed.starts_with("- [ ]"))
1005 {
1006 count += 1;
1007 }
1008 }
1009
1010 count
1011 };
1012
1013 let checked_count = total_count.saturating_sub(unchecked_count);
1014 let verified = unchecked_count == 0 && total_count > 0;
1015
1016 let unchecked_items = if unchecked_count > 0 {
1018 let acceptance_criteria_marker = "## Acceptance Criteria";
1019 let mut in_ac_section = false;
1020 let mut in_code_fence = false;
1021 let mut items = Vec::new();
1022
1023 for line in spec.body.lines() {
1024 let trimmed = line.trim_start();
1025
1026 if trimmed.starts_with("```") {
1027 in_code_fence = !in_code_fence;
1028 continue;
1029 }
1030
1031 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
1032 in_ac_section = true;
1033 continue;
1034 }
1035
1036 if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
1037 break;
1038 }
1039
1040 if in_ac_section && !in_code_fence && trimmed.starts_with("- [ ]") {
1041 items.push(trimmed.to_string());
1042 }
1043 }
1044
1045 items
1046 } else {
1047 Vec::new()
1048 };
1049
1050 let verification_notes = if total_count == 0 {
1051 "No acceptance criteria found".to_string()
1052 } else if verified {
1053 "All acceptance criteria met".to_string()
1054 } else {
1055 format!("{} criteria not yet checked", unchecked_count)
1056 };
1057
1058 let result = json!({
1059 "spec_id": spec_id,
1060 "verified": verified,
1061 "criteria": {
1062 "total": total_count,
1063 "checked": checked_count,
1064 "unchecked": unchecked_count
1065 },
1066 "unchecked_items": unchecked_items,
1067 "verification_notes": verification_notes
1068 });
1069
1070 Ok(json!({
1071 "content": [
1072 {
1073 "type": "text",
1074 "text": serde_json::to_string_pretty(&result)?
1075 }
1076 ]
1077 }))
1078}