1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct AdvisoryDefinition {
22 #[serde(default)]
25 pub level: Vec<String>,
26
27 #[serde(default)]
30 pub phase: Vec<String>,
31
32 #[serde(default)]
35 pub role: Vec<String>,
36
37 #[serde(default)]
40 pub domain: Vec<String>,
41
42 #[serde(default)]
44 pub content: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WorkflowSettings {
50 #[serde(default = "default_initial_state")]
52 pub initial_state: String,
53
54 #[serde(default = "default_disconnect_state")]
56 pub disconnect_state: String,
57
58 #[serde(default = "default_blocking_states")]
60 pub blocking_states: Vec<String>,
61
62 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct TransitionPrompts {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub enter: Option<String>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub exit: Option<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct StateWorkflow {
109 #[serde(default)]
111 pub exits: Vec<String>,
112
113 #[serde(default)]
115 pub timed: bool,
116
117 #[serde(default)]
119 pub prompts: TransitionPrompts,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct PhaseWorkflow {
125 #[serde(default)]
127 pub prompts: TransitionPrompts,
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct ComboPrompts {
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub enter: Option<String>,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub exit: Option<String>,
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct RoleDefinition {
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub description: Option<String>,
148
149 #[serde(default)]
151 pub tags: Vec<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub max_claims: Option<u32>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub can_assign: Option<bool>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub can_create_subtasks: Option<bool>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct WorkflowsConfig {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub name: Option<String>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub description: Option<String>,
177
178 #[serde(skip)]
181 pub source_file: Option<std::path::PathBuf>,
182
183 #[serde(default)]
185 pub settings: WorkflowSettings,
186
187 #[serde(default)]
189 pub states: HashMap<String, StateWorkflow>,
190
191 #[serde(default)]
193 pub phases: HashMap<String, PhaseWorkflow>,
194
195 #[serde(default)]
197 pub combos: HashMap<String, ComboPrompts>,
198
199 #[serde(default)]
202 pub gates: HashMap<String, Vec<GateDefinition>>,
203
204 #[serde(default)]
206 pub roles: HashMap<String, RoleDefinition>,
207
208 #[serde(default)]
211 pub role_prompts: HashMap<String, HashMap<String, String>>,
212
213 #[serde(default)]
216 pub advisories: HashMap<String, AdvisoryDefinition>,
217
218 #[serde(skip)]
221 pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
222
223 #[serde(skip)]
226 pub default_workflow_key: Option<String>,
227
228 #[serde(skip)]
231 pub named_overlays: HashMap<String, Arc<WorkflowsConfig>>,
232
233 #[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 pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
263 self.named_workflows.get(name)
264 }
265
266 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 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 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 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 pub fn get_role(&self, role_name: &str) -> Option<&RoleDefinition> {
308 self.roles.get(role_name)
309 }
310
311 pub fn all_role_tags(&self) -> Vec<String> {
314 let mut tags = std::collections::HashSet::new();
315 for role in self.roles.values() {
317 for tag in &role.tags {
318 tags.insert(tag.clone());
319 }
320 }
321 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 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 pub fn apply_overlay(&mut self, overlay: &WorkflowsConfig) {
353 const PROMPT_SEPARATOR: &str = "\n\n---\n\n";
354
355 for (name, overlay_state) in &overlay.states {
357 if let Some(existing) = self.states.get_mut(name) {
358 for exit in &overlay_state.exits {
360 if !existing.exits.contains(exit) {
361 existing.exits.push(exit.clone());
362 }
363 }
364 existing.timed |= overlay_state.timed;
366 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 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 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 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 for (name, overlay_role) in &overlay.roles {
420 self.roles
421 .entry(name.clone())
422 .or_insert_with(|| overlay_role.clone());
423 }
424
425 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 for (topic, overlay_advisory) in &overlay.advisories {
441 self.advisories
442 .entry(topic.clone())
443 .and_modify(|existing| {
444 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 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 if overlay.settings.initial_state != default_initial_state() {
478 self.settings.initial_state = overlay.settings.initial_state.clone();
479 }
480 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 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 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 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
542fn 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
555fn append_optional_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
557 append_prompt(target, source, separator);
558}
559
560fn 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
677fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
679 let mut phases = HashMap::new();
680
681 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 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 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 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 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 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 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 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 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 let state = &rest[..idx];
812 let phase = &rest[idx + 1..];
813 self.get_combo_enter_prompt(state, phase)
814 } else {
815 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 let state = &rest[..idx];
822 let phase = &rest[idx + 1..];
823 self.get_combo_exit_prompt(state, phase)
824 } else {
825 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 pub fn list_prompt_triggers(&self) -> Vec<String> {
839 let mut triggers = Vec::new();
840
841 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 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 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 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 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 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
903impl 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
929impl 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 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 assert!(workflows.states.contains_key("pending"));
961 assert!(workflows.states.contains_key("working"));
962 assert!(workflows.states.contains_key("completed"));
963
964 assert!(workflows.states.get("working").unwrap().timed);
966
967 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 let prompt = workflows.get_prompt("enter~working");
978 assert!(prompt.is_some());
979 assert!(prompt.unwrap().contains("actively working"));
980
981 let prompt = workflows.get_prompt("exit~working");
983 assert!(prompt.is_some());
984 assert!(prompt.unwrap().contains("Unmark"));
985
986 let prompt = workflows.get_prompt("enter%implement");
988 assert!(prompt.is_some());
989 assert!(prompt.unwrap().contains("Implementation"));
990
991 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 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 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 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 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 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 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 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 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 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 assert!(
1482 merged.role_prompts.contains_key("integrator"),
1483 "should have integrator role prompts"
1484 );
1485
1486 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}