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            prompt: None,
398            reports_to: None,
399            use_worktrees: false,
400        };
401        let members = vec![member.clone()];
402        let caps = resolve_member_capabilities(&member, &members);
403        assert!(caps.is_empty());
404    }
405
406    #[test]
407    fn empty_member_list_produces_operator_and_orchestrator_only() {
408        let capability_map = resolve_capability_map(&[]);
409
410        // No agent members
411        assert!(
412            !capability_map
413                .keys()
414                .any(|k| matches!(k, CapabilitySubject::Member(_)))
415        );
416
417        // Operator should have reviewer fallback since no agent reviewers
418        assert_eq!(
419            capability_map
420                .get(&CapabilitySubject::Operator)
421                .cloned()
422                .unwrap(),
423            capability_set(&[WorkflowCapability::Operator, WorkflowCapability::Reviewer])
424        );
425        assert_eq!(
426            capability_map
427                .get(&CapabilitySubject::Orchestrator)
428                .cloned()
429                .unwrap(),
430            capability_set(&[WorkflowCapability::Orchestrator])
431        );
432    }
433
434    #[test]
435    fn architect_gets_dispatch_when_no_manager_exists() {
436        let members = members_from_yaml(
437            r#"
438name: no-manager
439roles:
440  - name: arch
441    role_type: architect
442    agent: claude
443    instances: 1
444  - name: dev
445    role_type: engineer
446    agent: codex
447    instances: 1
448"#,
449        );
450
451        let capability_map = resolve_capability_map(&members);
452
453        // Architect picks up Dispatcher because no manager
454        assert!(
455            member_capabilities(&capability_map, "arch").contains(&WorkflowCapability::Dispatcher)
456        );
457    }
458
459    #[test]
460    fn architect_loses_dispatch_when_manager_present() {
461        let members = members_from_yaml(
462            r#"
463name: full-team
464roles:
465  - name: arch
466    role_type: architect
467    agent: claude
468    instances: 1
469  - name: mgr
470    role_type: manager
471    agent: claude
472    instances: 1
473  - name: dev
474    role_type: engineer
475    agent: codex
476    instances: 1
477"#,
478        );
479
480        let capability_map = resolve_capability_map(&members);
481
482        // Architect should NOT have Dispatcher when manager exists
483        assert!(
484            !member_capabilities(&capability_map, "arch").contains(&WorkflowCapability::Dispatcher)
485        );
486        // Manager should have Dispatcher
487        assert!(
488            member_capabilities(&capability_map, "mgr").contains(&WorkflowCapability::Dispatcher)
489        );
490    }
491
492    #[test]
493    fn subordinate_engineer_never_gets_planner_or_dispatcher() {
494        let members = members_from_yaml(
495            r#"
496name: hierarchy
497roles:
498  - name: lead
499    role_type: manager
500    agent: claude
501    instances: 1
502  - name: worker
503    role_type: engineer
504    agent: codex
505    instances: 3
506"#,
507        );
508
509        let capability_map = resolve_capability_map(&members);
510
511        for i in 1..=3 {
512            let name = format!("worker-1-{}", i);
513            let caps = member_capabilities(&capability_map, &name);
514            assert_eq!(caps, capability_set(&[WorkflowCapability::Executor]));
515            assert!(!caps.contains(&WorkflowCapability::Planner));
516            assert!(!caps.contains(&WorkflowCapability::Dispatcher));
517        }
518    }
519
520    #[test]
521    fn manager_without_architect_and_top_level_gets_planner() {
522        let members = members_from_yaml(
523            r#"
524name: manager-only
525roles:
526  - name: mgr
527    role_type: manager
528    agent: claude
529    instances: 1
530"#,
531        );
532
533        let capability_map = resolve_capability_map(&members);
534
535        assert!(member_capabilities(&capability_map, "mgr").contains(&WorkflowCapability::Planner));
536    }
537
538    #[test]
539    fn manager_with_architect_does_not_get_planner() {
540        let members = members_from_yaml(
541            r#"
542name: with-arch
543roles:
544  - name: arch
545    role_type: architect
546    agent: claude
547    instances: 1
548  - name: mgr
549    role_type: manager
550    agent: claude
551    instances: 1
552"#,
553        );
554
555        let capability_map = resolve_capability_map(&members);
556
557        assert!(
558            !member_capabilities(&capability_map, "mgr").contains(&WorkflowCapability::Planner)
559        );
560    }
561
562    #[test]
563    fn operator_gets_reviewer_when_no_architect_or_manager() {
564        let members = members_from_yaml(
565            r#"
566name: engineers-only
567roles:
568  - name: dev
569    role_type: engineer
570    agent: codex
571    instances: 2
572"#,
573        );
574
575        let capability_map = resolve_capability_map(&members);
576
577        // No supervisory roles → operator is review fallback
578        let operator_caps = capability_map
579            .get(&CapabilitySubject::Operator)
580            .cloned()
581            .unwrap();
582        assert!(operator_caps.contains(&WorkflowCapability::Reviewer));
583    }
584
585    #[test]
586    fn operator_does_not_get_reviewer_when_architect_exists() {
587        let members = members_from_yaml(
588            r#"
589name: with-arch
590roles:
591  - name: arch
592    role_type: architect
593    agent: claude
594    instances: 1
595  - name: dev
596    role_type: engineer
597    agent: codex
598    instances: 1
599"#,
600        );
601
602        let capability_map = resolve_capability_map(&members);
603
604        let operator_caps = capability_map
605            .get(&CapabilitySubject::Operator)
606            .cloned()
607            .unwrap();
608        assert!(!operator_caps.contains(&WorkflowCapability::Reviewer));
609    }
610
611    #[test]
612    fn operator_does_not_get_reviewer_when_manager_exists() {
613        let members = members_from_yaml(
614            r#"
615name: with-mgr
616roles:
617  - name: mgr
618    role_type: manager
619    agent: claude
620    instances: 1
621  - name: dev
622    role_type: engineer
623    agent: codex
624    instances: 1
625"#,
626        );
627
628        let capability_map = resolve_capability_map(&members);
629
630        let operator_caps = capability_map
631            .get(&CapabilitySubject::Operator)
632            .cloned()
633            .unwrap();
634        assert!(!operator_caps.contains(&WorkflowCapability::Reviewer));
635    }
636
637    #[test]
638    fn orchestrator_always_has_only_orchestrator_capability() {
639        // Test across multiple topologies
640        let topologies = vec![
641            // Solo
642            r#"
643name: solo
644roles:
645  - name: dev
646    role_type: engineer
647    agent: codex
648    instances: 1
649"#,
650            // Full team
651            r#"
652name: full
653roles:
654  - name: arch
655    role_type: architect
656    agent: claude
657    instances: 1
658  - name: mgr
659    role_type: manager
660    agent: claude
661    instances: 1
662  - name: dev
663    role_type: engineer
664    agent: codex
665    instances: 1
666"#,
667        ];
668
669        for yaml in topologies {
670            let members = members_from_yaml(yaml);
671            let capability_map = resolve_capability_map(&members);
672            assert_eq!(
673                capability_map
674                    .get(&CapabilitySubject::Orchestrator)
675                    .cloned()
676                    .unwrap(),
677                capability_set(&[WorkflowCapability::Orchestrator])
678            );
679        }
680    }
681
682    #[test]
683    fn has_agent_reviewer_returns_true_with_architect() {
684        let members = members_from_yaml(
685            r#"
686name: test
687roles:
688  - name: arch
689    role_type: architect
690    agent: claude
691    instances: 1
692  - name: dev
693    role_type: engineer
694    agent: codex
695    instances: 1
696"#,
697        );
698
699        assert!(has_agent_reviewer(&members));
700    }
701
702    #[test]
703    fn has_agent_reviewer_returns_false_with_only_engineers() {
704        let members = members_from_yaml(
705            r#"
706name: test
707roles:
708  - name: dev
709    role_type: engineer
710    agent: codex
711    instances: 2
712"#,
713        );
714
715        assert!(!has_agent_reviewer(&members));
716    }
717
718    #[test]
719    fn has_agent_reviewer_returns_false_with_only_user_roles() {
720        let member = MemberInstance {
721            name: "human".to_string(),
722            role_name: "human".to_string(),
723            role_type: RoleType::User,
724            agent: None,
725            prompt: None,
726            reports_to: None,
727            use_worktrees: false,
728        };
729        assert!(!has_agent_reviewer(&[member]));
730    }
731
732    #[test]
733    fn capability_map_member_count_matches_non_user_members() {
734        let members = members_from_yaml(
735            r#"
736name: mixed
737roles:
738  - name: human
739    role_type: user
740  - name: arch
741    role_type: architect
742    agent: claude
743    instances: 1
744  - name: dev
745    role_type: engineer
746    agent: codex
747    instances: 2
748"#,
749        );
750
751        let capability_map = resolve_capability_map(&members);
752
753        let member_entries = capability_map
754            .keys()
755            .filter(|k| matches!(k, CapabilitySubject::Member(_)))
756            .count();
757        // 1 architect + 2 engineers = 3 members (user excluded)
758        assert_eq!(member_entries, 3);
759    }
760
761    #[test]
762    fn solo_engineer_gets_all_three_agent_capabilities() {
763        let members = members_from_yaml(
764            r#"
765name: solo
766roles:
767  - name: lone-wolf
768    role_type: engineer
769    agent: codex
770    instances: 1
771"#,
772        );
773
774        let capability_map = resolve_capability_map(&members);
775        let caps = member_capabilities(&capability_map, "lone-wolf");
776
777        assert!(caps.contains(&WorkflowCapability::Planner));
778        assert!(caps.contains(&WorkflowCapability::Dispatcher));
779        assert!(caps.contains(&WorkflowCapability::Executor));
780        assert!(!caps.contains(&WorkflowCapability::Reviewer));
781        assert!(!caps.contains(&WorkflowCapability::Orchestrator));
782        assert!(!caps.contains(&WorkflowCapability::Operator));
783    }
784
785    #[test]
786    fn multi_instance_engineers_all_get_same_capabilities() {
787        let members = members_from_yaml(
788            r#"
789name: team
790roles:
791  - name: arch
792    role_type: architect
793    agent: claude
794    instances: 1
795  - name: eng
796    role_type: engineer
797    agent: codex
798    instances: 4
799"#,
800        );
801
802        let capability_map = resolve_capability_map(&members);
803        let expected = capability_set(&[WorkflowCapability::Executor]);
804
805        // No manager → flat naming: eng-1, eng-2, eng-3, eng-4
806        for i in 1..=4 {
807            let name = format!("eng-{}", i);
808            assert_eq!(member_capabilities(&capability_map, &name), expected);
809        }
810    }
811
812    #[test]
813    fn capability_subject_ordering_is_deterministic() {
814        // CapabilitySubject derives Ord — variant order: Member, Orchestrator, Operator
815        let a = CapabilitySubject::Member("aaa".to_string());
816        let b = CapabilitySubject::Member("zzz".to_string());
817        let orch = CapabilitySubject::Orchestrator;
818        let op = CapabilitySubject::Operator;
819
820        assert!(a < b);
821        assert!(a < orch);
822        assert!(orch < op);
823    }
824}