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