use anyhow::{Result, bail};
use super::config::{RoleDef, RoleType, TeamConfig};
#[derive(Debug, Clone)]
pub struct MemberInstance {
pub name: String,
pub role_name: String,
pub role_type: RoleType,
pub agent: Option<String>,
pub model: Option<String>,
pub prompt: Option<String>,
pub posture: Option<String>,
pub model_class: Option<String>,
pub provider_overlay: Option<String>,
pub reports_to: Option<String>,
pub use_worktrees: bool,
}
impl Default for MemberInstance {
fn default() -> Self {
Self {
name: String::new(),
role_name: String::new(),
role_type: RoleType::Engineer,
agent: None,
model: None,
prompt: None,
posture: None,
model_class: None,
provider_overlay: None,
reports_to: None,
use_worktrees: false,
}
}
}
pub fn resolve_hierarchy(config: &TeamConfig) -> Result<Vec<MemberInstance>> {
let mut members = Vec::new();
let managers: Vec<_> = config
.roles
.iter()
.filter(|r| r.role_type == RoleType::Manager)
.collect();
let engineers: Vec<_> = config
.roles
.iter()
.filter(|r| r.role_type == RoleType::Engineer)
.collect();
for role in config
.roles
.iter()
.filter(|r| r.role_type == RoleType::User)
{
members.push(MemberInstance {
name: role.name.clone(),
role_name: role.name.clone(),
role_type: RoleType::User,
agent: None,
model: None,
prompt: None,
posture: None,
model_class: None,
provider_overlay: None,
reports_to: None,
use_worktrees: false,
});
}
for role in config
.roles
.iter()
.filter(|r| r.role_type == RoleType::Architect)
{
let resolved_agent = config.resolve_agent(role);
for i in 1..=role.instances {
let name = if role.instances == 1 {
role.name.clone()
} else {
format!("{}-{i}", role.name)
};
members.push(MemberInstance {
name: name.clone(),
role_name: role.name.clone(),
role_type: RoleType::Architect,
agent: resolved_member_agent(role, &name, resolved_agent.clone()),
model: resolved_member_model(role, &name),
prompt: resolved_member_prompt(role, &name),
posture: resolved_member_posture(role, &name),
model_class: resolved_member_model_class(role, &name),
provider_overlay: resolved_member_provider_overlay(role, &name),
reports_to: None,
use_worktrees: role.use_worktrees,
});
}
}
let mut manager_instances = Vec::new();
for role in &managers {
let resolved_agent = config.resolve_agent(role);
for i in 1..=role.instances {
let name = if role.instances == 1 {
role.name.clone()
} else {
format!("{}-{i}", role.name)
};
manager_instances.push((name.clone(), role.name.clone()));
let reports_to = config
.roles
.iter()
.find(|r| r.role_type == RoleType::Architect)
.map(|a| {
if a.instances == 1 {
a.name.clone()
} else {
format!("{}-1", a.name)
}
});
members.push(MemberInstance {
name: name.clone(),
role_name: role.name.clone(),
role_type: RoleType::Manager,
agent: resolved_member_agent(role, &name, resolved_agent.clone()),
model: resolved_member_model(role, &name),
prompt: resolved_member_prompt(role, &name),
posture: resolved_member_posture(role, &name),
model_class: resolved_member_model_class(role, &name),
provider_overlay: resolved_member_provider_overlay(role, &name),
reports_to,
use_worktrees: role.use_worktrees,
});
}
}
let multiple_engineer_roles = engineers.len() > 1;
for role in &engineers {
let resolved_agent = config.resolve_agent(role);
let compatible_managers: Vec<_> = if manager_instances.is_empty() {
Vec::new()
} else if role.talks_to.is_empty() {
manager_instances.iter().collect()
} else {
manager_instances
.iter()
.filter(|(member_name, role_name)| {
role.talks_to
.iter()
.any(|target| target == role_name || target == member_name)
})
.collect()
};
if compatible_managers.is_empty() {
for i in 1..=role.instances {
let name = if role.instances == 1 {
role.name.clone()
} else {
format!("{}-{i}", role.name)
};
members.push(MemberInstance {
name: name.clone(),
role_name: role.name.clone(),
role_type: RoleType::Engineer,
agent: resolved_member_agent(role, &name, resolved_agent.clone()),
model: resolved_member_model(role, &name),
prompt: resolved_member_prompt(role, &name),
posture: resolved_member_posture(role, &name),
model_class: resolved_member_model_class(role, &name),
provider_overlay: resolved_member_provider_overlay(role, &name),
reports_to: None,
use_worktrees: role.use_worktrees,
});
}
} else {
for (mgr_idx, (mgr_name, _mgr_role_name)) in compatible_managers.iter().enumerate() {
for eng_idx in 1..=role.instances {
let name = engineer_instance_name(
role.name.as_str(),
multiple_engineer_roles,
mgr_idx + 1,
eng_idx,
);
members.push(MemberInstance {
name: name.clone(),
role_name: role.name.clone(),
role_type: RoleType::Engineer,
agent: resolved_member_agent(role, &name, resolved_agent.clone()),
model: resolved_member_model(role, &name),
prompt: resolved_member_prompt(role, &name),
posture: resolved_member_posture(role, &name),
model_class: resolved_member_model_class(role, &name),
provider_overlay: resolved_member_provider_overlay(role, &name),
reports_to: Some(mgr_name.clone()),
use_worktrees: role.use_worktrees,
});
}
}
}
}
if members
.iter()
.filter(|m| m.role_type != RoleType::User)
.count()
== 0
{
bail!("team has no agent members (only user roles)");
}
Ok(members)
}
fn engineer_instance_name(
role_name: &str,
multiple_engineer_roles: bool,
manager_index: usize,
engineer_index: u32,
) -> String {
if !multiple_engineer_roles && role_name == "engineer" {
format!("eng-{manager_index}-{engineer_index}")
} else {
format!("{role_name}-{manager_index}-{engineer_index}")
}
}
fn resolved_member_agent(
role: &RoleDef,
member_name: &str,
resolved_agent: Option<String>,
) -> Option<String> {
role.instance_overrides
.get(member_name)
.and_then(|override_cfg| override_cfg.agent.clone())
.or(resolved_agent)
}
fn resolved_member_model(role: &RoleDef, member_name: &str) -> Option<String> {
role.instance_overrides
.get(member_name)
.and_then(|override_cfg| override_cfg.model.clone())
.or_else(|| role.model.clone())
}
fn resolved_member_prompt(role: &RoleDef, member_name: &str) -> Option<String> {
role.instance_overrides
.get(member_name)
.and_then(|override_cfg| override_cfg.prompt.clone())
.or_else(|| role.prompt.clone())
}
fn resolved_member_posture(role: &RoleDef, member_name: &str) -> Option<String> {
role.instance_overrides
.get(member_name)
.and_then(|override_cfg| override_cfg.posture.clone())
.or_else(|| role.posture.clone())
}
fn resolved_member_model_class(role: &RoleDef, member_name: &str) -> Option<String> {
role.instance_overrides
.get(member_name)
.and_then(|override_cfg| override_cfg.model_class.clone())
.or_else(|| role.model_class.clone())
}
fn resolved_member_provider_overlay(role: &RoleDef, member_name: &str) -> Option<String> {
role.instance_overrides
.get(member_name)
.and_then(|override_cfg| override_cfg.provider_overlay.clone())
.or_else(|| role.provider_overlay.clone())
}
pub fn pane_count(members: &[MemberInstance]) -> usize {
members
.iter()
.filter(|m| m.role_type != RoleType::User)
.count()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(yaml: &str) -> TeamConfig {
serde_yaml::from_str(yaml).unwrap()
}
#[test]
fn simple_team_3_engineers() {
let config = make_config(
r#"
name: test
roles:
- name: architect
role_type: architect
agent: claude
instances: 1
- name: manager
role_type: manager
agent: claude
instances: 1
- name: engineer
role_type: engineer
agent: codex
instances: 3
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members.len(), 5);
assert_eq!(pane_count(&members), 5);
let engineers: Vec<_> = members
.iter()
.filter(|m| m.role_type == RoleType::Engineer)
.collect();
assert_eq!(engineers.len(), 3);
assert_eq!(engineers[0].name, "eng-1-1");
assert_eq!(engineers[1].name, "eng-1-2");
assert_eq!(engineers[2].name, "eng-1-3");
assert_eq!(engineers[0].reports_to.as_deref(), Some("manager"));
}
#[test]
fn large_team_multiplicative() {
let config = make_config(
r#"
name: large
roles:
- name: architect
role_type: architect
agent: claude
instances: 1
- name: manager
role_type: manager
agent: claude
instances: 3
- name: engineer
role_type: engineer
agent: codex
instances: 5
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members.len(), 19);
assert_eq!(pane_count(&members), 19);
let engineers: Vec<_> = members
.iter()
.filter(|m| m.role_type == RoleType::Engineer)
.collect();
assert_eq!(engineers.len(), 15);
assert_eq!(engineers[0].name, "eng-1-1");
assert_eq!(engineers[0].reports_to.as_deref(), Some("manager-1"));
assert_eq!(engineers[4].name, "eng-1-5");
assert_eq!(engineers[5].name, "eng-2-1");
assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
assert_eq!(engineers[10].name, "eng-3-1");
assert_eq!(engineers[10].reports_to.as_deref(), Some("manager-3"));
}
#[test]
fn user_role_excluded_from_pane_count() {
let config = make_config(
r#"
name: with-user
roles:
- name: human
role_type: user
talks_to: [architect]
- name: architect
role_type: architect
agent: claude
instances: 1
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members.len(), 2);
assert_eq!(pane_count(&members), 1);
}
#[test]
fn manager_reports_to_architect() {
let config = make_config(
r#"
name: test
roles:
- name: arch
role_type: architect
agent: claude
instances: 1
- name: mgr
role_type: manager
agent: claude
instances: 2
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let mgr1 = members.iter().find(|m| m.name == "mgr-1").unwrap();
assert_eq!(mgr1.reports_to.as_deref(), Some("arch"));
}
#[test]
fn single_instance_no_number_suffix() {
let config = make_config(
r#"
name: test
roles:
- name: architect
role_type: architect
agent: claude
instances: 1
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members[0].name, "architect");
}
#[test]
fn multi_instance_has_number_suffix() {
let config = make_config(
r#"
name: test
roles:
- name: manager
role_type: manager
agent: claude
instances: 2
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members[0].name, "manager-1");
assert_eq!(members[1].name, "manager-2");
}
#[test]
fn engineers_without_managers_report_to_nobody() {
let config = make_config(
r#"
name: flat
roles:
- name: worker
role_type: engineer
agent: codex
instances: 3
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members.len(), 3);
for m in &members {
assert!(m.reports_to.is_none());
}
assert_eq!(members[0].name, "worker-1");
}
#[test]
fn rejects_user_only_team() {
let config = make_config(
r#"
name: empty
roles:
- name: human
role_type: user
"#,
);
let err = resolve_hierarchy(&config).unwrap_err().to_string();
assert!(err.contains("no agent members"));
}
#[test]
fn engineer_roles_can_target_specific_manager_roles() {
let config = make_config(
r#"
name: split-team
roles:
- name: architect
role_type: architect
agent: claude
- name: black-lead
role_type: manager
agent: claude
talks_to: [architect, black-eng]
- name: red-lead
role_type: manager
agent: claude
talks_to: [architect, red-eng]
- name: black-eng
role_type: engineer
agent: codex
instances: 3
talks_to: [black-lead]
- name: red-eng
role_type: engineer
agent: codex
instances: 3
talks_to: [red-lead]
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let engineers: Vec<_> = members
.iter()
.filter(|m| m.role_type == RoleType::Engineer)
.collect();
assert_eq!(engineers.len(), 6);
assert_eq!(
engineers
.iter()
.filter(|m| m.role_name == "black-eng")
.count(),
3
);
assert_eq!(
engineers
.iter()
.filter(|m| m.role_name == "red-eng")
.count(),
3
);
assert!(engineers.iter().all(|m| {
if m.role_name == "black-eng" {
m.reports_to.as_deref() == Some("black-lead")
} else {
m.reports_to.as_deref() == Some("red-lead")
}
}));
let unique_names: std::collections::HashSet<_> =
engineers.iter().map(|m| m.name.as_str()).collect();
assert_eq!(unique_names.len(), engineers.len());
assert!(unique_names.contains("black-eng-1-1"));
assert!(unique_names.contains("red-eng-1-1"));
}
#[test]
fn engineer_role_without_matching_manager_talks_to_stays_flat() {
let config = make_config(
r#"
name: unmatched
roles:
- name: architect
role_type: architect
agent: claude
- name: manager
role_type: manager
agent: claude
- name: specialist
role_type: engineer
agent: codex
instances: 2
talks_to: [architect]
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let engineers: Vec<_> = members
.iter()
.filter(|m| m.role_type == RoleType::Engineer)
.collect();
assert_eq!(engineers.len(), 2);
assert!(engineers.iter().all(|m| m.reports_to.is_none()));
assert_eq!(engineers[0].name, "specialist-1");
assert_eq!(engineers[1].name, "specialist-2");
}
#[test]
fn team_level_agent_propagates_to_members() {
let config = make_config(
r#"
name: team-default
agent: codex
roles:
- name: architect
role_type: architect
- name: manager
role_type: manager
- name: engineer
role_type: engineer
instances: 2
"#,
);
let members = resolve_hierarchy(&config).unwrap();
for m in &members {
assert_eq!(
m.agent.as_deref(),
Some("codex"),
"member {} should have team default agent 'codex'",
m.name
);
}
}
#[test]
fn role_agent_overrides_team_default() {
let config = make_config(
r#"
name: mixed
agent: codex
roles:
- name: architect
role_type: architect
agent: claude
- name: manager
role_type: manager
- name: engineer
role_type: engineer
instances: 2
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let architect = members.iter().find(|m| m.name == "architect").unwrap();
assert_eq!(
architect.agent.as_deref(),
Some("claude"),
"architect should use role-level override"
);
let manager = members.iter().find(|m| m.name == "manager").unwrap();
assert_eq!(
manager.agent.as_deref(),
Some("codex"),
"manager should use team default"
);
}
#[test]
fn mixed_backend_engineers_under_same_manager() {
let config = make_config(
r#"
name: mixed-eng
agent: codex
roles:
- name: architect
role_type: architect
agent: claude
- name: manager
role_type: manager
agent: claude
- name: claude-eng
role_type: engineer
agent: claude
instances: 2
talks_to: [manager]
- name: codex-eng
role_type: engineer
instances: 2
talks_to: [manager]
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let claude_engs: Vec<_> = members
.iter()
.filter(|m| m.role_name == "claude-eng")
.collect();
let codex_engs: Vec<_> = members
.iter()
.filter(|m| m.role_name == "codex-eng")
.collect();
assert_eq!(claude_engs.len(), 2);
assert_eq!(codex_engs.len(), 2);
for m in &claude_engs {
assert_eq!(m.agent.as_deref(), Some("claude"));
assert_eq!(m.reports_to.as_deref(), Some("manager"));
}
for m in &codex_engs {
assert_eq!(m.agent.as_deref(), Some("codex"));
assert_eq!(m.reports_to.as_deref(), Some("manager"));
}
}
#[test]
fn no_team_agent_defaults_to_claude() {
let config = make_config(
r#"
name: default-fallback
roles:
- name: worker
role_type: engineer
agent: claude
instances: 1
"#,
);
let members = resolve_hierarchy(&config).unwrap();
assert_eq!(members[0].agent.as_deref(), Some("claude"));
}
#[test]
fn instance_overrides_apply_to_named_member() {
let config = make_config(
r#"
name: overrides
roles:
- name: manager
role_type: manager
agent: claude
- name: engineer
role_type: engineer
agent: codex
posture: deep_worker
model_class: standard
instances: 2
instance_overrides:
eng-1-1:
agent: claude
model: claude-opus-4-1
model_class: frontier
posture: fast_lane
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let overridden = members.iter().find(|m| m.name == "eng-1-1").unwrap();
let inherited = members.iter().find(|m| m.name == "eng-1-2").unwrap();
assert_eq!(overridden.agent.as_deref(), Some("claude"));
assert_eq!(overridden.model.as_deref(), Some("claude-opus-4-1"));
assert_eq!(overridden.model_class.as_deref(), Some("frontier"));
assert_eq!(overridden.posture.as_deref(), Some("fast_lane"));
assert_eq!(inherited.agent.as_deref(), Some("codex"));
assert_eq!(inherited.model_class.as_deref(), Some("standard"));
assert_eq!(inherited.posture.as_deref(), Some("deep_worker"));
}
#[test]
fn resolved_member_agent_prefers_instance_override_then_role_then_team() {
let config = make_config(
r#"
name: override-chain
agent: claude
roles:
- name: architect
role_type: architect
agent: claude
- name: manager
role_type: manager
- name: role-eng
role_type: engineer
agent: codex
instances: 2
talks_to: [manager]
instance_overrides:
role-eng-1-1:
agent: kiro
- name: team-eng
role_type: engineer
instances: 1
talks_to: [manager]
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let override_member = members.iter().find(|m| m.name == "role-eng-1-1").unwrap();
let role_member = members.iter().find(|m| m.name == "role-eng-1-2").unwrap();
let team_member = members.iter().find(|m| m.name == "team-eng-1-1").unwrap();
assert_eq!(override_member.agent.as_deref(), Some("kiro"));
assert_eq!(role_member.agent.as_deref(), Some("codex"));
assert_eq!(team_member.agent.as_deref(), Some("claude"));
}
#[test]
fn mixed_team_instance_overrides_resolve_per_member_agents() {
let config = make_config(
r#"
name: mixed-providers
agent: claude
roles:
- name: architect
role_type: architect
agent: claude
- name: manager
role_type: manager
agent: claude
- name: engineer
role_type: engineer
agent: codex
instances: 3
talks_to: [manager]
instance_overrides:
eng-1-2:
agent: kiro
model: claude-opus-4.6-1m
eng-1-3:
agent: gemini
"#,
);
let members = resolve_hierarchy(&config).unwrap();
let eng_1 = members.iter().find(|m| m.name == "eng-1-1").unwrap();
let eng_2 = members.iter().find(|m| m.name == "eng-1-2").unwrap();
let eng_3 = members.iter().find(|m| m.name == "eng-1-3").unwrap();
assert_eq!(eng_1.agent.as_deref(), Some("codex"));
assert_eq!(eng_2.agent.as_deref(), Some("kiro"));
assert_eq!(eng_2.model.as_deref(), Some("claude-opus-4.6-1m"));
assert_eq!(eng_3.agent.as_deref(), Some("gemini"));
}
}