Skip to main content

zeroclaw_tools/
project_intel.rs

1//! Project delivery intelligence tool.
2//!
3//! Provides read-only analysis and generation for project management:
4//! status reports, risk detection, client communication drafting,
5//! sprint summaries, and effort estimation.
6
7use super::report_templates;
8use async_trait::async_trait;
9use serde_json::json;
10use std::collections::HashMap;
11use std::fmt::Write as _;
12use zeroclaw_api::tool::{Tool, ToolResult};
13
14/// Project intelligence tool for consulting project management.
15///
16/// All actions are read-only analysis/generation; nothing is modified externally.
17pub struct ProjectIntelTool {
18    default_language: String,
19    risk_sensitivity: RiskSensitivity,
20}
21
22/// Risk detection sensitivity level.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum RiskSensitivity {
25    Low,
26    Medium,
27    High,
28}
29
30impl RiskSensitivity {
31    fn from_str(s: &str) -> Self {
32        match s.to_lowercase().as_str() {
33            "low" => Self::Low,
34            "high" => Self::High,
35            _ => Self::Medium,
36        }
37    }
38
39    /// Threshold multiplier: higher sensitivity means lower thresholds.
40    fn threshold_factor(self) -> f64 {
41        match self {
42            Self::Low => 1.5,
43            Self::Medium => 1.0,
44            Self::High => 0.5,
45        }
46    }
47}
48
49impl ProjectIntelTool {
50    pub fn new(default_language: String, risk_sensitivity: String) -> Self {
51        Self {
52            default_language,
53            risk_sensitivity: RiskSensitivity::from_str(&risk_sensitivity),
54        }
55    }
56
57    fn execute_status_report(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
58        let project_name = args
59            .get("project_name")
60            .and_then(|v| v.as_str())
61            .filter(|s| !s.trim().is_empty())
62            .ok_or_else(|| {
63                ::zeroclaw_log::record!(
64                    WARN,
65                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
66                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
67                        .with_attrs(::serde_json::json!({
68                            "action": "status_report",
69                            "param": "project_name",
70                        })),
71                    "project_intel: status_report missing project_name"
72                );
73                anyhow::Error::msg("missing required 'project_name' for status_report")
74            })?;
75        let period = args
76            .get("period")
77            .and_then(|v| v.as_str())
78            .filter(|s| !s.trim().is_empty())
79            .ok_or_else(|| {
80                ::zeroclaw_log::record!(
81                    WARN,
82                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
83                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
84                        .with_attrs(::serde_json::json!({
85                            "action": "status_report",
86                            "param": "period",
87                        })),
88                    "project_intel: status_report missing period"
89                );
90                anyhow::Error::msg("missing required 'period' for status_report")
91            })?;
92        let lang = args
93            .get("language")
94            .and_then(|v| v.as_str())
95            .unwrap_or(&self.default_language);
96        let git_log = args
97            .get("git_log")
98            .and_then(|v| v.as_str())
99            .unwrap_or("No git data provided");
100        let jira_summary = args
101            .get("jira_summary")
102            .and_then(|v| v.as_str())
103            .unwrap_or("No Jira data provided");
104        let notes = args.get("notes").and_then(|v| v.as_str()).unwrap_or("");
105
106        let tpl = report_templates::weekly_status_template(lang);
107        let mut vars = HashMap::new();
108        vars.insert("project_name".into(), project_name.to_string());
109        vars.insert("period".into(), period.to_string());
110        vars.insert("completed".into(), git_log.to_string());
111        vars.insert("in_progress".into(), jira_summary.to_string());
112        vars.insert("blocked".into(), notes.to_string());
113        vars.insert("next_steps".into(), "To be determined".into());
114
115        let rendered = tpl.render(&vars);
116        Ok(ToolResult {
117            success: true,
118            output: rendered,
119            error: None,
120        })
121    }
122
123    fn execute_risk_scan(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
124        let deadlines = args
125            .get("deadlines")
126            .and_then(|v| v.as_str())
127            .unwrap_or_default();
128        let velocity = args
129            .get("velocity")
130            .and_then(|v| v.as_str())
131            .unwrap_or_default();
132        let blockers = args
133            .get("blockers")
134            .and_then(|v| v.as_str())
135            .unwrap_or_default();
136        let lang = args
137            .get("language")
138            .and_then(|v| v.as_str())
139            .unwrap_or(&self.default_language);
140
141        let mut risks = Vec::new();
142
143        // Heuristic risk detection based on signals
144        let factor = self.risk_sensitivity.threshold_factor();
145
146        if !blockers.is_empty() {
147            let blocker_count = blockers.lines().filter(|l| !l.trim().is_empty()).count();
148            let severity = if (blocker_count as f64) > 3.0 * factor {
149                "critical"
150            } else if (blocker_count as f64) > 1.0 * factor {
151                "high"
152            } else {
153                "medium"
154            };
155            risks.push(RiskItem {
156                title: "Active blockers detected".into(),
157                severity: severity.into(),
158                detail: format!("{blocker_count} blocker(s) identified"),
159                mitigation: "Escalate blockers, assign owners, set resolution deadlines".into(),
160            });
161        }
162
163        if deadlines.to_lowercase().contains("overdue")
164            || deadlines.to_lowercase().contains("missed")
165        {
166            risks.push(RiskItem {
167                title: "Deadline risk".into(),
168                severity: "high".into(),
169                detail: "Overdue or missed deadlines detected in project context".into(),
170                mitigation: "Re-prioritize scope, negotiate timeline, add resources".into(),
171            });
172        }
173
174        if velocity.to_lowercase().contains("declining") || velocity.to_lowercase().contains("slow")
175        {
176            risks.push(RiskItem {
177                title: "Velocity degradation".into(),
178                severity: "medium".into(),
179                detail: "Team velocity is declining or below expectations".into(),
180                mitigation: "Identify bottlenecks, reduce WIP, address technical debt".into(),
181            });
182        }
183
184        if risks.is_empty() {
185            risks.push(RiskItem {
186                title: "No significant risks detected".into(),
187                severity: "low".into(),
188                detail: "Current project signals within normal parameters".into(),
189                mitigation: "Continue monitoring".into(),
190            });
191        }
192
193        let tpl = report_templates::risk_register_template(lang);
194        let risks_text = risks
195            .iter()
196            .map(|r| {
197                format!(
198                    "- [{}] {}: {}",
199                    r.severity.to_uppercase(),
200                    r.title,
201                    r.detail
202                )
203            })
204            .collect::<Vec<_>>()
205            .join("\n");
206        let mitigations_text = risks
207            .iter()
208            .map(|r| format!("- {}: {}", r.title, r.mitigation))
209            .collect::<Vec<_>>()
210            .join("\n");
211
212        let mut vars = HashMap::new();
213        vars.insert(
214            "project_name".into(),
215            args.get("project_name")
216                .and_then(|v| v.as_str())
217                .unwrap_or("Unknown")
218                .to_string(),
219        );
220        vars.insert("risks".into(), risks_text);
221        vars.insert("mitigations".into(), mitigations_text);
222
223        Ok(ToolResult {
224            success: true,
225            output: tpl.render(&vars),
226            error: None,
227        })
228    }
229
230    fn execute_draft_update(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
231        let project_name = args
232            .get("project_name")
233            .and_then(|v| v.as_str())
234            .filter(|s| !s.trim().is_empty())
235            .ok_or_else(|| {
236                ::zeroclaw_log::record!(
237                    WARN,
238                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
239                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
240                        .with_attrs(::serde_json::json!({
241                            "action": "draft_update",
242                            "param": "project_name",
243                        })),
244                    "project_intel: draft_update missing project_name"
245                );
246                anyhow::Error::msg("missing required 'project_name' for draft_update")
247            })?;
248        let audience = args
249            .get("audience")
250            .and_then(|v| v.as_str())
251            .unwrap_or("client");
252        let tone = args
253            .get("tone")
254            .and_then(|v| v.as_str())
255            .unwrap_or("formal");
256        let highlights = args
257            .get("highlights")
258            .and_then(|v| v.as_str())
259            .filter(|s| !s.trim().is_empty())
260            .ok_or_else(|| {
261                ::zeroclaw_log::record!(
262                    WARN,
263                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
264                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
265                        .with_attrs(::serde_json::json!({
266                            "action": "draft_update",
267                            "param": "highlights",
268                        })),
269                    "project_intel: draft_update missing highlights"
270                );
271                anyhow::Error::msg("missing required 'highlights' for draft_update")
272            })?;
273        let concerns = args.get("concerns").and_then(|v| v.as_str()).unwrap_or("");
274
275        let greeting = match (audience, tone) {
276            ("client", "casual") => "Hi there,".to_string(),
277            ("client", _) => "Dear valued partner,".to_string(),
278            ("internal", "casual") => "Hey team,".to_string(),
279            ("internal", _) => "Dear team,".to_string(),
280            (_, "casual") => "Hi,".to_string(),
281            _ => "Dear reader,".to_string(),
282        };
283
284        let closing = match tone {
285            "casual" => "Cheers",
286            _ => "Best regards",
287        };
288
289        let mut body = format!(
290            "{greeting}\n\nHere is an update on {project_name}.\n\n**Highlights:**\n{highlights}"
291        );
292        if !concerns.is_empty() {
293            let _ = write!(body, "\n\n**Items requiring attention:**\n{concerns}");
294        }
295        let _ = write!(
296            body,
297            "\n\nPlease do not hesitate to reach out with any questions.\n\n{closing}"
298        );
299
300        Ok(ToolResult {
301            success: true,
302            output: body,
303            error: None,
304        })
305    }
306
307    fn execute_sprint_summary(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
308        let sprint_dates = args
309            .get("sprint_dates")
310            .and_then(|v| v.as_str())
311            .unwrap_or("current sprint");
312        let completed = args
313            .get("completed")
314            .and_then(|v| v.as_str())
315            .unwrap_or("None specified");
316        let in_progress = args
317            .get("in_progress")
318            .and_then(|v| v.as_str())
319            .unwrap_or("None specified");
320        let blocked = args
321            .get("blocked")
322            .and_then(|v| v.as_str())
323            .unwrap_or("None");
324        let velocity = args
325            .get("velocity")
326            .and_then(|v| v.as_str())
327            .unwrap_or("Not calculated");
328        let lang = args
329            .get("language")
330            .and_then(|v| v.as_str())
331            .unwrap_or(&self.default_language);
332
333        let tpl = report_templates::sprint_review_template(lang);
334        let mut vars = HashMap::new();
335        vars.insert("sprint_dates".into(), sprint_dates.to_string());
336        vars.insert("completed".into(), completed.to_string());
337        vars.insert("in_progress".into(), in_progress.to_string());
338        vars.insert("blocked".into(), blocked.to_string());
339        vars.insert("velocity".into(), velocity.to_string());
340
341        Ok(ToolResult {
342            success: true,
343            output: tpl.render(&vars),
344            error: None,
345        })
346    }
347
348    fn execute_effort_estimate(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
349        let tasks = args.get("tasks").and_then(|v| v.as_str()).unwrap_or("");
350
351        if tasks.trim().is_empty() {
352            return Ok(ToolResult {
353                success: false,
354                output: String::new(),
355                error: Some("No task descriptions provided".into()),
356            });
357        }
358
359        let mut estimates = Vec::new();
360        for line in tasks.lines() {
361            let line = line.trim();
362            if line.is_empty() {
363                continue;
364            }
365            let (size, rationale) = estimate_task_effort(line);
366            estimates.push(format!("- **{size}** | {line}\n  Rationale: {rationale}"));
367        }
368
369        let output = format!(
370            "## Effort Estimates\n\n{}\n\n_Sizes: XS (<2h), S (2-4h), M (4-8h), L (1-3d), XL (3-5d), XXL (>5d)_",
371            estimates.join("\n")
372        );
373
374        Ok(ToolResult {
375            success: true,
376            output,
377            error: None,
378        })
379    }
380}
381
382struct RiskItem {
383    title: String,
384    severity: String,
385    detail: String,
386    mitigation: String,
387}
388
389/// Heuristic effort estimation from task description text.
390fn estimate_task_effort(description: &str) -> (&'static str, &'static str) {
391    let lower = description.to_lowercase();
392    let word_count = description.split_whitespace().count();
393
394    // Signal-based heuristics
395    let complexity_signals = [
396        "refactor",
397        "rewrite",
398        "migrate",
399        "redesign",
400        "architecture",
401        "infrastructure",
402    ];
403    let medium_signals = [
404        "implement",
405        "create",
406        "build",
407        "integrate",
408        "add feature",
409        "new module",
410    ];
411    let small_signals = [
412        "fix", "update", "tweak", "adjust", "rename", "typo", "bump", "config",
413    ];
414
415    if complexity_signals.iter().any(|s| lower.contains(s)) {
416        if word_count > 15 {
417            return (
418                "XXL",
419                "Large-scope structural change with extensive description",
420            );
421        }
422        return ("XL", "Structural change requiring significant effort");
423    }
424
425    if medium_signals.iter().any(|s| lower.contains(s)) {
426        if word_count > 12 {
427            return ("L", "Feature implementation with detailed requirements");
428        }
429        return ("M", "Standard feature implementation");
430    }
431
432    if small_signals.iter().any(|s| lower.contains(s)) {
433        if word_count > 10 {
434            return ("S", "Small change with additional context");
435        }
436        return ("XS", "Minor targeted change");
437    }
438
439    // Fallback: estimate by description length as a proxy for complexity
440    if word_count > 20 {
441        ("L", "Complex task inferred from detailed description")
442    } else if word_count > 10 {
443        ("M", "Moderate task inferred from description length")
444    } else {
445        ("S", "Simple task inferred from brief description")
446    }
447}
448
449#[async_trait]
450impl Tool for ProjectIntelTool {
451    fn name(&self) -> &str {
452        "project_intel"
453    }
454
455    fn description(&self) -> &str {
456        "Project delivery intelligence: generate status reports, detect risks, draft client updates, summarize sprints, and estimate effort. Read-only analysis tool."
457    }
458
459    fn parameters_schema(&self) -> serde_json::Value {
460        json!({
461            "type": "object",
462            "properties": {
463                "action": {
464                    "type": "string",
465                    "enum": ["status_report", "risk_scan", "draft_update", "sprint_summary", "effort_estimate"],
466                    "description": "The analysis action to perform"
467                },
468                "project_name": {
469                    "type": "string",
470                    "description": "Project name (for status_report, risk_scan, draft_update)"
471                },
472                "period": {
473                    "type": "string",
474                    "description": "Reporting period: week, sprint, or month (for status_report)"
475                },
476                "language": {
477                    "type": "string",
478                    "description": "Report language: en, de, fr, it (default from config)"
479                },
480                "git_log": {
481                    "type": "string",
482                    "description": "Git log summary text (for status_report)"
483                },
484                "jira_summary": {
485                    "type": "string",
486                    "description": "Jira/issue tracker summary (for status_report)"
487                },
488                "notes": {
489                    "type": "string",
490                    "description": "Additional notes or context"
491                },
492                "deadlines": {
493                    "type": "string",
494                    "description": "Deadline information (for risk_scan)"
495                },
496                "velocity": {
497                    "type": "string",
498                    "description": "Team velocity data (for risk_scan, sprint_summary)"
499                },
500                "blockers": {
501                    "type": "string",
502                    "description": "Current blockers (for risk_scan)"
503                },
504                "audience": {
505                    "type": "string",
506                    "enum": ["client", "internal"],
507                    "description": "Target audience (for draft_update)"
508                },
509                "tone": {
510                    "type": "string",
511                    "enum": ["formal", "casual"],
512                    "description": "Communication tone (for draft_update)"
513                },
514                "highlights": {
515                    "type": "string",
516                    "description": "Key highlights for the update (for draft_update)"
517                },
518                "concerns": {
519                    "type": "string",
520                    "description": "Items requiring attention (for draft_update)"
521                },
522                "sprint_dates": {
523                    "type": "string",
524                    "description": "Sprint date range (for sprint_summary)"
525                },
526                "completed": {
527                    "type": "string",
528                    "description": "Completed items (for sprint_summary)"
529                },
530                "in_progress": {
531                    "type": "string",
532                    "description": "In-progress items (for sprint_summary)"
533                },
534                "blocked": {
535                    "type": "string",
536                    "description": "Blocked items (for sprint_summary)"
537                },
538                "tasks": {
539                    "type": "string",
540                    "description": "Task descriptions, one per line (for effort_estimate)"
541                }
542            },
543            "required": ["action"]
544        })
545    }
546
547    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
548        let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
549            ::zeroclaw_log::record!(
550                WARN,
551                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
552                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
553                    .with_attrs(::serde_json::json!({"param": "action"})),
554                "project_intel: missing action parameter"
555            );
556            anyhow::Error::msg("Missing required 'action' parameter")
557        })?;
558
559        match action {
560            "status_report" => self.execute_status_report(&args),
561            "risk_scan" => self.execute_risk_scan(&args),
562            "draft_update" => self.execute_draft_update(&args),
563            "sprint_summary" => self.execute_sprint_summary(&args),
564            "effort_estimate" => self.execute_effort_estimate(&args),
565            other => Ok(ToolResult {
566                success: false,
567                output: String::new(),
568                error: Some(format!(
569                    "Unknown action '{other}'. Valid actions: status_report, risk_scan, draft_update, sprint_summary, effort_estimate"
570                )),
571            }),
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    fn tool() -> ProjectIntelTool {
581        ProjectIntelTool::new("en".into(), "medium".into())
582    }
583
584    #[test]
585    fn tool_name_and_description() {
586        let t = tool();
587        assert_eq!(t.name(), "project_intel");
588        assert!(!t.description().is_empty());
589    }
590
591    #[test]
592    fn parameters_schema_has_action() {
593        let t = tool();
594        let schema = t.parameters_schema();
595        assert!(schema["properties"]["action"].is_object());
596        let required = schema["required"].as_array().unwrap();
597        assert!(required.contains(&serde_json::Value::String("action".into())));
598    }
599
600    #[tokio::test]
601    async fn status_report_renders() {
602        let t = tool();
603        let result = t
604            .execute(json!({
605                "action": "status_report",
606                "project_name": "TestProject",
607                "period": "week",
608                "git_log": "- feat: added login"
609            }))
610            .await
611            .unwrap();
612        assert!(result.success);
613        assert!(result.output.contains("TestProject"));
614        assert!(result.output.contains("added login"));
615    }
616
617    #[tokio::test]
618    async fn risk_scan_detects_blockers() {
619        let t = tool();
620        let result = t
621            .execute(json!({
622                "action": "risk_scan",
623                "blockers": "DB migration stuck\nCI pipeline broken\nAPI key expired"
624            }))
625            .await
626            .unwrap();
627        assert!(result.success);
628        assert!(result.output.contains("blocker"));
629    }
630
631    #[tokio::test]
632    async fn risk_scan_detects_deadline_risk() {
633        let t = tool();
634        let result = t
635            .execute(json!({
636                "action": "risk_scan",
637                "deadlines": "Sprint deadline overdue by 3 days"
638            }))
639            .await
640            .unwrap();
641        assert!(result.success);
642        assert!(result.output.contains("Deadline risk"));
643    }
644
645    #[tokio::test]
646    async fn risk_scan_no_signals_returns_low_risk() {
647        let t = tool();
648        let result = t.execute(json!({ "action": "risk_scan" })).await.unwrap();
649        assert!(result.success);
650        assert!(result.output.contains("No significant risks"));
651    }
652
653    #[tokio::test]
654    async fn draft_update_formal_client() {
655        let t = tool();
656        let result = t
657            .execute(json!({
658                "action": "draft_update",
659                "project_name": "Portal",
660                "audience": "client",
661                "tone": "formal",
662                "highlights": "Phase 1 delivered"
663            }))
664            .await
665            .unwrap();
666        assert!(result.success);
667        assert!(result.output.contains("Dear valued partner"));
668        assert!(result.output.contains("Portal"));
669        assert!(result.output.contains("Phase 1 delivered"));
670    }
671
672    #[tokio::test]
673    async fn draft_update_casual_internal() {
674        let t = tool();
675        let result = t
676            .execute(json!({
677                "action": "draft_update",
678                "project_name": "ZeroClaw",
679                "audience": "internal",
680                "tone": "casual",
681                "highlights": "Core loop stabilized"
682            }))
683            .await
684            .unwrap();
685        assert!(result.success);
686        assert!(result.output.contains("Hey team"));
687        assert!(result.output.contains("Cheers"));
688    }
689
690    #[tokio::test]
691    async fn sprint_summary_renders() {
692        let t = tool();
693        let result = t
694            .execute(json!({
695                "action": "sprint_summary",
696                "sprint_dates": "2026-03-01 to 2026-03-14",
697                "completed": "- Login page\n- API endpoints",
698                "in_progress": "- Dashboard",
699                "blocked": "- Payment integration"
700            }))
701            .await
702            .unwrap();
703        assert!(result.success);
704        assert!(result.output.contains("Login page"));
705        assert!(result.output.contains("Dashboard"));
706    }
707
708    #[tokio::test]
709    async fn effort_estimate_basic() {
710        let t = tool();
711        let result = t
712            .execute(json!({
713                "action": "effort_estimate",
714                "tasks": "Fix typo in README\nImplement user authentication\nRefactor database layer"
715            }))
716            .await
717            .unwrap();
718        assert!(result.success);
719        assert!(result.output.contains("XS"));
720        assert!(result.output.contains("Refactor database layer"));
721    }
722
723    #[tokio::test]
724    async fn effort_estimate_empty_tasks_fails() {
725        let t = tool();
726        let result = t
727            .execute(json!({ "action": "effort_estimate", "tasks": "" }))
728            .await
729            .unwrap();
730        assert!(!result.success);
731        assert!(result.error.unwrap().contains("No task descriptions"));
732    }
733
734    #[tokio::test]
735    async fn unknown_action_returns_error() {
736        let t = tool();
737        let result = t
738            .execute(json!({ "action": "invalid_thing" }))
739            .await
740            .unwrap();
741        assert!(!result.success);
742        assert!(result.error.unwrap().contains("Unknown action"));
743    }
744
745    #[tokio::test]
746    async fn missing_action_returns_error() {
747        let t = tool();
748        let result = t.execute(json!({})).await;
749        assert!(result.is_err());
750    }
751
752    #[test]
753    fn effort_estimate_heuristics_coverage() {
754        assert_eq!(estimate_task_effort("Fix typo").0, "XS");
755        assert_eq!(estimate_task_effort("Update config values").0, "XS");
756        assert_eq!(
757            estimate_task_effort("Implement new notification system").0,
758            "M"
759        );
760        assert_eq!(
761            estimate_task_effort("Refactor the entire authentication module").0,
762            "XL"
763        );
764        assert_eq!(
765            estimate_task_effort("Migrate the database schema to support multi-tenancy with data isolation and proper indexing across all services").0,
766            "XXL"
767        );
768    }
769
770    #[test]
771    fn risk_sensitivity_threshold_ordering() {
772        assert!(
773            RiskSensitivity::High.threshold_factor() < RiskSensitivity::Medium.threshold_factor()
774        );
775        assert!(
776            RiskSensitivity::Medium.threshold_factor() < RiskSensitivity::Low.threshold_factor()
777        );
778    }
779
780    #[test]
781    fn risk_sensitivity_from_str_variants() {
782        assert_eq!(RiskSensitivity::from_str("low"), RiskSensitivity::Low);
783        assert_eq!(RiskSensitivity::from_str("high"), RiskSensitivity::High);
784        assert_eq!(RiskSensitivity::from_str("medium"), RiskSensitivity::Medium);
785        assert_eq!(
786            RiskSensitivity::from_str("unknown"),
787            RiskSensitivity::Medium
788        );
789    }
790
791    #[tokio::test]
792    async fn high_sensitivity_detects_single_blocker_as_high() {
793        let t = ProjectIntelTool::new("en".into(), "high".into());
794        let result = t
795            .execute(json!({
796                "action": "risk_scan",
797                "blockers": "Single blocker"
798            }))
799            .await
800            .unwrap();
801        assert!(result.success);
802        assert!(result.output.contains("[HIGH]") || result.output.contains("[CRITICAL]"));
803    }
804}