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).
223pub fn pane_count(members: &[MemberInstance]) -> usize {
224    members
225        .iter()
226        .filter(|m| m.role_type != RoleType::User)
227        .count()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn make_config(yaml: &str) -> TeamConfig {
235        serde_yaml::from_str(yaml).unwrap()
236    }
237
238    #[test]
239    fn simple_team_3_engineers() {
240        let config = make_config(
241            r#"
242name: test
243roles:
244  - name: architect
245    role_type: architect
246    agent: claude
247    instances: 1
248  - name: manager
249    role_type: manager
250    agent: claude
251    instances: 1
252  - name: engineer
253    role_type: engineer
254    agent: codex
255    instances: 3
256"#,
257        );
258        let members = resolve_hierarchy(&config).unwrap();
259        // 1 architect + 1 manager + 3 engineers = 5
260        assert_eq!(members.len(), 5);
261        assert_eq!(pane_count(&members), 5);
262
263        let engineers: Vec<_> = members
264            .iter()
265            .filter(|m| m.role_type == RoleType::Engineer)
266            .collect();
267        assert_eq!(engineers.len(), 3);
268        assert_eq!(engineers[0].name, "eng-1-1");
269        assert_eq!(engineers[1].name, "eng-1-2");
270        assert_eq!(engineers[2].name, "eng-1-3");
271        // All report to manager
272        assert_eq!(engineers[0].reports_to.as_deref(), Some("manager"));
273    }
274
275    #[test]
276    fn large_team_multiplicative() {
277        let config = make_config(
278            r#"
279name: large
280roles:
281  - name: architect
282    role_type: architect
283    agent: claude
284    instances: 1
285  - name: manager
286    role_type: manager
287    agent: claude
288    instances: 3
289  - name: engineer
290    role_type: engineer
291    agent: codex
292    instances: 5
293"#,
294        );
295        let members = resolve_hierarchy(&config).unwrap();
296        // 1 architect + 3 managers + 15 engineers = 19
297        assert_eq!(members.len(), 19);
298        assert_eq!(pane_count(&members), 19);
299
300        let engineers: Vec<_> = members
301            .iter()
302            .filter(|m| m.role_type == RoleType::Engineer)
303            .collect();
304        assert_eq!(engineers.len(), 15);
305        // First manager's engineers
306        assert_eq!(engineers[0].name, "eng-1-1");
307        assert_eq!(engineers[0].reports_to.as_deref(), Some("manager-1"));
308        assert_eq!(engineers[4].name, "eng-1-5");
309        // Second manager's engineers
310        assert_eq!(engineers[5].name, "eng-2-1");
311        assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
312        // Third manager's engineers
313        assert_eq!(engineers[10].name, "eng-3-1");
314        assert_eq!(engineers[10].reports_to.as_deref(), Some("manager-3"));
315    }
316
317    #[test]
318    fn user_role_excluded_from_pane_count() {
319        let config = make_config(
320            r#"
321name: with-user
322roles:
323  - name: human
324    role_type: user
325    talks_to: [architect]
326  - name: architect
327    role_type: architect
328    agent: claude
329    instances: 1
330"#,
331        );
332        let members = resolve_hierarchy(&config).unwrap();
333        assert_eq!(members.len(), 2);
334        assert_eq!(pane_count(&members), 1);
335    }
336
337    #[test]
338    fn manager_reports_to_architect() {
339        let config = make_config(
340            r#"
341name: test
342roles:
343  - name: arch
344    role_type: architect
345    agent: claude
346    instances: 1
347  - name: mgr
348    role_type: manager
349    agent: claude
350    instances: 2
351"#,
352        );
353        let members = resolve_hierarchy(&config).unwrap();
354        let mgr1 = members.iter().find(|m| m.name == "mgr-1").unwrap();
355        assert_eq!(mgr1.reports_to.as_deref(), Some("arch"));
356    }
357
358    #[test]
359    fn single_instance_no_number_suffix() {
360        let config = make_config(
361            r#"
362name: test
363roles:
364  - name: architect
365    role_type: architect
366    agent: claude
367    instances: 1
368"#,
369        );
370        let members = resolve_hierarchy(&config).unwrap();
371        assert_eq!(members[0].name, "architect");
372    }
373
374    #[test]
375    fn multi_instance_has_number_suffix() {
376        let config = make_config(
377            r#"
378name: test
379roles:
380  - name: manager
381    role_type: manager
382    agent: claude
383    instances: 2
384"#,
385        );
386        let members = resolve_hierarchy(&config).unwrap();
387        assert_eq!(members[0].name, "manager-1");
388        assert_eq!(members[1].name, "manager-2");
389    }
390
391    #[test]
392    fn engineers_without_managers_report_to_nobody() {
393        let config = make_config(
394            r#"
395name: flat
396roles:
397  - name: worker
398    role_type: engineer
399    agent: codex
400    instances: 3
401"#,
402        );
403        let members = resolve_hierarchy(&config).unwrap();
404        assert_eq!(members.len(), 3);
405        for m in &members {
406            assert!(m.reports_to.is_none());
407        }
408        assert_eq!(members[0].name, "worker-1");
409    }
410
411    #[test]
412    fn rejects_user_only_team() {
413        let config = make_config(
414            r#"
415name: empty
416roles:
417  - name: human
418    role_type: user
419"#,
420        );
421        let err = resolve_hierarchy(&config).unwrap_err().to_string();
422        assert!(err.contains("no agent members"));
423    }
424
425    #[test]
426    fn engineer_roles_can_target_specific_manager_roles() {
427        let config = make_config(
428            r#"
429name: split-team
430roles:
431  - name: architect
432    role_type: architect
433    agent: claude
434  - name: black-lead
435    role_type: manager
436    agent: claude
437    talks_to: [architect, black-eng]
438  - name: red-lead
439    role_type: manager
440    agent: claude
441    talks_to: [architect, red-eng]
442  - name: black-eng
443    role_type: engineer
444    agent: codex
445    instances: 3
446    talks_to: [black-lead]
447  - name: red-eng
448    role_type: engineer
449    agent: codex
450    instances: 3
451    talks_to: [red-lead]
452"#,
453        );
454
455        let members = resolve_hierarchy(&config).unwrap();
456        let engineers: Vec<_> = members
457            .iter()
458            .filter(|m| m.role_type == RoleType::Engineer)
459            .collect();
460
461        assert_eq!(engineers.len(), 6);
462        assert_eq!(
463            engineers
464                .iter()
465                .filter(|m| m.role_name == "black-eng")
466                .count(),
467            3
468        );
469        assert_eq!(
470            engineers
471                .iter()
472                .filter(|m| m.role_name == "red-eng")
473                .count(),
474            3
475        );
476        assert!(engineers.iter().all(|m| {
477            if m.role_name == "black-eng" {
478                m.reports_to.as_deref() == Some("black-lead")
479            } else {
480                m.reports_to.as_deref() == Some("red-lead")
481            }
482        }));
483
484        let unique_names: std::collections::HashSet<_> =
485            engineers.iter().map(|m| m.name.as_str()).collect();
486        assert_eq!(unique_names.len(), engineers.len());
487        assert!(unique_names.contains("black-eng-1-1"));
488        assert!(unique_names.contains("red-eng-1-1"));
489    }
490
491    #[test]
492    fn engineer_role_without_matching_manager_talks_to_stays_flat() {
493        let config = make_config(
494            r#"
495name: unmatched
496roles:
497  - name: architect
498    role_type: architect
499    agent: claude
500  - name: manager
501    role_type: manager
502    agent: claude
503  - name: specialist
504    role_type: engineer
505    agent: codex
506    instances: 2
507    talks_to: [architect]
508"#,
509        );
510
511        let members = resolve_hierarchy(&config).unwrap();
512        let engineers: Vec<_> = members
513            .iter()
514            .filter(|m| m.role_type == RoleType::Engineer)
515            .collect();
516
517        assert_eq!(engineers.len(), 2);
518        assert!(engineers.iter().all(|m| m.reports_to.is_none()));
519        assert_eq!(engineers[0].name, "specialist-1");
520        assert_eq!(engineers[1].name, "specialist-2");
521    }
522
523    #[test]
524    fn team_level_agent_propagates_to_members() {
525        let config = make_config(
526            r#"
527name: team-default
528agent: codex
529roles:
530  - name: architect
531    role_type: architect
532  - name: manager
533    role_type: manager
534  - name: engineer
535    role_type: engineer
536    instances: 2
537"#,
538        );
539        let members = resolve_hierarchy(&config).unwrap();
540        // All non-user members should have the team default agent
541        for m in &members {
542            assert_eq!(
543                m.agent.as_deref(),
544                Some("codex"),
545                "member {} should have team default agent 'codex'",
546                m.name
547            );
548        }
549    }
550
551    #[test]
552    fn role_agent_overrides_team_default() {
553        let config = make_config(
554            r#"
555name: mixed
556agent: codex
557roles:
558  - name: architect
559    role_type: architect
560    agent: claude
561  - name: manager
562    role_type: manager
563  - name: engineer
564    role_type: engineer
565    instances: 2
566"#,
567        );
568        let members = resolve_hierarchy(&config).unwrap();
569        let architect = members.iter().find(|m| m.name == "architect").unwrap();
570        assert_eq!(
571            architect.agent.as_deref(),
572            Some("claude"),
573            "architect should use role-level override"
574        );
575        let manager = members.iter().find(|m| m.name == "manager").unwrap();
576        assert_eq!(
577            manager.agent.as_deref(),
578            Some("codex"),
579            "manager should use team default"
580        );
581    }
582
583    #[test]
584    fn mixed_backend_engineers_under_same_manager() {
585        let config = make_config(
586            r#"
587name: mixed-eng
588agent: codex
589roles:
590  - name: architect
591    role_type: architect
592    agent: claude
593  - name: manager
594    role_type: manager
595    agent: claude
596  - name: claude-eng
597    role_type: engineer
598    agent: claude
599    instances: 2
600    talks_to: [manager]
601  - name: codex-eng
602    role_type: engineer
603    instances: 2
604    talks_to: [manager]
605"#,
606        );
607        let members = resolve_hierarchy(&config).unwrap();
608        let claude_engs: Vec<_> = members
609            .iter()
610            .filter(|m| m.role_name == "claude-eng")
611            .collect();
612        let codex_engs: Vec<_> = members
613            .iter()
614            .filter(|m| m.role_name == "codex-eng")
615            .collect();
616
617        assert_eq!(claude_engs.len(), 2);
618        assert_eq!(codex_engs.len(), 2);
619
620        for m in &claude_engs {
621            assert_eq!(m.agent.as_deref(), Some("claude"));
622            assert_eq!(m.reports_to.as_deref(), Some("manager"));
623        }
624        for m in &codex_engs {
625            assert_eq!(m.agent.as_deref(), Some("codex"));
626            assert_eq!(m.reports_to.as_deref(), Some("manager"));
627        }
628    }
629
630    #[test]
631    fn no_team_agent_defaults_to_claude() {
632        let config = make_config(
633            r#"
634name: default-fallback
635roles:
636  - name: worker
637    role_type: engineer
638    agent: claude
639    instances: 1
640"#,
641        );
642        let members = resolve_hierarchy(&config).unwrap();
643        assert_eq!(members[0].agent.as_deref(), Some("claude"));
644    }
645}