Skip to main content

task_graph_mcp/config/
workflows.rs

1//! Workflow configuration for states, phases, and transition prompts.
2//!
3//! This module defines the unified workflow configuration that combines:
4//! - State definitions (exits, timed)
5//! - Phase definitions
6//! - Transition prompts (enter/exit for states, phases, and combos)
7
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use super::types::{
13    GateDefinition, PhasesConfig, StateDefinition, StatesConfig, UnknownKeyBehavior,
14};
15
16/// Settings for workflow behavior.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkflowSettings {
19    /// Default state for new tasks.
20    #[serde(default = "default_initial_state")]
21    pub initial_state: String,
22
23    /// State for tasks when agent disconnects (must be untimed).
24    #[serde(default = "default_disconnect_state")]
25    pub disconnect_state: String,
26
27    /// States that block dependent tasks (tasks in these states count as "not done").
28    #[serde(default = "default_blocking_states")]
29    pub blocking_states: Vec<String>,
30
31    /// Behavior for unknown phase values (allow, warn, reject).
32    #[serde(default)]
33    pub unknown_phase: UnknownKeyBehavior,
34}
35
36fn default_initial_state() -> String {
37    "pending".to_string()
38}
39
40fn default_disconnect_state() -> String {
41    "pending".to_string()
42}
43
44fn default_blocking_states() -> Vec<String> {
45    vec![
46        "pending".to_string(),
47        "assigned".to_string(),
48        "working".to_string(),
49    ]
50}
51
52impl Default for WorkflowSettings {
53    fn default() -> Self {
54        Self {
55            initial_state: default_initial_state(),
56            disconnect_state: default_disconnect_state(),
57            blocking_states: default_blocking_states(),
58            unknown_phase: UnknownKeyBehavior::default(),
59        }
60    }
61}
62
63/// Prompts for state/phase transitions.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct TransitionPrompts {
66    /// Prompt shown when entering this state/phase.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub enter: Option<String>,
69
70    /// Prompt shown when exiting this state/phase.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub exit: Option<String>,
73}
74
75/// Definition of a single state in the workflow.
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct StateWorkflow {
78    /// Allowed states to transition to from this state.
79    #[serde(default)]
80    pub exits: Vec<String>,
81
82    /// Whether time spent in this state should be tracked.
83    #[serde(default)]
84    pub timed: bool,
85
86    /// Prompts for entering/exiting this state.
87    #[serde(default)]
88    pub prompts: TransitionPrompts,
89}
90
91/// Definition of a phase in the workflow.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct PhaseWorkflow {
94    /// Prompts for entering/exiting this phase.
95    #[serde(default)]
96    pub prompts: TransitionPrompts,
97}
98
99/// Prompts for state+phase combinations.
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct ComboPrompts {
102    /// Prompt shown when entering this state+phase combination.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub enter: Option<String>,
105
106    /// Prompt shown when exiting this state+phase combination.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub exit: Option<String>,
109}
110
111/// Definition of a role in a workflow (e.g., "lead", "worker").
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct RoleDefinition {
114    /// Human-readable description of this role.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117
118    /// Tags that identify agents in this role.
119    #[serde(default)]
120    pub tags: Vec<String>,
121
122    /// Maximum number of tasks this role can claim simultaneously.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub max_claims: Option<u32>,
125
126    /// Whether this role can assign tasks to other agents.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub can_assign: Option<bool>,
129
130    /// Whether this role can create subtasks.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub can_create_subtasks: Option<bool>,
133}
134
135/// Unified workflow configuration.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct WorkflowsConfig {
138    /// Short identifier for the workflow (e.g., "swarm", "relay", "solo").
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub name: Option<String>,
141
142    /// Human-readable description of the workflow's coordination model.
143    /// Should explain when to choose this workflow and how agents coordinate.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub description: Option<String>,
146
147    /// Path to the source file this workflow was loaded from.
148    /// Not deserialized from YAML - populated by the loader.
149    #[serde(skip)]
150    pub source_file: Option<std::path::PathBuf>,
151
152    /// Global workflow settings.
153    #[serde(default)]
154    pub settings: WorkflowSettings,
155
156    /// State definitions with transitions, timing, and prompts.
157    #[serde(default)]
158    pub states: HashMap<String, StateWorkflow>,
159
160    /// Phase definitions with prompts.
161    #[serde(default)]
162    pub phases: HashMap<String, PhaseWorkflow>,
163
164    /// State+phase combination prompts (key format: "state+phase").
165    #[serde(default)]
166    pub combos: HashMap<String, ComboPrompts>,
167
168    /// Gate definitions for status and phase exits.
169    /// Keys are "status:<name>" or "phase:<name>", values are lists of gate definitions.
170    #[serde(default)]
171    pub gates: HashMap<String, Vec<GateDefinition>>,
172
173    /// Role definitions (e.g., "lead", "worker") with tags, permissions, and constraints.
174    #[serde(default)]
175    pub roles: HashMap<String, RoleDefinition>,
176
177    /// Role-specific prompts. Outer key is role name, inner key is prompt name
178    /// (e.g., "claiming", "completing"), value is the prompt content.
179    #[serde(default)]
180    pub role_prompts: HashMap<String, HashMap<String, String>>,
181
182    /// Cache of named workflow configs (e.g., "swarm" -> workflow-swarm.yaml).
183    /// Populated at server startup, not serialized.
184    #[serde(skip)]
185    pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
186
187    /// Key to look up the default workflow in named_workflows cache.
188    /// If set, workers without a workflow use this instead of the base config.
189    #[serde(skip)]
190    pub default_workflow_key: Option<String>,
191
192    /// Cache of named overlay configs (e.g., "git" -> overlay-git.yaml).
193    /// Overlays are loaded as raw deltas (NOT merged with defaults).
194    #[serde(skip)]
195    pub named_overlays: HashMap<String, Arc<WorkflowsConfig>>,
196
197    /// Active overlay names applied to this config (for tracking).
198    #[serde(skip)]
199    pub active_overlays: Vec<String>,
200}
201
202impl Default for WorkflowsConfig {
203    fn default() -> Self {
204        Self {
205            name: None,
206            description: None,
207            source_file: None,
208            settings: WorkflowSettings::default(),
209            states: default_state_workflows(),
210            phases: default_phase_workflows(),
211            combos: HashMap::new(),
212            gates: HashMap::new(),
213            roles: HashMap::new(),
214            role_prompts: HashMap::new(),
215            named_workflows: HashMap::new(),
216            default_workflow_key: None,
217            named_overlays: HashMap::new(),
218            active_overlays: Vec::new(),
219        }
220    }
221}
222
223impl WorkflowsConfig {
224    /// Get a named workflow config, or None if not found.
225    pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
226        self.named_workflows.get(name)
227    }
228
229    /// Get the default workflow config from the cache, if one is configured.
230    pub fn get_default_workflow(&self) -> Option<&Arc<WorkflowsConfig>> {
231        self.default_workflow_key
232            .as_ref()
233            .and_then(|key| self.named_workflows.get(key))
234    }
235
236    /// Match worker tags to a role defined in this workflow.
237    /// Returns the role name if any role's tags overlap with the worker's tags.
238    /// If multiple roles match, returns the first match (by sorted key order for determinism).
239    pub fn match_role(&self, worker_tags: &[String]) -> Option<String> {
240        let mut role_names: Vec<&String> = self.roles.keys().collect();
241        role_names.sort();
242        for role_name in role_names {
243            if let Some(role) = self.roles.get(role_name)
244                && role.tags.iter().any(|t| worker_tags.contains(t))
245            {
246                return Some(role_name.clone());
247            }
248        }
249        None
250    }
251
252    /// Get all prompts for a matched role.
253    /// Returns an empty HashMap if the role has no prompts defined.
254    pub fn get_role_prompts(&self, role_name: &str) -> HashMap<String, String> {
255        self.role_prompts
256            .get(role_name)
257            .cloned()
258            .unwrap_or_default()
259    }
260
261    /// Get a specific role prompt by role name and prompt key.
262    pub fn get_role_prompt(&self, role_name: &str, prompt_key: &str) -> Option<&str> {
263        self.role_prompts
264            .get(role_name)
265            .and_then(|prompts| prompts.get(prompt_key))
266            .map(|s| s.as_str())
267    }
268
269    /// Get the role definition for a matched role.
270    pub fn get_role(&self, role_name: &str) -> Option<&RoleDefinition> {
271        self.roles.get(role_name)
272    }
273
274    /// Collect all unique role tags across this workflow, all named workflows, and all overlays.
275    /// Returns a deduplicated list of tag names used in role definitions.
276    pub fn all_role_tags(&self) -> Vec<String> {
277        let mut tags = std::collections::HashSet::new();
278        // Collect from this workflow's roles
279        for role in self.roles.values() {
280            for tag in &role.tags {
281                tags.insert(tag.clone());
282            }
283        }
284        // Collect from all named workflows
285        for workflow in self.named_workflows.values() {
286            for role in workflow.roles.values() {
287                for tag in &role.tags {
288                    tags.insert(tag.clone());
289                }
290            }
291        }
292        // Collect from all named overlays
293        for overlay in self.named_overlays.values() {
294            for role in overlay.roles.values() {
295                for tag in &role.tags {
296                    tags.insert(tag.clone());
297                }
298            }
299        }
300        tags.into_iter().collect()
301    }
302
303    /// Apply an overlay on top of this workflow using additive merge semantics.
304    ///
305    /// Unlike deep-merge (which replaces), overlay merge:
306    /// - **states**: union keys; existing states get exits unioned (deduplicated),
307    ///   `timed |= overlay.timed`, prompts appended with separator
308    /// - **phases**: union keys; existing phases get prompts appended
309    /// - **combos**: union keys; existing combos get enter/exit appended
310    /// - **gates**: union keys; existing keys extend their Vec (never replace)
311    /// - **roles**: union keys; existing roles NOT overridden (first wins)
312    /// - **role_prompts**: outer keys unioned; inner keys appended or added
313    /// - **settings.initial_state**: overlay wins if it differs from default ("pending")
314    /// - **settings.blocking_states**: union (deduplicated)
315    pub fn apply_overlay(&mut self, overlay: &WorkflowsConfig) {
316        const PROMPT_SEPARATOR: &str = "\n\n---\n\n";
317
318        // --- states ---
319        for (name, overlay_state) in &overlay.states {
320            if let Some(existing) = self.states.get_mut(name) {
321                // Union exits (deduplicated)
322                for exit in &overlay_state.exits {
323                    if !existing.exits.contains(exit) {
324                        existing.exits.push(exit.clone());
325                    }
326                }
327                // timed |= overlay.timed
328                existing.timed |= overlay_state.timed;
329                // Append prompts
330                append_prompt(
331                    &mut existing.prompts.enter,
332                    &overlay_state.prompts.enter,
333                    PROMPT_SEPARATOR,
334                );
335                append_prompt(
336                    &mut existing.prompts.exit,
337                    &overlay_state.prompts.exit,
338                    PROMPT_SEPARATOR,
339                );
340            } else {
341                self.states.insert(name.clone(), overlay_state.clone());
342            }
343        }
344
345        // --- phases ---
346        for (name, overlay_phase) in &overlay.phases {
347            if let Some(existing) = self.phases.get_mut(name) {
348                append_prompt(
349                    &mut existing.prompts.enter,
350                    &overlay_phase.prompts.enter,
351                    PROMPT_SEPARATOR,
352                );
353                append_prompt(
354                    &mut existing.prompts.exit,
355                    &overlay_phase.prompts.exit,
356                    PROMPT_SEPARATOR,
357                );
358            } else {
359                self.phases.insert(name.clone(), overlay_phase.clone());
360            }
361        }
362
363        // --- combos ---
364        for (name, overlay_combo) in &overlay.combos {
365            if let Some(existing) = self.combos.get_mut(name) {
366                append_optional_prompt(&mut existing.enter, &overlay_combo.enter, PROMPT_SEPARATOR);
367                append_optional_prompt(&mut existing.exit, &overlay_combo.exit, PROMPT_SEPARATOR);
368            } else {
369                self.combos.insert(name.clone(), overlay_combo.clone());
370            }
371        }
372
373        // --- gates ---
374        for (key, overlay_gates) in &overlay.gates {
375            self.gates
376                .entry(key.clone())
377                .or_default()
378                .extend(overlay_gates.iter().cloned());
379        }
380
381        // --- roles (first wins: existing roles NOT overridden) ---
382        for (name, overlay_role) in &overlay.roles {
383            self.roles
384                .entry(name.clone())
385                .or_insert_with(|| overlay_role.clone());
386        }
387
388        // --- role_prompts ---
389        for (role_name, overlay_prompts) in &overlay.role_prompts {
390            let existing = self.role_prompts.entry(role_name.clone()).or_default();
391            for (key, overlay_value) in overlay_prompts {
392                existing
393                    .entry(key.clone())
394                    .and_modify(|v| {
395                        v.push_str(PROMPT_SEPARATOR);
396                        v.push_str(overlay_value);
397                    })
398                    .or_insert_with(|| overlay_value.clone());
399            }
400        }
401
402        // --- settings ---
403        if overlay.settings.initial_state != default_initial_state() {
404            self.settings.initial_state = overlay.settings.initial_state.clone();
405        }
406        // Union blocking_states (deduplicated)
407        for state in &overlay.settings.blocking_states {
408            if !self.settings.blocking_states.contains(state) {
409                self.settings.blocking_states.push(state.clone());
410            }
411        }
412    }
413
414    /// Compute a diff showing what an overlay changed relative to a base workflow.
415    /// Returns a JSON object with added/modified states, exits, gates, and prompts.
416    pub fn compute_overlay_diff(&self, base: &WorkflowsConfig) -> serde_json::Value {
417        let mut states_added: Vec<String> = Vec::new();
418        let mut exits_added: HashMap<String, Vec<String>> = HashMap::new();
419        let mut gates_added: Vec<String> = Vec::new();
420        let mut prompts_modified: Vec<String> = Vec::new();
421
422        for (name, state) in &self.states {
423            if !base.states.contains_key(name) {
424                states_added.push(name.clone());
425            } else {
426                let base_state = &base.states[name];
427                // Check for new exits
428                let new_exits: Vec<String> = state
429                    .exits
430                    .iter()
431                    .filter(|e| !base_state.exits.contains(e))
432                    .cloned()
433                    .collect();
434                if !new_exits.is_empty() {
435                    exits_added.insert(name.clone(), new_exits);
436                }
437                // Check for modified prompts
438                if state.prompts.enter != base_state.prompts.enter {
439                    prompts_modified.push(format!("enter~{}", name));
440                }
441                if state.prompts.exit != base_state.prompts.exit {
442                    prompts_modified.push(format!("exit~{}", name));
443                }
444            }
445        }
446
447        for key in self.gates.keys() {
448            if !base.gates.contains_key(key) {
449                gates_added.push(key.clone());
450            } else if self.gates[key].len() > base.gates[key].len() {
451                gates_added.push(format!(
452                    "{}(+{})",
453                    key,
454                    self.gates[key].len() - base.gates[key].len()
455                ));
456            }
457        }
458
459        serde_json::json!({
460            "states_added": states_added,
461            "exits_added": exits_added,
462            "gates_added": gates_added,
463            "prompts_modified": prompts_modified,
464        })
465    }
466}
467
468/// Append an overlay prompt to an existing Option<String> prompt.
469fn append_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
470    if let Some(src) = source {
471        match target {
472            Some(existing) => {
473                existing.push_str(separator);
474                existing.push_str(src);
475            }
476            None => *target = Some(src.clone()),
477        }
478    }
479}
480
481/// Append an overlay prompt to an existing Option<String> (combo-style).
482fn append_optional_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
483    append_prompt(target, source, separator);
484}
485
486/// Default state workflow definitions.
487fn default_state_workflows() -> HashMap<String, StateWorkflow> {
488    let mut states = HashMap::new();
489
490    states.insert(
491        "pending".to_string(),
492        StateWorkflow {
493            exits: vec![
494                "assigned".to_string(),
495                "working".to_string(),
496                "cancelled".to_string(),
497            ],
498            timed: false,
499            prompts: TransitionPrompts::default(),
500        },
501    );
502
503    states.insert(
504        "assigned".to_string(),
505        StateWorkflow {
506            exits: vec![
507                "working".to_string(),
508                "pending".to_string(),
509                "cancelled".to_string(),
510            ],
511            timed: false,
512            prompts: TransitionPrompts {
513                enter: Some(
514                    "A task has been assigned to you. Review and claim when ready.".to_string(),
515                ),
516                exit: None,
517            },
518        },
519    );
520
521    states.insert(
522        "working".to_string(),
523        StateWorkflow {
524            exits: vec![
525                "completed".to_string(),
526                "failed".to_string(),
527                "pending".to_string(),
528            ],
529            timed: true,
530            prompts: TransitionPrompts {
531                enter: Some(
532                    r#"You are now actively working on this task. Keep your thinking updated regularly using the `thinking` tool to show progress and allow coordination with other agents.
533
534### Heartbeat & Coordination
535- Call `thinking(agent=your_id, thought="...")` regularly to maintain heartbeat
536- Call `mark_updates(agent=your_id)` every 30-60s during long operations to detect file conflicts
537- Stale workers (no heartbeat for 5+ min) get evicted automatically
538- The lead monitors worker heartbeats -- stay visible to avoid reassignment
539
540## Valid Next States
541
542From `working` you can transition to:
543{{valid_exits}}
544
545Use `update(status="completed")` when done, `update(status="failed")` if blocked, or `update(status="pending")` to release without completing.
546
547## Phase
548
549Current phase: {{current_phase}}
550
551Valid phases: {{valid_phases}}
552
553Set a phase with `update(phase="implement")` to categorize the type of work you're doing.
554"#
555                        .to_string(),
556                ),
557                exit: Some(
558                    "Before completing:\n- [ ] Unmark files\n- [ ] Attach results or notes\n- [ ] `log_metrics()`".to_string(),
559                ),
560            },
561        },
562    );
563
564    states.insert(
565        "completed".to_string(),
566        StateWorkflow {
567            exits: vec!["pending".to_string()],
568            timed: false,
569            prompts: TransitionPrompts {
570                enter: Some("Task completed. Results should be attached.".to_string()),
571                exit: None,
572            },
573        },
574    );
575
576    states.insert(
577        "failed".to_string(),
578        StateWorkflow {
579            exits: vec!["pending".to_string()],
580            timed: false,
581            prompts: TransitionPrompts {
582                enter: Some(
583                    "Task failed. Document: what was attempted, what blocked, suggested next steps."
584                        .to_string(),
585                ),
586                exit: None,
587            },
588        },
589    );
590
591    states.insert(
592        "cancelled".to_string(),
593        StateWorkflow {
594            exits: Vec::new(),
595            timed: false,
596            prompts: TransitionPrompts::default(),
597        },
598    );
599
600    states
601}
602
603/// Default phase workflow definitions.
604fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
605    let mut phases = HashMap::new();
606
607    // Phases with prompts
608    phases.insert(
609        "explore".to_string(),
610        PhaseWorkflow {
611            prompts: TransitionPrompts {
612                enter: None,
613                exit: Some(
614                    "Capture exploration findings before moving on.\nAttach discoveries to parent task for sibling agents.".to_string(),
615                ),
616            },
617        },
618    );
619
620    phases.insert(
621        "implement".to_string(),
622        PhaseWorkflow {
623            prompts: TransitionPrompts {
624                enter: Some("Implementation phase. Mark files before editing.".to_string()),
625                exit: None,
626            },
627        },
628    );
629
630    phases.insert(
631        "review".to_string(),
632        PhaseWorkflow {
633            prompts: TransitionPrompts {
634                enter: Some("Review: tests pass, no new warnings, docs updated.".to_string()),
635                exit: None,
636            },
637        },
638    );
639
640    phases.insert(
641        "test".to_string(),
642        PhaseWorkflow {
643            prompts: TransitionPrompts {
644                enter: Some(
645                    "Testing phase. Verify the implementation works correctly.".to_string(),
646                ),
647                exit: None,
648            },
649        },
650    );
651
652    phases.insert(
653        "security".to_string(),
654        PhaseWorkflow {
655            prompts: TransitionPrompts {
656                enter: Some(
657                    "Security: input validation, auth/authz, no secrets in code.".to_string(),
658                ),
659                exit: None,
660            },
661        },
662    );
663
664    // Phases without prompts
665    for phase in &[
666        "deliver",
667        "triage",
668        "diagnose",
669        "design",
670        "plan",
671        "doc",
672        "integrate",
673        "deploy",
674        "monitor",
675        "optimize",
676    ] {
677        phases.insert(phase.to_string(), PhaseWorkflow::default());
678    }
679
680    phases
681}
682
683impl WorkflowsConfig {
684    /// Get the enter prompt for a state.
685    pub fn get_state_enter_prompt(&self, state: &str) -> Option<&str> {
686        self.states
687            .get(state)
688            .and_then(|s| s.prompts.enter.as_deref())
689    }
690
691    /// Get the exit prompt for a state.
692    pub fn get_state_exit_prompt(&self, state: &str) -> Option<&str> {
693        self.states
694            .get(state)
695            .and_then(|s| s.prompts.exit.as_deref())
696    }
697
698    /// Get the enter prompt for a phase.
699    pub fn get_phase_enter_prompt(&self, phase: &str) -> Option<&str> {
700        self.phases
701            .get(phase)
702            .and_then(|p| p.prompts.enter.as_deref())
703    }
704
705    /// Get the exit prompt for a phase.
706    pub fn get_phase_exit_prompt(&self, phase: &str) -> Option<&str> {
707        self.phases
708            .get(phase)
709            .and_then(|p| p.prompts.exit.as_deref())
710    }
711
712    /// Get the enter prompt for a state+phase combo.
713    pub fn get_combo_enter_prompt(&self, state: &str, phase: &str) -> Option<&str> {
714        let key = format!("{}+{}", state, phase);
715        self.combos.get(&key).and_then(|c| c.enter.as_deref())
716    }
717
718    /// Get the exit prompt for a state+phase combo.
719    pub fn get_combo_exit_prompt(&self, state: &str, phase: &str) -> Option<&str> {
720        let key = format!("{}+{}", state, phase);
721        self.combos.get(&key).and_then(|c| c.exit.as_deref())
722    }
723
724    /// Get a prompt by trigger name.
725    ///
726    /// Trigger format:
727    /// - `enter~{state}` - entering a state
728    /// - `exit~{state}` - exiting a state
729    /// - `enter%{phase}` - entering a phase
730    /// - `exit%{phase}` - exiting a phase
731    /// - `enter~{state}%{phase}` - entering a state+phase combo
732    /// - `exit~{state}%{phase}` - exiting a state+phase combo
733    pub fn get_prompt(&self, trigger: &str) -> Option<&str> {
734        if let Some(rest) = trigger.strip_prefix("enter~") {
735            if let Some(idx) = rest.find('%') {
736                // Combo: enter~state%phase
737                let state = &rest[..idx];
738                let phase = &rest[idx + 1..];
739                self.get_combo_enter_prompt(state, phase)
740            } else {
741                // State: enter~state
742                self.get_state_enter_prompt(rest)
743            }
744        } else if let Some(rest) = trigger.strip_prefix("exit~") {
745            if let Some(idx) = rest.find('%') {
746                // Combo: exit~state%phase
747                let state = &rest[..idx];
748                let phase = &rest[idx + 1..];
749                self.get_combo_exit_prompt(state, phase)
750            } else {
751                // State: exit~state
752                self.get_state_exit_prompt(rest)
753            }
754        } else if let Some(phase) = trigger.strip_prefix("enter%") {
755            self.get_phase_enter_prompt(phase)
756        } else if let Some(phase) = trigger.strip_prefix("exit%") {
757            self.get_phase_exit_prompt(phase)
758        } else {
759            None
760        }
761    }
762
763    /// List all available prompt triggers.
764    pub fn list_prompt_triggers(&self) -> Vec<String> {
765        let mut triggers = Vec::new();
766
767        // State prompts
768        for (state, workflow) in &self.states {
769            if workflow.prompts.enter.is_some() {
770                triggers.push(format!("enter~{}", state));
771            }
772            if workflow.prompts.exit.is_some() {
773                triggers.push(format!("exit~{}", state));
774            }
775        }
776
777        // Phase prompts
778        for (phase, workflow) in &self.phases {
779            if workflow.prompts.enter.is_some() {
780                triggers.push(format!("enter%{}", phase));
781            }
782            if workflow.prompts.exit.is_some() {
783                triggers.push(format!("exit%{}", phase));
784            }
785        }
786
787        // Combo prompts
788        for (combo, prompts) in &self.combos {
789            if prompts.enter.is_some() {
790                triggers.push(format!("enter~{}", combo.replace('+', "%")));
791            }
792            if prompts.exit.is_some() {
793                triggers.push(format!("exit~{}", combo.replace('+', "%")));
794            }
795        }
796
797        triggers.sort();
798        triggers
799    }
800
801    /// Get exit gates for a status transition.
802    /// Returns gates defined under "status:<name>" key.
803    pub fn get_status_exit_gates(&self, status: &str) -> Vec<&GateDefinition> {
804        self.gates
805            .get(&format!("status:{}", status))
806            .map(|v| v.iter().collect())
807            .unwrap_or_default()
808    }
809
810    /// Get exit gates for a phase transition.
811    /// Returns gates defined under "phase:<name>" key.
812    pub fn get_phase_exit_gates(&self, phase: &str) -> Vec<&GateDefinition> {
813        self.gates
814            .get(&format!("phase:{}", phase))
815            .map(|v| v.iter().collect())
816            .unwrap_or_default()
817    }
818}
819
820/// Convert WorkflowsConfig to StatesConfig for backwards compatibility.
821impl From<&WorkflowsConfig> for StatesConfig {
822    fn from(workflows: &WorkflowsConfig) -> Self {
823        let definitions = workflows
824            .states
825            .iter()
826            .map(|(name, workflow)| {
827                (
828                    name.clone(),
829                    StateDefinition {
830                        exits: workflow.exits.clone(),
831                        timed: workflow.timed,
832                    },
833                )
834            })
835            .collect();
836
837        StatesConfig {
838            initial: workflows.settings.initial_state.clone(),
839            disconnect_state: workflows.settings.disconnect_state.clone(),
840            blocking_states: workflows.settings.blocking_states.clone(),
841            definitions,
842        }
843    }
844}
845
846/// Convert WorkflowsConfig to PhasesConfig for backwards compatibility.
847impl From<&WorkflowsConfig> for PhasesConfig {
848    fn from(workflows: &WorkflowsConfig) -> Self {
849        let definitions: HashSet<String> = workflows.phases.keys().cloned().collect();
850
851        PhasesConfig {
852            unknown_phase: workflows.settings.unknown_phase,
853            definitions,
854        }
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn test_default_workflows() {
864        let workflows = WorkflowsConfig::default();
865
866        // Check settings
867        assert_eq!(workflows.settings.initial_state, "pending");
868        assert_eq!(workflows.settings.disconnect_state, "pending");
869        assert!(
870            workflows
871                .settings
872                .blocking_states
873                .contains(&"working".to_string())
874        );
875
876        // Check states
877        assert!(workflows.states.contains_key("pending"));
878        assert!(workflows.states.contains_key("working"));
879        assert!(workflows.states.contains_key("completed"));
880
881        // Check working is timed
882        assert!(workflows.states.get("working").unwrap().timed);
883
884        // Check phases
885        assert!(workflows.phases.contains_key("implement"));
886        assert!(workflows.phases.contains_key("test"));
887    }
888
889    #[test]
890    fn test_get_prompt() {
891        let workflows = WorkflowsConfig::default();
892
893        // State enter prompt
894        let prompt = workflows.get_prompt("enter~working");
895        assert!(prompt.is_some());
896        assert!(prompt.unwrap().contains("actively working"));
897
898        // State exit prompt
899        let prompt = workflows.get_prompt("exit~working");
900        assert!(prompt.is_some());
901        assert!(prompt.unwrap().contains("Unmark"));
902
903        // Phase enter prompt
904        let prompt = workflows.get_prompt("enter%implement");
905        assert!(prompt.is_some());
906        assert!(prompt.unwrap().contains("Implementation"));
907
908        // Phase exit prompt
909        let prompt = workflows.get_prompt("exit%explore");
910        assert!(prompt.is_some());
911        assert!(prompt.unwrap().contains("findings"));
912    }
913
914    #[test]
915    fn test_states_config_from_workflows() {
916        let workflows = WorkflowsConfig::default();
917        let states: StatesConfig = (&workflows).into();
918
919        assert_eq!(states.initial, "pending");
920        assert!(states.definitions.contains_key("working"));
921        assert!(states.definitions.get("working").unwrap().timed);
922    }
923
924    #[test]
925    fn test_phases_config_from_workflows() {
926        let workflows = WorkflowsConfig::default();
927        let phases: PhasesConfig = (&workflows).into();
928
929        assert!(phases.definitions.contains("implement"));
930        assert!(phases.definitions.contains("test"));
931    }
932
933    #[test]
934    fn test_list_prompt_triggers() {
935        let workflows = WorkflowsConfig::default();
936        let triggers = workflows.list_prompt_triggers();
937
938        assert!(triggers.contains(&"enter~working".to_string()));
939        assert!(triggers.contains(&"exit~working".to_string()));
940        assert!(triggers.contains(&"enter%implement".to_string()));
941    }
942
943    #[test]
944    fn test_all_role_tags_from_base_config() {
945        let mut workflows = WorkflowsConfig::default();
946        workflows.roles.insert(
947            "worker".to_string(),
948            RoleDefinition {
949                tags: vec!["worker".to_string(), "backend".to_string()],
950                ..Default::default()
951            },
952        );
953        workflows.roles.insert(
954            "lead".to_string(),
955            RoleDefinition {
956                tags: vec!["lead".to_string(), "coordinator".to_string()],
957                ..Default::default()
958            },
959        );
960
961        let tags = workflows.all_role_tags();
962        assert_eq!(tags.len(), 4);
963        assert!(tags.contains(&"worker".to_string()));
964        assert!(tags.contains(&"backend".to_string()));
965        assert!(tags.contains(&"lead".to_string()));
966        assert!(tags.contains(&"coordinator".to_string()));
967    }
968
969    #[test]
970    fn test_all_role_tags_includes_named_workflows() {
971        let mut workflows = WorkflowsConfig::default();
972
973        // Add a named workflow with its own roles
974        let mut named = WorkflowsConfig::default();
975        named.roles.insert(
976            "reviewer".to_string(),
977            RoleDefinition {
978                tags: vec!["reviewer".to_string()],
979                ..Default::default()
980            },
981        );
982        workflows
983            .named_workflows
984            .insert("review".to_string(), Arc::new(named));
985
986        // Base has no roles, but named workflow does
987        let tags = workflows.all_role_tags();
988        assert_eq!(tags.len(), 1);
989        assert!(tags.contains(&"reviewer".to_string()));
990    }
991
992    #[test]
993    fn test_all_role_tags_deduplicates() {
994        let mut workflows = WorkflowsConfig::default();
995        workflows.roles.insert(
996            "worker".to_string(),
997            RoleDefinition {
998                tags: vec!["shared-tag".to_string()],
999                ..Default::default()
1000            },
1001        );
1002
1003        let mut named = WorkflowsConfig::default();
1004        named.roles.insert(
1005            "builder".to_string(),
1006            RoleDefinition {
1007                tags: vec!["shared-tag".to_string()],
1008                ..Default::default()
1009            },
1010        );
1011        workflows
1012            .named_workflows
1013            .insert("build".to_string(), Arc::new(named));
1014
1015        let tags = workflows.all_role_tags();
1016        assert_eq!(tags.len(), 1);
1017        assert!(tags.contains(&"shared-tag".to_string()));
1018    }
1019
1020    #[test]
1021    fn test_apply_overlay_adds_new_state() {
1022        let mut base = WorkflowsConfig::default();
1023        let mut overlay = WorkflowsConfig {
1024            states: HashMap::new(),
1025            phases: HashMap::new(),
1026            combos: HashMap::new(),
1027            gates: HashMap::new(),
1028            roles: HashMap::new(),
1029            role_prompts: HashMap::new(),
1030            ..Default::default()
1031        };
1032        overlay.states.insert(
1033            "reviewing".to_string(),
1034            StateWorkflow {
1035                exits: vec!["completed".to_string()],
1036                timed: true,
1037                prompts: TransitionPrompts {
1038                    enter: Some("Review the changes.".to_string()),
1039                    exit: None,
1040                },
1041            },
1042        );
1043
1044        base.apply_overlay(&overlay);
1045        assert!(base.states.contains_key("reviewing"));
1046        assert!(base.states["reviewing"].timed);
1047        assert_eq!(
1048            base.states["reviewing"].prompts.enter.as_deref(),
1049            Some("Review the changes.")
1050        );
1051    }
1052
1053    #[test]
1054    fn test_apply_overlay_appends_prompts() {
1055        let mut base = WorkflowsConfig::default();
1056        let original_enter = base.states["working"].prompts.enter.clone();
1057
1058        let mut overlay = WorkflowsConfig {
1059            states: HashMap::new(),
1060            phases: HashMap::new(),
1061            combos: HashMap::new(),
1062            gates: HashMap::new(),
1063            roles: HashMap::new(),
1064            role_prompts: HashMap::new(),
1065            ..Default::default()
1066        };
1067        overlay.states.insert(
1068            "working".to_string(),
1069            StateWorkflow {
1070                exits: vec![],
1071                timed: false,
1072                prompts: TransitionPrompts {
1073                    enter: Some("Create a feature branch.".to_string()),
1074                    exit: None,
1075                },
1076            },
1077        );
1078
1079        base.apply_overlay(&overlay);
1080        let enter = base.states["working"].prompts.enter.as_ref().unwrap();
1081        assert!(enter.contains(&original_enter.unwrap()));
1082        assert!(enter.contains("Create a feature branch."));
1083        assert!(enter.contains("---"));
1084    }
1085
1086    #[test]
1087    fn test_apply_overlay_unions_exits() {
1088        let mut base = WorkflowsConfig::default();
1089        let original_exits = base.states["working"].exits.clone();
1090
1091        let mut overlay = WorkflowsConfig {
1092            states: HashMap::new(),
1093            phases: HashMap::new(),
1094            combos: HashMap::new(),
1095            gates: HashMap::new(),
1096            roles: HashMap::new(),
1097            role_prompts: HashMap::new(),
1098            ..Default::default()
1099        };
1100        overlay.states.insert(
1101            "working".to_string(),
1102            StateWorkflow {
1103                exits: vec!["reviewing".to_string(), "completed".to_string()],
1104                timed: false,
1105                prompts: TransitionPrompts::default(),
1106            },
1107        );
1108
1109        base.apply_overlay(&overlay);
1110        // Should have original exits plus "reviewing" (but not duplicate "completed")
1111        assert!(
1112            base.states["working"]
1113                .exits
1114                .contains(&"reviewing".to_string())
1115        );
1116        for exit in &original_exits {
1117            assert!(base.states["working"].exits.contains(exit));
1118        }
1119    }
1120
1121    #[test]
1122    fn test_apply_overlay_extends_gates() {
1123        let mut base = WorkflowsConfig::default();
1124        let mut overlay = WorkflowsConfig {
1125            states: HashMap::new(),
1126            phases: HashMap::new(),
1127            combos: HashMap::new(),
1128            gates: HashMap::new(),
1129            roles: HashMap::new(),
1130            role_prompts: HashMap::new(),
1131            ..Default::default()
1132        };
1133        overlay.gates.insert(
1134            "status:completed".to_string(),
1135            vec![GateDefinition {
1136                gate_type: "gate/commit".to_string(),
1137                enforcement: super::super::types::GateEnforcement::Warn,
1138                description: "Changes should be committed.".to_string(),
1139            }],
1140        );
1141
1142        base.apply_overlay(&overlay);
1143        assert_eq!(base.gates["status:completed"].len(), 1);
1144        assert_eq!(base.gates["status:completed"][0].gate_type, "gate/commit");
1145    }
1146
1147    #[test]
1148    fn test_apply_overlay_roles_first_wins() {
1149        let mut base = WorkflowsConfig::default();
1150        base.roles.insert(
1151            "worker".to_string(),
1152            RoleDefinition {
1153                description: Some("Base worker".to_string()),
1154                tags: vec!["worker".to_string()],
1155                ..Default::default()
1156            },
1157        );
1158
1159        let mut overlay = WorkflowsConfig {
1160            states: HashMap::new(),
1161            phases: HashMap::new(),
1162            combos: HashMap::new(),
1163            gates: HashMap::new(),
1164            roles: HashMap::new(),
1165            role_prompts: HashMap::new(),
1166            ..Default::default()
1167        };
1168        overlay.roles.insert(
1169            "worker".to_string(),
1170            RoleDefinition {
1171                description: Some("Overlay worker".to_string()),
1172                tags: vec!["overlay-worker".to_string()],
1173                ..Default::default()
1174            },
1175        );
1176
1177        base.apply_overlay(&overlay);
1178        // First wins — base description should remain
1179        assert_eq!(
1180            base.roles["worker"].description.as_deref(),
1181            Some("Base worker")
1182        );
1183    }
1184
1185    #[test]
1186    fn test_compute_overlay_diff() {
1187        let base = WorkflowsConfig::default();
1188        let mut merged = base.clone();
1189
1190        let mut overlay = WorkflowsConfig {
1191            states: HashMap::new(),
1192            phases: HashMap::new(),
1193            combos: HashMap::new(),
1194            gates: HashMap::new(),
1195            roles: HashMap::new(),
1196            role_prompts: HashMap::new(),
1197            ..Default::default()
1198        };
1199        overlay.states.insert(
1200            "reviewing".to_string(),
1201            StateWorkflow {
1202                exits: vec!["completed".to_string()],
1203                timed: true,
1204                prompts: TransitionPrompts::default(),
1205            },
1206        );
1207        overlay.states.insert(
1208            "working".to_string(),
1209            StateWorkflow {
1210                exits: vec![],
1211                timed: false,
1212                prompts: TransitionPrompts {
1213                    enter: Some("Git overlay prompt.".to_string()),
1214                    exit: None,
1215                },
1216            },
1217        );
1218
1219        merged.apply_overlay(&overlay);
1220        let diff = merged.compute_overlay_diff(&base);
1221
1222        let states_added = diff["states_added"].as_array().unwrap();
1223        assert!(states_added.iter().any(|v| v.as_str() == Some("reviewing")));
1224
1225        let prompts_modified = diff["prompts_modified"].as_array().unwrap();
1226        assert!(
1227            prompts_modified
1228                .iter()
1229                .any(|v| v.as_str() == Some("enter~working"))
1230        );
1231    }
1232}