Skip to main content

batty_cli/team/config/
types.rs

1//! Type definitions for team configuration.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Deserializer, de};
6
7use super::super::DEFAULT_EVENT_LOG_MAX_BYTES;
8
9#[derive(Debug, Clone)]
10pub struct TeamConfig {
11    pub name: String,
12    /// Team-level default agent backend. Individual roles can override this
13    /// with their own `agent` field. Resolution order:
14    /// role-level agent > team-level agent > "claude" (hardcoded default).
15    pub agent: Option<String>,
16    pub workflow_mode: WorkflowMode,
17    pub board: BoardConfig,
18    pub standup: StandupConfig,
19    pub automation: AutomationConfig,
20    pub automation_sender: Option<String>,
21    /// External senders (e.g. email-router, slack-bridge) that are allowed to
22    /// message any role even though they are not team members.
23    pub external_senders: Vec<String>,
24    pub orchestrator_pane: bool,
25    pub orchestrator_position: OrchestratorPosition,
26    pub layout: Option<LayoutConfig>,
27    pub workflow_policy: WorkflowPolicy,
28    pub cost: CostConfig,
29    pub grafana: GrafanaConfig,
30    /// When true, agents are spawned as shim subprocesses instead of
31    /// directly in tmux panes. The shim manages PTY, state classification,
32    /// and message delivery over a structured channel.
33    pub use_shim: bool,
34    /// When true and `use_shim` is enabled, agents that support structured I/O
35    /// (Claude Code, Codex) communicate via JSON protocols instead of PTY
36    /// screen-scraping. Requires `use_shim: true`. Defaults to true.
37    pub use_sdk_mode: bool,
38    /// When true and `use_shim` is enabled, crashed agents are automatically
39    /// respawned instead of escalating to the manager. This is the default
40    /// posture for unattended teams; disable it only for debugging or
41    /// deliberate manual supervision.
42    pub auto_respawn_on_crash: bool,
43    /// Interval in seconds between Ping health checks sent to shim handles.
44    pub shim_health_check_interval_secs: u64,
45    /// Seconds without a Pong response before a shim handle is considered stale.
46    pub shim_health_timeout_secs: u64,
47    /// Seconds to wait for graceful shutdown before sending Kill.
48    pub shim_shutdown_timeout_secs: u32,
49    /// Maximum seconds an agent can remain in "Working" state before being
50    /// force-transitioned to Idle. Prevents permanent stalls where the shim
51    /// state classifier gets stuck on "working" while the agent is actually
52    /// idle. 0 or None disables the check. Default: 1800 (30 minutes).
53    pub shim_working_state_timeout_secs: u64,
54    /// Maximum seconds a message can sit in the pending delivery queue before
55    /// being force-delivered via inbox fallback. Prevents message loss when
56    /// the target agent appears permanently busy. Default: 600 (10 minutes).
57    pub pending_queue_max_age_secs: u64,
58    pub event_log_max_bytes: u64,
59    pub retro_min_duration_secs: u64,
60    pub roles: Vec<RoleDef>,
61}
62
63#[derive(Debug, Deserialize)]
64struct TeamConfigWire {
65    pub name: String,
66    #[serde(default)]
67    pub agent: Option<String>,
68    #[serde(default)]
69    pub workflow_mode: Option<WorkflowMode>,
70    #[serde(default)]
71    pub board: BoardConfig,
72    #[serde(default)]
73    pub standup: StandupConfig,
74    #[serde(default)]
75    pub automation: AutomationConfig,
76    #[serde(default)]
77    pub automation_sender: Option<String>,
78    #[serde(default)]
79    pub external_senders: Vec<String>,
80    #[serde(default)]
81    pub orchestrator_pane: Option<bool>,
82    #[serde(default)]
83    pub orchestrator_position: OrchestratorPosition,
84    #[serde(default)]
85    pub layout: Option<LayoutConfig>,
86    #[serde(default)]
87    pub workflow_policy: WorkflowPolicy,
88    #[serde(default)]
89    pub cost: CostConfig,
90    #[serde(default)]
91    pub grafana: GrafanaConfig,
92    #[serde(default)]
93    pub use_shim: bool,
94    #[serde(default = "default_use_sdk_mode")]
95    pub use_sdk_mode: bool,
96    #[serde(default = "default_auto_respawn_on_crash")]
97    pub auto_respawn_on_crash: bool,
98    #[serde(default = "default_shim_health_check_interval_secs")]
99    pub shim_health_check_interval_secs: u64,
100    #[serde(default = "default_shim_health_timeout_secs")]
101    pub shim_health_timeout_secs: u64,
102    #[serde(default = "default_shim_shutdown_timeout_secs")]
103    pub shim_shutdown_timeout_secs: u32,
104    #[serde(default = "default_shim_working_state_timeout_secs")]
105    pub shim_working_state_timeout_secs: u64,
106    #[serde(default = "default_pending_queue_max_age_secs")]
107    pub pending_queue_max_age_secs: u64,
108    #[serde(default = "default_event_log_max_bytes")]
109    pub event_log_max_bytes: u64,
110    #[serde(default = "default_retro_min_duration_secs")]
111    pub retro_min_duration_secs: u64,
112    pub roles: Vec<RoleDef>,
113}
114
115impl From<TeamConfigWire> for TeamConfig {
116    fn from(wire: TeamConfigWire) -> Self {
117        let orchestrator_pane = wire
118            .orchestrator_pane
119            .unwrap_or_else(default_orchestrator_pane);
120        let workflow_mode = wire.workflow_mode.unwrap_or_else(|| {
121            if matches!(wire.orchestrator_pane, Some(true)) {
122                WorkflowMode::Hybrid
123            } else {
124                default_workflow_mode()
125            }
126        });
127
128        Self {
129            name: wire.name,
130            agent: wire.agent,
131            workflow_mode,
132            board: wire.board,
133            standup: wire.standup,
134            automation: wire.automation,
135            automation_sender: wire.automation_sender,
136            external_senders: wire.external_senders,
137            orchestrator_pane,
138            orchestrator_position: wire.orchestrator_position,
139            layout: wire.layout,
140            workflow_policy: wire.workflow_policy,
141            cost: wire.cost,
142            grafana: wire.grafana,
143            use_shim: wire.use_shim,
144            use_sdk_mode: wire.use_sdk_mode,
145            auto_respawn_on_crash: wire.auto_respawn_on_crash,
146            shim_health_check_interval_secs: wire.shim_health_check_interval_secs,
147            shim_health_timeout_secs: wire.shim_health_timeout_secs,
148            shim_shutdown_timeout_secs: wire.shim_shutdown_timeout_secs,
149            shim_working_state_timeout_secs: wire.shim_working_state_timeout_secs,
150            pending_queue_max_age_secs: wire.pending_queue_max_age_secs,
151            event_log_max_bytes: wire.event_log_max_bytes,
152            retro_min_duration_secs: wire.retro_min_duration_secs,
153            roles: wire.roles,
154        }
155    }
156}
157
158impl<'de> Deserialize<'de> for TeamConfig {
159    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
160    where
161        D: Deserializer<'de>,
162    {
163        TeamConfigWire::deserialize(deserializer).map(Into::into)
164    }
165}
166
167#[derive(Debug, Clone, Deserialize)]
168pub struct GrafanaConfig {
169    #[serde(default)]
170    pub enabled: bool,
171    #[serde(default = "default_grafana_port")]
172    pub port: u16,
173}
174
175impl Default for GrafanaConfig {
176    fn default() -> Self {
177        Self {
178            enabled: false,
179            port: default_grafana_port(),
180        }
181    }
182}
183
184fn default_grafana_port() -> u16 {
185    3000
186}
187
188fn default_use_sdk_mode() -> bool {
189    true
190}
191
192fn default_auto_respawn_on_crash() -> bool {
193    true
194}
195
196#[derive(Debug, Clone, Deserialize, Default)]
197pub struct CostConfig {
198    #[serde(default)]
199    pub models: HashMap<String, ModelPricing>,
200}
201
202#[derive(Debug, Clone, Deserialize)]
203pub struct ModelPricing {
204    pub input_usd_per_mtok: f64,
205    #[serde(default)]
206    pub cached_input_usd_per_mtok: f64,
207    #[serde(default)]
208    pub cache_creation_input_usd_per_mtok: Option<f64>,
209    #[serde(default)]
210    pub cache_creation_5m_input_usd_per_mtok: Option<f64>,
211    #[serde(default)]
212    pub cache_creation_1h_input_usd_per_mtok: Option<f64>,
213    #[serde(default)]
214    pub cache_read_input_usd_per_mtok: f64,
215    pub output_usd_per_mtok: f64,
216    #[serde(default)]
217    pub reasoning_output_usd_per_mtok: Option<f64>,
218}
219
220#[derive(Debug, Clone, Deserialize)]
221pub struct WorkflowPolicy {
222    #[serde(default)]
223    pub clean_room_mode: bool,
224    #[serde(default)]
225    pub barrier_groups: HashMap<String, Vec<String>>,
226    #[serde(default = "default_handoff_directory")]
227    pub handoff_directory: String,
228    #[serde(default)]
229    pub wip_limit_per_engineer: Option<u32>,
230    #[serde(default)]
231    pub wip_limit_per_reviewer: Option<u32>,
232    #[serde(default = "default_pipeline_starvation_threshold")]
233    pub pipeline_starvation_threshold: Option<usize>,
234    #[serde(default = "default_escalation_threshold_secs")]
235    pub escalation_threshold_secs: u64,
236    #[serde(default = "default_review_nudge_threshold_secs")]
237    pub review_nudge_threshold_secs: u64,
238    #[serde(default = "default_review_timeout_secs")]
239    pub review_timeout_secs: u64,
240    #[serde(default = "default_stale_in_progress_hours")]
241    pub stale_in_progress_hours: u64,
242    #[serde(default = "default_aged_todo_hours")]
243    pub aged_todo_hours: u64,
244    #[serde(default = "default_stale_review_hours")]
245    pub stale_review_hours: u64,
246    #[serde(default)]
247    pub review_timeout_overrides: HashMap<String, ReviewTimeoutOverride>,
248    #[serde(default)]
249    pub auto_archive_done_after_secs: Option<u64>,
250    #[serde(default)]
251    pub capability_overrides: HashMap<String, Vec<String>>,
252    #[serde(default = "default_stall_threshold_secs")]
253    pub stall_threshold_secs: u64,
254    #[serde(default = "default_max_stall_restarts")]
255    pub max_stall_restarts: u32,
256    #[serde(default = "default_health_check_interval_secs")]
257    pub health_check_interval_secs: u64,
258    #[serde(default = "default_planning_cycle_cooldown_secs")]
259    pub planning_cycle_cooldown_secs: u64,
260    #[serde(default = "default_narration_threshold")]
261    pub narration_threshold: f64,
262    #[serde(default = "default_narration_nudge_max")]
263    pub narration_nudge_max: u32,
264    #[serde(default = "default_narration_detection_enabled")]
265    pub narration_detection_enabled: bool,
266    #[serde(default = "default_narration_threshold_polls")]
267    pub narration_threshold_polls: u32,
268    #[serde(default = "default_context_pressure_threshold")]
269    pub context_pressure_threshold: u64,
270    #[serde(default = "default_context_pressure_threshold_bytes")]
271    pub context_pressure_threshold_bytes: u64,
272    #[serde(default = "default_context_pressure_restart_delay_secs")]
273    pub context_pressure_restart_delay_secs: u64,
274    #[serde(default = "default_graceful_shutdown_timeout_secs")]
275    pub graceful_shutdown_timeout_secs: u64,
276    #[serde(default = "default_auto_commit_on_restart")]
277    pub auto_commit_on_restart: bool,
278    #[serde(default = "default_uncommitted_warn_threshold")]
279    pub uncommitted_warn_threshold: usize,
280    /// Legacy global test command. Verification now prefers
281    /// `workflow_policy.verification.test_command` and falls back here.
282    #[serde(default)]
283    pub test_command: Option<String>,
284    #[serde(default)]
285    pub verification: VerificationPolicy,
286    #[serde(default)]
287    pub claim_ttl: ClaimTtlPolicy,
288    #[serde(default)]
289    pub allocation: AllocationPolicy,
290    #[serde(default)]
291    pub auto_merge: AutoMergePolicy,
292    /// When true, context exhaustion restarts capture a work summary and
293    /// inject it into the new agent session so it can continue where the
294    /// old session left off.
295    #[serde(default = "default_context_handoff_enabled")]
296    pub context_handoff_enabled: bool,
297    /// Number of PTY screen pages to include in the handoff summary.
298    #[serde(default = "default_handoff_screen_history")]
299    pub handoff_screen_history: usize,
300}
301
302#[derive(Debug, Clone, Deserialize)]
303pub struct ClaimTtlPolicy {
304    #[serde(default = "default_claim_ttl_default_secs")]
305    pub default_secs: u64,
306    #[serde(default = "default_claim_ttl_critical_secs")]
307    pub critical_secs: u64,
308    #[serde(default = "default_claim_ttl_max_extensions")]
309    pub max_extensions: u32,
310    #[serde(default = "default_claim_ttl_progress_check_interval_secs")]
311    pub progress_check_interval_secs: u64,
312    #[serde(default = "default_claim_ttl_warning_secs")]
313    pub warning_secs: u64,
314}
315
316impl Default for ClaimTtlPolicy {
317    fn default() -> Self {
318        Self {
319            default_secs: default_claim_ttl_default_secs(),
320            critical_secs: default_claim_ttl_critical_secs(),
321            max_extensions: default_claim_ttl_max_extensions(),
322            progress_check_interval_secs: default_claim_ttl_progress_check_interval_secs(),
323            warning_secs: default_claim_ttl_warning_secs(),
324        }
325    }
326}
327
328/// Per-priority override for review timeout thresholds.
329/// When a task's priority matches a key in `review_timeout_overrides`,
330/// these values replace the global defaults.
331#[derive(Debug, Clone, Deserialize)]
332pub struct ReviewTimeoutOverride {
333    /// Nudge threshold override (seconds). Falls back to global if absent.
334    pub review_nudge_threshold_secs: Option<u64>,
335    /// Escalation threshold override (seconds). Falls back to global if absent.
336    pub review_timeout_secs: Option<u64>,
337}
338
339impl Default for WorkflowPolicy {
340    fn default() -> Self {
341        Self {
342            clean_room_mode: false,
343            barrier_groups: HashMap::new(),
344            handoff_directory: default_handoff_directory(),
345            wip_limit_per_engineer: None,
346            wip_limit_per_reviewer: None,
347            pipeline_starvation_threshold: default_pipeline_starvation_threshold(),
348            escalation_threshold_secs: default_escalation_threshold_secs(),
349            review_nudge_threshold_secs: default_review_nudge_threshold_secs(),
350            review_timeout_secs: default_review_timeout_secs(),
351            stale_in_progress_hours: default_stale_in_progress_hours(),
352            aged_todo_hours: default_aged_todo_hours(),
353            stale_review_hours: default_stale_review_hours(),
354            review_timeout_overrides: HashMap::new(),
355            auto_archive_done_after_secs: None,
356            capability_overrides: HashMap::new(),
357            stall_threshold_secs: default_stall_threshold_secs(),
358            max_stall_restarts: default_max_stall_restarts(),
359            health_check_interval_secs: default_health_check_interval_secs(),
360            planning_cycle_cooldown_secs: default_planning_cycle_cooldown_secs(),
361            narration_threshold: default_narration_threshold(),
362            narration_nudge_max: default_narration_nudge_max(),
363            narration_detection_enabled: default_narration_detection_enabled(),
364            narration_threshold_polls: default_narration_threshold_polls(),
365            context_pressure_threshold: default_context_pressure_threshold(),
366            context_pressure_threshold_bytes: default_context_pressure_threshold_bytes(),
367            context_pressure_restart_delay_secs: default_context_pressure_restart_delay_secs(),
368            graceful_shutdown_timeout_secs: default_graceful_shutdown_timeout_secs(),
369            auto_commit_on_restart: default_auto_commit_on_restart(),
370            uncommitted_warn_threshold: default_uncommitted_warn_threshold(),
371            test_command: None,
372            verification: VerificationPolicy::default(),
373            claim_ttl: ClaimTtlPolicy::default(),
374            allocation: AllocationPolicy::default(),
375            auto_merge: AutoMergePolicy::default(),
376            context_handoff_enabled: default_context_handoff_enabled(),
377            handoff_screen_history: default_handoff_screen_history(),
378        }
379    }
380}
381
382#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
383#[serde(rename_all = "snake_case")]
384pub enum AllocationStrategy {
385    RoundRobin,
386    Scored,
387}
388
389#[derive(Debug, Clone, Deserialize)]
390pub struct AllocationPolicy {
391    #[serde(default = "default_allocation_strategy")]
392    pub strategy: AllocationStrategy,
393    #[serde(default = "default_allocation_tag_weight")]
394    pub tag_weight: i32,
395    #[serde(default = "default_allocation_file_overlap_weight")]
396    pub file_overlap_weight: i32,
397    #[serde(default = "default_allocation_load_penalty")]
398    pub load_penalty: i32,
399    #[serde(default = "default_allocation_conflict_penalty")]
400    pub conflict_penalty: i32,
401    #[serde(default = "default_allocation_experience_bonus")]
402    pub experience_bonus: i32,
403}
404
405impl Default for AllocationPolicy {
406    fn default() -> Self {
407        Self {
408            strategy: default_allocation_strategy(),
409            tag_weight: default_allocation_tag_weight(),
410            file_overlap_weight: default_allocation_file_overlap_weight(),
411            load_penalty: default_allocation_load_penalty(),
412            conflict_penalty: default_allocation_conflict_penalty(),
413            experience_bonus: default_allocation_experience_bonus(),
414        }
415    }
416}
417
418#[derive(Debug, Clone, Deserialize)]
419pub struct VerificationPolicy {
420    #[serde(default = "default_verification_max_iterations")]
421    pub max_iterations: u32,
422    #[serde(default = "default_verification_auto_run_tests")]
423    pub auto_run_tests: bool,
424    #[serde(default = "default_verification_require_evidence")]
425    pub require_evidence: bool,
426    #[serde(default)]
427    pub test_command: Option<String>,
428}
429
430impl Default for VerificationPolicy {
431    fn default() -> Self {
432        Self {
433            max_iterations: default_verification_max_iterations(),
434            auto_run_tests: default_verification_auto_run_tests(),
435            require_evidence: default_verification_require_evidence(),
436            test_command: None,
437        }
438    }
439}
440
441fn default_graceful_shutdown_timeout_secs() -> u64 {
442    30
443}
444
445fn default_verification_max_iterations() -> u32 {
446    5
447}
448
449fn default_verification_auto_run_tests() -> bool {
450    true
451}
452
453fn default_verification_require_evidence() -> bool {
454    true
455}
456
457fn default_claim_ttl_default_secs() -> u64 {
458    1800
459}
460
461fn default_claim_ttl_critical_secs() -> u64 {
462    900
463}
464
465fn default_claim_ttl_max_extensions() -> u32 {
466    2
467}
468
469fn default_claim_ttl_progress_check_interval_secs() -> u64 {
470    120
471}
472
473fn default_claim_ttl_warning_secs() -> u64 {
474    300
475}
476
477fn default_allocation_strategy() -> AllocationStrategy {
478    AllocationStrategy::Scored
479}
480
481fn default_allocation_tag_weight() -> i32 {
482    15
483}
484
485fn default_allocation_file_overlap_weight() -> i32 {
486    10
487}
488
489fn default_allocation_load_penalty() -> i32 {
490    8
491}
492
493fn default_allocation_conflict_penalty() -> i32 {
494    12
495}
496
497fn default_allocation_experience_bonus() -> i32 {
498    3
499}
500
501fn default_auto_commit_on_restart() -> bool {
502    true
503}
504
505fn default_context_handoff_enabled() -> bool {
506    true
507}
508
509fn default_handoff_screen_history() -> usize {
510    20
511}
512
513fn default_handoff_directory() -> String {
514    ".batty/handoff".to_string()
515}
516
517fn default_planning_cycle_cooldown_secs() -> u64 {
518    300
519}
520
521fn default_context_pressure_threshold() -> u64 {
522    100
523}
524
525fn default_context_pressure_threshold_bytes() -> u64 {
526    512_000
527}
528
529fn default_context_pressure_restart_delay_secs() -> u64 {
530    120
531}
532
533fn default_sensitive_paths() -> Vec<String> {
534    vec![
535        "Cargo.toml".to_string(),
536        "team.yaml".to_string(),
537        ".env".to_string(),
538    ]
539}
540
541#[derive(Debug, Clone, Deserialize)]
542pub struct AutoMergePolicy {
543    #[serde(default = "default_auto_merge_enabled")]
544    pub enabled: bool,
545    #[serde(default = "default_max_diff_lines")]
546    pub max_diff_lines: usize,
547    #[serde(default = "default_max_files_changed")]
548    pub max_files_changed: usize,
549    #[serde(default = "default_max_modules_touched")]
550    pub max_modules_touched: usize,
551    #[serde(default = "default_sensitive_paths")]
552    pub sensitive_paths: Vec<String>,
553    #[serde(default = "default_confidence_threshold")]
554    pub confidence_threshold: f64,
555    #[serde(default = "default_require_tests_pass")]
556    pub require_tests_pass: bool,
557    #[serde(default = "default_post_merge_verify")]
558    pub post_merge_verify: bool,
559}
560
561fn default_max_diff_lines() -> usize {
562    2000 // Real tasks produce 300-600 lines; 200 blocked everything
563}
564fn default_auto_merge_enabled() -> bool {
565    true
566}
567fn default_max_files_changed() -> usize {
568    30 // Real tasks touch 8-14 files; 5 blocked everything
569}
570fn default_max_modules_touched() -> usize {
571    10 // Real tasks touch 3-5 modules; 2 blocked everything
572}
573fn default_narration_threshold() -> f64 {
574    0.8
575}
576fn default_narration_nudge_max() -> u32 {
577    2
578}
579fn default_narration_detection_enabled() -> bool {
580    true
581}
582fn default_narration_threshold_polls() -> u32 {
583    5
584}
585fn default_confidence_threshold() -> f64 {
586    0.0 // Trust tests as the merge gate, not heuristic confidence scoring
587}
588fn default_require_tests_pass() -> bool {
589    true
590}
591fn default_post_merge_verify() -> bool {
592    true
593}
594
595impl Default for AutoMergePolicy {
596    fn default() -> Self {
597        Self {
598            enabled: true,
599            max_diff_lines: default_max_diff_lines(),
600            max_files_changed: default_max_files_changed(),
601            max_modules_touched: default_max_modules_touched(),
602            sensitive_paths: default_sensitive_paths(),
603            confidence_threshold: default_confidence_threshold(),
604            require_tests_pass: default_require_tests_pass(),
605            post_merge_verify: default_post_merge_verify(),
606        }
607    }
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
611#[serde(rename_all = "snake_case")]
612pub enum WorkflowMode {
613    #[default]
614    Legacy,
615    Hybrid,
616    WorkflowFirst,
617    BoardFirst,
618}
619
620#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
621#[serde(rename_all = "snake_case")]
622pub enum OrchestratorPosition {
623    #[default]
624    Left,
625    Bottom,
626}
627
628impl WorkflowMode {
629    #[cfg_attr(not(test), allow(dead_code))]
630    pub fn legacy_runtime_enabled(self) -> bool {
631        matches!(self, Self::Legacy | Self::Hybrid)
632    }
633
634    #[cfg_attr(not(test), allow(dead_code))]
635    pub fn workflow_state_primary(self) -> bool {
636        matches!(self, Self::WorkflowFirst | Self::BoardFirst)
637    }
638
639    pub fn enables_runtime_surface(self) -> bool {
640        matches!(self, Self::Hybrid | Self::WorkflowFirst | Self::BoardFirst)
641    }
642
643    pub fn suppresses_manager_relay(self) -> bool {
644        matches!(self, Self::BoardFirst)
645    }
646
647    pub fn as_str(self) -> &'static str {
648        match self {
649            Self::Legacy => "legacy",
650            Self::Hybrid => "hybrid",
651            Self::WorkflowFirst => "workflow_first",
652            Self::BoardFirst => "board_first",
653        }
654    }
655}
656
657#[derive(Debug, Clone, Deserialize)]
658pub struct BoardConfig {
659    #[serde(default = "default_rotation_threshold")]
660    pub rotation_threshold: u32,
661    #[serde(default = "default_board_auto_dispatch")]
662    pub auto_dispatch: bool,
663    #[serde(default = "default_worktree_stale_rebase_threshold")]
664    pub worktree_stale_rebase_threshold: u32,
665    #[serde(default = "default_board_auto_replenish")]
666    pub auto_replenish: bool,
667    #[serde(default = "default_state_reconciliation_interval_secs")]
668    pub state_reconciliation_interval_secs: u64,
669    #[serde(default = "default_dispatch_stabilization_delay_secs")]
670    pub dispatch_stabilization_delay_secs: u64,
671    #[serde(default = "default_dispatch_dedup_window_secs")]
672    pub dispatch_dedup_window_secs: u64,
673    #[serde(default = "default_dispatch_manual_cooldown_secs")]
674    pub dispatch_manual_cooldown_secs: u64,
675}
676
677impl Default for BoardConfig {
678    fn default() -> Self {
679        Self {
680            rotation_threshold: default_rotation_threshold(),
681            auto_dispatch: default_board_auto_dispatch(),
682            worktree_stale_rebase_threshold: default_worktree_stale_rebase_threshold(),
683            auto_replenish: default_board_auto_replenish(),
684            state_reconciliation_interval_secs: default_state_reconciliation_interval_secs(),
685            dispatch_stabilization_delay_secs: default_dispatch_stabilization_delay_secs(),
686            dispatch_dedup_window_secs: default_dispatch_dedup_window_secs(),
687            dispatch_manual_cooldown_secs: default_dispatch_manual_cooldown_secs(),
688        }
689    }
690}
691
692#[derive(Debug, Clone, Deserialize)]
693pub struct StandupConfig {
694    #[serde(default = "default_standup_interval")]
695    pub interval_secs: u64,
696    #[serde(default = "default_output_lines")]
697    pub output_lines: u32,
698}
699
700impl Default for StandupConfig {
701    fn default() -> Self {
702        Self {
703            interval_secs: default_standup_interval(),
704            output_lines: default_output_lines(),
705        }
706    }
707}
708
709#[derive(Debug, Clone, Deserialize)]
710pub struct AutomationConfig {
711    #[serde(default = "default_enabled")]
712    pub timeout_nudges: bool,
713    #[serde(default = "default_enabled")]
714    pub standups: bool,
715    #[serde(default)]
716    pub clean_room_mode: bool,
717    #[serde(default = "default_enabled")]
718    pub failure_pattern_detection: bool,
719    #[serde(default = "default_enabled")]
720    pub triage_interventions: bool,
721    #[serde(default = "default_enabled")]
722    pub review_interventions: bool,
723    #[serde(default = "default_enabled")]
724    pub owned_task_interventions: bool,
725    #[serde(default = "default_enabled")]
726    pub manager_dispatch_interventions: bool,
727    #[serde(default = "default_enabled")]
728    pub architect_utilization_interventions: bool,
729    #[serde(default)]
730    pub replenishment_threshold: Option<usize>,
731    #[serde(default = "default_intervention_idle_grace_secs")]
732    pub intervention_idle_grace_secs: u64,
733    #[serde(default = "default_intervention_cooldown_secs")]
734    pub intervention_cooldown_secs: u64,
735    #[serde(default = "default_utilization_recovery_interval_secs")]
736    pub utilization_recovery_interval_secs: u64,
737    #[serde(default = "default_enabled")]
738    pub commit_before_reset: bool,
739    #[serde(default)]
740    pub disk_hygiene: DiskHygieneConfig,
741}
742
743impl Default for AutomationConfig {
744    fn default() -> Self {
745        Self {
746            timeout_nudges: default_enabled(),
747            standups: default_enabled(),
748            clean_room_mode: false,
749            failure_pattern_detection: default_enabled(),
750            triage_interventions: default_enabled(),
751            review_interventions: default_enabled(),
752            owned_task_interventions: default_enabled(),
753            manager_dispatch_interventions: default_enabled(),
754            architect_utilization_interventions: default_enabled(),
755            replenishment_threshold: None,
756            intervention_idle_grace_secs: default_intervention_idle_grace_secs(),
757            intervention_cooldown_secs: default_intervention_cooldown_secs(),
758            utilization_recovery_interval_secs: default_utilization_recovery_interval_secs(),
759            commit_before_reset: default_enabled(),
760            disk_hygiene: DiskHygieneConfig::default(),
761        }
762    }
763}
764
765#[derive(Debug, Clone, Deserialize)]
766pub struct LayoutConfig {
767    pub zones: Vec<ZoneDef>,
768}
769
770#[derive(Debug, Clone, Deserialize)]
771pub struct ZoneDef {
772    pub name: String,
773    pub width_pct: u32,
774    #[serde(default)]
775    pub split: Option<SplitDef>,
776}
777
778#[derive(Debug, Clone, Deserialize)]
779pub struct SplitDef {
780    pub horizontal: u32,
781}
782
783#[derive(Debug, Clone, Deserialize)]
784pub struct RoleDef {
785    pub name: String,
786    pub role_type: RoleType,
787    #[serde(default)]
788    pub agent: Option<String>,
789    #[serde(default)]
790    pub model: Option<String>,
791    #[serde(default)]
792    pub auth_mode: Option<ClaudeAuthMode>,
793    #[serde(default)]
794    pub auth_env: Vec<String>,
795    #[serde(default = "default_instances")]
796    pub instances: u32,
797    #[serde(default)]
798    pub prompt: Option<String>,
799    #[serde(default)]
800    pub posture: Option<String>,
801    #[serde(default)]
802    pub model_class: Option<String>,
803    #[serde(default)]
804    pub provider_overlay: Option<String>,
805    #[serde(default)]
806    pub instance_overrides: HashMap<String, RoleInstanceOverride>,
807    #[serde(default)]
808    pub talks_to: Vec<String>,
809    #[serde(default)]
810    pub channel: Option<String>,
811    #[serde(default)]
812    pub channel_config: Option<ChannelConfig>,
813    #[serde(default)]
814    pub nudge_interval_secs: Option<u64>,
815    #[serde(default)]
816    pub receives_standup: Option<bool>,
817    #[serde(default)]
818    pub standup_interval_secs: Option<u64>,
819    #[serde(default)]
820    #[allow(dead_code)] // Parsed for future ownership semantics but not yet enforced.
821    pub owns: Vec<String>,
822    #[serde(default)]
823    pub barrier_group: Option<String>,
824    #[serde(default)]
825    pub use_worktrees: bool,
826}
827
828impl Default for RoleDef {
829    fn default() -> Self {
830        Self {
831            name: String::new(),
832            role_type: RoleType::Engineer,
833            agent: None,
834            model: None,
835            auth_mode: None,
836            auth_env: Vec::new(),
837            instances: default_instances(),
838            prompt: None,
839            posture: None,
840            model_class: None,
841            provider_overlay: None,
842            instance_overrides: HashMap::new(),
843            talks_to: Vec::new(),
844            channel: None,
845            channel_config: None,
846            nudge_interval_secs: None,
847            receives_standup: None,
848            standup_interval_secs: None,
849            owns: Vec::new(),
850            barrier_group: None,
851            use_worktrees: false,
852        }
853    }
854}
855
856#[derive(Debug, Clone, Default, Deserialize)]
857pub struct RoleInstanceOverride {
858    #[serde(default)]
859    pub agent: Option<String>,
860    #[serde(default)]
861    pub model: Option<String>,
862    #[serde(default)]
863    pub prompt: Option<String>,
864    #[serde(default)]
865    pub posture: Option<String>,
866    #[serde(default)]
867    pub model_class: Option<String>,
868    #[serde(default)]
869    pub provider_overlay: Option<String>,
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
873#[serde(rename_all = "snake_case")]
874pub enum ClaudeAuthMode {
875    #[default]
876    Oauth,
877    ApiKey,
878    Custom,
879}
880
881#[derive(Debug, Clone, Default, Deserialize)]
882pub struct ChannelConfig {
883    #[serde(default)]
884    pub target: String,
885    #[serde(default)]
886    pub provider: String,
887    /// Telegram bot token for native API (optional; falls back to provider CLI).
888    /// Can also be set via `BATTY_TELEGRAM_BOT_TOKEN` env var.
889    #[serde(default)]
890    pub bot_token: Option<String>,
891    /// User IDs allowed to send messages (access control).
892    ///
893    /// Accepts either numeric YAML values or quoted strings so the same field
894    /// can represent Telegram integers and Discord snowflake IDs.
895    #[serde(default, deserialize_with = "deserialize_user_id_list")]
896    pub allowed_user_ids: Vec<i64>,
897    #[serde(default)]
898    pub events_channel_id: Option<String>,
899    #[serde(default)]
900    pub agents_channel_id: Option<String>,
901    #[serde(default)]
902    pub commands_channel_id: Option<String>,
903    #[serde(default)]
904    pub board_channel_id: Option<String>,
905}
906
907#[derive(Deserialize)]
908#[serde(untagged)]
909enum UserIdValue {
910    Integer(i64),
911    String(String),
912}
913
914fn deserialize_user_id_list<'de, D>(deserializer: D) -> Result<Vec<i64>, D::Error>
915where
916    D: Deserializer<'de>,
917{
918    let values = Vec::<UserIdValue>::deserialize(deserializer)?;
919    values
920        .into_iter()
921        .map(|value| match value {
922            UserIdValue::Integer(id) => Ok(id),
923            UserIdValue::String(raw) => raw
924                .parse::<i64>()
925                .map_err(|error| de::Error::custom(format!("invalid user id '{raw}': {error}"))),
926        })
927        .collect()
928}
929
930#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
931#[serde(rename_all = "lowercase")]
932pub enum RoleType {
933    User,
934    Architect,
935    Manager,
936    Engineer,
937}
938
939// --- Default value functions ---
940
941fn default_rotation_threshold() -> u32 {
942    20
943}
944
945fn default_workflow_mode() -> WorkflowMode {
946    WorkflowMode::Legacy
947}
948
949fn default_board_auto_dispatch() -> bool {
950    true
951}
952
953fn default_worktree_stale_rebase_threshold() -> u32 {
954    5
955}
956
957fn default_board_auto_replenish() -> bool {
958    true
959}
960
961fn default_state_reconciliation_interval_secs() -> u64 {
962    30
963}
964
965fn default_dispatch_stabilization_delay_secs() -> u64 {
966    30
967}
968
969fn default_dispatch_dedup_window_secs() -> u64 {
970    900 // 15 minutes — prevents dispatch→fail→re-enqueue loops
971}
972
973fn default_dispatch_manual_cooldown_secs() -> u64 {
974    30
975}
976
977fn default_standup_interval() -> u64 {
978    300
979}
980
981fn default_pipeline_starvation_threshold() -> Option<usize> {
982    Some(1)
983}
984
985fn default_output_lines() -> u32 {
986    30
987}
988
989fn default_instances() -> u32 {
990    1
991}
992
993fn default_escalation_threshold_secs() -> u64 {
994    3600
995}
996
997fn default_review_nudge_threshold_secs() -> u64 {
998    1800
999}
1000
1001fn default_review_timeout_secs() -> u64 {
1002    7200
1003}
1004
1005fn default_stale_in_progress_hours() -> u64 {
1006    4
1007}
1008
1009fn default_aged_todo_hours() -> u64 {
1010    48
1011}
1012
1013fn default_stale_review_hours() -> u64 {
1014    1
1015}
1016
1017fn default_stall_threshold_secs() -> u64 {
1018    300
1019}
1020
1021fn default_max_stall_restarts() -> u32 {
1022    2
1023}
1024
1025fn default_health_check_interval_secs() -> u64 {
1026    60
1027}
1028
1029fn default_uncommitted_warn_threshold() -> usize {
1030    200
1031}
1032
1033fn default_enabled() -> bool {
1034    true
1035}
1036
1037fn default_orchestrator_pane() -> bool {
1038    true
1039}
1040
1041fn default_intervention_idle_grace_secs() -> u64 {
1042    60
1043}
1044
1045fn default_intervention_cooldown_secs() -> u64 {
1046    120
1047}
1048
1049fn default_utilization_recovery_interval_secs() -> u64 {
1050    1200
1051}
1052
1053fn default_event_log_max_bytes() -> u64 {
1054    DEFAULT_EVENT_LOG_MAX_BYTES
1055}
1056
1057fn default_retro_min_duration_secs() -> u64 {
1058    60
1059}
1060
1061fn default_disk_hygiene_check_interval_secs() -> u64 {
1062    600
1063}
1064
1065fn default_disk_hygiene_min_free_gb() -> u64 {
1066    10
1067}
1068
1069fn default_disk_hygiene_max_shared_target_gb() -> u64 {
1070    4
1071}
1072
1073fn default_disk_hygiene_log_rotation_hours() -> u64 {
1074    24
1075}
1076
1077/// Configuration for automated disk hygiene during long runs.
1078#[derive(Debug, Clone, Deserialize)]
1079pub struct DiskHygieneConfig {
1080    /// Enable automated disk hygiene checks.
1081    #[serde(default = "default_enabled")]
1082    pub enabled: bool,
1083    /// Interval in seconds between periodic disk pressure checks.
1084    #[serde(default = "default_disk_hygiene_check_interval_secs")]
1085    pub check_interval_secs: u64,
1086    /// Minimum free disk space in GB before triggering cleanup.
1087    #[serde(default = "default_disk_hygiene_min_free_gb")]
1088    pub min_free_gb: u64,
1089    /// Maximum size in GB for the shared-target directory.
1090    #[serde(default = "default_disk_hygiene_max_shared_target_gb")]
1091    pub max_shared_target_gb: u64,
1092    /// Hours after which shim-logs and inbox messages are rotated.
1093    #[serde(default = "default_disk_hygiene_log_rotation_hours")]
1094    pub log_rotation_hours: u64,
1095    /// Run `cargo clean --profile dev` in engineer worktree shared-target after merge.
1096    #[serde(default = "default_enabled")]
1097    pub post_merge_cleanup: bool,
1098    /// Prune completed task branches after merge.
1099    #[serde(default = "default_enabled")]
1100    pub prune_merged_branches: bool,
1101}
1102
1103impl Default for DiskHygieneConfig {
1104    fn default() -> Self {
1105        Self {
1106            enabled: default_enabled(),
1107            check_interval_secs: default_disk_hygiene_check_interval_secs(),
1108            min_free_gb: default_disk_hygiene_min_free_gb(),
1109            max_shared_target_gb: default_disk_hygiene_max_shared_target_gb(),
1110            log_rotation_hours: default_disk_hygiene_log_rotation_hours(),
1111            post_merge_cleanup: default_enabled(),
1112            prune_merged_branches: default_enabled(),
1113        }
1114    }
1115}
1116
1117fn default_shim_health_check_interval_secs() -> u64 {
1118    60
1119}
1120
1121fn default_shim_health_timeout_secs() -> u64 {
1122    120
1123}
1124
1125fn default_shim_shutdown_timeout_secs() -> u32 {
1126    30
1127}
1128
1129fn default_shim_working_state_timeout_secs() -> u64 {
1130    7200 // 2 hours — modern agents work for extended periods
1131}
1132
1133fn default_pending_queue_max_age_secs() -> u64 {
1134    600 // 10 minutes
1135}