Skip to main content

chant/mcp/tools/
spec.rs

1//! MCP tools for spec query and management
2
3use anyhow::Result;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use crate::diagnose;
9use crate::paths::LOGS_DIR;
10use crate::spec::{load_all_specs, resolve_spec, SpecStatus, SpecType};
11use crate::spec_group;
12
13use super::super::handlers::mcp_ensure_initialized;
14use super::super::response::{mcp_error_response, mcp_text_response};
15
16pub fn tool_chant_spec_list(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 mut specs = load_all_specs(&specs_dir)?;
23    specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
24
25    // Filter by status if provided
26    if let Some(args) = arguments {
27        if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
28            let filter_status = SpecStatus::from_str(status_str).ok();
29
30            if let Some(status) = filter_status {
31                specs.retain(|s| s.frontmatter.status == status);
32            }
33        } else {
34            // No status filter provided - filter out cancelled specs by default
35            specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
36        }
37    } else {
38        // No arguments provided - filter out cancelled specs by default
39        specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
40    }
41
42    // Get limit (default 50)
43    let limit = arguments
44        .and_then(|a| a.get("limit"))
45        .and_then(|v| v.as_u64())
46        .unwrap_or(50) as usize;
47
48    let total = specs.len();
49    let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
50
51    let specs_json: Vec<Value> = limited_specs
52        .iter()
53        .map(|s| {
54            json!({
55                "id": s.id,
56                "title": s.title,
57                "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
58                "type": s.frontmatter.r#type,
59                "depends_on": s.frontmatter.depends_on,
60                "labels": s.frontmatter.labels
61            })
62        })
63        .collect();
64
65    let response = json!({
66        "specs": specs_json,
67        "total": total,
68        "limit": limit,
69        "returned": limited_specs.len()
70    });
71
72    Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
73}
74
75pub fn tool_chant_spec_get(arguments: Option<&Value>) -> Result<Value> {
76    let specs_dir = match mcp_ensure_initialized() {
77        Ok(dir) => dir,
78        Err(err_response) => return Ok(err_response),
79    };
80
81    let id = arguments
82        .and_then(|a| a.get("id"))
83        .and_then(|v| v.as_str())
84        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
85
86    let spec = match resolve_spec(&specs_dir, id) {
87        Ok(s) => s,
88        Err(e) => {
89            return Ok(mcp_error_response(e.to_string()));
90        }
91    };
92
93    let spec_json = json!({
94        "id": spec.id,
95        "title": spec.title,
96        "status": format!("{:?}", spec.frontmatter.status).to_lowercase(),
97        "type": spec.frontmatter.r#type,
98        "depends_on": spec.frontmatter.depends_on,
99        "labels": spec.frontmatter.labels,
100        "target_files": spec.frontmatter.target_files,
101        "context": spec.frontmatter.context,
102        "prompt": spec.frontmatter.prompt,
103        "branch": spec.frontmatter.branch,
104        "commits": spec.frontmatter.commits,
105        "completed_at": spec.frontmatter.completed_at,
106        "model": spec.frontmatter.model,
107        "body": spec.body
108    });
109
110    Ok(mcp_text_response(serde_json::to_string_pretty(&spec_json)?))
111}
112
113pub fn tool_chant_ready(arguments: Option<&Value>) -> Result<Value> {
114    let specs_dir = match mcp_ensure_initialized() {
115        Ok(dir) => dir,
116        Err(err_response) => return Ok(err_response),
117    };
118
119    let mut specs = load_all_specs(&specs_dir)?;
120    specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
121
122    // Filter to ready specs only
123    let all_specs = specs.clone();
124    specs.retain(|s| s.is_ready(&all_specs));
125    // Filter out group specs - they are containers, not actionable work
126    specs.retain(|s| s.frontmatter.r#type != SpecType::Group);
127
128    // Get limit (default 50)
129    let limit = arguments
130        .and_then(|a| a.get("limit"))
131        .and_then(|v| v.as_u64())
132        .unwrap_or(50) as usize;
133
134    let total = specs.len();
135    let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
136
137    let specs_json: Vec<Value> = limited_specs
138        .iter()
139        .map(|s| {
140            json!({
141                "id": s.id,
142                "title": s.title,
143                "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
144                "type": s.frontmatter.r#type,
145                "depends_on": s.frontmatter.depends_on,
146                "labels": s.frontmatter.labels
147            })
148        })
149        .collect();
150
151    let response = json!({
152        "specs": specs_json,
153        "total": total,
154        "limit": limit,
155        "returned": limited_specs.len()
156    });
157
158    Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
159}
160
161pub fn tool_chant_spec_update(arguments: Option<&Value>) -> Result<Value> {
162    let specs_dir = match mcp_ensure_initialized() {
163        Ok(dir) => dir,
164        Err(err_response) => return Ok(err_response),
165    };
166
167    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
168
169    let id = args
170        .get("id")
171        .and_then(|v| v.as_str())
172        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
173
174    let mut spec = match resolve_spec(&specs_dir, id) {
175        Ok(s) => s,
176        Err(e) => {
177            return Ok(mcp_error_response(e.to_string()));
178        }
179    };
180
181    let spec_id = spec.id.clone();
182    let spec_path = specs_dir.join(format!("{}.md", spec.id));
183
184    // Parse status if provided
185    let status = if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
186        match SpecStatus::from_str(status_str) {
187            Ok(s) => Some(s),
188            Err(e) => {
189                return Ok(mcp_error_response(format!("{}", e)));
190            }
191        }
192    } else {
193        None
194    };
195
196    // Parse other fields
197    let depends_on = args
198        .get("depends_on")
199        .and_then(|v| v.as_array())
200        .map(|arr| {
201            arr.iter()
202                .filter_map(|v| v.as_str().map(String::from))
203                .collect()
204        });
205
206    let labels = args.get("labels").and_then(|v| v.as_array()).map(|arr| {
207        arr.iter()
208            .filter_map(|v| v.as_str().map(String::from))
209            .collect()
210    });
211
212    let target_files = args
213        .get("target_files")
214        .and_then(|v| v.as_array())
215        .map(|arr| {
216            arr.iter()
217                .filter_map(|v| v.as_str().map(String::from))
218                .collect()
219        });
220
221    let model = args.get("model").and_then(|v| v.as_str()).map(String::from);
222
223    let output = args
224        .get("output")
225        .and_then(|v| v.as_str())
226        .map(String::from);
227
228    let replace_body = args
229        .get("replace_body")
230        .and_then(|v| v.as_bool())
231        .unwrap_or(false);
232
233    // Parse force parameter if provided
234    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
235
236    // Use operations module for update
237    let options = crate::operations::update::UpdateOptions {
238        status,
239        depends_on,
240        labels,
241        target_files,
242        model,
243        output,
244        replace_body,
245        force,
246    };
247
248    match crate::operations::update::update_spec(&mut spec, &spec_path, options) {
249        Ok(_) => Ok(mcp_text_response(format!("Updated spec: {}", spec_id))),
250        Err(e) => Ok(mcp_error_response(format!("Failed to update spec: {}", e))),
251    }
252}
253
254pub fn tool_chant_status(arguments: Option<&Value>) -> Result<Value> {
255    let specs_dir = match mcp_ensure_initialized() {
256        Ok(dir) => dir,
257        Err(err_response) => return Ok(err_response),
258    };
259
260    let specs = load_all_specs(&specs_dir)?;
261
262    // Parse options
263    let brief = arguments
264        .and_then(|a| a.get("brief"))
265        .and_then(|v| v.as_bool())
266        .unwrap_or(false);
267    let include_activity = arguments
268        .and_then(|a| a.get("include_activity"))
269        .and_then(|v| v.as_bool())
270        .unwrap_or(false);
271
272    // Count by status
273    let mut pending = 0;
274    let mut in_progress = 0;
275    let mut completed = 0;
276    let mut failed = 0;
277    let mut blocked = 0;
278    let mut cancelled = 0;
279    let mut needs_attention = 0;
280
281    for spec in &specs {
282        match spec.frontmatter.status {
283            SpecStatus::Pending => pending += 1,
284            SpecStatus::InProgress => in_progress += 1,
285            SpecStatus::Paused => in_progress += 1, // Count paused as in_progress for summary
286            SpecStatus::Completed => completed += 1,
287            SpecStatus::Failed => failed += 1,
288            SpecStatus::Ready => pending += 1, // Ready is computed, treat as pending
289            SpecStatus::Blocked => blocked += 1,
290            SpecStatus::Cancelled => cancelled += 1,
291            SpecStatus::NeedsAttention => needs_attention += 1,
292        }
293    }
294
295    // Brief output mode
296    if brief {
297        let mut parts = vec![];
298        if pending > 0 {
299            parts.push(format!("{} pending", pending));
300        }
301        if in_progress > 0 {
302            parts.push(format!("{} in_progress", in_progress));
303        }
304        if completed > 0 {
305            parts.push(format!("{} completed", completed));
306        }
307        if failed > 0 {
308            parts.push(format!("{} failed", failed));
309        }
310        if blocked > 0 {
311            parts.push(format!("{} blocked", blocked));
312        }
313        if cancelled > 0 {
314            parts.push(format!("{} cancelled", cancelled));
315        }
316        if needs_attention > 0 {
317            parts.push(format!("{} needs_attention", needs_attention));
318        }
319        let brief_text = if parts.is_empty() {
320            "No specs".to_string()
321        } else {
322            parts.join(" | ")
323        };
324        return Ok(mcp_text_response(brief_text));
325    }
326
327    // Build status response
328    let mut status_json = json!({
329        "total": specs.len(),
330        "pending": pending,
331        "in_progress": in_progress,
332        "completed": completed,
333        "failed": failed,
334        "blocked": blocked,
335        "cancelled": cancelled,
336        "needs_attention": needs_attention
337    });
338
339    // Include activity info for in_progress specs
340    if include_activity {
341        let logs_dir = PathBuf::from(LOGS_DIR);
342        let mut activity: Vec<Value> = vec![];
343
344        for spec in &specs {
345            if spec.frontmatter.status != SpecStatus::InProgress {
346                continue;
347            }
348
349            let spec_path = specs_dir.join(format!("{}.md", spec.id));
350            let log_path = logs_dir.join(format!("{}.log", spec.id));
351
352            // Get spec file modification time
353            let spec_mtime = std::fs::metadata(&spec_path)
354                .and_then(|m| m.modified())
355                .ok()
356                .map(|t| {
357                    chrono::DateTime::<chrono::Local>::from(t)
358                        .format("%Y-%m-%d %H:%M:%S")
359                        .to_string()
360                });
361
362            // Get log file modification time (indicates last agent activity)
363            let log_mtime = std::fs::metadata(&log_path)
364                .and_then(|m| m.modified())
365                .ok()
366                .map(|t| {
367                    chrono::DateTime::<chrono::Local>::from(t)
368                        .format("%Y-%m-%d %H:%M:%S")
369                        .to_string()
370                });
371
372            // Check if log file exists
373            let has_log = log_path.exists();
374
375            activity.push(json!({
376                "id": spec.id,
377                "title": spec.title,
378                "spec_modified": spec_mtime,
379                "log_modified": log_mtime,
380                "has_log": has_log
381            }));
382        }
383
384        status_json["in_progress_activity"] = json!(activity);
385    }
386
387    Ok(mcp_text_response(serde_json::to_string_pretty(
388        &status_json,
389    )?))
390}
391
392pub fn tool_chant_log(arguments: Option<&Value>) -> Result<Value> {
393    let specs_dir = match mcp_ensure_initialized() {
394        Ok(dir) => dir,
395        Err(err_response) => return Ok(err_response),
396    };
397
398    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
399
400    let id = args
401        .get("id")
402        .and_then(|v| v.as_str())
403        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
404
405    let lines = args
406        .get("lines")
407        .and_then(|v| v.as_u64())
408        .map(|v| v as usize);
409    let byte_offset = args.get("offset").and_then(|v| v.as_u64());
410    let since = args.get("since").and_then(|v| v.as_str());
411
412    // Resolve spec to get full ID
413    let spec = match resolve_spec(&specs_dir, id) {
414        Ok(s) => s,
415        Err(e) => {
416            return Ok(mcp_error_response(e.to_string()));
417        }
418    };
419
420    let logs_dir = PathBuf::from(LOGS_DIR);
421    let log_path = logs_dir.join(format!("{}.log", spec.id));
422
423    if !log_path.exists() {
424        return Ok(mcp_error_response(format!("No log file found for spec '{}'. Logs are created when a spec is executed with `chant work`.", spec.id)));
425    }
426
427    // Read log file
428    let content = std::fs::read_to_string(&log_path)?;
429    let file_byte_len = content.len() as u64;
430
431    // Filter by offset if provided
432    let content_after_offset = if let Some(offset) = byte_offset {
433        if offset >= file_byte_len {
434            // Offset is at or beyond end of file
435            String::new()
436        } else {
437            content[(offset as usize)..].to_string()
438        }
439    } else {
440        content.clone()
441    };
442
443    // Filter by timestamp if provided
444    let content_after_since = if let Some(since_ts) = since {
445        if let Ok(since_time) = chrono::DateTime::parse_from_rfc3339(since_ts) {
446            content_after_offset
447                .lines()
448                .filter(|line| {
449                    // Try to parse timestamp from line start
450                    // Assumes log format: YYYY-MM-DDTHH:MM:SS.sssZ ...
451                    if line.len() >= 24 {
452                        if let Ok(line_time) = chrono::DateTime::parse_from_rfc3339(&line[..24]) {
453                            return line_time > since_time;
454                        }
455                    }
456                    true // Include lines without parseable timestamps
457                })
458                .collect::<Vec<&str>>()
459                .join("\n")
460        } else {
461            content_after_offset
462        }
463    } else {
464        content_after_offset
465    };
466
467    // Apply lines limit
468    let all_lines: Vec<&str> = content_after_since.lines().collect();
469    let lines_limit = lines.unwrap_or(100);
470    let start = if all_lines.len() > lines_limit {
471        all_lines.len() - lines_limit
472    } else {
473        0
474    };
475    let log_output = all_lines[start..].join("\n");
476
477    // Calculate new byte offset
478    let new_byte_offset = if byte_offset.is_some() {
479        file_byte_len
480    } else {
481        content.len() as u64
482    };
483
484    let has_more = all_lines.len() > lines_limit;
485    let line_count = all_lines[start..].len();
486
487    let response = json!({
488        "content": log_output,
489        "byte_offset": new_byte_offset,
490        "line_count": line_count,
491        "has_more": has_more
492    });
493
494    Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
495}
496
497pub fn tool_chant_search(arguments: Option<&Value>) -> Result<Value> {
498    let specs_dir = match mcp_ensure_initialized() {
499        Ok(dir) => dir,
500        Err(err_response) => return Ok(err_response),
501    };
502
503    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
504
505    let query = args
506        .get("query")
507        .and_then(|v| v.as_str())
508        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?
509        .to_lowercase();
510
511    let status_filter = args.get("status").and_then(|v| v.as_str());
512
513    let mut specs = load_all_specs(&specs_dir)?;
514
515    // Filter by query (case-insensitive search in title and body)
516    specs.retain(|s| {
517        let title_match = s
518            .title
519            .as_ref()
520            .map(|t| t.to_lowercase().contains(&query))
521            .unwrap_or(false);
522        title_match || s.body.to_lowercase().contains(&query)
523    });
524
525    // Filter by status if provided
526    if let Some(status_str) = status_filter {
527        let filter_status = SpecStatus::from_str(status_str).ok();
528
529        if let Some(status) = filter_status {
530            specs.retain(|s| s.frontmatter.status == status);
531        }
532    }
533
534    specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
535
536    let specs_json: Vec<Value> = specs
537        .iter()
538        .map(|s| {
539            json!({
540                "id": s.id,
541                "title": s.title,
542                "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
543                "type": s.frontmatter.r#type
544            })
545        })
546        .collect();
547
548    Ok(mcp_text_response(serde_json::to_string_pretty(
549        &specs_json,
550    )?))
551}
552
553pub fn tool_chant_diagnose(arguments: Option<&Value>) -> Result<Value> {
554    let specs_dir = match mcp_ensure_initialized() {
555        Ok(dir) => dir,
556        Err(err_response) => return Ok(err_response),
557    };
558
559    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
560
561    let id = args
562        .get("id")
563        .and_then(|v| v.as_str())
564        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
565
566    // Resolve spec to get full ID
567    let spec = match resolve_spec(&specs_dir, id) {
568        Ok(s) => s,
569        Err(e) => {
570            return Ok(mcp_error_response(e.to_string()));
571        }
572    };
573
574    // Run diagnostics
575    let report = match diagnose::diagnose_spec(&spec.id) {
576        Ok(r) => r,
577        Err(e) => {
578            return Ok(mcp_error_response(format!(
579                "Failed to diagnose spec: {}",
580                e
581            )));
582        }
583    };
584
585    // Format report as JSON
586    let checks_json: Vec<Value> = report
587        .checks
588        .iter()
589        .map(|c| {
590            json!({
591                "name": c.name,
592                "passed": c.passed,
593                "details": c.details
594            })
595        })
596        .collect();
597
598    let report_json = json!({
599        "spec_id": report.spec_id,
600        "status": format!("{:?}", report.status).to_lowercase(),
601        "location": report.location,
602        "checks": checks_json,
603        "diagnosis": report.diagnosis,
604        "suggestion": report.suggestion
605    });
606
607    Ok(mcp_text_response(serde_json::to_string_pretty(
608        &report_json,
609    )?))
610}
611
612pub fn tool_chant_lint(arguments: Option<&Value>) -> Result<Value> {
613    let specs_dir = match mcp_ensure_initialized() {
614        Ok(dir) => dir,
615        Err(err_response) => return Ok(err_response),
616    };
617
618    let spec_id = arguments
619        .and_then(|args| args.get("id"))
620        .and_then(|v| v.as_str());
621
622    use crate::config::Config;
623    use crate::score::traffic_light;
624    use crate::scoring::{calculate_spec_score, TrafficLight};
625    use crate::spec::Spec;
626
627    // Load config for scoring
628    let config = match Config::load() {
629        Ok(c) => c,
630        Err(e) => {
631            return Ok(mcp_error_response(format!("Failed to load config: {}", e)));
632        }
633    };
634
635    // Load all specs (needed for isolation scoring)
636    let all_specs = load_all_specs(&specs_dir)?;
637
638    // Collect specs to check
639    let specs_to_check: Vec<Spec> = if let Some(id) = spec_id {
640        match resolve_spec(&specs_dir, id) {
641            Ok(spec) => vec![spec],
642            Err(e) => {
643                return Ok(mcp_error_response(e.to_string()));
644            }
645        }
646    } else {
647        all_specs.clone()
648    };
649
650    let mut results: Vec<Value> = Vec::new();
651    let mut red_count = 0;
652    let mut yellow_count = 0;
653    let mut green_count = 0;
654
655    // Run full quality assessment on each spec (same as chant work)
656    for spec in &specs_to_check {
657        let score = calculate_spec_score(spec, &all_specs, &config);
658        let suggestions = traffic_light::generate_suggestions(&score);
659
660        let traffic_light_str = match score.traffic_light {
661            TrafficLight::Ready => {
662                green_count += 1;
663                "green"
664            }
665            TrafficLight::Review => {
666                yellow_count += 1;
667                "yellow"
668            }
669            TrafficLight::Refine => {
670                red_count += 1;
671                "red"
672            }
673        };
674
675        results.push(json!({
676            "id": spec.id,
677            "title": spec.title,
678            "traffic_light": traffic_light_str,
679            "complexity": score.complexity.to_string(),
680            "confidence": score.confidence.to_string(),
681            "splittability": score.splittability.to_string(),
682            "ac_quality": score.ac_quality.to_string(),
683            "isolation": score.isolation.map(|i| i.to_string()),
684            "suggestions": suggestions
685        }));
686    }
687
688    let summary = json!({
689        "specs_checked": specs_to_check.len(),
690        "red": red_count,
691        "yellow": yellow_count,
692        "green": green_count,
693        "results": results
694    });
695
696    Ok(mcp_text_response(serde_json::to_string_pretty(&summary)?))
697}
698
699pub fn tool_chant_add(arguments: Option<&Value>) -> Result<Value> {
700    let specs_dir = match mcp_ensure_initialized() {
701        Ok(dir) => dir,
702        Err(err_response) => return Ok(err_response),
703    };
704
705    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
706
707    let description = args
708        .get("description")
709        .and_then(|v| v.as_str())
710        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: description"))?;
711
712    let prompt = args.get("prompt").and_then(|v| v.as_str());
713
714    // Load config for derivation
715    let config = match crate::config::Config::load() {
716        Ok(c) => c,
717        Err(e) => {
718            return Ok(mcp_error_response(format!("Failed to load config: {}", e)));
719        }
720    };
721
722    // Use operations module for spec creation
723    let options = crate::operations::create::CreateOptions {
724        prompt: prompt.map(String::from),
725        needs_approval: false,
726        auto_commit: false, // MCP doesn't auto-commit
727    };
728
729    let (spec, _filepath) =
730        match crate::operations::create::create_spec(description, &specs_dir, &config, options) {
731            Ok(result) => result,
732            Err(e) => {
733                return Ok(mcp_error_response(format!("Failed to create spec: {}", e)));
734            }
735        };
736
737    // Load all specs for scoring (needed for isolation)
738    let all_specs = match load_all_specs(&specs_dir) {
739        Ok(specs) => specs,
740        Err(e) => {
741            return Ok(mcp_error_response(format!("Failed to load specs: {}", e)));
742        }
743    };
744
745    // Calculate lint score for the newly created spec
746    use crate::score::traffic_light;
747    use crate::scoring::calculate_spec_score;
748
749    let score = calculate_spec_score(&spec, &all_specs, &config);
750    let suggestions = traffic_light::generate_suggestions(&score);
751
752    // Convert traffic light to string
753    let traffic_light_str = match score.traffic_light {
754        crate::scoring::TrafficLight::Ready => "green",
755        crate::scoring::TrafficLight::Review => "yellow",
756        crate::scoring::TrafficLight::Refine => "red",
757    };
758
759    // Build response JSON with lint results
760    let response = json!({
761        "spec_id": spec.id,
762        "message": format!("Created spec: {}", spec.id),
763        "lint": {
764            "traffic_light": traffic_light_str,
765            "complexity": score.complexity.to_string(),
766            "confidence": score.confidence.to_string(),
767            "splittability": score.splittability.to_string(),
768            "ac_quality": score.ac_quality.to_string(),
769            "isolation": score.isolation.map(|i| i.to_string()),
770            "suggestions": suggestions
771        }
772    });
773
774    Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
775}
776
777pub fn tool_chant_verify(arguments: Option<&Value>) -> Result<Value> {
778    let specs_dir = match mcp_ensure_initialized() {
779        Ok(dir) => dir,
780        Err(err_response) => return Ok(err_response),
781    };
782
783    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
784
785    let id = args
786        .get("id")
787        .and_then(|v| v.as_str())
788        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
789
790    let spec = match resolve_spec(&specs_dir, id) {
791        Ok(s) => s,
792        Err(e) => {
793            return Ok(mcp_error_response(e.to_string()));
794        }
795    };
796
797    let spec_id = spec.id.clone();
798
799    // Count checked and unchecked criteria using operations layer
800    let unchecked_count = spec.count_unchecked_checkboxes();
801
802    // Find total checkboxes in Acceptance Criteria section
803    let total_count: usize = {
804        let acceptance_criteria_marker = "## Acceptance Criteria";
805        let mut in_ac_section = false;
806        let mut in_code_fence = false;
807        let mut count = 0;
808
809        for line in spec.body.lines() {
810            let trimmed = line.trim_start();
811
812            if trimmed.starts_with("```") {
813                in_code_fence = !in_code_fence;
814                continue;
815            }
816
817            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
818                in_ac_section = true;
819                continue;
820            }
821
822            if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
823                break;
824            }
825
826            if in_ac_section
827                && !in_code_fence
828                && (trimmed.starts_with("- [x]") || trimmed.starts_with("- [ ]"))
829            {
830                count += 1;
831            }
832        }
833
834        count
835    };
836
837    let checked_count = total_count.saturating_sub(unchecked_count);
838    let verified = unchecked_count == 0 && total_count > 0;
839
840    // Extract unchecked items
841    let unchecked_items = if unchecked_count > 0 {
842        let acceptance_criteria_marker = "## Acceptance Criteria";
843        let mut in_ac_section = false;
844        let mut in_code_fence = false;
845        let mut items = Vec::new();
846
847        for line in spec.body.lines() {
848            let trimmed = line.trim_start();
849
850            if trimmed.starts_with("```") {
851                in_code_fence = !in_code_fence;
852                continue;
853            }
854
855            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
856                in_ac_section = true;
857                continue;
858            }
859
860            if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
861                break;
862            }
863
864            if in_ac_section && !in_code_fence && trimmed.starts_with("- [ ]") {
865                items.push(trimmed.to_string());
866            }
867        }
868
869        items
870    } else {
871        Vec::new()
872    };
873
874    let verification_notes = if total_count == 0 {
875        "No acceptance criteria found".to_string()
876    } else if verified {
877        "All acceptance criteria met".to_string()
878    } else {
879        format!("{} criteria not yet checked", unchecked_count)
880    };
881
882    let result = json!({
883        "spec_id": spec_id,
884        "verified": verified,
885        "criteria": {
886            "total": total_count,
887            "checked": checked_count,
888            "unchecked": unchecked_count
889        },
890        "unchecked_items": unchecked_items,
891        "verification_notes": verification_notes
892    });
893
894    Ok(mcp_text_response(serde_json::to_string_pretty(&result)?))
895}