Skip to main content

batty_cli/team/tact/
mod.rs

1use std::path::Path;
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5
6pub mod parser;
7pub mod prompt;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TactPrompt {
11    pub board_summary: String,
12    pub recent_completions: Vec<String>,
13    pub roadmap_priorities: Vec<String>,
14    pub idle_count: usize,
15    pub dispatchable_count: usize,
16}
17
18/// A parsed task specification from the architect's planning response.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TaskSpec {
21    pub title: String,
22    pub body: String,
23    pub priority: Option<String>,
24    pub depends_on: Vec<u32>,
25    pub tags: Vec<String>,
26}
27
28pub type GeneratedTask = TaskSpec;
29
30pub use parser::{create_board_tasks, parse_planning_response};
31pub use prompt::{PLANNING_RESPONSE_FORMAT, compose_planning_prompt};
32
33pub fn dispatchable_task_count(
34    board_dir: &Path,
35    _members: &[crate::team::hierarchy::MemberInstance],
36) -> Result<usize> {
37    Ok(crate::team::resolver::dispatchable_tasks(board_dir)?.len())
38}
39
40pub fn should_trigger_planning_cycle(idle_engineers: usize, dispatchable_tasks: usize) -> bool {
41    idle_engineers > dispatchable_tasks
42}
43
44pub fn should_trigger(idle_engineers: usize, dispatchable_tasks: usize) -> bool {
45    should_trigger_planning_cycle(idle_engineers, dispatchable_tasks)
46}
47
48pub fn compose_prompt(ctx: &TactPrompt) -> String {
49    prompt::compose_prompt(ctx)
50}
51
52pub fn parse_task_specs(response: &str) -> Vec<TaskSpec> {
53    parser::parse_task_specs(response)
54}
55
56pub fn planning_cycle_ready(
57    active: bool,
58    last_fired: Option<Instant>,
59    cooldown: Duration,
60    idle_engineers: usize,
61    dispatchable_tasks: usize,
62) -> bool {
63    if active || !should_trigger_planning_cycle(idle_engineers, dispatchable_tasks) {
64        return false;
65    }
66
67    last_fired.is_none_or(|last| last.elapsed() >= cooldown)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::team::config::TeamConfig;
74    use crate::team::hierarchy::resolve_hierarchy;
75
76    fn solo_members() -> Vec<crate::team::hierarchy::MemberInstance> {
77        let config: TeamConfig = serde_yaml::from_str(
78            r#"
79name: solo
80roles:
81  - name: engineer
82    role_type: engineer
83    agent: codex
84    instances: 1
85"#,
86        )
87        .unwrap();
88        resolve_hierarchy(&config).unwrap()
89    }
90
91    #[test]
92    fn trigger_fires_when_idle_exceeds_dispatchable() {
93        assert!(should_trigger_planning_cycle(3, 1));
94    }
95
96    #[test]
97    fn trigger_does_not_fire_when_board_has_enough_tasks() {
98        assert!(!should_trigger_planning_cycle(2, 2));
99        assert!(!should_trigger_planning_cycle(1, 3));
100    }
101
102    #[test]
103    fn cooldown_prevents_rapid_retrigger() {
104        assert!(!planning_cycle_ready(
105            false,
106            Some(Instant::now()),
107            Duration::from_secs(120),
108            3,
109            0,
110        ));
111    }
112
113    #[test]
114    fn planning_cycle_ready_when_cooldown_elapsed() {
115        assert!(planning_cycle_ready(
116            false,
117            Some(Instant::now() - Duration::from_secs(121)),
118            Duration::from_secs(120),
119            3,
120            0,
121        ));
122    }
123
124    #[test]
125    fn dispatchable_task_count_uses_workflow_resolver() {
126        let tmp = tempfile::tempdir().unwrap();
127        let board_dir = tmp.path();
128        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
129        std::fs::write(
130            board_dir.join("tasks/001-runnable.md"),
131            "---\nid: 1\ntitle: runnable\nstatus: backlog\npriority: medium\nclass: standard\n---\n\nBody.\n",
132        )
133        .unwrap();
134        std::fs::write(
135            board_dir.join("tasks/002-blocked.md"),
136            "---\nid: 2\ntitle: blocked\nstatus: blocked\npriority: medium\nclass: standard\nblocked: waiting\n---\n\nBody.\n",
137        )
138        .unwrap();
139
140        let count = dispatchable_task_count(board_dir, &solo_members()).unwrap();
141        assert_eq!(count, 1);
142    }
143
144    #[test]
145    fn dispatchable_task_count_excludes_manual_blocked_todo() {
146        let tmp = tempfile::tempdir().unwrap();
147        let board_dir = tmp.path();
148        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
149        std::fs::write(
150            board_dir.join("tasks/001-runnable-a.md"),
151            "---\nid: 1\ntitle: runnable-a\nstatus: todo\npriority: medium\nclass: standard\n---\n\nBody.\n",
152        )
153        .unwrap();
154        std::fs::write(
155            board_dir.join("tasks/002-runnable-b.md"),
156            "---\nid: 2\ntitle: runnable-b\nstatus: todo\npriority: medium\nclass: standard\n---\n\nBody.\n",
157        )
158        .unwrap();
159        std::fs::write(
160            board_dir.join("tasks/003-manual.md"),
161            "---\nid: 3\ntitle: manual\nstatus: todo\npriority: medium\nblocked: manual provider-console token rotation\nclass: standard\n---\n\nBody.\n",
162        )
163        .unwrap();
164
165        let count = dispatchable_task_count(board_dir, &solo_members()).unwrap();
166        assert_eq!(count, 2);
167    }
168
169    #[test]
170    fn test_should_trigger_true() {
171        assert!(should_trigger(3, 1));
172    }
173
174    #[test]
175    fn test_should_trigger_false() {
176        assert!(!should_trigger(1, 3));
177    }
178
179    #[test]
180    fn test_should_trigger_equal() {
181        assert!(!should_trigger(2, 2));
182    }
183
184    #[test]
185    fn prompt_request_count_uses_dispatchable_deficit() {
186        let prompt = compose_prompt(&TactPrompt {
187            board_summary: "todo=3, dispatchable_tasks=1, idle_engineers=2".to_string(),
188            recent_completions: vec!["Finished parser".to_string()],
189            roadmap_priorities: vec!["Ship tact".to_string()],
190            idle_count: 2,
191            dispatchable_count: 1,
192        });
193        assert!(prompt.contains("Please specify 1 new tasks"));
194    }
195}