1use anyhow::{Result, bail};
10
11use super::config::{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 prompt: Option<String>,
26 pub reports_to: Option<String>,
28 pub use_worktrees: bool,
30}
31
32pub fn resolve_hierarchy(config: &TeamConfig) -> Result<Vec<MemberInstance>> {
42 let mut members = Vec::new();
43
44 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 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 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 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 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 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 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 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
222pub 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 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 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 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 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 assert_eq!(engineers[5].name, "eng-2-1");
311 assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
312 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 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}