Skip to main content

omne_cli/
sentinel.rs

1//! Reserved-token scanner for reconstructed assistant lines.
2//!
3//! Unit 10 of the v1 kernel plan. Consumed by the loop controller in
4//! Unit 11: for every line yielded by `claude_proc::ClaudeProcess`, the
5//! controller feeds the `text` into a [`Scanner`] and decides what to
6//! do with the returned [`Hit`].
7//!
8//! Vocabulary in v1:
9//!
10//! - [`BLOCKED`] — always reserved. Signals the pipe run is waiting on
11//!   a human or an external condition. Detected regardless of whether
12//!   the active loop registers it.
13//! - User-supplied `until` tokens — carried on `loop.until` in the
14//!   pipe YAML. `ALL_TASKS_COMPLETE` is the canonical one for
15//!   ship-or-halt loops, but any string the user registered is fair
16//!   game.
17//!
18//! Match rule: the scanner returns `Some(Hit)` **only** when the
19//! input line, after `trim`, is exactly equal to a registered token.
20//! A narrative like `I am BLOCKED by a dependency` must not match; a
21//! standalone `BLOCKED` line (or one padded with whitespace) must.
22//!
23//! Priority: when a stream contains both a `BLOCKED` and an `until`
24//! line, `BLOCKED` wins. Per-line [`Scanner::feed`] is unaware of
25//! prior lines, so callers that want this guarantee either use
26//! [`Scanner::scan`] (stateful pass over an iterator) or track the
27//! precedence themselves via [`Kind::Blocked`] vs [`Kind::Until`].
28//!
29//! Wrap-form tokens (`<promise>TOKEN</promise>`) are deferred past v1
30//! — the plan explicitly narrows the surface to standalone lines.
31
32#![allow(dead_code)]
33
34/// The reserved sentinel that always takes priority over user tokens.
35pub const BLOCKED: &str = "BLOCKED";
36
37/// Classifies a [`Hit`] so callers can resolve priority without
38/// re-comparing against the constant.
39#[derive(Debug, Clone, Copy, Eq, PartialEq)]
40pub enum Kind {
41    /// Matched the reserved [`BLOCKED`] constant.
42    Blocked,
43    /// Matched a user-registered `until` token.
44    Until,
45}
46
47/// Outcome of a successful sentinel match.
48#[derive(Debug, Clone, Eq, PartialEq)]
49pub struct Hit {
50    /// The exact token text (e.g. `"BLOCKED"`, `"ALL_TASKS_COMPLETE"`).
51    /// Owned, because the line it came from is typically borrowed from
52    /// a streaming iterator that will reuse its buffer.
53    pub token: String,
54    pub kind: Kind,
55}
56
57/// Stateless sentinel matcher.
58///
59/// `Scanner::new(until_tokens)` builds one; [`feed`](Scanner::feed)
60/// tests a single reconstructed line; [`scan`](Scanner::scan) folds an
61/// iterator of lines into a single priority-respecting outcome.
62///
63/// The type holds no interior mutable state, so a single `Scanner` is
64/// safe to share across threads for the rare case where multiple
65/// streams feed the same loop controller.
66#[derive(Debug, Clone)]
67pub struct Scanner {
68    /// User-supplied tokens. Stored owned so borrow lifetimes don't
69    /// leak through the public API. Duplicates and empty strings are
70    /// filtered at construction to avoid surprising hits on blank
71    /// lines (which `feed` rejects anyway, but the extra work costs
72    /// nothing and documents the invariant).
73    until: Vec<String>,
74}
75
76impl Scanner {
77    /// Build a scanner that always watches for [`BLOCKED`] plus every
78    /// non-empty, unique `until` token in `tokens`.
79    ///
80    /// Any token that trims to empty is dropped; passing
81    /// `BLOCKED` explicitly is a no-op (it is always reserved).
82    pub fn new<S: AsRef<str>>(tokens: &[S]) -> Self {
83        let mut until: Vec<String> = Vec::with_capacity(tokens.len());
84        for t in tokens {
85            let trimmed = t.as_ref().trim();
86            if trimmed.is_empty() || trimmed == BLOCKED {
87                continue;
88            }
89            let owned = trimmed.to_string();
90            if !until.contains(&owned) {
91                until.push(owned);
92            }
93        }
94        Self { until }
95    }
96
97    /// Test a single reconstructed line for an exact-token match.
98    ///
99    /// Returns `Some(Hit { kind: Blocked, .. })` when the trimmed line
100    /// equals [`BLOCKED`]; returns `Some(Hit { kind: Until, .. })`
101    /// when it equals any registered `until` token; otherwise `None`.
102    /// `BLOCKED` is checked first so a caller that registered it by
103    /// mistake via `tokens` still gets the correct [`Kind`].
104    pub fn feed(&self, line: &str) -> Option<Hit> {
105        let trimmed = line.trim();
106        if trimmed.is_empty() {
107            return None;
108        }
109        if trimmed == BLOCKED {
110            return Some(Hit {
111                token: BLOCKED.to_string(),
112                kind: Kind::Blocked,
113            });
114        }
115        for u in &self.until {
116            if trimmed == u.as_str() {
117                return Some(Hit {
118                    token: u.clone(),
119                    kind: Kind::Until,
120                });
121            }
122        }
123        None
124    }
125
126    /// Fold an iterator of lines into a priority-respecting outcome.
127    ///
128    /// Returns the first [`Hit`] observed unless a later line produces
129    /// a [`Kind::Blocked`] hit, in which case `BLOCKED` wins and the
130    /// function short-circuits — there is nothing a later line could
131    /// say that would outrank `BLOCKED`. This matches the plan's
132    /// "BLOCKED always takes priority" contract and lets the loop
133    /// controller drive a single pass without manual priority logic.
134    pub fn scan<L, I>(&self, lines: I) -> Option<Hit>
135    where
136        L: AsRef<str>,
137        I: IntoIterator<Item = L>,
138    {
139        let mut current: Option<Hit> = None;
140        for line in lines {
141            let Some(hit) = self.feed(line.as_ref()) else {
142                continue;
143            };
144            match hit.kind {
145                Kind::Blocked => return Some(hit),
146                Kind::Until if current.is_none() => current = Some(hit),
147                Kind::Until => { /* keep first Until hit */ }
148            }
149        }
150        current
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    // ---- basic feed semantics -----------------------------------------
159
160    #[test]
161    fn feed_hits_on_standalone_reserved_token() {
162        let s = Scanner::new::<&str>(&[]);
163        let hit = s.feed("BLOCKED").expect("standalone BLOCKED line hits");
164        assert_eq!(hit.token, "BLOCKED");
165        assert_eq!(hit.kind, Kind::Blocked);
166    }
167
168    #[test]
169    fn feed_hits_on_whitespace_padded_reserved_token() {
170        let s = Scanner::new::<&str>(&[]);
171        let hit = s.feed("   BLOCKED   ").expect("trim() before compare");
172        assert_eq!(hit.kind, Kind::Blocked);
173    }
174
175    #[test]
176    fn feed_misses_on_substring_containing_token() {
177        let s = Scanner::new::<&str>(&[]);
178        assert!(
179            s.feed("I am BLOCKED by a dependency").is_none(),
180            "narrative mention must not match"
181        );
182        assert!(
183            s.feed("BLOCKED: yesterday").is_none(),
184            "prefix-only no match"
185        );
186        assert!(s.feed("un-BLOCKED").is_none(), "hyphen-prefixed no match");
187    }
188
189    #[test]
190    fn feed_misses_on_empty_or_whitespace_line() {
191        let s = Scanner::new::<&str>(&[]);
192        assert!(s.feed("").is_none());
193        assert!(s.feed("   ").is_none());
194        assert!(s.feed("\t\t").is_none());
195    }
196
197    #[test]
198    fn feed_is_case_sensitive() {
199        let s = Scanner::new::<&str>(&[]);
200        assert!(s.feed("blocked").is_none(), "lowercase must not match");
201        assert!(s.feed("Blocked").is_none(), "mixed case must not match");
202    }
203
204    // ---- user `until` tokens ------------------------------------------
205
206    #[test]
207    fn user_until_token_hits_with_kind_until() {
208        let s = Scanner::new(&["ALL_TASKS_COMPLETE"]);
209        let hit = s.feed("ALL_TASKS_COMPLETE").expect("registered until hits");
210        assert_eq!(hit.token, "ALL_TASKS_COMPLETE");
211        assert_eq!(hit.kind, Kind::Until);
212    }
213
214    #[test]
215    fn multiple_until_tokens_register() {
216        let s = Scanner::new(&["READY", "APPROVED_LATER"]);
217        assert_eq!(s.feed("READY").unwrap().kind, Kind::Until);
218        assert_eq!(s.feed("APPROVED_LATER").unwrap().kind, Kind::Until);
219        assert!(s.feed("UNREGISTERED").is_none());
220    }
221
222    #[test]
223    fn explicit_blocked_in_tokens_is_still_classified_as_blocked() {
224        // A caller that naively passes BLOCKED in the until slice
225        // still gets Kind::Blocked back — the reserved check runs
226        // first in `feed`.
227        let s = Scanner::new(&["BLOCKED"]);
228        assert_eq!(s.feed("BLOCKED").unwrap().kind, Kind::Blocked);
229    }
230
231    #[test]
232    fn constructor_drops_empty_and_duplicate_until_tokens() {
233        let s = Scanner::new(&["READY", "", "READY", "  ", "DONE"]);
234        // No direct getter — assert via hits instead.
235        assert!(s.feed("READY").is_some());
236        assert!(s.feed("DONE").is_some());
237        // Empty string must not hit (feed rejects empty lines anyway,
238        // but the constructor-level filter documents the invariant).
239        assert!(s.feed("").is_none());
240    }
241
242    // ---- cross-line priority via scan() -------------------------------
243
244    #[test]
245    fn scan_returns_blocked_when_both_tokens_appear() {
246        let s = Scanner::new(&["ALL_TASKS_COMPLETE"]);
247        let lines = ["noise", "ALL_TASKS_COMPLETE", "more noise", "BLOCKED"];
248        let hit = s.scan(lines).expect("some hit");
249        assert_eq!(hit.kind, Kind::Blocked, "BLOCKED beats Until");
250    }
251
252    #[test]
253    fn scan_returns_blocked_short_circuit_even_if_until_follows() {
254        // BLOCKED appears first; scan should return it without
255        // consulting later lines. Use a panicking iterator to prove
256        // short-circuit.
257        struct OnceThenPanic {
258            yielded: bool,
259        }
260        impl Iterator for OnceThenPanic {
261            type Item = &'static str;
262            fn next(&mut self) -> Option<&'static str> {
263                if !self.yielded {
264                    self.yielded = true;
265                    Some("BLOCKED")
266                } else {
267                    panic!("scan did not short-circuit on BLOCKED");
268                }
269            }
270        }
271        let s = Scanner::new(&["ALL_TASKS_COMPLETE"]);
272        let hit = s
273            .scan(OnceThenPanic { yielded: false })
274            .expect("BLOCKED returned");
275        assert_eq!(hit.kind, Kind::Blocked);
276    }
277
278    #[test]
279    fn scan_returns_first_until_when_no_blocked_appears() {
280        let s = Scanner::new(&["READY", "ALL_TASKS_COMPLETE"]);
281        let hit = s
282            .scan(["noise", "READY", "ALL_TASKS_COMPLETE", "tail"])
283            .expect("some hit");
284        assert_eq!(hit.kind, Kind::Until);
285        assert_eq!(hit.token, "READY", "first Until wins");
286    }
287
288    #[test]
289    fn scan_returns_none_on_no_hits() {
290        let s = Scanner::new(&["READY"]);
291        assert!(s.scan(["foo", "bar", "baz"]).is_none());
292    }
293
294    // ---- fuzz: zero false positives around token boundaries -----------
295
296    #[test]
297    fn fuzz_lines_around_tokens_never_false_positive() {
298        // A scanner with two registered tokens must never match a
299        // line that is not exactly one of {BLOCKED, READY,
300        // ALL_TASKS_COMPLETE}. The fuzz asserts this across 1000
301        // perturbations of each token (prefixes, suffixes, internal
302        // punctuation) as required by plan Unit 10 verification.
303        let s = Scanner::new(&["READY", "ALL_TASKS_COMPLETE"]);
304        let perturbations = [
305            "{}.",
306            "{} now",
307            "now {}",
308            "{}?",
309            "({})",
310            " prefix {} suffix ",
311            "{}!",
312            "maybe-{}",
313            "{}_x",
314            "x_{}",
315        ];
316        for seed in 0..100 {
317            for token in ["BLOCKED", "READY", "ALL_TASKS_COMPLETE"] {
318                for pat in perturbations {
319                    let line = pat.replace("{}", token);
320                    assert!(
321                        s.feed(&line).is_none(),
322                        "false positive at seed={seed} token={token} line={line:?}"
323                    );
324                }
325            }
326        }
327        // Sanity-check the exact forms still hit, so the fuzz's
328        // null result is meaningful and not caused by a broken scanner.
329        assert!(s.feed("BLOCKED").is_some());
330        assert!(s.feed("READY").is_some());
331        assert!(s.feed("ALL_TASKS_COMPLETE").is_some());
332    }
333}