1#![cfg_attr(not(test), allow(dead_code))]
2
3use std::collections::{BTreeMap, BTreeSet};
21
22use super::config::RoleType;
23use super::hierarchy::MemberInstance;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub enum WorkflowCapability {
27 Planner,
28 Dispatcher,
29 Executor,
30 Reviewer,
31 Orchestrator,
32 Operator,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum CapabilitySubject {
37 Member(String),
38 Orchestrator,
39 Operator,
40}
41
42pub type CapabilitySet = BTreeSet<WorkflowCapability>;
43pub type CapabilityMap = BTreeMap<CapabilitySubject, CapabilitySet>;
44
45pub fn resolve_member_capabilities(
50 member: &MemberInstance,
51 members: &[MemberInstance],
52) -> CapabilitySet {
53 let has_architect = members
54 .iter()
55 .any(|candidate| candidate.role_type == RoleType::Architect);
56 let has_manager = members
57 .iter()
58 .any(|candidate| candidate.role_type == RoleType::Manager);
59 let is_top_level = member.reports_to.is_none();
60
61 let mut capabilities = CapabilitySet::new();
62 match member.role_type {
63 RoleType::User => {}
64 RoleType::Architect => {
65 capabilities.insert(WorkflowCapability::Planner);
66 capabilities.insert(WorkflowCapability::Reviewer);
67 if !has_manager {
68 capabilities.insert(WorkflowCapability::Dispatcher);
69 }
70 }
71 RoleType::Manager => {
72 capabilities.insert(WorkflowCapability::Dispatcher);
73 capabilities.insert(WorkflowCapability::Reviewer);
74 if !has_architect && is_top_level {
75 capabilities.insert(WorkflowCapability::Planner);
76 }
77 }
78 RoleType::Engineer => {
79 capabilities.insert(WorkflowCapability::Executor);
80 if !has_architect && !has_manager && is_top_level {
81 capabilities.insert(WorkflowCapability::Planner);
82 capabilities.insert(WorkflowCapability::Dispatcher);
83 }
84 }
85 }
86
87 capabilities
88}
89
90pub fn resolve_capability_map(members: &[MemberInstance]) -> CapabilityMap {
96 let mut map = CapabilityMap::new();
97
98 for member in members {
99 if member.role_type == RoleType::User {
100 continue;
101 }
102 let capabilities = resolve_member_capabilities(member, members);
103 if !capabilities.is_empty() {
104 map.insert(CapabilitySubject::Member(member.name.clone()), capabilities);
105 }
106 }
107
108 let mut operator_capabilities = CapabilitySet::from([WorkflowCapability::Operator]);
109 if !has_agent_reviewer(members) {
110 operator_capabilities.insert(WorkflowCapability::Reviewer);
111 }
112 map.insert(CapabilitySubject::Operator, operator_capabilities);
113 map.insert(
114 CapabilitySubject::Orchestrator,
115 CapabilitySet::from([WorkflowCapability::Orchestrator]),
116 );
117
118 map
119}
120
121fn has_agent_reviewer(members: &[MemberInstance]) -> bool {
122 members.iter().any(|member| {
123 matches!(member.role_type, RoleType::Architect | RoleType::Manager)
124 && !resolve_member_capabilities(member, members).is_empty()
125 && resolve_member_capabilities(member, members).contains(&WorkflowCapability::Reviewer)
126 })
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::team::config::TeamConfig;
133 use crate::team::hierarchy;
134
135 fn members_from_yaml(yaml: &str) -> Vec<MemberInstance> {
136 let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
137 hierarchy::resolve_hierarchy(&config).unwrap()
138 }
139
140 fn member_capabilities(map: &CapabilityMap, member_name: &str) -> CapabilitySet {
141 map.get(&CapabilitySubject::Member(member_name.to_string()))
142 .cloned()
143 .unwrap_or_default()
144 }
145
146 fn capability_set(capabilities: &[WorkflowCapability]) -> CapabilitySet {
147 capabilities.iter().copied().collect()
148 }
149
150 #[test]
151 fn solo_topology_falls_back_to_operator_review() {
152 let members = members_from_yaml(
153 r#"
154name: solo
155roles:
156 - name: builder
157 role_type: engineer
158 agent: codex
159 instances: 1
160"#,
161 );
162
163 let capability_map = resolve_capability_map(&members);
164
165 assert_eq!(
166 member_capabilities(&capability_map, "builder"),
167 capability_set(&[
168 WorkflowCapability::Planner,
169 WorkflowCapability::Dispatcher,
170 WorkflowCapability::Executor,
171 ])
172 );
173 assert_eq!(
174 capability_map
175 .get(&CapabilitySubject::Operator)
176 .cloned()
177 .unwrap(),
178 capability_set(&[WorkflowCapability::Operator, WorkflowCapability::Reviewer])
179 );
180 assert_eq!(
181 capability_map
182 .get(&CapabilitySubject::Orchestrator)
183 .cloned()
184 .unwrap(),
185 capability_set(&[WorkflowCapability::Orchestrator])
186 );
187 }
188
189 #[test]
190 fn pair_topology_assigns_architect_planning_dispatch_and_review() {
191 let members = members_from_yaml(
192 r#"
193name: pair
194roles:
195 - name: planner
196 role_type: architect
197 agent: claude
198 instances: 1
199 - name: builder
200 role_type: engineer
201 agent: codex
202 instances: 1
203"#,
204 );
205
206 let capability_map = resolve_capability_map(&members);
207
208 assert_eq!(
209 member_capabilities(&capability_map, "planner"),
210 capability_set(&[
211 WorkflowCapability::Planner,
212 WorkflowCapability::Dispatcher,
213 WorkflowCapability::Reviewer,
214 ])
215 );
216 assert_eq!(
217 member_capabilities(&capability_map, "builder"),
218 capability_set(&[WorkflowCapability::Executor])
219 );
220 assert_eq!(
221 capability_map
222 .get(&CapabilitySubject::Operator)
223 .cloned()
224 .unwrap(),
225 capability_set(&[WorkflowCapability::Operator])
226 );
227 }
228
229 #[test]
230 fn manager_led_topology_promotes_top_level_manager_to_planner() {
231 let members = members_from_yaml(
232 r#"
233name: manager-led
234roles:
235 - name: lead
236 role_type: manager
237 agent: claude
238 instances: 1
239 - name: implementer
240 role_type: engineer
241 agent: codex
242 instances: 2
243"#,
244 );
245
246 let capability_map = resolve_capability_map(&members);
247
248 assert_eq!(
249 member_capabilities(&capability_map, "lead"),
250 capability_set(&[
251 WorkflowCapability::Planner,
252 WorkflowCapability::Dispatcher,
253 WorkflowCapability::Reviewer,
254 ])
255 );
256 assert_eq!(
257 member_capabilities(&capability_map, "implementer-1-1"),
258 capability_set(&[WorkflowCapability::Executor])
259 );
260 assert_eq!(
261 member_capabilities(&capability_map, "implementer-1-2"),
262 capability_set(&[WorkflowCapability::Executor])
263 );
264 }
265
266 #[test]
267 fn multi_manager_topology_keeps_architect_planning_and_managers_dispatching() {
268 let members = members_from_yaml(
269 r#"
270name: squad
271roles:
272 - name: architect
273 role_type: architect
274 agent: claude
275 instances: 1
276 - name: manager
277 role_type: manager
278 agent: claude
279 instances: 2
280 - name: engineer
281 role_type: engineer
282 agent: codex
283 instances: 2
284"#,
285 );
286
287 let capability_map = resolve_capability_map(&members);
288
289 assert_eq!(
290 member_capabilities(&capability_map, "architect"),
291 capability_set(&[WorkflowCapability::Planner, WorkflowCapability::Reviewer])
292 );
293 assert_eq!(
294 member_capabilities(&capability_map, "manager-1"),
295 capability_set(&[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer])
296 );
297 assert_eq!(
298 member_capabilities(&capability_map, "manager-2"),
299 capability_set(&[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer])
300 );
301 assert_eq!(
302 member_capabilities(&capability_map, "eng-1-1"),
303 capability_set(&[WorkflowCapability::Executor])
304 );
305 assert_eq!(
306 member_capabilities(&capability_map, "eng-2-2"),
307 capability_set(&[WorkflowCapability::Executor])
308 );
309 }
310
311 #[test]
312 fn renamed_roles_resolve_from_role_type_instead_of_role_name() {
313 let members = members_from_yaml(
314 r#"
315name: renamed
316roles:
317 - name: human
318 role_type: user
319 - name: tech-lead
320 role_type: architect
321 agent: claude
322 instances: 1
323 - name: backend-mgr
324 role_type: manager
325 agent: claude
326 instances: 1
327 - name: frontend-mgr
328 role_type: manager
329 agent: claude
330 instances: 1
331 - name: developer
332 role_type: engineer
333 agent: codex
334 instances: 1
335"#,
336 );
337
338 let capability_map = resolve_capability_map(&members);
339
340 assert_eq!(
341 member_capabilities(&capability_map, "tech-lead"),
342 capability_set(&[WorkflowCapability::Planner, WorkflowCapability::Reviewer])
343 );
344 assert_eq!(
345 member_capabilities(&capability_map, "backend-mgr"),
346 capability_set(&[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer])
347 );
348 assert_eq!(
349 member_capabilities(&capability_map, "frontend-mgr"),
350 capability_set(&[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer])
351 );
352 assert_eq!(
353 member_capabilities(&capability_map, "developer-1-1"),
354 capability_set(&[WorkflowCapability::Executor])
355 );
356 }
357
358 #[test]
361 fn user_role_gets_no_capabilities_and_is_excluded_from_map() {
362 let members = members_from_yaml(
363 r#"
364name: with-user
365roles:
366 - name: human
367 role_type: user
368 - name: builder
369 role_type: engineer
370 agent: codex
371 instances: 1
372"#,
373 );
374
375 let capability_map = resolve_capability_map(&members);
376
377 assert!(!capability_map.contains_key(&CapabilitySubject::Member("human".to_string())));
379 assert_eq!(
381 member_capabilities(&capability_map, "builder"),
382 capability_set(&[
383 WorkflowCapability::Planner,
384 WorkflowCapability::Dispatcher,
385 WorkflowCapability::Executor,
386 ])
387 );
388 }
389
390 #[test]
391 fn resolve_member_capabilities_returns_empty_for_user() {
392 let member = MemberInstance {
393 name: "human".to_string(),
394 role_name: "human".to_string(),
395 role_type: RoleType::User,
396 agent: None,
397 model: None,
398 prompt: None,
399 posture: None,
400 model_class: None,
401 provider_overlay: None,
402 reports_to: None,
403 use_worktrees: false,
404 };
405 let members = vec![member.clone()];
406 let caps = resolve_member_capabilities(&member, &members);
407 assert!(caps.is_empty());
408 }
409
410 #[test]
411 fn empty_member_list_produces_operator_and_orchestrator_only() {
412 let capability_map = resolve_capability_map(&[]);
413
414 assert!(
416 !capability_map
417 .keys()
418 .any(|k| matches!(k, CapabilitySubject::Member(_)))
419 );
420
421 assert_eq!(
423 capability_map
424 .get(&CapabilitySubject::Operator)
425 .cloned()
426 .unwrap(),
427 capability_set(&[WorkflowCapability::Operator, WorkflowCapability::Reviewer])
428 );
429 assert_eq!(
430 capability_map
431 .get(&CapabilitySubject::Orchestrator)
432 .cloned()
433 .unwrap(),
434 capability_set(&[WorkflowCapability::Orchestrator])
435 );
436 }
437
438 #[test]
439 fn architect_gets_dispatch_when_no_manager_exists() {
440 let members = members_from_yaml(
441 r#"
442name: no-manager
443roles:
444 - name: arch
445 role_type: architect
446 agent: claude
447 instances: 1
448 - name: dev
449 role_type: engineer
450 agent: codex
451 instances: 1
452"#,
453 );
454
455 let capability_map = resolve_capability_map(&members);
456
457 assert!(
459 member_capabilities(&capability_map, "arch").contains(&WorkflowCapability::Dispatcher)
460 );
461 }
462
463 #[test]
464 fn architect_loses_dispatch_when_manager_present() {
465 let members = members_from_yaml(
466 r#"
467name: full-team
468roles:
469 - name: arch
470 role_type: architect
471 agent: claude
472 instances: 1
473 - name: mgr
474 role_type: manager
475 agent: claude
476 instances: 1
477 - name: dev
478 role_type: engineer
479 agent: codex
480 instances: 1
481"#,
482 );
483
484 let capability_map = resolve_capability_map(&members);
485
486 assert!(
488 !member_capabilities(&capability_map, "arch").contains(&WorkflowCapability::Dispatcher)
489 );
490 assert!(
492 member_capabilities(&capability_map, "mgr").contains(&WorkflowCapability::Dispatcher)
493 );
494 }
495
496 #[test]
497 fn subordinate_engineer_never_gets_planner_or_dispatcher() {
498 let members = members_from_yaml(
499 r#"
500name: hierarchy
501roles:
502 - name: lead
503 role_type: manager
504 agent: claude
505 instances: 1
506 - name: worker
507 role_type: engineer
508 agent: codex
509 instances: 3
510"#,
511 );
512
513 let capability_map = resolve_capability_map(&members);
514
515 for i in 1..=3 {
516 let name = format!("worker-1-{}", i);
517 let caps = member_capabilities(&capability_map, &name);
518 assert_eq!(caps, capability_set(&[WorkflowCapability::Executor]));
519 assert!(!caps.contains(&WorkflowCapability::Planner));
520 assert!(!caps.contains(&WorkflowCapability::Dispatcher));
521 }
522 }
523
524 #[test]
525 fn manager_without_architect_and_top_level_gets_planner() {
526 let members = members_from_yaml(
527 r#"
528name: manager-only
529roles:
530 - name: mgr
531 role_type: manager
532 agent: claude
533 instances: 1
534"#,
535 );
536
537 let capability_map = resolve_capability_map(&members);
538
539 assert!(member_capabilities(&capability_map, "mgr").contains(&WorkflowCapability::Planner));
540 }
541
542 #[test]
543 fn manager_with_architect_does_not_get_planner() {
544 let members = members_from_yaml(
545 r#"
546name: with-arch
547roles:
548 - name: arch
549 role_type: architect
550 agent: claude
551 instances: 1
552 - name: mgr
553 role_type: manager
554 agent: claude
555 instances: 1
556"#,
557 );
558
559 let capability_map = resolve_capability_map(&members);
560
561 assert!(
562 !member_capabilities(&capability_map, "mgr").contains(&WorkflowCapability::Planner)
563 );
564 }
565
566 #[test]
567 fn operator_gets_reviewer_when_no_architect_or_manager() {
568 let members = members_from_yaml(
569 r#"
570name: engineers-only
571roles:
572 - name: dev
573 role_type: engineer
574 agent: codex
575 instances: 2
576"#,
577 );
578
579 let capability_map = resolve_capability_map(&members);
580
581 let operator_caps = capability_map
583 .get(&CapabilitySubject::Operator)
584 .cloned()
585 .unwrap();
586 assert!(operator_caps.contains(&WorkflowCapability::Reviewer));
587 }
588
589 #[test]
590 fn operator_does_not_get_reviewer_when_architect_exists() {
591 let members = members_from_yaml(
592 r#"
593name: with-arch
594roles:
595 - name: arch
596 role_type: architect
597 agent: claude
598 instances: 1
599 - name: dev
600 role_type: engineer
601 agent: codex
602 instances: 1
603"#,
604 );
605
606 let capability_map = resolve_capability_map(&members);
607
608 let operator_caps = capability_map
609 .get(&CapabilitySubject::Operator)
610 .cloned()
611 .unwrap();
612 assert!(!operator_caps.contains(&WorkflowCapability::Reviewer));
613 }
614
615 #[test]
616 fn operator_does_not_get_reviewer_when_manager_exists() {
617 let members = members_from_yaml(
618 r#"
619name: with-mgr
620roles:
621 - name: mgr
622 role_type: manager
623 agent: claude
624 instances: 1
625 - name: dev
626 role_type: engineer
627 agent: codex
628 instances: 1
629"#,
630 );
631
632 let capability_map = resolve_capability_map(&members);
633
634 let operator_caps = capability_map
635 .get(&CapabilitySubject::Operator)
636 .cloned()
637 .unwrap();
638 assert!(!operator_caps.contains(&WorkflowCapability::Reviewer));
639 }
640
641 #[test]
642 fn orchestrator_always_has_only_orchestrator_capability() {
643 let topologies = vec![
645 r#"
647name: solo
648roles:
649 - name: dev
650 role_type: engineer
651 agent: codex
652 instances: 1
653"#,
654 r#"
656name: full
657roles:
658 - name: arch
659 role_type: architect
660 agent: claude
661 instances: 1
662 - name: mgr
663 role_type: manager
664 agent: claude
665 instances: 1
666 - name: dev
667 role_type: engineer
668 agent: codex
669 instances: 1
670"#,
671 ];
672
673 for yaml in topologies {
674 let members = members_from_yaml(yaml);
675 let capability_map = resolve_capability_map(&members);
676 assert_eq!(
677 capability_map
678 .get(&CapabilitySubject::Orchestrator)
679 .cloned()
680 .unwrap(),
681 capability_set(&[WorkflowCapability::Orchestrator])
682 );
683 }
684 }
685
686 #[test]
687 fn has_agent_reviewer_returns_true_with_architect() {
688 let members = members_from_yaml(
689 r#"
690name: test
691roles:
692 - name: arch
693 role_type: architect
694 agent: claude
695 instances: 1
696 - name: dev
697 role_type: engineer
698 agent: codex
699 instances: 1
700"#,
701 );
702
703 assert!(has_agent_reviewer(&members));
704 }
705
706 #[test]
707 fn has_agent_reviewer_returns_false_with_only_engineers() {
708 let members = members_from_yaml(
709 r#"
710name: test
711roles:
712 - name: dev
713 role_type: engineer
714 agent: codex
715 instances: 2
716"#,
717 );
718
719 assert!(!has_agent_reviewer(&members));
720 }
721
722 #[test]
723 fn has_agent_reviewer_returns_false_with_only_user_roles() {
724 let member = MemberInstance {
725 name: "human".to_string(),
726 role_name: "human".to_string(),
727 role_type: RoleType::User,
728 agent: None,
729 model: None,
730 prompt: None,
731 posture: None,
732 model_class: None,
733 provider_overlay: None,
734 reports_to: None,
735 use_worktrees: false,
736 };
737 assert!(!has_agent_reviewer(&[member]));
738 }
739
740 #[test]
741 fn capability_map_member_count_matches_non_user_members() {
742 let members = members_from_yaml(
743 r#"
744name: mixed
745roles:
746 - name: human
747 role_type: user
748 - name: arch
749 role_type: architect
750 agent: claude
751 instances: 1
752 - name: dev
753 role_type: engineer
754 agent: codex
755 instances: 2
756"#,
757 );
758
759 let capability_map = resolve_capability_map(&members);
760
761 let member_entries = capability_map
762 .keys()
763 .filter(|k| matches!(k, CapabilitySubject::Member(_)))
764 .count();
765 assert_eq!(member_entries, 3);
767 }
768
769 #[test]
770 fn solo_engineer_gets_all_three_agent_capabilities() {
771 let members = members_from_yaml(
772 r#"
773name: solo
774roles:
775 - name: lone-wolf
776 role_type: engineer
777 agent: codex
778 instances: 1
779"#,
780 );
781
782 let capability_map = resolve_capability_map(&members);
783 let caps = member_capabilities(&capability_map, "lone-wolf");
784
785 assert!(caps.contains(&WorkflowCapability::Planner));
786 assert!(caps.contains(&WorkflowCapability::Dispatcher));
787 assert!(caps.contains(&WorkflowCapability::Executor));
788 assert!(!caps.contains(&WorkflowCapability::Reviewer));
789 assert!(!caps.contains(&WorkflowCapability::Orchestrator));
790 assert!(!caps.contains(&WorkflowCapability::Operator));
791 }
792
793 #[test]
794 fn multi_instance_engineers_all_get_same_capabilities() {
795 let members = members_from_yaml(
796 r#"
797name: team
798roles:
799 - name: arch
800 role_type: architect
801 agent: claude
802 instances: 1
803 - name: eng
804 role_type: engineer
805 agent: codex
806 instances: 4
807"#,
808 );
809
810 let capability_map = resolve_capability_map(&members);
811 let expected = capability_set(&[WorkflowCapability::Executor]);
812
813 for i in 1..=4 {
815 let name = format!("eng-{}", i);
816 assert_eq!(member_capabilities(&capability_map, &name), expected);
817 }
818 }
819
820 #[test]
821 fn capability_subject_ordering_is_deterministic() {
822 let a = CapabilitySubject::Member("aaa".to_string());
824 let b = CapabilitySubject::Member("zzz".to_string());
825 let orch = CapabilitySubject::Orchestrator;
826 let op = CapabilitySubject::Operator;
827
828 assert!(a < b);
829 assert!(a < orch);
830 assert!(orch < op);
831 }
832}