Skip to main content

devflow_core/
mode.rs

1//! Execution mode and the mode-driven gate decision.
2//!
3//! Mode is a per-session CLI flag on `devflow start` — there is no config file
4//! and no per-phase toggling.
5//!
6//! - **Auto:** Define and Plan run once. Code ↔ Validate auto-loop until clean.
7//!   Then Ship. The only human gate is at Ship — unless Validate fails
8//!   [`MAX_CONSECUTIVE_FAILURES`] times in a row, which forces a gate.
9//! - **Supervise:** Same pipeline, but Validate always fires a gate to Hermes →
10//!   Human before advancing to Ship.
11
12use crate::stage::Stage;
13use serde::{Deserialize, Serialize};
14use std::fmt;
15use std::str::FromStr;
16
17/// Number of consecutive Validate failures in Auto mode before a gate is forced.
18pub const MAX_CONSECUTIVE_FAILURES: u32 = 3;
19
20/// How DevFlow drives the pipeline for a session.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Mode {
24    /// Run the pipeline without human gates until Ship (or repeated failure).
25    Auto,
26    /// Fire a Validate gate to Hermes → Human before Ship.
27    Supervise,
28}
29
30impl Mode {
31    /// Whether `stage` should fire a gate, given how many consecutive Validate
32    /// failures have already occurred this session.
33    ///
34    /// - Ship always gates (both modes).
35    /// - Supervise gates at every Validate.
36    /// - Auto gates at Validate only after [`MAX_CONSECUTIVE_FAILURES`] failures.
37    pub fn should_gate(self, stage: Stage, consecutive_failures: u32) -> bool {
38        match stage {
39            Stage::Ship => true,
40            Stage::Validate => match self {
41                Mode::Supervise => true,
42                Mode::Auto => consecutive_failures >= MAX_CONSECUTIVE_FAILURES,
43            },
44            _ => false,
45        }
46    }
47
48    /// Whether a failed Validate at `stage` may auto-loop back to Code without a
49    /// human gate. Auto loops Code↔Validate; Supervise requires human approval.
50    pub fn should_auto_loop(self, stage: Stage) -> bool {
51        matches!(stage, Stage::Validate) && matches!(self, Mode::Auto)
52    }
53}
54
55impl fmt::Display for Mode {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        let name = match self {
58            Mode::Auto => "auto",
59            Mode::Supervise => "supervise",
60        };
61        f.write_str(name)
62    }
63}
64
65impl FromStr for Mode {
66    type Err = ModeParseError;
67
68    fn from_str(value: &str) -> Result<Self, Self::Err> {
69        match value.to_ascii_lowercase().as_str() {
70            "auto" => Ok(Mode::Auto),
71            "supervise" | "supervised" => Ok(Mode::Supervise),
72            other => Err(ModeParseError(other.to_string())),
73        }
74    }
75}
76
77/// Error returned when parsing an unsupported mode name.
78#[derive(Debug, Clone, thiserror::Error)]
79#[error("unsupported mode `{0}`; expected auto or supervise")]
80pub struct ModeParseError(String);
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn from_str_accepts_canonical_and_alias() {
88        assert_eq!("auto".parse::<Mode>().unwrap(), Mode::Auto);
89        assert_eq!("AUTO".parse::<Mode>().unwrap(), Mode::Auto);
90        assert_eq!("supervise".parse::<Mode>().unwrap(), Mode::Supervise);
91        assert_eq!("supervised".parse::<Mode>().unwrap(), Mode::Supervise);
92    }
93
94    #[test]
95    fn from_str_rejects_unknown() {
96        let err = "yolo".parse::<Mode>().unwrap_err();
97        assert!(err.to_string().contains("yolo"));
98    }
99
100    #[test]
101    fn auto_does_not_gate_validate_until_failure_threshold() {
102        assert!(!Mode::Auto.should_gate(Stage::Validate, 0));
103        assert!(!Mode::Auto.should_gate(Stage::Validate, 2));
104        assert!(Mode::Auto.should_gate(Stage::Validate, MAX_CONSECUTIVE_FAILURES));
105        assert!(Mode::Auto.should_gate(Stage::Validate, 9));
106    }
107
108    #[test]
109    fn supervise_always_gates_validate() {
110        assert!(Mode::Supervise.should_gate(Stage::Validate, 0));
111        assert!(Mode::Supervise.should_gate(Stage::Validate, 5));
112    }
113
114    #[test]
115    fn ship_always_gates_in_both_modes() {
116        assert!(Mode::Auto.should_gate(Stage::Ship, 0));
117        assert!(Mode::Supervise.should_gate(Stage::Ship, 0));
118    }
119
120    #[test]
121    fn non_gate_stages_never_gate() {
122        for stage in [Stage::Define, Stage::Plan, Stage::Code] {
123            assert!(!Mode::Auto.should_gate(stage, 99));
124            assert!(!Mode::Supervise.should_gate(stage, 99));
125        }
126    }
127
128    #[test]
129    fn auto_loops_validate_supervise_does_not() {
130        assert!(Mode::Auto.should_auto_loop(Stage::Validate));
131        assert!(!Mode::Supervise.should_auto_loop(Stage::Validate));
132        assert!(!Mode::Auto.should_auto_loop(Stage::Code));
133    }
134
135    #[test]
136    fn display_round_trips_through_from_str() {
137        for mode in [Mode::Auto, Mode::Supervise] {
138            assert_eq!(mode.to_string().parse::<Mode>().unwrap(), mode);
139        }
140    }
141}