1use anyhow::{Result, bail};
10
11use super::config::{RoleDef, RoleType, TeamConfig};
12
13#[derive(Debug, Clone)]
15pub struct MemberInstance {
16 pub name: String,
18 pub role_name: String,
20 pub role_type: RoleType,
22 pub agent: Option<String>,
24 pub model: Option<String>,
26 pub prompt: Option<String>,
28 pub posture: Option<String>,
30 pub model_class: Option<String>,
32 pub provider_overlay: Option<String>,
34 pub reports_to: Option<String>,
36 pub use_worktrees: bool,
38}
39
40impl Default for MemberInstance {
41 fn default() -> Self {
42 Self {
43 name: String::new(),
44 role_name: String::new(),
45 role_type: RoleType::Engineer,
46 agent: None,
47 model: None,
48 prompt: None,
49 posture: None,
50 model_class: None,
51 provider_overlay: None,
52 reports_to: None,
53 use_worktrees: false,
54 }
55 }
56}
57
58pub fn resolve_hierarchy(config: &TeamConfig) -> Result<Vec<MemberInstance>> {
68 let mut members = Vec::new();
69
70 let managers: Vec<_> = config
72 .roles
73 .iter()
74 .filter(|r| r.role_type == RoleType::Manager)
75 .collect();
76 let engineers: Vec<_> = config
77 .roles
78 .iter()
79 .filter(|r| r.role_type == RoleType::Engineer)
80 .collect();
81
82 for role in config
84 .roles
85 .iter()
86 .filter(|r| r.role_type == RoleType::User)
87 {
88 members.push(MemberInstance {
89 name: role.name.clone(),
90 role_name: role.name.clone(),
91 role_type: RoleType::User,
92 agent: None,
93 model: None,
94 prompt: None,
95 posture: None,
96 model_class: None,
97 provider_overlay: None,
98 reports_to: None,
99 use_worktrees: false,
100 });
101 }
102
103 for role in config
105 .roles
106 .iter()
107 .filter(|r| r.role_type == RoleType::Architect)
108 {
109 let resolved_agent = config.resolve_agent(role);
110 for i in 1..=role.instances {
111 let name = if role.instances == 1 {
112 role.name.clone()
113 } else {
114 format!("{}-{i}", role.name)
115 };
116 members.push(MemberInstance {
117 name: name.clone(),
118 role_name: role.name.clone(),
119 role_type: RoleType::Architect,
120 agent: resolved_member_agent(role, &name, resolved_agent.clone()),
121 model: resolved_member_model(role, &name),
122 prompt: resolved_member_prompt(role, &name),
123 posture: resolved_member_posture(role, &name),
124 model_class: resolved_member_model_class(role, &name),
125 provider_overlay: resolved_member_provider_overlay(role, &name),
126 reports_to: None,
127 use_worktrees: role.use_worktrees,
128 });
129 }
130 }
131
132 let mut manager_instances = Vec::new();
134 for role in &managers {
135 let resolved_agent = config.resolve_agent(role);
136 for i in 1..=role.instances {
137 let name = if role.instances == 1 {
138 role.name.clone()
139 } else {
140 format!("{}-{i}", role.name)
141 };
142 manager_instances.push((name.clone(), role.name.clone()));
143
144 let reports_to = config
146 .roles
147 .iter()
148 .find(|r| r.role_type == RoleType::Architect)
149 .map(|a| {
150 if a.instances == 1 {
151 a.name.clone()
152 } else {
153 format!("{}-1", a.name)
154 }
155 });
156
157 members.push(MemberInstance {
158 name: name.clone(),
159 role_name: role.name.clone(),
160 role_type: RoleType::Manager,
161 agent: resolved_member_agent(role, &name, resolved_agent.clone()),
162 model: resolved_member_model(role, &name),
163 prompt: resolved_member_prompt(role, &name),
164 posture: resolved_member_posture(role, &name),
165 model_class: resolved_member_model_class(role, &name),
166 provider_overlay: resolved_member_provider_overlay(role, &name),
167 reports_to,
168 use_worktrees: role.use_worktrees,
169 });
170 }
171 }
172
173 let multiple_engineer_roles = engineers.len() > 1;
174
175 for role in &engineers {
177 let resolved_agent = config.resolve_agent(role);
178 let compatible_managers: Vec<_> = if manager_instances.is_empty() {
179 Vec::new()
180 } else if role.talks_to.is_empty() {
181 manager_instances.iter().collect()
182 } else {
183 manager_instances
184 .iter()
185 .filter(|(member_name, role_name)| {
186 role.talks_to
187 .iter()
188 .any(|target| target == role_name || target == member_name)
189 })
190 .collect()
191 };
192
193 if compatible_managers.is_empty() {
194 for i in 1..=role.instances {
196 let name = if role.instances == 1 {
197 role.name.clone()
198 } else {
199 format!("{}-{i}", role.name)
200 };
201 members.push(MemberInstance {
202 name: name.clone(),
203 role_name: role.name.clone(),
204 role_type: RoleType::Engineer,
205 agent: resolved_member_agent(role, &name, resolved_agent.clone()),
206 model: resolved_member_model(role, &name),
207 prompt: resolved_member_prompt(role, &name),
208 posture: resolved_member_posture(role, &name),
209 model_class: resolved_member_model_class(role, &name),
210 provider_overlay: resolved_member_provider_overlay(role, &name),
211 reports_to: None,
212 use_worktrees: role.use_worktrees,
213 });
214 }
215 } else {
216 for (mgr_idx, (mgr_name, _mgr_role_name)) in compatible_managers.iter().enumerate() {
218 for eng_idx in 1..=role.instances {
219 let name = engineer_instance_name(
220 role.name.as_str(),
221 multiple_engineer_roles,
222 mgr_idx + 1,
223 eng_idx,
224 );
225 members.push(MemberInstance {
226 name: name.clone(),
227 role_name: role.name.clone(),
228 role_type: RoleType::Engineer,
229 agent: resolved_member_agent(role, &name, resolved_agent.clone()),
230 model: resolved_member_model(role, &name),
231 prompt: resolved_member_prompt(role, &name),
232 posture: resolved_member_posture(role, &name),
233 model_class: resolved_member_model_class(role, &name),
234 provider_overlay: resolved_member_provider_overlay(role, &name),
235 reports_to: Some(mgr_name.clone()),
236 use_worktrees: role.use_worktrees,
237 });
238 }
239 }
240 }
241 }
242
243 if members
244 .iter()
245 .filter(|m| m.role_type != RoleType::User)
246 .count()
247 == 0
248 {
249 bail!("team has no agent members (only user roles)");
250 }
251
252 Ok(members)
253}
254
255fn engineer_instance_name(
256 role_name: &str,
257 multiple_engineer_roles: bool,
258 manager_index: usize,
259 engineer_index: u32,
260) -> String {
261 if !multiple_engineer_roles && role_name == "engineer" {
262 format!("eng-{manager_index}-{engineer_index}")
263 } else {
264 format!("{role_name}-{manager_index}-{engineer_index}")
265 }
266}
267
268fn resolved_member_agent(
269 role: &RoleDef,
270 member_name: &str,
271 resolved_agent: Option<String>,
272) -> Option<String> {
273 role.instance_overrides
274 .get(member_name)
275 .and_then(|override_cfg| override_cfg.agent.clone())
276 .or(resolved_agent)
277}
278
279fn resolved_member_model(role: &RoleDef, member_name: &str) -> Option<String> {
280 role.instance_overrides
281 .get(member_name)
282 .and_then(|override_cfg| override_cfg.model.clone())
283 .or_else(|| role.model.clone())
284}
285
286fn resolved_member_prompt(role: &RoleDef, member_name: &str) -> Option<String> {
287 role.instance_overrides
288 .get(member_name)
289 .and_then(|override_cfg| override_cfg.prompt.clone())
290 .or_else(|| role.prompt.clone())
291}
292
293fn resolved_member_posture(role: &RoleDef, member_name: &str) -> Option<String> {
294 role.instance_overrides
295 .get(member_name)
296 .and_then(|override_cfg| override_cfg.posture.clone())
297 .or_else(|| role.posture.clone())
298}
299
300fn resolved_member_model_class(role: &RoleDef, member_name: &str) -> Option<String> {
301 role.instance_overrides
302 .get(member_name)
303 .and_then(|override_cfg| override_cfg.model_class.clone())
304 .or_else(|| role.model_class.clone())
305}
306
307fn resolved_member_provider_overlay(role: &RoleDef, member_name: &str) -> Option<String> {
308 role.instance_overrides
309 .get(member_name)
310 .and_then(|override_cfg| override_cfg.provider_overlay.clone())
311 .or_else(|| role.provider_overlay.clone())
312}
313
314pub fn pane_count(members: &[MemberInstance]) -> usize {
316 members
317 .iter()
318 .filter(|m| m.role_type != RoleType::User)
319 .count()
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn make_config(yaml: &str) -> TeamConfig {
327 serde_yaml::from_str(yaml).unwrap()
328 }
329
330 #[test]
331 fn simple_team_3_engineers() {
332 let config = make_config(
333 r#"
334name: test
335roles:
336 - name: architect
337 role_type: architect
338 agent: claude
339 instances: 1
340 - name: manager
341 role_type: manager
342 agent: claude
343 instances: 1
344 - name: engineer
345 role_type: engineer
346 agent: codex
347 instances: 3
348"#,
349 );
350 let members = resolve_hierarchy(&config).unwrap();
351 assert_eq!(members.len(), 5);
353 assert_eq!(pane_count(&members), 5);
354
355 let engineers: Vec<_> = members
356 .iter()
357 .filter(|m| m.role_type == RoleType::Engineer)
358 .collect();
359 assert_eq!(engineers.len(), 3);
360 assert_eq!(engineers[0].name, "eng-1-1");
361 assert_eq!(engineers[1].name, "eng-1-2");
362 assert_eq!(engineers[2].name, "eng-1-3");
363 assert_eq!(engineers[0].reports_to.as_deref(), Some("manager"));
365 }
366
367 #[test]
368 fn large_team_multiplicative() {
369 let config = make_config(
370 r#"
371name: large
372roles:
373 - name: architect
374 role_type: architect
375 agent: claude
376 instances: 1
377 - name: manager
378 role_type: manager
379 agent: claude
380 instances: 3
381 - name: engineer
382 role_type: engineer
383 agent: codex
384 instances: 5
385"#,
386 );
387 let members = resolve_hierarchy(&config).unwrap();
388 assert_eq!(members.len(), 19);
390 assert_eq!(pane_count(&members), 19);
391
392 let engineers: Vec<_> = members
393 .iter()
394 .filter(|m| m.role_type == RoleType::Engineer)
395 .collect();
396 assert_eq!(engineers.len(), 15);
397 assert_eq!(engineers[0].name, "eng-1-1");
399 assert_eq!(engineers[0].reports_to.as_deref(), Some("manager-1"));
400 assert_eq!(engineers[4].name, "eng-1-5");
401 assert_eq!(engineers[5].name, "eng-2-1");
403 assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
404 assert_eq!(engineers[10].name, "eng-3-1");
406 assert_eq!(engineers[10].reports_to.as_deref(), Some("manager-3"));
407 }
408
409 #[test]
410 fn user_role_excluded_from_pane_count() {
411 let config = make_config(
412 r#"
413name: with-user
414roles:
415 - name: human
416 role_type: user
417 talks_to: [architect]
418 - name: architect
419 role_type: architect
420 agent: claude
421 instances: 1
422"#,
423 );
424 let members = resolve_hierarchy(&config).unwrap();
425 assert_eq!(members.len(), 2);
426 assert_eq!(pane_count(&members), 1);
427 }
428
429 #[test]
430 fn manager_reports_to_architect() {
431 let config = make_config(
432 r#"
433name: test
434roles:
435 - name: arch
436 role_type: architect
437 agent: claude
438 instances: 1
439 - name: mgr
440 role_type: manager
441 agent: claude
442 instances: 2
443"#,
444 );
445 let members = resolve_hierarchy(&config).unwrap();
446 let mgr1 = members.iter().find(|m| m.name == "mgr-1").unwrap();
447 assert_eq!(mgr1.reports_to.as_deref(), Some("arch"));
448 }
449
450 #[test]
451 fn single_instance_no_number_suffix() {
452 let config = make_config(
453 r#"
454name: test
455roles:
456 - name: architect
457 role_type: architect
458 agent: claude
459 instances: 1
460"#,
461 );
462 let members = resolve_hierarchy(&config).unwrap();
463 assert_eq!(members[0].name, "architect");
464 }
465
466 #[test]
467 fn multi_instance_has_number_suffix() {
468 let config = make_config(
469 r#"
470name: test
471roles:
472 - name: manager
473 role_type: manager
474 agent: claude
475 instances: 2
476"#,
477 );
478 let members = resolve_hierarchy(&config).unwrap();
479 assert_eq!(members[0].name, "manager-1");
480 assert_eq!(members[1].name, "manager-2");
481 }
482
483 #[test]
484 fn engineers_without_managers_report_to_nobody() {
485 let config = make_config(
486 r#"
487name: flat
488roles:
489 - name: worker
490 role_type: engineer
491 agent: codex
492 instances: 3
493"#,
494 );
495 let members = resolve_hierarchy(&config).unwrap();
496 assert_eq!(members.len(), 3);
497 for m in &members {
498 assert!(m.reports_to.is_none());
499 }
500 assert_eq!(members[0].name, "worker-1");
501 }
502
503 #[test]
504 fn rejects_user_only_team() {
505 let config = make_config(
506 r#"
507name: empty
508roles:
509 - name: human
510 role_type: user
511"#,
512 );
513 let err = resolve_hierarchy(&config).unwrap_err().to_string();
514 assert!(err.contains("no agent members"));
515 }
516
517 #[test]
518 fn engineer_roles_can_target_specific_manager_roles() {
519 let config = make_config(
520 r#"
521name: split-team
522roles:
523 - name: architect
524 role_type: architect
525 agent: claude
526 - name: black-lead
527 role_type: manager
528 agent: claude
529 talks_to: [architect, black-eng]
530 - name: red-lead
531 role_type: manager
532 agent: claude
533 talks_to: [architect, red-eng]
534 - name: black-eng
535 role_type: engineer
536 agent: codex
537 instances: 3
538 talks_to: [black-lead]
539 - name: red-eng
540 role_type: engineer
541 agent: codex
542 instances: 3
543 talks_to: [red-lead]
544"#,
545 );
546
547 let members = resolve_hierarchy(&config).unwrap();
548 let engineers: Vec<_> = members
549 .iter()
550 .filter(|m| m.role_type == RoleType::Engineer)
551 .collect();
552
553 assert_eq!(engineers.len(), 6);
554 assert_eq!(
555 engineers
556 .iter()
557 .filter(|m| m.role_name == "black-eng")
558 .count(),
559 3
560 );
561 assert_eq!(
562 engineers
563 .iter()
564 .filter(|m| m.role_name == "red-eng")
565 .count(),
566 3
567 );
568 assert!(engineers.iter().all(|m| {
569 if m.role_name == "black-eng" {
570 m.reports_to.as_deref() == Some("black-lead")
571 } else {
572 m.reports_to.as_deref() == Some("red-lead")
573 }
574 }));
575
576 let unique_names: std::collections::HashSet<_> =
577 engineers.iter().map(|m| m.name.as_str()).collect();
578 assert_eq!(unique_names.len(), engineers.len());
579 assert!(unique_names.contains("black-eng-1-1"));
580 assert!(unique_names.contains("red-eng-1-1"));
581 }
582
583 #[test]
584 fn engineer_role_without_matching_manager_talks_to_stays_flat() {
585 let config = make_config(
586 r#"
587name: unmatched
588roles:
589 - name: architect
590 role_type: architect
591 agent: claude
592 - name: manager
593 role_type: manager
594 agent: claude
595 - name: specialist
596 role_type: engineer
597 agent: codex
598 instances: 2
599 talks_to: [architect]
600"#,
601 );
602
603 let members = resolve_hierarchy(&config).unwrap();
604 let engineers: Vec<_> = members
605 .iter()
606 .filter(|m| m.role_type == RoleType::Engineer)
607 .collect();
608
609 assert_eq!(engineers.len(), 2);
610 assert!(engineers.iter().all(|m| m.reports_to.is_none()));
611 assert_eq!(engineers[0].name, "specialist-1");
612 assert_eq!(engineers[1].name, "specialist-2");
613 }
614
615 #[test]
616 fn team_level_agent_propagates_to_members() {
617 let config = make_config(
618 r#"
619name: team-default
620agent: codex
621roles:
622 - name: architect
623 role_type: architect
624 - name: manager
625 role_type: manager
626 - name: engineer
627 role_type: engineer
628 instances: 2
629"#,
630 );
631 let members = resolve_hierarchy(&config).unwrap();
632 for m in &members {
634 assert_eq!(
635 m.agent.as_deref(),
636 Some("codex"),
637 "member {} should have team default agent 'codex'",
638 m.name
639 );
640 }
641 }
642
643 #[test]
644 fn role_agent_overrides_team_default() {
645 let config = make_config(
646 r#"
647name: mixed
648agent: codex
649roles:
650 - name: architect
651 role_type: architect
652 agent: claude
653 - name: manager
654 role_type: manager
655 - name: engineer
656 role_type: engineer
657 instances: 2
658"#,
659 );
660 let members = resolve_hierarchy(&config).unwrap();
661 let architect = members.iter().find(|m| m.name == "architect").unwrap();
662 assert_eq!(
663 architect.agent.as_deref(),
664 Some("claude"),
665 "architect should use role-level override"
666 );
667 let manager = members.iter().find(|m| m.name == "manager").unwrap();
668 assert_eq!(
669 manager.agent.as_deref(),
670 Some("codex"),
671 "manager should use team default"
672 );
673 }
674
675 #[test]
676 fn mixed_backend_engineers_under_same_manager() {
677 let config = make_config(
678 r#"
679name: mixed-eng
680agent: codex
681roles:
682 - name: architect
683 role_type: architect
684 agent: claude
685 - name: manager
686 role_type: manager
687 agent: claude
688 - name: claude-eng
689 role_type: engineer
690 agent: claude
691 instances: 2
692 talks_to: [manager]
693 - name: codex-eng
694 role_type: engineer
695 instances: 2
696 talks_to: [manager]
697"#,
698 );
699 let members = resolve_hierarchy(&config).unwrap();
700 let claude_engs: Vec<_> = members
701 .iter()
702 .filter(|m| m.role_name == "claude-eng")
703 .collect();
704 let codex_engs: Vec<_> = members
705 .iter()
706 .filter(|m| m.role_name == "codex-eng")
707 .collect();
708
709 assert_eq!(claude_engs.len(), 2);
710 assert_eq!(codex_engs.len(), 2);
711
712 for m in &claude_engs {
713 assert_eq!(m.agent.as_deref(), Some("claude"));
714 assert_eq!(m.reports_to.as_deref(), Some("manager"));
715 }
716 for m in &codex_engs {
717 assert_eq!(m.agent.as_deref(), Some("codex"));
718 assert_eq!(m.reports_to.as_deref(), Some("manager"));
719 }
720 }
721
722 #[test]
723 fn no_team_agent_defaults_to_claude() {
724 let config = make_config(
725 r#"
726name: default-fallback
727roles:
728 - name: worker
729 role_type: engineer
730 agent: claude
731 instances: 1
732"#,
733 );
734 let members = resolve_hierarchy(&config).unwrap();
735 assert_eq!(members[0].agent.as_deref(), Some("claude"));
736 }
737
738 #[test]
739 fn instance_overrides_apply_to_named_member() {
740 let config = make_config(
741 r#"
742name: overrides
743roles:
744 - name: manager
745 role_type: manager
746 agent: claude
747 - name: engineer
748 role_type: engineer
749 agent: codex
750 posture: deep_worker
751 model_class: standard
752 instances: 2
753 instance_overrides:
754 eng-1-1:
755 agent: claude
756 model: claude-opus-4-1
757 model_class: frontier
758 posture: fast_lane
759"#,
760 );
761
762 let members = resolve_hierarchy(&config).unwrap();
763 let overridden = members.iter().find(|m| m.name == "eng-1-1").unwrap();
764 let inherited = members.iter().find(|m| m.name == "eng-1-2").unwrap();
765
766 assert_eq!(overridden.agent.as_deref(), Some("claude"));
767 assert_eq!(overridden.model.as_deref(), Some("claude-opus-4-1"));
768 assert_eq!(overridden.model_class.as_deref(), Some("frontier"));
769 assert_eq!(overridden.posture.as_deref(), Some("fast_lane"));
770
771 assert_eq!(inherited.agent.as_deref(), Some("codex"));
772 assert_eq!(inherited.model_class.as_deref(), Some("standard"));
773 assert_eq!(inherited.posture.as_deref(), Some("deep_worker"));
774 }
775
776 #[test]
777 fn resolved_member_agent_prefers_instance_override_then_role_then_team() {
778 let config = make_config(
779 r#"
780name: override-chain
781agent: claude
782roles:
783 - name: architect
784 role_type: architect
785 agent: claude
786 - name: manager
787 role_type: manager
788 - name: role-eng
789 role_type: engineer
790 agent: codex
791 instances: 2
792 talks_to: [manager]
793 instance_overrides:
794 role-eng-1-1:
795 agent: kiro
796 - name: team-eng
797 role_type: engineer
798 instances: 1
799 talks_to: [manager]
800"#,
801 );
802
803 let members = resolve_hierarchy(&config).unwrap();
804 let override_member = members.iter().find(|m| m.name == "role-eng-1-1").unwrap();
805 let role_member = members.iter().find(|m| m.name == "role-eng-1-2").unwrap();
806 let team_member = members.iter().find(|m| m.name == "team-eng-1-1").unwrap();
807
808 assert_eq!(override_member.agent.as_deref(), Some("kiro"));
809 assert_eq!(role_member.agent.as_deref(), Some("codex"));
810 assert_eq!(team_member.agent.as_deref(), Some("claude"));
811 }
812
813 #[test]
814 fn mixed_team_instance_overrides_resolve_per_member_agents() {
815 let config = make_config(
816 r#"
817name: mixed-providers
818agent: claude
819roles:
820 - name: architect
821 role_type: architect
822 agent: claude
823 - name: manager
824 role_type: manager
825 agent: claude
826 - name: engineer
827 role_type: engineer
828 agent: codex
829 instances: 3
830 talks_to: [manager]
831 instance_overrides:
832 eng-1-2:
833 agent: kiro
834 model: claude-opus-4.6-1m
835 eng-1-3:
836 agent: gemini
837"#,
838 );
839
840 let members = resolve_hierarchy(&config).unwrap();
841 let eng_1 = members.iter().find(|m| m.name == "eng-1-1").unwrap();
842 let eng_2 = members.iter().find(|m| m.name == "eng-1-2").unwrap();
843 let eng_3 = members.iter().find(|m| m.name == "eng-1-3").unwrap();
844
845 assert_eq!(eng_1.agent.as_deref(), Some("codex"));
846 assert_eq!(eng_2.agent.as_deref(), Some("kiro"));
847 assert_eq!(eng_2.model.as_deref(), Some("claude-opus-4.6-1m"));
848 assert_eq!(eng_3.agent.as_deref(), Some("gemini"));
849 }
850}