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}