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
222#[allow(dead_code)] pub 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 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 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 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 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 assert_eq!(engineers[5].name, "eng-2-1");
312 assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
313 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 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}