Skip to main content

batty_cli/team/
capability.rs

1#![cfg_attr(not(test), allow(dead_code))]
2
3//! Workflow capability resolution for topology-independent team behavior.
4//!
5//! The rules in this module intentionally resolve responsibilities from
6//! `RoleType` plus hierarchy position rather than from literal role names.
7//! That keeps the workflow model stable across:
8//! - default architect/manager/engineer teams
9//! - renamed roles such as tech leads and developers
10//! - reduced topologies such as solo and pair setups
11//!
12//! Fallback rules:
13//! - `Dispatcher` belongs to managers by default, falls back to architects
14//!   when no manager layer exists, and finally to a lone top-level engineer.
15//! - `Reviewer` belongs to supervisory agents by default. If the topology has
16//!   no non-executor reviewer, the operator becomes the review fallback.
17//! - `Orchestrator` and `Operator` are control-plane capabilities and are kept
18//!   separate from agent member responsibilities.
19
20use 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
45/// Resolve the workflow capabilities for a single team member.
46///
47/// This function only returns agent-side capabilities. Operator and
48/// orchestrator responsibilities are exposed by `resolve_capability_map`.
49pub 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
90/// Resolve the full workflow capability map for a team topology.
91///
92/// The returned map includes both agent members and control-plane subjects.
93/// This keeps orchestrator/operator duties explicit rather than quietly
94/// attaching them to a regular agent.
95pub 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    // --- New tests for task #261 ---
359
360    #[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        // User role should not appear in the capability map
378        assert!(!capability_map.contains_key(&CapabilitySubject::Member("human".to_string())));
379        // Engineer should still get full solo capabilities
380        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        // No agent members
415        assert!(
416            !capability_map
417                .keys()
418                .any(|k| matches!(k, CapabilitySubject::Member(_)))
419        );
420
421        // Operator should have reviewer fallback since no agent reviewers
422        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        // Architect picks up Dispatcher because no manager
458        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        // Architect should NOT have Dispatcher when manager exists
487        assert!(
488            !member_capabilities(&capability_map, "arch").contains(&WorkflowCapability::Dispatcher)
489        );
490        // Manager should have Dispatcher
491        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        // No supervisory roles → operator is review fallback
582        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        // Test across multiple topologies
644        let topologies = vec![
645            // Solo
646            r#"
647name: solo
648roles:
649  - name: dev
650    role_type: engineer
651    agent: codex
652    instances: 1
653"#,
654            // Full team
655            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        // 1 architect + 2 engineers = 3 members (user excluded)
766        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        // No manager → flat naming: eng-1, eng-2, eng-3, eng-4
814        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        // CapabilitySubject derives Ord — variant order: Member, Orchestrator, Operator
823        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}