Skip to main content

task_graph_mcp/
config.rs

1//! Configuration loading and management.
2
3use crate::format::OutputFormat;
4use anyhow::{anyhow, Result};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9/// Auto-advance configuration for automatically transitioning tasks when dependencies are satisfied.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct AutoAdvanceConfig {
12    /// Enable auto-advance when dependencies are satisfied (default: false).
13    #[serde(default)]
14    pub enabled: bool,
15
16    /// Target state for auto-advanced tasks (e.g., "ready").
17    /// If None, tasks remain in their current state even when unblocked.
18    #[serde(default)]
19    pub target_state: Option<String>,
20}
21
22/// Behavior for unknown attachment keys.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum UnknownKeyBehavior {
26    /// Silently use default mime/mode.
27    Allow,
28    /// Use defaults but return a warning in the response (default).
29    #[default]
30    Warn,
31    /// Reject unknown keys with an error.
32    Reject,
33}
34
35/// Definition of a preconfigured attachment key.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AttachmentKeyDefinition {
38    /// Default MIME type for this key.
39    pub mime: String,
40    /// Default mode: "append" or "replace".
41    #[serde(default = "default_append_mode")]
42    pub mode: String,
43}
44
45fn default_append_mode() -> String {
46    "append".to_string()
47}
48
49/// Attachments configuration with preconfigured key definitions.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AttachmentsConfig {
52    /// Behavior for unknown attachment keys (allow, warn, reject).
53    #[serde(default)]
54    pub unknown_key: UnknownKeyBehavior,
55    /// Preconfigured attachment key definitions.
56    #[serde(default = "AttachmentsConfig::default_definitions")]
57    pub definitions: HashMap<String, AttachmentKeyDefinition>,
58}
59
60impl Default for AttachmentsConfig {
61    fn default() -> Self {
62        Self {
63            unknown_key: UnknownKeyBehavior::default(),
64            definitions: Self::default_definitions(),
65        }
66    }
67}
68
69impl AttachmentsConfig {
70    /// Default attachment key definitions.
71    pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
72        let mut defs = HashMap::new();
73
74        defs.insert(
75            "commit".to_string(),
76            AttachmentKeyDefinition {
77                mime: "text/git.hash".to_string(),
78                mode: "append".to_string(),
79            },
80        );
81
82        defs.insert(
83            "checkin".to_string(),
84            AttachmentKeyDefinition {
85                mime: "text/p4.changelist".to_string(),
86                mode: "append".to_string(),
87            },
88        );
89
90        defs.insert(
91            "meta".to_string(),
92            AttachmentKeyDefinition {
93                mime: "application/json".to_string(),
94                mode: "replace".to_string(),
95            },
96        );
97
98        defs.insert(
99            "note".to_string(),
100            AttachmentKeyDefinition {
101                mime: "text/plain".to_string(),
102                mode: "append".to_string(),
103            },
104        );
105
106        defs.insert(
107            "log".to_string(),
108            AttachmentKeyDefinition {
109                mime: "text/plain".to_string(),
110                mode: "append".to_string(),
111            },
112        );
113
114        defs.insert(
115            "error".to_string(),
116            AttachmentKeyDefinition {
117                mime: "text/plain".to_string(),
118                mode: "append".to_string(),
119            },
120        );
121
122        defs.insert(
123            "output".to_string(),
124            AttachmentKeyDefinition {
125                mime: "text/plain".to_string(),
126                mode: "append".to_string(),
127            },
128        );
129
130        defs.insert(
131            "diff".to_string(),
132            AttachmentKeyDefinition {
133                mime: "text/x-diff".to_string(),
134                mode: "append".to_string(),
135            },
136        );
137
138        defs.insert(
139            "changelist".to_string(),
140            AttachmentKeyDefinition {
141                mime: "text/plain".to_string(),
142                mode: "append".to_string(),
143            },
144        );
145
146        defs.insert(
147            "plan".to_string(),
148            AttachmentKeyDefinition {
149                mime: "text/markdown".to_string(),
150                mode: "replace".to_string(),
151            },
152        );
153
154        defs.insert(
155            "result".to_string(),
156            AttachmentKeyDefinition {
157                mime: "application/json".to_string(),
158                mode: "replace".to_string(),
159            },
160        );
161
162        defs.insert(
163            "context".to_string(),
164            AttachmentKeyDefinition {
165                mime: "text/plain".to_string(),
166                mode: "replace".to_string(),
167            },
168        );
169
170        defs
171    }
172
173    /// Get the definition for a key, if it exists.
174    pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
175        self.definitions.get(key)
176    }
177
178    /// Check if a key is a known/configured key.
179    pub fn is_known_key(&self, key: &str) -> bool {
180        self.definitions.contains_key(key)
181    }
182
183    /// Get the default MIME type for a key, or fallback to text/plain.
184    pub fn get_mime_default(&self, key: &str) -> &str {
185        self.definitions
186            .get(key)
187            .map(|d| d.mime.as_str())
188            .unwrap_or("text/plain")
189    }
190
191    /// Get the default mode for a key, or fallback to "append".
192    pub fn get_mode_default(&self, key: &str) -> &str {
193        self.definitions
194            .get(key)
195            .map(|d| d.mode.as_str())
196            .unwrap_or("append")
197    }
198}
199
200/// Server configuration.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct Config {
203    #[serde(default)]
204    pub server: ServerConfig,
205
206    #[serde(default)]
207    pub paths: PathsConfig,
208
209    #[serde(default)]
210    pub states: StatesConfig,
211
212    #[serde(default)]
213    pub dependencies: DependenciesConfig,
214
215    #[serde(default)]
216    pub auto_advance: AutoAdvanceConfig,
217
218    #[serde(default)]
219    pub attachments: AttachmentsConfig,
220}
221
222impl Default for Config {
223    fn default() -> Self {
224        Self {
225            server: ServerConfig::default(),
226            paths: PathsConfig::default(),
227            states: StatesConfig::default(),
228            dependencies: DependenciesConfig::default(),
229            auto_advance: AutoAdvanceConfig::default(),
230            attachments: AttachmentsConfig::default(),
231        }
232    }
233}
234
235/// Paths configured for the server, returned by connect.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ServerPaths {
238    /// Path to the SQLite database file.
239    pub db_path: PathBuf,
240    /// Path to the media directory for file attachments.
241    pub media_dir: PathBuf,
242    /// Path to the log directory.
243    pub log_dir: PathBuf,
244    /// Path to the configuration file (if one was loaded).
245    pub config_path: Option<PathBuf>,
246}
247
248/// Server-specific configuration.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ServerConfig {
251    /// Path to the SQLite database file.
252    #[serde(default = "default_db_path")]
253    pub db_path: PathBuf,
254
255    /// Path to the media directory for file attachments.
256    #[serde(default = "default_media_dir")]
257    pub media_dir: PathBuf,
258
259    /// Maximum claims per agent.
260    #[serde(default = "default_claim_limit")]
261    pub claim_limit: i32,
262
263    /// Timeout for stale claims in seconds.
264    #[serde(default = "default_stale_timeout")]
265    pub stale_timeout_seconds: i64,
266
267    /// Default output format for query results (json or markdown).
268    #[serde(default)]
269    pub default_format: OutputFormat,
270
271    /// Path to the skills directory for skill overrides.
272    #[serde(default = "default_skills_dir")]
273    pub skills_dir: PathBuf,
274
275    /// Path to the log directory.
276    #[serde(default = "default_log_dir")]
277    pub log_dir: PathBuf,
278}
279
280impl Default for ServerConfig {
281    fn default() -> Self {
282        Self {
283            db_path: default_db_path(),
284            media_dir: default_media_dir(),
285            claim_limit: default_claim_limit(),
286            stale_timeout_seconds: default_stale_timeout(),
287            default_format: OutputFormat::default(),
288            skills_dir: default_skills_dir(),
289            log_dir: default_log_dir(),
290        }
291    }
292}
293
294fn default_db_path() -> PathBuf {
295    PathBuf::from(".task-graph/tasks.db")
296}
297
298fn default_media_dir() -> PathBuf {
299    PathBuf::from(".task-graph/media")
300}
301
302fn default_skills_dir() -> PathBuf {
303    PathBuf::from(".task-graph/skills")
304}
305
306
307fn default_log_dir() -> PathBuf {
308    PathBuf::from(".task-graph/logs")
309}
310
311fn default_claim_limit() -> i32 {
312    5
313}
314
315fn default_stale_timeout() -> i64 {
316    900 // 15 minutes
317}
318
319/// Path handling configuration.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct PathsConfig {
322    /// Style for representing file paths.
323    #[serde(default)]
324    pub style: PathStyle,
325}
326
327impl Default for PathsConfig {
328    fn default() -> Self {
329        Self {
330            style: PathStyle::Relative,
331        }
332    }
333}
334
335/// Path style for file locks.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum PathStyle {
339    /// Relative paths (e.g., src/main.rs)
340    Relative,
341    /// Project-prefixed paths (e.g., ${project}/src/main.rs)
342    ProjectPrefixed,
343}
344
345impl Default for PathStyle {
346    fn default() -> Self {
347        PathStyle::Relative
348    }
349}
350
351/// Task state configuration.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct StatesConfig {
354    /// Default state for new tasks.
355    #[serde(default = "default_initial_state")]
356    pub initial: String,
357
358    /// Default state for tasks when their owner disconnects (must be untimed).
359    #[serde(default = "default_disconnect_state")]
360    pub disconnect_state: String,
361
362    /// States that block dependent tasks (tasks in these states count as "not done").
363    #[serde(default = "default_blocking_states")]
364    pub blocking_states: Vec<String>,
365
366    /// State definitions with allowed transitions and timing behavior.
367    #[serde(default = "default_state_definitions")]
368    pub definitions: HashMap<String, StateDefinition>,
369}
370
371impl Default for StatesConfig {
372    fn default() -> Self {
373        Self {
374            initial: default_initial_state(),
375            disconnect_state: default_disconnect_state(),
376            blocking_states: default_blocking_states(),
377            definitions: default_state_definitions(),
378        }
379    }
380}
381
382fn default_initial_state() -> String {
383    "pending".to_string()
384}
385
386fn default_disconnect_state() -> String {
387    "pending".to_string()
388}
389
390fn default_blocking_states() -> Vec<String> {
391    vec!["pending".to_string(), "assigned".to_string(), "in_progress".to_string()]
392}
393
394fn default_state_definitions() -> HashMap<String, StateDefinition> {
395    let mut defs = HashMap::new();
396
397    defs.insert(
398        "pending".to_string(),
399        StateDefinition {
400            exits: vec!["assigned".to_string(), "in_progress".to_string(), "cancelled".to_string()],
401            timed: false,
402        },
403    );
404
405    defs.insert(
406        "assigned".to_string(),
407        StateDefinition {
408            exits: vec!["in_progress".to_string(), "pending".to_string(), "cancelled".to_string()],
409            timed: false,
410        },
411    );
412
413    defs.insert(
414        "in_progress".to_string(),
415        StateDefinition {
416            exits: vec![
417                "completed".to_string(),
418                "failed".to_string(),
419                "pending".to_string(),
420            ],
421            timed: true,
422        },
423    );
424
425    defs.insert(
426        "completed".to_string(),
427        StateDefinition {
428            exits: vec![],
429            timed: false,
430        },
431    );
432
433    defs.insert(
434        "failed".to_string(),
435        StateDefinition {
436            exits: vec!["pending".to_string()],
437            timed: false,
438        },
439    );
440
441    defs.insert(
442        "cancelled".to_string(),
443        StateDefinition {
444            exits: vec![],
445            timed: false,
446        },
447    );
448
449    defs
450}
451
452/// Definition of a single task state.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct StateDefinition {
455    /// Allowed states to transition to from this state.
456    #[serde(default)]
457    pub exits: Vec<String>,
458
459    /// Whether time spent in this state should be tracked (accumulated to time_actual_ms).
460    #[serde(default)]
461    pub timed: bool,
462}
463
464
465/// Dependency type configuration.
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct DependenciesConfig {
468    /// Dependency type definitions.
469    #[serde(default = "default_dependency_definitions")]
470    pub definitions: HashMap<String, DependencyDefinition>,
471}
472
473impl Default for DependenciesConfig {
474    fn default() -> Self {
475        Self {
476            definitions: default_dependency_definitions(),
477        }
478    }
479}
480
481/// Definition of a dependency type.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct DependencyDefinition {
484    /// Display orientation: "horizontal" (same level) or "vertical" (parent-child).
485    pub display: DependencyDisplay,
486
487    /// What this dependency blocks: "start" (blocks claiming) or "completion" (blocks completing).
488    pub blocks: BlockTarget,
489}
490
491/// Display orientation for dependency visualization.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum DependencyDisplay {
495    /// Same level dependencies (blocks, follows).
496    Horizontal,
497    /// Parent-child relationships (contains).
498    Vertical,
499}
500
501/// What a dependency blocks.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum BlockTarget {
505    /// Does not block - informational link only.
506    None,
507    /// Blocks the task from being started/claimed.
508    Start,
509    /// Blocks the task from being completed.
510    Completion,
511}
512
513fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
514    let mut defs = HashMap::new();
515
516    // Primary workflow types (blocking)
517    defs.insert(
518        "blocks".to_string(),
519        DependencyDefinition {
520            display: DependencyDisplay::Horizontal,
521            blocks: BlockTarget::Start,
522        },
523    );
524
525    defs.insert(
526        "follows".to_string(),
527        DependencyDefinition {
528            display: DependencyDisplay::Horizontal,
529            blocks: BlockTarget::Start,
530        },
531    );
532
533    defs.insert(
534        "contains".to_string(),
535        DependencyDefinition {
536            display: DependencyDisplay::Vertical,
537            blocks: BlockTarget::Completion,
538        },
539    );
540
541    // Non-blocking relationship types
542    defs.insert(
543        "duplicate".to_string(),
544        DependencyDefinition {
545            display: DependencyDisplay::Horizontal,
546            blocks: BlockTarget::None,
547        },
548    );
549
550    defs.insert(
551        "see-also".to_string(),
552        DependencyDefinition {
553            display: DependencyDisplay::Horizontal,
554            blocks: BlockTarget::None,
555        },
556    );
557
558    defs.insert(
559        "relates-to".to_string(),
560        DependencyDefinition {
561            display: DependencyDisplay::Horizontal,
562            blocks: BlockTarget::None,
563        },
564    );
565
566    defs
567}
568
569impl DependenciesConfig {
570    /// Check if a dependency type is valid.
571    pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
572        self.definitions.contains_key(dep_type)
573    }
574
575    /// Get the definition for a dependency type.
576    pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
577        self.definitions.get(dep_type)
578    }
579
580    /// Get all dependency types that block start.
581    pub fn start_blocking_types(&self) -> Vec<&str> {
582        self.definitions
583            .iter()
584            .filter(|(_, def)| def.blocks == BlockTarget::Start)
585            .map(|(name, _)| name.as_str())
586            .collect()
587    }
588
589    /// Get all dependency types that block completion.
590    pub fn completion_blocking_types(&self) -> Vec<&str> {
591        self.definitions
592            .iter()
593            .filter(|(_, def)| def.blocks == BlockTarget::Completion)
594            .map(|(name, _)| name.as_str())
595            .collect()
596    }
597
598    /// Get all vertical (parent-child) dependency types.
599    pub fn vertical_types(&self) -> Vec<&str> {
600        self.definitions
601            .iter()
602            .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
603            .map(|(name, _)| name.as_str())
604            .collect()
605    }
606
607    /// Get all dependency type names.
608    pub fn dep_type_names(&self) -> Vec<&str> {
609        self.definitions.keys().map(|s| s.as_str()).collect()
610    }
611
612    /// Validate the dependencies configuration.
613    pub fn validate(&self) -> anyhow::Result<()> {
614        if self.definitions.is_empty() {
615            return Err(anyhow::anyhow!(
616                "At least one dependency type must be defined"
617            ));
618        }
619
620        // Check for at least one start-blocking type (for task sequencing)
621        let has_start_blocking = self.definitions.values().any(|d| d.blocks == BlockTarget::Start);
622        if !has_start_blocking {
623            return Err(anyhow::anyhow!(
624                "At least one dependency type with blocks: start must be defined"
625            ));
626        }
627
628        Ok(())
629    }
630}
631
632impl StatesConfig {
633    /// Check if a state is a valid defined state.
634    pub fn is_valid_state(&self, state: &str) -> bool {
635        self.definitions.contains_key(state)
636    }
637
638    /// Check if a transition from one state to another is allowed.
639    pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
640        if let Some(def) = self.definitions.get(from) {
641            def.exits.contains(&to.to_string())
642        } else {
643            false
644        }
645    }
646
647    /// Check if a state is timed (accumulates duration).
648    pub fn is_timed_state(&self, state: &str) -> bool {
649        self.definitions
650            .get(state)
651            .map(|d| d.timed)
652            .unwrap_or(false)
653    }
654
655    /// Check if a state is terminal (has no exits).
656    pub fn is_terminal_state(&self, state: &str) -> bool {
657        self.definitions
658            .get(state)
659            .map(|d| d.exits.is_empty())
660            .unwrap_or(false)
661    }
662
663    /// Check if a state is a blocking state (blocks dependents).
664    pub fn is_blocking_state(&self, state: &str) -> bool {
665        self.blocking_states.contains(&state.to_string())
666    }
667
668    /// Get all defined state names.
669    pub fn state_names(&self) -> Vec<&str> {
670        self.definitions.keys().map(|s| s.as_str()).collect()
671    }
672
673    /// Get allowed exit states for a given state.
674    pub fn get_exits(&self, state: &str) -> Vec<&str> {
675        self.definitions
676            .get(state)
677            .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
678            .unwrap_or_default()
679    }
680
681    /// Get all untimed state names (valid for disconnect final_state).
682    pub fn untimed_state_names(&self) -> Vec<&str> {
683        self.definitions
684            .iter()
685            .filter(|(_, def)| !def.timed)
686            .map(|(name, _)| name.as_str())
687            .collect()
688    }
689
690    /// Validate the states configuration.
691    pub fn validate(&self) -> Result<()> {
692        // Check initial state exists
693        if !self.definitions.contains_key(&self.initial) {
694            return Err(anyhow!(
695                "Initial state '{}' is not defined in state definitions",
696                self.initial
697            ));
698        }
699
700        // Check disconnect_state exists and is not timed
701        if !self.definitions.contains_key(&self.disconnect_state) {
702            return Err(anyhow!(
703                "Disconnect state '{}' is not defined in state definitions",
704                self.disconnect_state
705            ));
706        }
707        if self.is_timed_state(&self.disconnect_state) {
708            return Err(anyhow!(
709                "Disconnect state '{}' must not be a timed state",
710                self.disconnect_state
711            ));
712        }
713
714        // Check all blocking_states exist
715        for state in &self.blocking_states {
716            if !self.definitions.contains_key(state) {
717                return Err(anyhow!(
718                    "Blocking state '{}' is not defined in state definitions",
719                    state
720                ));
721            }
722        }
723
724        // Check all exit targets exist
725        for (state_name, def) in &self.definitions {
726            for exit in &def.exits {
727                if !self.definitions.contains_key(exit) {
728                    return Err(anyhow!(
729                        "State '{}' has exit '{}' which is not defined",
730                        state_name,
731                        exit
732                    ));
733                }
734            }
735        }
736
737        // Check at least one terminal state exists
738        let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
739        if !has_terminal {
740            return Err(anyhow!(
741                "At least one terminal state (with empty exits) must be defined"
742            ));
743        }
744
745        Ok(())
746    }
747}
748
749impl Config {
750    /// Load configuration from file.
751    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
752        let content = std::fs::read_to_string(path)?;
753        let config: Config = serde_yaml::from_str(&content)?;
754        Ok(config)
755    }
756
757    /// Load configuration from default locations or return defaults.
758    pub fn load_or_default() -> Self {
759        // Try TASK_GRAPH_CONFIG_PATH environment variable first
760        if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH") {
761            if let Ok(config) = Self::load(&config_path) {
762                return config;
763            }
764        }
765
766        // Try .task-graph/config.yaml
767        if let Ok(config) = Self::load(".task-graph/config.yaml") {
768            return config;
769        }
770
771        // Fall back to defaults with environment variable overrides
772        let mut config = Self::default();
773
774        if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
775            config.server.db_path = PathBuf::from(db_path);
776        }
777
778        if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
779            config.server.media_dir = PathBuf::from(media_dir);
780        }
781
782        if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
783            config.server.log_dir = PathBuf::from(log_dir);
784        }
785
786        config
787    }
788
789    /// Ensure the database directory exists.
790    pub fn ensure_db_dir(&self) -> Result<()> {
791        if let Some(parent) = self.server.db_path.parent() {
792            std::fs::create_dir_all(parent)?;
793        }
794        Ok(())
795    }
796
797    /// Ensure the media directory exists.
798    pub fn ensure_media_dir(&self) -> Result<()> {
799        std::fs::create_dir_all(&self.server.media_dir)?;
800        Ok(())
801    }
802
803    /// Ensure the log directory exists.
804    pub fn ensure_log_dir(&self) -> Result<()> {
805        std::fs::create_dir_all(&self.server.log_dir)?;
806        Ok(())
807    }
808
809    /// Get the media directory path.
810    pub fn media_dir(&self) -> &Path {
811        &self.server.media_dir
812    }
813
814    /// Get the log directory path.
815    pub fn log_dir(&self) -> &Path {
816        &self.server.log_dir
817    }
818}
819
820/// Tool description override.
821#[derive(Debug, Clone, Serialize, Deserialize)]
822pub struct ToolPrompt {
823    pub description: String,
824}
825
826/// LLM-facing prompts configuration.
827#[derive(Debug, Clone, Serialize, Deserialize, Default)]
828pub struct Prompts {
829    /// Server instructions shown to the LLM.
830    pub instructions: Option<String>,
831
832    /// Tool description overrides by tool name.
833    #[serde(default)]
834    pub tools: HashMap<String, ToolPrompt>,
835}
836
837impl Prompts {
838    /// Load prompts from file.
839    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
840        let content = std::fs::read_to_string(path)?;
841        // Handle empty or comment-only YAML files (which parse as null)
842        let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
843        Ok(prompts.unwrap_or_default())
844    }
845
846    /// Load prompts from default location or return defaults.
847    pub fn load_or_default() -> Self {
848        // Try .task-graph/prompts.yaml
849        if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
850            return prompts;
851        }
852
853        Self::default()
854    }
855
856    /// Get a tool description override if available.
857    pub fn get_tool_description(&self, name: &str) -> Option<&str> {
858        self.tools.get(name).map(|t| t.description.as_str())
859    }
860}