Skip to main content

batty_cli/team/
hierarchy.rs

1//! Instance naming and manager↔engineer partitioning.
2//!
3//! With `instances: N`, the daemon creates named instances:
4//! - `architect-1` (just 1)
5//! - `manager-1`, `manager-2`, `manager-3`
6//! - Engineers partitioned across compatible managers: `eng-1-1..eng-1-5`
7//!   (under manager-1), etc.
8
9use anyhow::{Result, bail};
10
11use super::config::{RoleType, TeamConfig};
12
13/// A resolved team member instance with its name, role, and hierarchy position.
14#[derive(Debug, Clone)]
15pub struct MemberInstance {
16    /// Unique instance name (e.g., "architect-1", "manager-2", "eng-1-3").
17    pub name: String,
18    /// The role definition name from team.yaml.
19    pub role_name: String,
20    /// The role type.
21    pub role_type: RoleType,
22    /// Agent to use (None for user roles).
23    pub agent: Option<String>,
24    /// Prompt template filename (relative to team_config dir).
25    pub prompt: Option<String>,
26    /// Instance name this member reports to (None for top-level/user roles).
27    pub reports_to: Option<String>,
28    /// Whether this member uses git worktrees.
29    pub use_worktrees: bool,
30}
31
32/// Resolve the team hierarchy into a flat list of member instances.
33///
34/// Engineer instances are multiplicative across compatible managers: each
35/// compatible manager gets `engineer.instances` engineers assigned to it.
36///
37/// Compatibility rule:
38/// - if an engineer role's `talks_to` lists specific manager role names, only
39///   those manager instances receive engineers from that role
40/// - otherwise, the engineer role is assigned across all managers
41pub fn resolve_hierarchy(config: &TeamConfig) -> Result<Vec<MemberInstance>> {
42    let mut members = Vec::new();
43
44    // Collect role defs by type for hierarchy resolution
45    let managers: Vec<_> = config
46        .roles
47        .iter()
48        .filter(|r| r.role_type == RoleType::Manager)
49        .collect();
50    let engineers: Vec<_> = config
51        .roles
52        .iter()
53        .filter(|r| r.role_type == RoleType::Engineer)
54        .collect();
55
56    // Phase 1: Add user roles (no pane, no instances beyond routing)
57    for role in config
58        .roles
59        .iter()
60        .filter(|r| r.role_type == RoleType::User)
61    {
62        members.push(MemberInstance {
63            name: role.name.clone(),
64            role_name: role.name.clone(),
65            role_type: RoleType::User,
66            agent: None,
67            prompt: None,
68            reports_to: None,
69            use_worktrees: false,
70        });
71    }
72
73    // Phase 2: Add architect instances
74    for role in config
75        .roles
76        .iter()
77        .filter(|r| r.role_type == RoleType::Architect)
78    {
79        let resolved_agent = config.resolve_agent(role);
80        for i in 1..=role.instances {
81            let name = if role.instances == 1 {
82                role.name.clone()
83            } else {
84                format!("{}-{i}", role.name)
85            };
86            members.push(MemberInstance {
87                name,
88                role_name: role.name.clone(),
89                role_type: RoleType::Architect,
90                agent: resolved_agent.clone(),
91                prompt: role.prompt.clone(),
92                reports_to: None,
93                use_worktrees: role.use_worktrees,
94            });
95        }
96    }
97
98    // Phase 3: Add manager instances
99    let mut manager_instances = Vec::new();
100    for role in &managers {
101        let resolved_agent = config.resolve_agent(role);
102        for i in 1..=role.instances {
103            let name = if role.instances == 1 {
104                role.name.clone()
105            } else {
106                format!("{}-{i}", role.name)
107            };
108            manager_instances.push((name.clone(), role.name.clone()));
109
110            // Find architect to report to (first architect role, instance 1)
111            let reports_to = config
112                .roles
113                .iter()
114                .find(|r| r.role_type == RoleType::Architect)
115                .map(|a| {
116                    if a.instances == 1 {
117                        a.name.clone()
118                    } else {
119                        format!("{}-1", a.name)
120                    }
121                });
122
123            members.push(MemberInstance {
124                name,
125                role_name: role.name.clone(),
126                role_type: RoleType::Manager,
127                agent: resolved_agent.clone(),
128                prompt: role.prompt.clone(),
129                reports_to,
130                use_worktrees: role.use_worktrees,
131            });
132        }
133    }
134
135    let multiple_engineer_roles = engineers.len() > 1;
136
137    // Phase 4: Add engineer instances, partitioned across compatible managers
138    for role in &engineers {
139        let resolved_agent = config.resolve_agent(role);
140        let compatible_managers: Vec<_> = if manager_instances.is_empty() {
141            Vec::new()
142        } else if role.talks_to.is_empty() {
143            manager_instances.iter().collect()
144        } else {
145            manager_instances
146                .iter()
147                .filter(|(member_name, role_name)| {
148                    role.talks_to
149                        .iter()
150                        .any(|target| target == role_name || target == member_name)
151                })
152                .collect()
153        };
154
155        if compatible_managers.is_empty() {
156            // Engineers without managers report to nobody (flat team)
157            for i in 1..=role.instances {
158                let name = if role.instances == 1 {
159                    role.name.clone()
160                } else {
161                    format!("{}-{i}", role.name)
162                };
163                members.push(MemberInstance {
164                    name,
165                    role_name: role.name.clone(),
166                    role_type: RoleType::Engineer,
167                    agent: resolved_agent.clone(),
168                    prompt: role.prompt.clone(),
169                    reports_to: None,
170                    use_worktrees: role.use_worktrees,
171                });
172            }
173        } else {
174            // Multiplicative: each compatible manager gets `instances` engineers
175            for (mgr_idx, (mgr_name, _mgr_role_name)) in compatible_managers.iter().enumerate() {
176                for eng_idx in 1..=role.instances {
177                    let name = engineer_instance_name(
178                        role.name.as_str(),
179                        multiple_engineer_roles,
180                        mgr_idx + 1,
181                        eng_idx,
182                    );
183                    members.push(MemberInstance {
184                        name,
185                        role_name: role.name.clone(),
186                        role_type: RoleType::Engineer,
187                        agent: resolved_agent.clone(),
188                        prompt: role.prompt.clone(),
189                        reports_to: Some(mgr_name.clone()),
190                        use_worktrees: role.use_worktrees,
191                    });
192                }
193            }
194        }
195    }
196
197    if members
198        .iter()
199        .filter(|m| m.role_type != RoleType::User)
200        .count()
201        == 0
202    {
203        bail!("team has no agent members (only user roles)");
204    }
205
206    Ok(members)
207}
208
209fn engineer_instance_name(
210    role_name: &str,
211    multiple_engineer_roles: bool,
212    manager_index: usize,
213    engineer_index: u32,
214) -> String {
215    if !multiple_engineer_roles && role_name == "engineer" {
216        format!("eng-{manager_index}-{engineer_index}")
217    } else {
218        format!("{role_name}-{manager_index}-{engineer_index}")
219    }
220}
221
222/// Count total panes needed (excludes user roles which have no pane).
223#[allow(dead_code)] // Used by layout/capacity tests and future sizing checks.
224pub fn pane_count(members: &[MemberInstance]) -> usize {
225    members
226        .iter()
227        .filter(|m| m.role_type != RoleType::User)
228        .count()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn make_config(yaml: &str) -> TeamConfig {
236        serde_yaml::from_str(yaml).unwrap()
237    }
238
239    #[test]
240    fn simple_team_3_engineers() {
241        let config = make_config(
242            r#"
243name: test
244roles:
245  - name: architect
246    role_type: architect
247    agent: claude
248    instances: 1
249  - name: manager
250    role_type: manager
251    agent: claude
252    instances: 1
253  - name: engineer
254    role_type: engineer
255    agent: codex
256    instances: 3
257"#,
258        );
259        let members = resolve_hierarchy(&config).unwrap();
260        // 1 architect + 1 manager + 3 engineers = 5
261        assert_eq!(members.len(), 5);
262        assert_eq!(pane_count(&members), 5);
263
264        let engineers: Vec<_> = members
265            .iter()
266            .filter(|m| m.role_type == RoleType::Engineer)
267            .collect();
268        assert_eq!(engineers.len(), 3);
269        assert_eq!(engineers[0].name, "eng-1-1");
270        assert_eq!(engineers[1].name, "eng-1-2");
271        assert_eq!(engineers[2].name, "eng-1-3");
272        // All report to manager
273        assert_eq!(engineers[0].reports_to.as_deref(), Some("manager"));
274    }
275
276    #[test]
277    fn large_team_multiplicative() {
278        let config = make_config(
279            r#"
280name: large
281roles:
282  - name: architect
283    role_type: architect
284    agent: claude
285    instances: 1
286  - name: manager
287    role_type: manager
288    agent: claude
289    instances: 3
290  - name: engineer
291    role_type: engineer
292    agent: codex
293    instances: 5
294"#,
295        );
296        let members = resolve_hierarchy(&config).unwrap();
297        // 1 architect + 3 managers + 15 engineers = 19
298        assert_eq!(members.len(), 19);
299        assert_eq!(pane_count(&members), 19);
300
301        let engineers: Vec<_> = members
302            .iter()
303            .filter(|m| m.role_type == RoleType::Engineer)
304            .collect();
305        assert_eq!(engineers.len(), 15);
306        // First manager's engineers
307        assert_eq!(engineers[0].name, "eng-1-1");
308        assert_eq!(engineers[0].reports_to.as_deref(), Some("manager-1"));
309        assert_eq!(engineers[4].name, "eng-1-5");
310        // Second manager's engineers
311        assert_eq!(engineers[5].name, "eng-2-1");
312        assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
313        // Third manager's engineers
314        assert_eq!(engineers[10].name, "eng-3-1");
315        assert_eq!(engineers[10].reports_to.as_deref(), Some("manager-3"));
316    }
317
318    #[test]
319    fn user_role_excluded_from_pane_count() {
320        let config = make_config(
321            r#"
322name: with-user
323roles:
324  - name: human
325    role_type: user
326    talks_to: [architect]
327  - name: architect
328    role_type: architect
329    agent: claude
330    instances: 1
331"#,
332        );
333        let members = resolve_hierarchy(&config).unwrap();
334        assert_eq!(members.len(), 2);
335        assert_eq!(pane_count(&members), 1);
336    }
337
338    #[test]
339    fn manager_reports_to_architect() {
340        let config = make_config(
341            r#"
342name: test
343roles:
344  - name: arch
345    role_type: architect
346    agent: claude
347    instances: 1
348  - name: mgr
349    role_type: manager
350    agent: claude
351    instances: 2
352"#,
353        );
354        let members = resolve_hierarchy(&config).unwrap();
355        let mgr1 = members.iter().find(|m| m.name == "mgr-1").unwrap();
356        assert_eq!(mgr1.reports_to.as_deref(), Some("arch"));
357    }
358
359    #[test]
360    fn single_instance_no_number_suffix() {
361        let config = make_config(
362            r#"
363name: test
364roles:
365  - name: architect
366    role_type: architect
367    agent: claude
368    instances: 1
369"#,
370        );
371        let members = resolve_hierarchy(&config).unwrap();
372        assert_eq!(members[0].name, "architect");
373    }
374
375    #[test]
376    fn multi_instance_has_number_suffix() {
377        let config = make_config(
378            r#"
379name: test
380roles:
381  - name: manager
382    role_type: manager
383    agent: claude
384    instances: 2
385"#,
386        );
387        let members = resolve_hierarchy(&config).unwrap();
388        assert_eq!(members[0].name, "manager-1");
389        assert_eq!(members[1].name, "manager-2");
390    }
391
392    #[test]
393    fn engineers_without_managers_report_to_nobody() {
394        let config = make_config(
395            r#"
396name: flat
397roles:
398  - name: worker
399    role_type: engineer
400    agent: codex
401    instances: 3
402"#,
403        );
404        let members = resolve_hierarchy(&config).unwrap();
405        assert_eq!(members.len(), 3);
406        for m in &members {
407            assert!(m.reports_to.is_none());
408        }
409        assert_eq!(members[0].name, "worker-1");
410    }
411
412    #[test]
413    fn rejects_user_only_team() {
414        let config = make_config(
415            r#"
416name: empty
417roles:
418  - name: human
419    role_type: user
420"#,
421        );
422        let err = resolve_hierarchy(&config).unwrap_err().to_string();
423        assert!(err.contains("no agent members"));
424    }
425
426    #[test]
427    fn engineer_roles_can_target_specific_manager_roles() {
428        let config = make_config(
429            r#"
430name: split-team
431roles:
432  - name: architect
433    role_type: architect
434    agent: claude
435  - name: black-lead
436    role_type: manager
437    agent: claude
438    talks_to: [architect, black-eng]
439  - name: red-lead
440    role_type: manager
441    agent: claude
442    talks_to: [architect, red-eng]
443  - name: black-eng
444    role_type: engineer
445    agent: codex
446    instances: 3
447    talks_to: [black-lead]
448  - name: red-eng
449    role_type: engineer
450    agent: codex
451    instances: 3
452    talks_to: [red-lead]
453"#,
454        );
455
456        let members = resolve_hierarchy(&config).unwrap();
457        let engineers: Vec<_> = members
458            .iter()
459            .filter(|m| m.role_type == RoleType::Engineer)
460            .collect();
461
462        assert_eq!(engineers.len(), 6);
463        assert_eq!(
464            engineers
465                .iter()
466                .filter(|m| m.role_name == "black-eng")
467                .count(),
468            3
469        );
470        assert_eq!(
471            engineers
472                .iter()
473                .filter(|m| m.role_name == "red-eng")
474                .count(),
475            3
476        );
477        assert!(engineers.iter().all(|m| {
478            if m.role_name == "black-eng" {
479                m.reports_to.as_deref() == Some("black-lead")
480            } else {
481                m.reports_to.as_deref() == Some("red-lead")
482            }
483        }));
484
485        let unique_names: std::collections::HashSet<_> =
486            engineers.iter().map(|m| m.name.as_str()).collect();
487        assert_eq!(unique_names.len(), engineers.len());
488        assert!(unique_names.contains("black-eng-1-1"));
489        assert!(unique_names.contains("red-eng-1-1"));
490    }
491
492    #[test]
493    fn engineer_role_without_matching_manager_talks_to_stays_flat() {
494        let config = make_config(
495            r#"
496name: unmatched
497roles:
498  - name: architect
499    role_type: architect
500    agent: claude
501  - name: manager
502    role_type: manager
503    agent: claude
504  - name: specialist
505    role_type: engineer
506    agent: codex
507    instances: 2
508    talks_to: [architect]
509"#,
510        );
511
512        let members = resolve_hierarchy(&config).unwrap();
513        let engineers: Vec<_> = members
514            .iter()
515            .filter(|m| m.role_type == RoleType::Engineer)
516            .collect();
517
518        assert_eq!(engineers.len(), 2);
519        assert!(engineers.iter().all(|m| m.reports_to.is_none()));
520        assert_eq!(engineers[0].name, "specialist-1");
521        assert_eq!(engineers[1].name, "specialist-2");
522    }
523
524    #[test]
525    fn team_level_agent_propagates_to_members() {
526        let config = make_config(
527            r#"
528name: team-default
529agent: codex
530roles:
531  - name: architect
532    role_type: architect
533  - name: manager
534    role_type: manager
535  - name: engineer
536    role_type: engineer
537    instances: 2
538"#,
539        );
540        let members = resolve_hierarchy(&config).unwrap();
541        // All non-user members should have the team default agent
542        for m in &members {
543            assert_eq!(
544                m.agent.as_deref(),
545                Some("codex"),
546                "member {} should have team default agent 'codex'",
547                m.name
548            );
549        }
550    }
551
552    #[test]
553    fn role_agent_overrides_team_default() {
554        let config = make_config(
555            r#"
556name: mixed
557agent: codex
558roles:
559  - name: architect
560    role_type: architect
561    agent: claude
562  - name: manager
563    role_type: manager
564  - name: engineer
565    role_type: engineer
566    instances: 2
567"#,
568        );
569        let members = resolve_hierarchy(&config).unwrap();
570        let architect = members.iter().find(|m| m.name == "architect").unwrap();
571        assert_eq!(
572            architect.agent.as_deref(),
573            Some("claude"),
574            "architect should use role-level override"
575        );
576        let manager = members.iter().find(|m| m.name == "manager").unwrap();
577        assert_eq!(
578            manager.agent.as_deref(),
579            Some("codex"),
580            "manager should use team default"
581        );
582    }
583
584    #[test]
585    fn mixed_backend_engineers_under_same_manager() {
586        let config = make_config(
587            r#"
588name: mixed-eng
589agent: codex
590roles:
591  - name: architect
592    role_type: architect
593    agent: claude
594  - name: manager
595    role_type: manager
596    agent: claude
597  - name: claude-eng
598    role_type: engineer
599    agent: claude
600    instances: 2
601    talks_to: [manager]
602  - name: codex-eng
603    role_type: engineer
604    instances: 2
605    talks_to: [manager]
606"#,
607        );
608        let members = resolve_hierarchy(&config).unwrap();
609        let claude_engs: Vec<_> = members
610            .iter()
611            .filter(|m| m.role_name == "claude-eng")
612            .collect();
613        let codex_engs: Vec<_> = members
614            .iter()
615            .filter(|m| m.role_name == "codex-eng")
616            .collect();
617
618        assert_eq!(claude_engs.len(), 2);
619        assert_eq!(codex_engs.len(), 2);
620
621        for m in &claude_engs {
622            assert_eq!(m.agent.as_deref(), Some("claude"));
623            assert_eq!(m.reports_to.as_deref(), Some("manager"));
624        }
625        for m in &codex_engs {
626            assert_eq!(m.agent.as_deref(), Some("codex"));
627            assert_eq!(m.reports_to.as_deref(), Some("manager"));
628        }
629    }
630
631    #[test]
632    fn no_team_agent_defaults_to_claude() {
633        let config = make_config(
634            r#"
635name: default-fallback
636roles:
637  - name: worker
638    role_type: engineer
639    agent: claude
640    instances: 1
641"#,
642        );
643        let members = resolve_hierarchy(&config).unwrap();
644        assert_eq!(members[0].agent.as_deref(), Some("claude"));
645    }
646}