Skip to main content

batty_cli/team/
nudge.rs

1#![cfg_attr(not(test), allow(dead_code))]
2
3//! Dependency-aware nudge target selection.
4
5use std::collections::{BTreeMap, HashMap};
6use std::path::Path;
7
8use anyhow::Result;
9
10use super::capability::{WorkflowCapability, resolve_member_capabilities};
11use super::hierarchy::MemberInstance;
12use super::resolver::{ResolutionStatus, resolve_board};
13use super::standup::MemberState;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct NudgeTarget {
17    pub member: String,
18    pub reason: String,
19    pub capability: WorkflowCapability,
20}
21
22pub fn compute_nudges(
23    board_dir: &Path,
24    members: &[MemberInstance],
25    states: &HashMap<String, MemberState>,
26    pending_inbox: &HashMap<String, usize>,
27) -> Result<Vec<NudgeTarget>> {
28    let resolutions = resolve_board(board_dir, members)?;
29    let member_capabilities: HashMap<String, _> = members
30        .iter()
31        .map(|member| {
32            (
33                member.name.clone(),
34                resolve_member_capabilities(member, members),
35            )
36        })
37        .collect();
38
39    let mut targets = BTreeMap::new();
40    let mut has_runnable = false;
41    let mut has_blocked = false;
42
43    for resolution in &resolutions {
44        match resolution.status {
45            ResolutionStatus::Runnable => {
46                has_runnable = true;
47                if let Some(owner) = resolution.execution_owner.as_deref() {
48                    let owner = resolve_member_reference(owner, members);
49                    if member_is_eligible(
50                        &owner,
51                        WorkflowCapability::Executor,
52                        states,
53                        pending_inbox,
54                        &member_capabilities,
55                    ) {
56                        record_target(
57                            &mut targets,
58                            &owner,
59                            WorkflowCapability::Executor,
60                            format!(
61                                "resume runnable owned task #{}: {}",
62                                resolution.task_id, resolution.title
63                            ),
64                        );
65                    }
66                } else {
67                    for member in members {
68                        if member_is_eligible(
69                            &member.name,
70                            WorkflowCapability::Dispatcher,
71                            states,
72                            pending_inbox,
73                            &member_capabilities,
74                        ) {
75                            record_target(
76                                &mut targets,
77                                &member.name,
78                                WorkflowCapability::Dispatcher,
79                                format!(
80                                    "dispatch unassigned runnable task #{}: {}",
81                                    resolution.task_id, resolution.title
82                                ),
83                            );
84                        }
85                    }
86                }
87            }
88            ResolutionStatus::NeedsReview => {
89                if let Some(owner) = resolution.review_owner.as_deref() {
90                    let owner = resolve_member_reference(owner, members);
91                    if member_is_eligible(
92                        &owner,
93                        WorkflowCapability::Reviewer,
94                        states,
95                        pending_inbox,
96                        &member_capabilities,
97                    ) {
98                        record_target(
99                            &mut targets,
100                            &owner,
101                            WorkflowCapability::Reviewer,
102                            format!(
103                                "review backlog for task #{}: {}",
104                                resolution.task_id, resolution.title
105                            ),
106                        );
107                    }
108                } else {
109                    for member in members {
110                        if member_is_eligible(
111                            &member.name,
112                            WorkflowCapability::Reviewer,
113                            states,
114                            pending_inbox,
115                            &member_capabilities,
116                        ) {
117                            record_target(
118                                &mut targets,
119                                &member.name,
120                                WorkflowCapability::Reviewer,
121                                format!(
122                                    "review backlog for task #{}: {}",
123                                    resolution.task_id, resolution.title
124                                ),
125                            );
126                        }
127                    }
128                }
129            }
130            ResolutionStatus::Blocked => {
131                has_blocked = true;
132            }
133            ResolutionStatus::NeedsAction => {}
134        }
135    }
136
137    if !has_runnable && has_blocked {
138        for member in members {
139            if member_is_eligible(
140                &member.name,
141                WorkflowCapability::Planner,
142                states,
143                pending_inbox,
144                &member_capabilities,
145            ) {
146                record_target(
147                    &mut targets,
148                    &member.name,
149                    WorkflowCapability::Planner,
150                    "blocked frontier needs planning attention".to_string(),
151                );
152            }
153        }
154    }
155
156    Ok(targets
157        .into_iter()
158        .map(|((member, capability), reason)| NudgeTarget {
159            member,
160            reason,
161            capability,
162        })
163        .collect())
164}
165
166fn member_is_eligible(
167    member_name: &str,
168    capability: WorkflowCapability,
169    states: &HashMap<String, MemberState>,
170    pending_inbox: &HashMap<String, usize>,
171    member_capabilities: &HashMap<String, super::capability::CapabilitySet>,
172) -> bool {
173    matches!(states.get(member_name), Some(MemberState::Idle))
174        && pending_inbox.get(member_name).copied().unwrap_or(0) == 0
175        && member_capabilities
176            .get(member_name)
177            .is_some_and(|capabilities| capabilities.contains(&capability))
178}
179
180fn record_target(
181    targets: &mut BTreeMap<(String, WorkflowCapability), String>,
182    member_name: &str,
183    capability: WorkflowCapability,
184    reason: String,
185) {
186    targets
187        .entry((member_name.to_string(), capability))
188        .or_insert(reason);
189}
190
191fn resolve_member_reference(member_name: &str, members: &[MemberInstance]) -> String {
192    if members.iter().any(|member| member.name == member_name) {
193        return member_name.to_string();
194    }
195
196    let mut matches = members
197        .iter()
198        .filter(|member| member.role_name == member_name)
199        .map(|member| member.name.as_str());
200    let Some(first) = matches.next() else {
201        return member_name.to_string();
202    };
203
204    if matches.next().is_none() {
205        first.to_string()
206    } else {
207        member_name.to_string()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::team::config::TeamConfig;
215    use crate::team::hierarchy::resolve_hierarchy;
216
217    fn members(yaml: &str) -> Vec<MemberInstance> {
218        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
219        resolve_hierarchy(&config).unwrap()
220    }
221
222    fn write_task(tasks_dir: &Path, id: u32, extra_frontmatter: &str) {
223        let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
224        std::fs::write(
225            path,
226            format!(
227                "---\nid: {id}\ntitle: Task {id}\npriority: high\n{extra_frontmatter}class: standard\n---\n\nBody.\n"
228            ),
229        )
230        .unwrap();
231    }
232
233    #[test]
234    fn zero_members_produces_no_nudges() {
235        let tmp = tempfile::tempdir().unwrap();
236        std::fs::create_dir_all(tmp.path().join("tasks")).unwrap();
237
238        let nudges = compute_nudges(tmp.path(), &[], &HashMap::new(), &HashMap::new()).unwrap();
239
240        assert!(nudges.is_empty());
241    }
242
243    #[test]
244    fn idle_executor_with_runnable_owned_task_gets_nudged() {
245        let tmp = tempfile::tempdir().unwrap();
246        let tasks_dir = tmp.path().join("tasks");
247        std::fs::create_dir_all(&tasks_dir).unwrap();
248        write_task(
249            &tasks_dir,
250            1,
251            "status: todo\nexecution_owner: builder-1-1\nclaimed_by: builder-1-1\n",
252        );
253        let members = members(
254            r#"
255name: team
256roles:
257  - name: lead
258    role_type: manager
259    agent: claude
260  - name: builder
261    role_type: engineer
262    agent: codex
263    instances: 1
264"#,
265        );
266        let states = HashMap::from([
267            ("lead".to_string(), MemberState::Working),
268            ("builder-1-1".to_string(), MemberState::Idle),
269        ]);
270
271        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
272
273        assert_eq!(
274            nudges,
275            vec![NudgeTarget {
276                member: "builder-1-1".to_string(),
277                reason: "resume runnable owned task #1: Task 1".to_string(),
278                capability: WorkflowCapability::Executor,
279            }]
280        );
281    }
282
283    #[test]
284    fn all_members_busy_produces_no_nudges() {
285        let tmp = tempfile::tempdir().unwrap();
286        let tasks_dir = tmp.path().join("tasks");
287        std::fs::create_dir_all(&tasks_dir).unwrap();
288        write_task(
289            &tasks_dir,
290            2,
291            "status: todo\nexecution_owner: builder-1-1\nclaimed_by: builder-1-1\n",
292        );
293        let members = members(
294            r#"
295name: team
296roles:
297  - name: lead
298    role_type: manager
299    agent: claude
300  - name: builder
301    role_type: engineer
302    agent: codex
303"#,
304        );
305        let states = HashMap::from([
306            ("lead".to_string(), MemberState::Working),
307            ("builder-1-1".to_string(), MemberState::Working),
308        ]);
309
310        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
311
312        assert!(nudges.is_empty());
313    }
314
315    #[test]
316    fn idle_reviewer_with_review_backlog_gets_nudged() {
317        let tmp = tempfile::tempdir().unwrap();
318        let tasks_dir = tmp.path().join("tasks");
319        std::fs::create_dir_all(&tasks_dir).unwrap();
320        write_task(&tasks_dir, 2, "status: review\nreview_owner: lead\n");
321        let members = members(
322            r#"
323name: pair
324roles:
325  - name: lead
326    role_type: manager
327    agent: claude
328  - name: builder
329    role_type: engineer
330    agent: codex
331"#,
332        );
333        let states = HashMap::from([
334            ("lead".to_string(), MemberState::Idle),
335            ("builder-1-1".to_string(), MemberState::Working),
336        ]);
337
338        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
339
340        assert_eq!(
341            nudges,
342            vec![NudgeTarget {
343                member: "lead".to_string(),
344                reason: "review backlog for task #2: Task 2".to_string(),
345                capability: WorkflowCapability::Reviewer,
346            }]
347        );
348    }
349
350    #[test]
351    fn pending_inbox_suppresses_nudge() {
352        let tmp = tempfile::tempdir().unwrap();
353        let tasks_dir = tmp.path().join("tasks");
354        std::fs::create_dir_all(&tasks_dir).unwrap();
355        write_task(
356            &tasks_dir,
357            3,
358            "status: todo\nexecution_owner: builder-1-1\nclaimed_by: builder-1-1\n",
359        );
360        let members = members(
361            r#"
362name: team
363roles:
364  - name: builder
365    role_type: engineer
366    agent: codex
367"#,
368        );
369        let states = HashMap::from([("builder-1-1".to_string(), MemberState::Idle)]);
370        let pending = HashMap::from([("builder-1-1".to_string(), 1usize)]);
371
372        let nudges = compute_nudges(tmp.path(), &members, &states, &pending).unwrap();
373
374        assert!(nudges.is_empty());
375    }
376
377    #[test]
378    fn pending_inbox_suppresses_planner_nudge() {
379        let tmp = tempfile::tempdir().unwrap();
380        let tasks_dir = tmp.path().join("tasks");
381        std::fs::create_dir_all(&tasks_dir).unwrap();
382        write_task(
383            &tasks_dir,
384            4,
385            "status: todo\nblocked_on: waiting-on-decision\n",
386        );
387        let members = members(
388            r#"
389name: team
390roles:
391  - name: lead
392    role_type: manager
393    agent: claude
394  - name: builder
395    role_type: engineer
396    agent: codex
397"#,
398        );
399        let states = HashMap::from([
400            ("lead".to_string(), MemberState::Idle),
401            ("builder-1-1".to_string(), MemberState::Working),
402        ]);
403        let pending = HashMap::from([("lead".to_string(), 1usize)]);
404
405        let nudges = compute_nudges(tmp.path(), &members, &states, &pending).unwrap();
406
407        assert!(nudges.is_empty());
408    }
409
410    #[test]
411    fn blocked_frontier_without_runnable_work_nudges_planner() {
412        let tmp = tempfile::tempdir().unwrap();
413        let tasks_dir = tmp.path().join("tasks");
414        std::fs::create_dir_all(&tasks_dir).unwrap();
415        write_task(
416            &tasks_dir,
417            4,
418            "status: todo\nblocked_on: waiting-on-decision\n",
419        );
420        let members = members(
421            r#"
422name: team
423roles:
424  - name: lead
425    role_type: manager
426    agent: claude
427  - name: builder
428    role_type: engineer
429    agent: codex
430"#,
431        );
432        let states = HashMap::from([
433            ("lead".to_string(), MemberState::Idle),
434            ("builder".to_string(), MemberState::Working),
435        ]);
436
437        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
438
439        assert_eq!(
440            nudges,
441            vec![NudgeTarget {
442                member: "lead".to_string(),
443                reason: "blocked frontier needs planning attention".to_string(),
444                capability: WorkflowCapability::Planner,
445            }]
446        );
447    }
448
449    #[test]
450    fn multi_hop_blocked_dependency_chain_nudges_planner() {
451        let tmp = tempfile::tempdir().unwrap();
452        let tasks_dir = tmp.path().join("tasks");
453        std::fs::create_dir_all(&tasks_dir).unwrap();
454        write_task(&tasks_dir, 1, "status: todo\ndepends_on:\n  - 2\n");
455        write_task(&tasks_dir, 2, "status: todo\ndepends_on:\n  - 3\n");
456        write_task(
457            &tasks_dir,
458            3,
459            "status: todo\nblocked_on: waiting-on-decision\n",
460        );
461        let members = members(
462            r#"
463name: team
464roles:
465  - name: architect
466    role_type: architect
467    agent: claude
468  - name: builder
469    role_type: engineer
470    agent: codex
471"#,
472        );
473        let states = HashMap::from([
474            ("architect".to_string(), MemberState::Idle),
475            ("builder-1-1".to_string(), MemberState::Working),
476        ]);
477
478        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
479
480        assert_eq!(
481            nudges,
482            vec![NudgeTarget {
483                member: "architect".to_string(),
484                reason: "blocked frontier needs planning attention".to_string(),
485                capability: WorkflowCapability::Planner,
486            }]
487        );
488    }
489
490    #[test]
491    fn single_architect_can_receive_dispatcher_and_reviewer_nudges() {
492        let tmp = tempfile::tempdir().unwrap();
493        let tasks_dir = tmp.path().join("tasks");
494        std::fs::create_dir_all(&tasks_dir).unwrap();
495        write_task(&tasks_dir, 1, "status: review\nreview_owner: architect\n");
496        write_task(&tasks_dir, 2, "status: todo\n");
497        let members = members(
498            r#"
499name: solo-architect
500roles:
501  - name: architect
502    role_type: architect
503    agent: claude
504"#,
505        );
506        let states = HashMap::from([("architect".to_string(), MemberState::Idle)]);
507
508        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
509
510        assert_eq!(
511            nudges,
512            vec![
513                NudgeTarget {
514                    member: "architect".to_string(),
515                    reason: "dispatch unassigned runnable task #2: Task 2".to_string(),
516                    capability: WorkflowCapability::Dispatcher,
517                },
518                NudgeTarget {
519                    member: "architect".to_string(),
520                    reason: "review backlog for task #1: Task 1".to_string(),
521                    capability: WorkflowCapability::Reviewer,
522                },
523            ]
524        );
525    }
526
527    #[test]
528    fn renamed_roles_from_config_still_receive_role_type_capabilities() {
529        let tmp = tempfile::tempdir().unwrap();
530        let tasks_dir = tmp.path().join("tasks");
531        std::fs::create_dir_all(&tasks_dir).unwrap();
532        write_task(&tasks_dir, 1, "status: review\nreview_owner: triage-lead\n");
533        write_task(&tasks_dir, 2, "status: todo\n");
534        let members = members(
535            r#"
536name: renamed
537roles:
538  - name: planner
539    role_type: architect
540    agent: claude
541  - name: triage-lead
542    role_type: manager
543    agent: claude
544  - name: implementer
545    role_type: engineer
546    agent: codex
547"#,
548        );
549        let states = HashMap::from([
550            ("planner".to_string(), MemberState::Working),
551            ("triage-lead".to_string(), MemberState::Idle),
552            ("implementer-1-1".to_string(), MemberState::Working),
553        ]);
554
555        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
556
557        assert_eq!(
558            nudges,
559            vec![
560                NudgeTarget {
561                    member: "triage-lead".to_string(),
562                    reason: "dispatch unassigned runnable task #2: Task 2".to_string(),
563                    capability: WorkflowCapability::Dispatcher,
564                },
565                NudgeTarget {
566                    member: "triage-lead".to_string(),
567                    reason: "review backlog for task #1: Task 1".to_string(),
568                    capability: WorkflowCapability::Reviewer,
569                },
570            ]
571        );
572    }
573
574    #[test]
575    fn deterministic_ordering_with_member_name_ties_is_stable() {
576        let tmp = tempfile::tempdir().unwrap();
577        let tasks_dir = tmp.path().join("tasks");
578        std::fs::create_dir_all(&tasks_dir).unwrap();
579        write_task(
580            &tasks_dir,
581            1,
582            "status: todo\nexecution_owner: builder-1-1\nclaimed_by: builder-1-1\n",
583        );
584        write_task(
585            &tasks_dir,
586            2,
587            "status: todo\nexecution_owner: builder-2-1\nclaimed_by: builder-2-1\n",
588        );
589        let members = members(
590            r#"
591name: team
592roles:
593  - name: lead
594    role_type: manager
595    agent: claude
596    instances: 2
597  - name: builder
598    role_type: engineer
599    agent: codex
600    instances: 1
601"#,
602        );
603        let states = HashMap::from([
604            ("lead-1".to_string(), MemberState::Working),
605            ("lead-2".to_string(), MemberState::Working),
606            ("builder-1-1".to_string(), MemberState::Idle),
607            ("builder-2-1".to_string(), MemberState::Idle),
608        ]);
609
610        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
611
612        assert_eq!(
613            nudges,
614            vec![
615                NudgeTarget {
616                    member: "builder-1-1".to_string(),
617                    reason: "resume runnable owned task #1: Task 1".to_string(),
618                    capability: WorkflowCapability::Executor,
619                },
620                NudgeTarget {
621                    member: "builder-2-1".to_string(),
622                    reason: "resume runnable owned task #2: Task 2".to_string(),
623                    capability: WorkflowCapability::Executor,
624                },
625            ]
626        );
627    }
628
629    #[test]
630    fn multiple_targets_are_computed_deterministically() {
631        let tmp = tempfile::tempdir().unwrap();
632        let tasks_dir = tmp.path().join("tasks");
633        std::fs::create_dir_all(&tasks_dir).unwrap();
634        write_task(
635            &tasks_dir,
636            1,
637            "status: todo\nexecution_owner: builder-1-1\nclaimed_by: builder-1-1\n",
638        );
639        write_task(&tasks_dir, 2, "status: review\nreview_owner: lead\n");
640        write_task(&tasks_dir, 3, "status: todo\n");
641        let members = members(
642            r#"
643name: team
644roles:
645  - name: architect
646    role_type: architect
647    agent: claude
648  - name: lead
649    role_type: manager
650    agent: claude
651  - name: builder
652    role_type: engineer
653    agent: codex
654"#,
655        );
656        let states = HashMap::from([
657            ("architect".to_string(), MemberState::Idle),
658            ("lead".to_string(), MemberState::Idle),
659            ("builder-1-1".to_string(), MemberState::Idle),
660        ]);
661
662        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
663
664        assert_eq!(
665            nudges,
666            vec![
667                NudgeTarget {
668                    member: "builder-1-1".to_string(),
669                    reason: "resume runnable owned task #1: Task 1".to_string(),
670                    capability: WorkflowCapability::Executor,
671                },
672                NudgeTarget {
673                    member: "lead".to_string(),
674                    reason: "dispatch unassigned runnable task #3: Task 3".to_string(),
675                    capability: WorkflowCapability::Dispatcher,
676                },
677                NudgeTarget {
678                    member: "lead".to_string(),
679                    reason: "review backlog for task #2: Task 2".to_string(),
680                    capability: WorkflowCapability::Reviewer,
681                },
682            ]
683        );
684    }
685
686    // --- resolve_member_reference ---
687
688    #[test]
689    fn resolve_member_reference_exact_name_match() {
690        let members = members(
691            "name: team\nroles:\n  - name: lead\n    role_type: manager\n    agent: claude\n  - name: builder\n    role_type: engineer\n    agent: codex\n",
692        );
693        assert_eq!(resolve_member_reference("lead", &members), "lead");
694        assert_eq!(
695            resolve_member_reference("builder-1-1", &members),
696            "builder-1-1"
697        );
698    }
699
700    #[test]
701    fn resolve_member_reference_role_name_fallback() {
702        // With a manager present, engineer gets multiplicative name "builder-1-1"
703        // while role_name stays "builder". But with instances:1, flat team (no manager)
704        // gives name == role_name. So we need a manager to create the suffix.
705        let members = members(
706            "name: team\nroles:\n  - name: lead\n    role_type: manager\n    agent: claude\n  - name: builder\n    role_type: engineer\n    agent: codex\n",
707        );
708        // "builder" is the role_name, "builder-1-1" is the instance name
709        // Since there's only one instance with that role_name, it resolves to the instance
710        assert_eq!(resolve_member_reference("builder", &members), "builder-1-1");
711    }
712
713    #[test]
714    fn resolve_member_reference_unknown_returns_as_is() {
715        let members = members(
716            "name: team\nroles:\n  - name: lead\n    role_type: manager\n    agent: claude\n",
717        );
718        assert_eq!(
719            resolve_member_reference("unknown-member", &members),
720            "unknown-member"
721        );
722    }
723
724    // --- empty board ---
725
726    #[test]
727    fn empty_board_produces_no_nudges() {
728        let tmp = tempfile::tempdir().unwrap();
729        std::fs::create_dir_all(tmp.path().join("tasks")).unwrap();
730        let members = members(
731            "name: team\nroles:\n  - name: lead\n    role_type: manager\n    agent: claude\n",
732        );
733        let states = HashMap::from([("lead".to_string(), MemberState::Idle)]);
734
735        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
736        assert!(nudges.is_empty());
737    }
738
739    // --- review without owner nudges all eligible ---
740
741    #[test]
742    fn review_without_owner_nudges_all_eligible_reviewers() {
743        let tmp = tempfile::tempdir().unwrap();
744        let tasks_dir = tmp.path().join("tasks");
745        std::fs::create_dir_all(&tasks_dir).unwrap();
746        write_task(&tasks_dir, 1, "status: review\n"); // no review_owner
747        // Use two managers so both have reviewer capability
748        let members = members(
749            r#"
750name: team
751roles:
752  - name: lead
753    role_type: manager
754    agent: claude
755    instances: 2
756"#,
757        );
758        let states = HashMap::from([
759            ("lead-1".to_string(), MemberState::Idle),
760            ("lead-2".to_string(), MemberState::Idle),
761        ]);
762
763        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
764        let reviewer_names: Vec<&str> = nudges.iter().map(|n| n.member.as_str()).collect();
765        assert!(reviewer_names.contains(&"lead-1"));
766        assert!(reviewer_names.contains(&"lead-2"));
767        assert!(
768            nudges
769                .iter()
770                .all(|n| n.capability == WorkflowCapability::Reviewer)
771        );
772    }
773
774    // --- mixed blocked and runnable suppresses planner ---
775
776    #[test]
777    fn mixed_blocked_and_runnable_no_planner_nudge() {
778        let tmp = tempfile::tempdir().unwrap();
779        let tasks_dir = tmp.path().join("tasks");
780        std::fs::create_dir_all(&tasks_dir).unwrap();
781        write_task(&tasks_dir, 1, "status: todo\nblocked_on: waiting\n");
782        write_task(&tasks_dir, 2, "status: todo\n"); // runnable
783
784        let members = members(
785            r#"
786name: team
787roles:
788  - name: lead
789    role_type: manager
790    agent: claude
791  - name: builder
792    role_type: engineer
793    agent: codex
794"#,
795        );
796        let states = HashMap::from([
797            ("lead".to_string(), MemberState::Idle),
798            ("builder-1-1".to_string(), MemberState::Idle),
799        ]);
800
801        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
802        // Planner nudge only fires when ALL tasks are blocked and none are runnable
803        assert!(
804            !nudges
805                .iter()
806                .any(|n| n.capability == WorkflowCapability::Planner)
807        );
808    }
809
810    // --- unknown member state ---
811
812    #[test]
813    fn unknown_member_state_not_nudged() {
814        let tmp = tempfile::tempdir().unwrap();
815        let tasks_dir = tmp.path().join("tasks");
816        std::fs::create_dir_all(&tasks_dir).unwrap();
817        write_task(
818            &tasks_dir,
819            1,
820            "status: todo\nexecution_owner: builder-1-1\nclaimed_by: builder-1-1\n",
821        );
822        let members = members(
823            "name: team\nroles:\n  - name: builder\n    role_type: engineer\n    agent: codex\n",
824        );
825        // No state entry for builder-1-1 at all
826        let states = HashMap::new();
827
828        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
829        assert!(nudges.is_empty());
830    }
831
832    // --- working member not nudged ---
833
834    #[test]
835    fn working_member_not_nudged_for_dispatch() {
836        let tmp = tempfile::tempdir().unwrap();
837        let tasks_dir = tmp.path().join("tasks");
838        std::fs::create_dir_all(&tasks_dir).unwrap();
839        write_task(&tasks_dir, 1, "status: todo\n"); // unassigned
840        let members = members(
841            "name: team\nroles:\n  - name: lead\n    role_type: manager\n    agent: claude\n",
842        );
843        let states = HashMap::from([("lead".to_string(), MemberState::Working)]);
844
845        let nudges = compute_nudges(tmp.path(), &members, &states, &HashMap::new()).unwrap();
846        assert!(nudges.is_empty());
847    }
848}