Skip to main content

fno_agents/
manifest.rs

1//! Detection manifest engine (E6.2).
2//!
3//! E6.1 ([`crate::osc`], [`crate::screen`]) gave the read loop OSC title/progress
4//! as detection regions on a [`ScreenView`]. This module is the engine that turns
5//! a declarative TOML rule file into a state verdict over that view, so an agent's
6//! readiness rules live in a `*.toml` (authored in E6.3) instead of hardcoded Rust
7//! ([`crate::readiness`]).
8//!
9//! A manifest is a priority-ordered list of [`ManifestRule`]s. Each rule names a
10//! text [`Region`] of the screen and a recursive boolean [`Gate`] over that
11//! region's text, plus an optional context region/gate for surrounding evidence
12//! that should not expand the answer fingerprint window. [`Manifest::evaluate`]
13//! returns the highest-priority matching rule's `state` (and its
14//! `skip_state_update` flag) - so a "yes" buried in scrollback never out-votes a
15//! live-region rule that out-prioritizes it.
16//!
17//! Scope: E6.2 built the parser + region vocabulary + gate evaluator + priority
18//! arbiter; E6.3 added the bundled `claude.toml`/`codex.toml`/`gemini.toml` rule
19//! files and the [`load_manifest`] resolution chain (bundled + local override).
20//! Still NOT wired into the runtime: the daemon state badge consumes
21//! [`Manifest::evaluate`] only once E2 lands live claude panes to tune against.
22//! Remote/cached/version-gated resolution is a logged fast-follow
23//! (`min_engine_version` is parsed now so a later remote manifest can gate, but
24//! is otherwise unused).
25//!
26//! ponytail: a rule's regexes recompile on each `evaluate` (regions are tiny,
27//! evaluate runs at human-perception cadence on readiness polls); cache compiled
28//! `Regex`es per rule if a profiler ever flags it. The `prompt_box_body` region
29//! and `skip_state_update`/priority semantics are tuned against the reference design,
30//! not yet against a live claude TUI (E6.3's job).
31
32use crate::readiness::ScreenView;
33use regex::Regex;
34use serde::{Deserialize, Serialize};
35use std::path::Path;
36
37/// Max nesting depth for a [`Gate`] tree. A pathological manifest (deeply nested
38/// `all`/`any`/`not`) is refused while building the [`Gate`] so `evaluate`'s
39/// recursion is bounded. 16 is far past any real rule (the reference's deepest is ~3).
40///
41/// Note: this caps OUR tree-walk, not `toml::from_str`, which builds the nested
42/// `toml::Value` first. For locally-authored (trusted) manifests that is fine;
43/// when remote/cached resolution lands (the logged fast-follow) the input must be
44/// nesting-bounded BEFORE `toml::from_str`. Tracked as a carveout.
45const MAX_GATE_DEPTH: usize = 16;
46
47#[derive(Debug, thiserror::Error, PartialEq, Eq)]
48pub enum ManifestError {
49    #[error("manifest is not valid TOML: {0}")]
50    Toml(String),
51    #[error("manifest io error for {path}: {detail}")]
52    Io { path: String, detail: String },
53    #[error("rule {rule}: missing or wrong-typed field '{field}'")]
54    Field { rule: String, field: String },
55    #[error("rule {rule}: unknown region selector '{region}'")]
56    UnknownRegion { rule: String, region: String },
57    #[error("rule {rule}: bad regex '{pattern}': {detail}")]
58    BadRegex {
59        rule: String,
60        pattern: String,
61        detail: String,
62    },
63    #[error("rule {rule}: gate nested deeper than {max}", max = MAX_GATE_DEPTH)]
64    GateTooDeep { rule: String },
65    #[error(
66        "rule {rule}: gate table must have exactly one of contains/regex/line_regex/all/any/not"
67    )]
68    BadGate { rule: String },
69}
70
71/// A text region of the screen a rule's gate is matched against. `osc_title` /
72/// `osc_progress` read the OSC-captured strings (which survive scrollback/wrap/
73/// resize); the rest read the grid text. The v1 set is the design's recommended
74/// minimum; `after_last_horizontal_rule` / `after_last_prompt_marker` are
75/// deferred until a rule needs them.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Region {
78    /// The whole visible screen (scrollback already trimmed by the snapshot).
79    WholeRecent,
80    /// The last `N` non-empty lines, joined by `\n`. Where a CLI draws its
81    /// composer + status bar; scopes a match away from scrollback.
82    BottomNonEmptyLines(usize),
83    /// The body of the last box-drawn input box (claude's composer). Empty when
84    /// no box is on screen.
85    PromptBoxBody,
86    /// The latest OSC window title (OSC 0/2). Empty when none captured.
87    OscTitle,
88    /// The latest OSC 9;4 progress payload. Empty when none captured.
89    OscProgress,
90}
91
92impl Region {
93    /// Parse a region selector string. `rule` only seasons the error.
94    fn parse(s: &str, rule: &str) -> Result<Region, ManifestError> {
95        if let Some(arg) = s
96            .strip_prefix("bottom_non_empty_lines(")
97            .and_then(|r| r.strip_suffix(')'))
98        {
99            let n = arg
100                .trim()
101                .parse::<usize>()
102                .map_err(|_| ManifestError::Field {
103                    rule: rule.to_string(),
104                    field: "region (bottom_non_empty_lines arg)".to_string(),
105                })?;
106            if n == 0 {
107                // bottom(0) is an empty region: a degenerate rule that never
108                // fires. Reject it rather than silently never-match (fail closed).
109                return Err(ManifestError::Field {
110                    rule: rule.to_string(),
111                    field: "region (bottom_non_empty_lines arg must be > 0)".to_string(),
112                });
113            }
114            return Ok(Region::BottomNonEmptyLines(n));
115        }
116        match s {
117            "whole_recent" => Ok(Region::WholeRecent),
118            "prompt_box_body" => Ok(Region::PromptBoxBody),
119            "osc_title" => Ok(Region::OscTitle),
120            "osc_progress" => Ok(Region::OscProgress),
121            _ => Err(ManifestError::UnknownRegion {
122                rule: rule.to_string(),
123                region: s.to_string(),
124            }),
125        }
126    }
127
128    /// Extract this region's text from a screen view. An absent OSC region is the
129    /// empty string, so a `contains`/`regex` over it never matches (correct: no
130    /// title means no spinner) while `not(...)` over it reads as "vacuously true".
131    fn extract(&self, screen: &ScreenView) -> String {
132        match self {
133            Region::WholeRecent => screen.visible_text.to_string(),
134            Region::BottomNonEmptyLines(n) => {
135                let nonblank: Vec<&str> = screen
136                    .visible_text
137                    .lines()
138                    .filter(|l| !l.trim().is_empty())
139                    .collect();
140                let start = nonblank.len().saturating_sub(*n);
141                nonblank[start..].join("\n")
142            }
143            Region::PromptBoxBody => prompt_box_body(screen.visible_text),
144            Region::OscTitle => screen.osc_title.unwrap_or("").to_string(),
145            Region::OscProgress => screen.osc_progress.unwrap_or("").to_string(),
146        }
147    }
148}
149
150/// Pull the body out of the last box-drawn input box. claude's composer is a
151/// `╭─╮ / │ … │ / ╰─╯` box; the body is the `│`-bordered lines between the last
152/// bottom border (`╰`) and its nearest preceding top border (`╭`), with the
153/// vertical borders stripped. Returns "" when no complete box is present.
154///
155/// ponytail: a single-heuristic box finder, tuned to claude's box-drawing glyphs;
156/// it does not handle nested boxes or ASCII `+--+` frames, and it does NOT yet
157/// distinguish the live composer from a box-drawn TABLE up in scrollback - it just
158/// takes the bottommost `╰`/`╭` pair, so a scrollback table can be extracted as
159/// stale "prompt body" and let a rule false-match (codex peer P2). Disambiguating
160/// composer-vs-scrollback needs ground truth (the box near the status area / on the
161/// cursor row) against a live claude TUI; deliberately not guessed here. E6.3's
162/// `claude.toml` `live_prompt_box` rule consumes this region, so that
163/// disambiguation is its load-bearing follow-up (carveout, pinned when E2 lands).
164fn prompt_box_body(text: &str) -> String {
165    let lines: Vec<&str> = text.lines().collect();
166    let Some(bottom) = lines.iter().rposition(|l| l.contains('╰')) else {
167        return String::new();
168    };
169    let Some(top) = lines[..bottom].iter().rposition(|l| l.contains('╭')) else {
170        return String::new();
171    };
172    lines[top + 1..bottom]
173        .iter()
174        .map(|l| l.trim().trim_matches('│').trim().to_string())
175        .collect::<Vec<_>>()
176        .join("\n")
177}
178
179/// A recursive boolean predicate over a region's text. Leaf predicates test the
180/// region string; `all`/`any`/`not` compose them. Regexes are compiled at parse,
181/// so a constructed `Gate` is always valid.
182#[derive(Debug, Clone)]
183pub enum Gate {
184    /// Region contains this substring.
185    Contains(String),
186    /// Region matches this regex anywhere (use `^`/`$` to anchor).
187    Regex(Regex),
188    /// Any single line of the region matches this regex.
189    LineRegex(Regex),
190    /// Every sub-gate matches.
191    All(Vec<Gate>),
192    /// At least one sub-gate matches.
193    Any(Vec<Gate>),
194    /// The sub-gate does not match.
195    Not(Box<Gate>),
196}
197
198impl Gate {
199    /// Build a gate from a TOML value. The value must be a table with exactly one
200    /// recognized key. `depth` guards against pathological nesting.
201    fn parse(v: &toml::Value, rule: &str, depth: usize) -> Result<Gate, ManifestError> {
202        if depth > MAX_GATE_DEPTH {
203            return Err(ManifestError::GateTooDeep {
204                rule: rule.to_string(),
205            });
206        }
207        let table = v.as_table().ok_or_else(|| ManifestError::BadGate {
208            rule: rule.to_string(),
209        })?;
210        if table.len() != 1 {
211            return Err(ManifestError::BadGate {
212                rule: rule.to_string(),
213            });
214        }
215        let (key, val) = table.iter().next().expect("len checked == 1");
216        let compile = |p: &str| {
217            Regex::new(p).map_err(|e| ManifestError::BadRegex {
218                rule: rule.to_string(),
219                pattern: p.to_string(),
220                detail: e.to_string(),
221            })
222        };
223        let as_str = || {
224            val.as_str().ok_or_else(|| ManifestError::Field {
225                rule: rule.to_string(),
226                field: format!("gate.{key}"),
227            })
228        };
229        // An empty leaf pattern is fail-open the same way `all = []` is:
230        // `"".contains("")` and `Regex::new("")` both match every region, pinning
231        // the rule's state on every poll. Reject empty leaves at parse.
232        let leaf_str = || {
233            let s = as_str()?;
234            if s.is_empty() {
235                return Err(ManifestError::Field {
236                    rule: rule.to_string(),
237                    field: format!("gate.{key} (must be non-empty)"),
238                });
239            }
240            Ok(s)
241        };
242        let as_array = || {
243            val.as_array().ok_or_else(|| ManifestError::Field {
244                rule: rule.to_string(),
245                field: format!("gate.{key}"),
246            })
247        };
248        match key.as_str() {
249            "contains" => Ok(Gate::Contains(leaf_str()?.to_string())),
250            "regex" => Ok(Gate::Regex(compile(leaf_str()?)?)),
251            "line_regex" => Ok(Gate::LineRegex(compile(leaf_str()?)?)),
252            "all" => Ok(Gate::All(Self::parse_children(as_array()?, rule, depth)?)),
253            "any" => Ok(Gate::Any(Self::parse_children(as_array()?, rule, depth)?)),
254            "not" => Ok(Gate::Not(Box::new(Gate::parse(val, rule, depth + 1)?))),
255            _ => Err(ManifestError::BadGate {
256                rule: rule.to_string(),
257            }),
258        }
259    }
260
261    fn parse_children(
262        arr: &[toml::Value],
263        rule: &str,
264        depth: usize,
265    ) -> Result<Vec<Gate>, ManifestError> {
266        // An empty `all`/`any` is fail-open: `all([])` matches every screen
267        // (vacuous truth), so a high-priority rule with `all = []` would pin its
268        // state on every poll. Reject it at parse rather than mis-fire silently.
269        if arr.is_empty() {
270            return Err(ManifestError::BadGate {
271                rule: rule.to_string(),
272            });
273        }
274        arr.iter()
275            .map(|child| Gate::parse(child, rule, depth + 1))
276            .collect()
277    }
278
279    /// Evaluate against a region's text.
280    fn matches(&self, text: &str) -> bool {
281        match self {
282            Gate::Contains(s) => text.contains(s.as_str()),
283            Gate::Regex(re) => re.is_match(text),
284            Gate::LineRegex(re) => text.lines().any(|l| re.is_match(l)),
285            Gate::All(gs) => gs.iter().all(|g| g.matches(text)),
286            Gate::Any(gs) => gs.iter().any(|g| g.matches(text)),
287            Gate::Not(g) => !g.matches(text),
288        }
289    }
290}
291
292/// How a chosen option's captured index becomes PTY bytes. The entire v1
293/// vocabulary (Locked 4): numbered permission prompts, no arrow-navigation.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295enum SendMapping {
296    /// Send the captured `idx` as one ASCII byte.
297    Digit,
298    /// Send the captured `idx` then CR.
299    DigitEnter,
300}
301
302/// The optional `[rule.answer]` grammar on a `blocked` rule: how to enumerate a
303/// numbered prompt's options and how a picked option becomes a keystroke. Its
304/// presence is what makes a blocked prompt *answerable* (its absence means
305/// blocked-but-not-answerable, which the queue shows as focus-only).
306#[derive(Debug, Clone)]
307struct AnswerGrammar {
308    /// One option per line the regex matches; must name `idx` + `label` captures.
309    option: Regex,
310    send: SendMapping,
311}
312
313/// One selectable option of an answerable prompt.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct AnswerOption {
316    /// The captured menu index the operator presses, e.g. "1".
317    pub idx: String,
318    /// The display label (untruncated; the client truncates for width).
319    pub label: String,
320    /// The exact PTY bytes to inject for this pick, pinned by the manifest's
321    /// `send` mapping over `idx` - NEVER a runtime guess (Locked 2).
322    pub keystroke: Vec<u8>,
323}
324
325/// A blocked prompt the operator can answer from the queue without focusing the
326/// pane. Produced by [`ManifestRule::extract_answer`] and carried on the badge
327/// to the sideline; the mux server re-verifies `fingerprint` against its live
328/// grid before injecting a chosen option's `keystroke` (Locked 3).
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct AnswerablePrompt {
331    /// The lines above the first option, display-only.
332    pub prompt: String,
333    pub options: Vec<AnswerOption>,
334    /// blake3 of the region text the human reads - the server's freshness key.
335    pub fingerprint: [u8; 32],
336    /// The N of `bottom_non_empty_lines(N)`, so the server re-reads the same
337    /// region window to re-hash.
338    pub region_lines: usize,
339}
340
341impl AnswerGrammar {
342    /// Parse a `[rule.answer]` table. Fails loud (like every manifest field): an
343    /// answer grammar is meaningful ONLY on a `state = "blocked"` rule whose
344    /// region is a `bottom_non_empty_lines(N)` window (so the server can re-read
345    /// the same lines to re-hash). Either mismatch is a config bug, never ignored.
346    fn parse(
347        v: &toml::Value,
348        rule: &str,
349        state: &str,
350        region: &Region,
351    ) -> Result<AnswerGrammar, ManifestError> {
352        if state != "blocked" {
353            return Err(ManifestError::Field {
354                rule: rule.to_string(),
355                field: "answer (only allowed on a state = \"blocked\" rule)".to_string(),
356            });
357        }
358        if !matches!(region, Region::BottomNonEmptyLines(_)) {
359            return Err(ManifestError::Field {
360                rule: rule.to_string(),
361                field: "answer (region must be bottom_non_empty_lines(N))".to_string(),
362            });
363        }
364        let table = v.as_table().ok_or_else(|| ManifestError::Field {
365            rule: rule.to_string(),
366            field: "answer (must be a table)".to_string(),
367        })?;
368        const ALLOWED: &[&str] = &["option", "send"];
369        if let Some(unknown) = table.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
370            return Err(ManifestError::Field {
371                rule: rule.to_string(),
372                field: format!("answer unknown key '{unknown}'"),
373            });
374        }
375        let option_pat = table
376            .get("option")
377            .and_then(|x| x.as_str())
378            .ok_or_else(|| ManifestError::Field {
379                rule: rule.to_string(),
380                field: "answer.option".to_string(),
381            })?;
382        let option = Regex::new(option_pat).map_err(|e| ManifestError::BadRegex {
383            rule: rule.to_string(),
384            pattern: option_pat.to_string(),
385            detail: e.to_string(),
386        })?;
387        // extract_answer reads the captures by name, so both must exist - a
388        // missing `idx`/`label` group is a parse-time config error, not a
389        // runtime None that would silently make every blocked prompt focus-only.
390        let names: Vec<&str> = option.capture_names().flatten().collect();
391        for need in ["idx", "label"] {
392            if !names.contains(&need) {
393                return Err(ManifestError::Field {
394                    rule: rule.to_string(),
395                    field: format!("answer.option (missing named capture '{need}')"),
396                });
397            }
398        }
399        let send = match table.get("send").and_then(|x| x.as_str()) {
400            Some("digit") => SendMapping::Digit,
401            Some("digit_enter") => SendMapping::DigitEnter,
402            _ => {
403                return Err(ManifestError::Field {
404                    rule: rule.to_string(),
405                    field: "answer.send (must be \"digit\" or \"digit_enter\")".to_string(),
406                })
407            }
408        };
409        Ok(AnswerGrammar { option, send })
410    }
411}
412
413/// One detection rule: when `gate` matches `region` and the optional contextual
414/// gate also matches, the agent is in `state`. `priority` arbitrates between
415/// simultaneously-matching rules (highest wins). `skip_state_update` marks a
416/// rule whose match means "hold the current state, don't update it" - e.g.
417/// claude's ctrl+o transcript pager, which must not flip a working agent to idle.
418/// `answer` (x-c929) makes a `blocked` prompt answerable from the queue.
419#[derive(Debug, Clone)]
420pub struct ManifestRule {
421    pub id: String,
422    pub state: String,
423    pub priority: i32,
424    pub region: Region,
425    pub skip_state_update: bool,
426    pub gate: Gate,
427    context: Option<(Region, Gate)>,
428    answer: Option<AnswerGrammar>,
429}
430
431impl ManifestRule {
432    /// Match the rule's live answer region plus any wider contextual guard.
433    /// The returned text is always the primary region: answer extraction and
434    /// fingerprinting stay scoped to the small menu window even when detection
435    /// needs context elsewhere on the visible screen.
436    fn matched_region_text(&self, screen: &ScreenView) -> Option<String> {
437        let text = self.region.extract(screen);
438        if !self.gate.matches(&text) {
439            return None;
440        }
441        if let Some((region, gate)) = &self.context {
442            let context = region.extract(screen);
443            if !gate.matches(&context) {
444                return None;
445            }
446        }
447        Some(text)
448    }
449
450    /// Enumerate this rule's answerable options from `region_text`, fail-closed.
451    /// Returns `None` (blocked-but-not-answerable) unless the rule carries an
452    /// `[answer]` grammar AND the region yields a clean numbered menu: >=1
453    /// option, non-empty labels, and single-digit indices forming a contiguous
454    /// `1..N` run. A lowest index != 1 means the menu top scrolled past the
455    /// region window (truncated) and is not answerable (AC3-EDGE). Strictly
456    /// additive to detection: any miss leaves the blocked badge untouched.
457    pub fn extract_answer(&self, region_text: &str) -> Option<AnswerablePrompt> {
458        let grammar = self.answer.as_ref()?;
459        // Parse enforces a bottom-N region on any answer grammar; be defensive.
460        let Region::BottomNonEmptyLines(n) = self.region else {
461            return None;
462        };
463        let mut options: Vec<AnswerOption> = Vec::new();
464        let mut first_option_line: Option<usize> = None;
465        for (i, line) in region_text.lines().enumerate() {
466            let Some(caps) = grammar.option.captures(line) else {
467                continue;
468            };
469            let idx = caps.name("idx")?.as_str().to_string();
470            let label = caps.name("label")?.as_str().trim().to_string();
471            // One ASCII digit only (send maps one digit to one byte); a 2-digit
472            // or non-digit index, or an empty label, is not answerable in v1.
473            if idx.len() != 1 || !idx.as_bytes()[0].is_ascii_digit() || label.is_empty() {
474                return None;
475            }
476            if first_option_line.is_none() {
477                first_option_line = Some(i);
478            }
479            let mut keystroke = vec![idx.as_bytes()[0]];
480            if matches!(grammar.send, SendMapping::DigitEnter) {
481                keystroke.push(b'\r');
482            }
483            options.push(AnswerOption {
484                idx,
485                label,
486                keystroke,
487            });
488        }
489        if options.is_empty() {
490            return None;
491        }
492        // The indices must be exactly {1, 2, ..., N}: unique, contiguous, and
493        // starting at 1. This one check rejects duplicates (AC3-ERR), gaps, and
494        // a truncated menu whose first captured option is `2.`/`3.` (AC3-EDGE).
495        let mut idxs: Vec<u8> = options.iter().map(|o| o.idx.as_bytes()[0] - b'0').collect();
496        idxs.sort_unstable();
497        let expected: Vec<u8> = (1..=options.len() as u8).collect();
498        if idxs != expected {
499            return None;
500        }
501        let first = first_option_line.unwrap_or(0);
502        let prompt = region_text
503            .lines()
504            .take(first)
505            .collect::<Vec<_>>()
506            .join("\n");
507        let fingerprint = *blake3::hash(region_text.as_bytes()).as_bytes();
508        Some(AnswerablePrompt {
509            prompt,
510            options,
511            fingerprint,
512            region_lines: n,
513        })
514    }
515}
516
517impl ManifestRule {
518    fn parse(v: &toml::Value) -> Result<ManifestRule, ManifestError> {
519        // id is read first so every later error can name the rule.
520        let id = v
521            .get("id")
522            .and_then(|x| x.as_str())
523            .ok_or_else(|| ManifestError::Field {
524                rule: "<unnamed>".to_string(),
525                field: "id".to_string(),
526            })?
527            .to_string();
528        if id.trim().is_empty() {
529            // The id seasons every error and rides in the Verdict; an empty one
530            // makes both useless. Require it non-blank.
531            return Err(ManifestError::Field {
532                rule: "<unnamed>".to_string(),
533                field: "id (must be non-empty)".to_string(),
534            });
535        }
536        let str_field = |f: &str| {
537            v.get(f)
538                .and_then(|x| x.as_str())
539                .ok_or_else(|| ManifestError::Field {
540                    rule: id.clone(),
541                    field: f.to_string(),
542                })
543        };
544        let state = str_field("state")?.to_string();
545        let priority_i64 = v
546            .get("priority")
547            .and_then(|x| x.as_integer())
548            .ok_or_else(|| ManifestError::Field {
549                rule: id.clone(),
550                field: "priority".to_string(),
551            })?;
552        // TOML integers are i64; `as i32` would silently wrap a too-big priority
553        // and corrupt arbitration. Reject out-of-range rather than truncate.
554        let priority = i32::try_from(priority_i64).map_err(|_| ManifestError::Field {
555            rule: id.clone(),
556            field: "priority (out of i32 range)".to_string(),
557        })?;
558        let region = Region::parse(str_field("region")?, &id)?;
559        // Present-but-wrong-type (e.g. `skip_state_update = "true"`) must error,
560        // not silently read as false and swallow an authoring typo.
561        let skip_state_update = match v.get("skip_state_update") {
562            None => false,
563            Some(x) => x.as_bool().ok_or_else(|| ManifestError::Field {
564                rule: id.clone(),
565                field: "skip_state_update (must be a boolean)".to_string(),
566            })?,
567        };
568        let gate_val = v.get("gate").ok_or_else(|| ManifestError::Field {
569            rule: id.clone(),
570            field: "gate".to_string(),
571        })?;
572        let gate = Gate::parse(gate_val, &id, 0)?;
573        let context = match (v.get("context_region"), v.get("context_gate")) {
574            (None, None) => None,
575            (Some(region), Some(gate)) => {
576                let region = region.as_str().ok_or_else(|| ManifestError::Field {
577                    rule: id.clone(),
578                    field: "context_region".to_string(),
579                })?;
580                Some((Region::parse(region, &id)?, Gate::parse(gate, &id, 0)?))
581            }
582            _ => {
583                return Err(ManifestError::Field {
584                    rule: id.clone(),
585                    field: "context_region and context_gate (must be provided together)"
586                        .to_string(),
587                })
588            }
589        };
590        // Optional `[rule.answer]` (x-c929): parsed here so its blocked-only /
591        // bottom-N-region constraints fail loud alongside every other field.
592        let answer = match v.get("answer") {
593            None => None,
594            Some(a) => Some(AnswerGrammar::parse(a, &id, &state, &region)?),
595        };
596        // Reject unknown keys: a typo like `skip_state_updates = true` would
597        // otherwise parse fine and silently drop the real flag, changing
598        // arbitration. Fail closed instead (matches the gate's one-key rule).
599        if let Some(table) = v.as_table() {
600            const ALLOWED: &[&str] = &[
601                "id",
602                "state",
603                "priority",
604                "region",
605                "skip_state_update",
606                "gate",
607                "context_region",
608                "context_gate",
609                "answer",
610            ];
611            if let Some(unknown) = table.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
612                return Err(ManifestError::Field {
613                    rule: id.clone(),
614                    field: format!("unknown key '{unknown}'"),
615                });
616            }
617        }
618        Ok(ManifestRule {
619            id,
620            state,
621            priority,
622            region,
623            skip_state_update,
624            gate,
625            context,
626            answer,
627        })
628    }
629}
630
631/// The verdict of evaluating a manifest against a screen: the matching rule's
632/// id, the state it asserts, and whether the caller should hold the current
633/// state instead of applying `state`.
634#[derive(Debug, Clone, PartialEq, Eq)]
635pub struct Verdict<'a> {
636    pub rule_id: &'a str,
637    pub state: &'a str,
638    pub skip_state_update: bool,
639}
640
641/// A parsed agent detection manifest: an engine-version floor plus the rules,
642/// pre-sorted highest-priority-first so [`evaluate`](Manifest::evaluate) is a
643/// linear scan that returns on the first match.
644#[derive(Debug, Clone)]
645pub struct Manifest {
646    /// Minimum engine version a (future remote) manifest may demand. Parsed and
647    /// stored now though unused in v1 (design: "Engine-version field now even if
648    /// unused, so a later remote manifest can gate"). Defaults to 0.
649    pub min_engine_version: u32,
650    /// Rules sorted by `priority` descending; ties keep TOML order (stable sort).
651    /// Private so the sort invariant `evaluate` relies on can only be established
652    /// by [`parse`](Manifest::parse); read via [`rules`](Manifest::rules).
653    rules: Vec<ManifestRule>,
654}
655
656impl Manifest {
657    /// Parse a manifest TOML. Fails closed: a bad regex, unknown region, or
658    /// over-deep gate is a parse error naming the offending rule, never a
659    /// silently-dropped rule.
660    pub fn parse(s: &str) -> Result<Manifest, ManifestError> {
661        let root: toml::Value =
662            toml::from_str(s).map_err(|e| ManifestError::Toml(e.to_string()))?;
663        // Reject unknown root keys (typo like `min_engine_versions`). A later
664        // format bump is gated by `min_engine_version`, not by tolerating
665        // unknown keys, so fail closed in v1.
666        if let Some(table) = root.as_table() {
667            if let Some(unknown) = table
668                .keys()
669                .find(|k| !matches!(k.as_str(), "min_engine_version" | "rule"))
670            {
671                return Err(ManifestError::Field {
672                    rule: "<root>".to_string(),
673                    field: format!("unknown key '{unknown}'"),
674                });
675            }
676        }
677        // Absent -> 0. Present-but-wrong-type or negative is a malformed manifest,
678        // not a silent default-to-0 (fail closed, matching the per-rule fields).
679        let min_engine_version = match root.get("min_engine_version") {
680            None => 0,
681            Some(v) => v
682                .as_integer()
683                .and_then(|n| u32::try_from(n).ok())
684                .ok_or_else(|| ManifestError::Field {
685                    rule: "<root>".to_string(),
686                    field: "min_engine_version (must be a non-negative integer)".to_string(),
687                })?,
688        };
689        let mut rules = match root.get("rule") {
690            Some(v) => v
691                .as_array()
692                .ok_or_else(|| ManifestError::Field {
693                    rule: "<root>".to_string(),
694                    field: "rule (must be an array of tables)".to_string(),
695                })?
696                .iter()
697                .map(ManifestRule::parse)
698                .collect::<Result<Vec<_>, _>>()?,
699            None => Vec::new(),
700        };
701        // Highest priority first; stable so equal-priority rules keep file order.
702        rules.sort_by(|a, b| b.priority.cmp(&a.priority));
703        Ok(Manifest {
704            min_engine_version,
705            rules,
706        })
707    }
708
709    /// The parsed rules, highest-priority-first. Read-only: the sort invariant is
710    /// owned by [`parse`](Manifest::parse).
711    pub fn rules(&self) -> &[ManifestRule] {
712        &self.rules
713    }
714
715    /// Return the highest-priority rule whose gate matches the screen, or `None`
716    /// when no rule matches (the caller decides what an undetected state means -
717    /// the engine never guesses).
718    pub fn evaluate(&self, screen: &ScreenView) -> Option<Verdict<'_>> {
719        self.rules.iter().find_map(|rule| {
720            rule.matched_region_text(screen).map(|_| Verdict {
721                rule_id: &rule.id,
722                state: &rule.state,
723                skip_state_update: rule.skip_state_update,
724            })
725        })
726    }
727
728    /// Like [`evaluate`](Self::evaluate) but also returns the winning rule's
729    /// [`AnswerablePrompt`] when it carries an `[answer]` grammar and the region
730    /// yields a clean menu (`None` otherwise - blocked-but-not-answerable). The
731    /// scrape sweep uses this so the answer payload rides the same badge; the
732    /// cheap `evaluate` stays for callers that only need the state.
733    pub fn evaluate_answerable(
734        &self,
735        screen: &ScreenView,
736    ) -> Option<(Verdict<'_>, Option<AnswerablePrompt>)> {
737        self.rules.iter().find_map(|rule| {
738            let text = rule.matched_region_text(screen)?;
739            let answerable = rule.extract_answer(&text);
740            Some((
741                Verdict {
742                    rule_id: &rule.id,
743                    state: &rule.state,
744                    skip_state_update: rule.skip_state_update,
745                },
746                answerable,
747            ))
748        })
749    }
750}
751
752/// The detection manifest compiled into the binary for a known agent (E6.3).
753/// Returns `None` for an unknown agent - the caller fails loud rather than
754/// guessing a manifest (mirrors `readiness.rs`'s Open Question #9: no
755/// fail-open default).
756pub fn bundled_manifest(agent: &str) -> Option<&'static str> {
757    match agent {
758        "claude" => Some(include_str!("manifests/claude.toml")),
759        "codex" => Some(include_str!("manifests/codex.toml")),
760        "gemini" => Some(include_str!("manifests/gemini.toml")),
761        // x-8f7f: agy (hosted, US1) + opencode (staged/inert until x-51f6, US2).
762        "agy" => Some(include_str!("manifests/agy.toml")),
763        "opencode" => Some(include_str!("manifests/opencode.toml")),
764        // x-83e7: full-roster roster. All staged/inert - none has a provider
765        // host yet (no build_pane_argv arm), so each is bundled-but-dormant like
766        // opencode. Adapted from the reference manifests per manifests/ADAPTING.md.
767        // "copilot" resolves github-copilot.toml, mirroring the reference's own mapping.
768        // antigravity is intentionally absent: the reference antigravity manifest is the
769        // agy harness (id "agy"), already covered by agy.toml above.
770        "amp" => Some(include_str!("manifests/amp.toml")),
771        "cline" => Some(include_str!("manifests/cline.toml")),
772        "cursor" => Some(include_str!("manifests/cursor.toml")),
773        "devin" => Some(include_str!("manifests/devin.toml")),
774        "droid" => Some(include_str!("manifests/droid.toml")),
775        "copilot" => Some(include_str!("manifests/github-copilot.toml")),
776        "grok" => Some(include_str!("manifests/grok.toml")),
777        "hermes" => Some(include_str!("manifests/hermes.toml")),
778        "kilo" => Some(include_str!("manifests/kilo.toml")),
779        "kimi" => Some(include_str!("manifests/kimi.toml")),
780        "kiro" => Some(include_str!("manifests/kiro.toml")),
781        "pi" => Some(include_str!("manifests/pi.toml")),
782        "qodercli" => Some(include_str!("manifests/qodercli.toml")),
783        _ => None,
784    }
785}
786
787/// Resolve and parse an agent's manifest. v1 resolution chain (design: bundled +
788/// local override; remote/cached deferred): a readable `<agent>.toml` in
789/// `override_dir` wins over the bundled copy, so an operator can hand-author a
790/// rule file without a rebuild.
791///
792/// Returns `None` when no manifest exists for `agent` (unknown agent, no
793/// override) - the caller decides what "no manifest" means and never guesses.
794/// `Some(Err(..))` is a present-but-malformed manifest (the override or bundled
795/// TOML failed to parse), surfaced verbatim so a bad hand edit fails loud
796/// instead of silently falling back.
797pub fn load_manifest(
798    agent: &str,
799    override_dir: Option<&Path>,
800) -> Option<Result<Manifest, ManifestError>> {
801    if let Some(dir) = override_dir {
802        let path = dir.join(format!("{agent}.toml"));
803        // A PRESENT override file is honoured as the operator's intent and fails
804        // loud: a parse-bad TOML surfaces ManifestError::Toml, and a present file
805        // that won't read (invalid UTF-8, permission-denied, lookup error)
806        // surfaces ManifestError::Io. ONLY a genuinely absent override (a
807        // NotFound read error) falls through to bundled. We match on the read
808        // error kind rather than pre-checking is_file(), because is_file()
809        // collapses every metadata error (permission, symlink loop) to false and
810        // would silently fall back to bundled on a real error (codex peer P2).
811        match std::fs::read_to_string(&path) {
812            Ok(text) => return Some(Manifest::parse(&text)),
813            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
814            Err(e) => {
815                return Some(Err(ManifestError::Io {
816                    path: path.display().to_string(),
817                    detail: e.to_string(),
818                }))
819            }
820        }
821    }
822    bundled_manifest(agent).map(Manifest::parse)
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn view(text: &str) -> ScreenView<'_> {
830        ScreenView {
831            visible_text: text,
832            cursor_row: 0,
833            cursor_col: 0,
834            osc_title: None,
835            osc_progress: None,
836        }
837    }
838
839    fn view_title<'a>(text: &'a str, title: &'a str) -> ScreenView<'a> {
840        ScreenView {
841            visible_text: text,
842            cursor_row: 0,
843            cursor_col: 0,
844            osc_title: Some(title),
845            osc_progress: None,
846        }
847    }
848
849    #[test]
850    fn parses_fields_and_sorts_by_priority_desc() {
851        let m = Manifest::parse(
852            r#"
853            min_engine_version = 2
854            [[rule]]
855            id = "low"
856            state = "idle"
857            priority = 10
858            region = "whole_recent"
859            gate = { contains = "x" }
860            [[rule]]
861            id = "high"
862            state = "working"
863            priority = 100
864            region = "whole_recent"
865            gate = { contains = "y" }
866            "#,
867        )
868        .unwrap();
869        assert_eq!(m.min_engine_version, 2);
870        assert_eq!(m.rules().len(), 2);
871        assert_eq!(m.rules()[0].id, "high", "highest priority sorts first");
872        assert_eq!(m.rules()[1].id, "low");
873    }
874
875    #[test]
876    fn min_engine_version_defaults_to_zero() {
877        let m = Manifest::parse(
878            r#"
879            [[rule]]
880            id = "r"
881            state = "idle"
882            priority = 1
883            region = "whole_recent"
884            gate = { contains = "x" }
885            "#,
886        )
887        .unwrap();
888        assert_eq!(m.min_engine_version, 0);
889    }
890
891    // AC-E6-5: highest-priority match wins. A "yes" in scrollback must NOT fake a
892    // permission prompt because the live-region rule out-prioritizes it.
893    #[test]
894    fn highest_priority_match_wins_over_scrollback() {
895        let m = Manifest::parse(
896            r#"
897            [[rule]]
898            id = "scrollback_yes"
899            state = "blocked"
900            priority = 100
901            region = "whole_recent"
902            gate = { contains = "yes" }
903            [[rule]]
904            id = "live_prompt"
905            state = "idle"
906            priority = 900
907            region = "bottom_non_empty_lines(1)"
908            gate = { line_regex = "^\\s*❯" }
909            "#,
910        )
911        .unwrap();
912        // "yes" is up in scrollback; the live composer shows the idle prompt.
913        let screen = "I said yes earlier\nlots of reply text\n  ❯ ";
914        let v = m.evaluate(&view(screen)).unwrap();
915        assert_eq!(v.state, "idle", "live-region rule beats scrollback match");
916        assert_eq!(v.rule_id, "live_prompt");
917    }
918
919    // AC-E6-2 (engine half): a braille-spinner title badges working from the
920    // title alone, with the grid showing only scrollback (no glyph in the grid).
921    #[test]
922    fn osc_title_braille_spinner_badges_working_from_title_alone() {
923        let m = Manifest::parse(
924            r#"
925            [[rule]]
926            id = "osc_title_working"
927            state = "working"
928            priority = 1100
929            region = "osc_title"
930            gate = { regex = "^[\\x{2800}-\\x{28FF}]" }
931            "#,
932        )
933        .unwrap();
934        // Grid is pure scrollback (no spinner); the title carries U+280B.
935        let screen = view_title("old output\nmore scrollback\n", "\u{280b} Compiling");
936        let v = m.evaluate(&screen).unwrap();
937        assert_eq!(v.state, "working");
938        // No title -> the rule does not fire (engine never guesses).
939        assert!(m.evaluate(&view("old output")).is_none());
940    }
941
942    // AC-E6-3: skip_state_update on a transcript-viewer rule keeps a working
943    // agent from flipping to idle when the ctrl+o pager is open.
944    #[test]
945    fn skip_state_update_flag_is_carried_through() {
946        let m = Manifest::parse(
947            r#"
948            [[rule]]
949            id = "transcript_viewer"
950            state = "idle"
951            priority = 1000
952            region = "bottom_non_empty_lines(3)"
953            skip_state_update = true
954            gate = { contains = "(END)" }
955            "#,
956        )
957        .unwrap();
958        let v = m.evaluate(&view("scrollback\nmore\n(END)")).unwrap();
959        assert!(v.skip_state_update, "pager rule must not update state");
960    }
961
962    #[test]
963    fn gate_all_any_not_compose() {
964        let m = Manifest::parse(
965            r#"
966            [[rule]]
967            id = "blocked_form"
968            state = "blocked"
969            priority = 980
970            region = "whole_recent"
971            gate = { all = [ { contains = "enter to select" }, { contains = "esc to cancel" }, { not = { contains = "esc to interrupt" } } ] }
972            "#,
973        )
974        .unwrap();
975        // all three sub-gates satisfied
976        assert!(m
977            .evaluate(&view("press enter to select, esc to cancel"))
978            .is_some());
979        // missing "esc to cancel" -> all() fails
980        assert!(m.evaluate(&view("enter to select something")).is_none());
981        // the not() clause: an interrupt hint present -> blocked rule must NOT fire
982        assert!(m
983            .evaluate(&view("enter to select, esc to cancel, esc to interrupt"))
984            .is_none());
985    }
986
987    #[test]
988    fn any_gate_matches_on_one() {
989        let m = Manifest::parse(
990            r#"
991            [[rule]]
992            id = "perm"
993            state = "blocked"
994            priority = 850
995            region = "whole_recent"
996            gate = { any = [ { contains = "do you want to proceed?" }, { contains = "1. Yes" } ] }
997            "#,
998        )
999        .unwrap();
1000        assert!(m.evaluate(&view("1. Yes\n2. No")).is_some());
1001        assert!(m.evaluate(&view("nothing relevant")).is_none());
1002    }
1003
1004    #[test]
1005    fn region_prompt_box_body_extracts_box_interior() {
1006        let m = Manifest::parse(
1007            r#"
1008            [[rule]]
1009            id = "live_prompt_box"
1010            state = "idle"
1011            priority = 950
1012            region = "prompt_box_body"
1013            gate = { regex = "❯" }
1014            "#,
1015        )
1016        .unwrap();
1017        // A "❯" in scrollback above the box must not count; only the box body does.
1018        let screen = "❯ earlier command in history\n\
1019                      ╭──────────────╮\n\
1020                      │ ❯ type here  │\n\
1021                      ╰──────────────╯";
1022        assert!(m.evaluate(&view(screen)).is_some());
1023        // No box on screen -> empty region -> no match.
1024        assert!(m.evaluate(&view("just text, no box")).is_none());
1025    }
1026
1027    #[test]
1028    fn region_osc_progress_reads_progress_payload() {
1029        let m = Manifest::parse(
1030            r#"
1031            [[rule]]
1032            id = "progressing"
1033            state = "working"
1034            priority = 500
1035            region = "osc_progress"
1036            gate = { regex = "^4;" }
1037            "#,
1038        )
1039        .unwrap();
1040        let screen = ScreenView {
1041            visible_text: "anything",
1042            cursor_row: 0,
1043            cursor_col: 0,
1044            osc_title: None,
1045            osc_progress: Some("4;1;50"),
1046        };
1047        assert_eq!(m.evaluate(&screen).unwrap().state, "working");
1048    }
1049
1050    #[test]
1051    fn no_rule_matches_returns_none() {
1052        let m = Manifest::parse(
1053            r#"
1054            [[rule]]
1055            id = "r"
1056            state = "idle"
1057            priority = 1
1058            region = "whole_recent"
1059            gate = { contains = "zzz" }
1060            "#,
1061        )
1062        .unwrap();
1063        assert!(m.evaluate(&view("nothing here")).is_none());
1064    }
1065
1066    #[test]
1067    fn empty_manifest_parses_to_no_rules() {
1068        let m = Manifest::parse("min_engine_version = 1").unwrap();
1069        assert!(m.rules().is_empty());
1070        assert!(m.evaluate(&view("anything")).is_none());
1071    }
1072
1073    #[test]
1074    fn bad_regex_is_a_parse_error_not_a_silent_drop() {
1075        let err = Manifest::parse(
1076            r#"
1077            [[rule]]
1078            id = "broken"
1079            state = "x"
1080            priority = 1
1081            region = "whole_recent"
1082            gate = { regex = "(" }
1083            "#,
1084        )
1085        .unwrap_err();
1086        assert!(matches!(err, ManifestError::BadRegex { rule, .. } if rule == "broken"));
1087    }
1088
1089    #[test]
1090    fn unknown_region_is_a_parse_error() {
1091        let err = Manifest::parse(
1092            r#"
1093            [[rule]]
1094            id = "r"
1095            state = "x"
1096            priority = 1
1097            region = "the_moon"
1098            gate = { contains = "x" }
1099            "#,
1100        )
1101        .unwrap_err();
1102        assert!(matches!(err, ManifestError::UnknownRegion { region, .. } if region == "the_moon"));
1103    }
1104
1105    #[test]
1106    fn missing_required_field_names_the_rule() {
1107        let err = Manifest::parse(
1108            r#"
1109            [[rule]]
1110            id = "r"
1111            priority = 1
1112            region = "whole_recent"
1113            gate = { contains = "x" }
1114            "#,
1115        )
1116        .unwrap_err();
1117        assert!(
1118            matches!(err, ManifestError::Field { rule, field } if rule == "r" && field == "state")
1119        );
1120    }
1121
1122    #[test]
1123    fn multi_key_gate_table_is_rejected() {
1124        let err = Manifest::parse(
1125            r#"
1126            [[rule]]
1127            id = "r"
1128            state = "x"
1129            priority = 1
1130            region = "whole_recent"
1131            gate = { contains = "a", regex = "b" }
1132            "#,
1133        )
1134        .unwrap_err();
1135        assert!(matches!(err, ManifestError::BadGate { rule } if rule == "r"));
1136    }
1137
1138    #[test]
1139    fn over_deep_gate_is_refused() {
1140        // Build a gate nested past MAX_GATE_DEPTH with chained `not`s.
1141        let mut gate = "{ contains = \"x\" }".to_string();
1142        for _ in 0..(MAX_GATE_DEPTH + 2) {
1143            gate = format!("{{ not = {gate} }}");
1144        }
1145        let toml = format!(
1146            "[[rule]]\nid = \"deep\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {gate}\n"
1147        );
1148        let err = Manifest::parse(&toml).unwrap_err();
1149        assert!(matches!(err, ManifestError::GateTooDeep { rule } if rule == "deep"));
1150    }
1151
1152    #[test]
1153    fn bottom_non_empty_lines_scopes_to_tail() {
1154        let m = Manifest::parse(
1155            r#"
1156            [[rule]]
1157            id = "r"
1158            state = "hit"
1159            priority = 1
1160            region = "bottom_non_empty_lines(2)"
1161            gate = { contains = "needle" }
1162            "#,
1163        )
1164        .unwrap();
1165        // needle is on line 1 of 4 non-empty lines; bottom(2) must not see it.
1166        let screen = "needle up here\n\nfiller\nmore filler\nlast line";
1167        assert!(m.evaluate(&view(screen)).is_none());
1168        // needle in the last two lines -> match.
1169        assert!(m.evaluate(&view("filler\nfiller\nneedle\nlast")).is_some());
1170    }
1171
1172    #[test]
1173    fn empty_composite_gate_is_rejected_not_fail_open() {
1174        // `all = []` is vacuously true and would pin its state on every screen.
1175        // Both empty `all` and empty `any` must be parse errors.
1176        for body in ["all = []", "any = []"] {
1177            let err = Manifest::parse(&format!(
1178                "[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {{ {body} }}\n"
1179            ))
1180            .unwrap_err();
1181            assert!(
1182                matches!(err, ManifestError::BadGate { rule } if rule == "r"),
1183                "{body} should be rejected"
1184            );
1185        }
1186    }
1187
1188    #[test]
1189    fn bottom_non_empty_lines_zero_is_rejected() {
1190        let err = Manifest::parse(
1191            r#"
1192            [[rule]]
1193            id = "r"
1194            state = "x"
1195            priority = 1
1196            region = "bottom_non_empty_lines(0)"
1197            gate = { contains = "x" }
1198            "#,
1199        )
1200        .unwrap_err();
1201        assert!(matches!(err, ManifestError::Field { rule, .. } if rule == "r"));
1202    }
1203
1204    #[test]
1205    fn malformed_scalar_fields_are_rejected_not_coerced() {
1206        // priority out of i32 range -> error (not a silent wrap).
1207        let big = i64::from(i32::MAX) + 1;
1208        let err = Manifest::parse(&format!(
1209            "[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = {big}\nregion = \"whole_recent\"\ngate = {{ contains = \"x\" }}\n"
1210        ))
1211        .unwrap_err();
1212        assert!(matches!(err, ManifestError::Field { field, .. } if field.starts_with("priority")));
1213
1214        // skip_state_update present but not a bool -> error (not silent false).
1215        let err = Manifest::parse(
1216            r#"
1217            [[rule]]
1218            id = "r"
1219            state = "x"
1220            priority = 1
1221            region = "whole_recent"
1222            skip_state_update = "yes"
1223            gate = { contains = "x" }
1224            "#,
1225        )
1226        .unwrap_err();
1227        assert!(
1228            matches!(err, ManifestError::Field { field, .. } if field.starts_with("skip_state_update"))
1229        );
1230
1231        // min_engine_version present but wrong type -> error (not silent 0).
1232        let err = Manifest::parse(
1233            r#"
1234            min_engine_version = "two"
1235            [[rule]]
1236            id = "r"
1237            state = "x"
1238            priority = 1
1239            region = "whole_recent"
1240            gate = { contains = "x" }
1241            "#,
1242        )
1243        .unwrap_err();
1244        assert!(
1245            matches!(err, ManifestError::Field { field, .. } if field.starts_with("min_engine_version"))
1246        );
1247    }
1248
1249    #[test]
1250    fn empty_leaf_gate_pattern_is_rejected_not_fail_open() {
1251        // `contains = ""` / `regex = ""` / `line_regex = ""` each match every
1252        // region; reject them like an empty `all = []` (codex peer P2).
1253        for leaf in [r#"contains = """#, r#"regex = """#, r#"line_regex = """#] {
1254            let err = Manifest::parse(&format!(
1255                "[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {{ {leaf} }}\n"
1256            ))
1257            .unwrap_err();
1258            assert!(
1259                matches!(err, ManifestError::Field { rule, .. } if rule == "r"),
1260                "{leaf} should be rejected"
1261            );
1262        }
1263    }
1264
1265    #[test]
1266    fn unknown_rule_and_root_keys_are_rejected() {
1267        // A typo'd rule key (`skip_state_updates`) would silently drop the real
1268        // flag; reject it (codex peer P2).
1269        let err = Manifest::parse(
1270            r#"
1271            [[rule]]
1272            id = "r"
1273            state = "x"
1274            priority = 1
1275            region = "whole_recent"
1276            skip_state_updates = true
1277            gate = { contains = "x" }
1278            "#,
1279        )
1280        .unwrap_err();
1281        assert!(
1282            matches!(err, ManifestError::Field { rule, field } if rule == "r" && field.contains("unknown key"))
1283        );
1284
1285        // A typo'd root key is rejected too.
1286        let err = Manifest::parse(
1287            r#"
1288            min_engine_versions = 1
1289            [[rule]]
1290            id = "r"
1291            state = "x"
1292            priority = 1
1293            region = "whole_recent"
1294            gate = { contains = "x" }
1295            "#,
1296        )
1297        .unwrap_err();
1298        assert!(
1299            matches!(err, ManifestError::Field { rule, field } if rule == "<root>" && field.contains("unknown key"))
1300        );
1301    }
1302
1303    #[test]
1304    fn empty_id_is_rejected() {
1305        let err = Manifest::parse(
1306            r#"
1307            [[rule]]
1308            id = ""
1309            state = "x"
1310            priority = 1
1311            region = "whole_recent"
1312            gate = { contains = "x" }
1313            "#,
1314        )
1315        .unwrap_err();
1316        assert!(matches!(err, ManifestError::Field { field, .. } if field.starts_with("id")));
1317    }
1318
1319    // ---- E6.3: bundled rule files (claude/codex/gemini) ----
1320
1321    use crate::readiness::{CodexReadinessDetector, GeminiReadinessDetector, ReadinessDetector};
1322
1323    fn bundled(agent: &str) -> Manifest {
1324        Manifest::parse(bundled_manifest(agent).expect("bundled manifest exists"))
1325            .expect("bundled manifest parses")
1326    }
1327
1328    /// Derive the old boolean readiness from a manifest verdict: ready only when
1329    /// the live state is `idle` and the rule did not ask us to hold state. This
1330    /// is the mapping the daemon badge will use when E2 wires evaluate() in.
1331    fn manifest_ready(m: &Manifest, screen: &ScreenView) -> bool {
1332        matches!(m.evaluate(screen), Some(v) if v.state == "idle" && !v.skip_state_update)
1333    }
1334
1335    #[test]
1336    fn bundled_manifests_all_parse() {
1337        // Every bundled agent must parse, carry rules, and evaluate against a
1338        // synthetic view without panicking (x-83e7 AC-happy). This is the
1339        // parse-coverage guard the domain pitfall calls for: a leftover reference-
1340        // only key (unknown region/field/root key) fails loud here, NAMING the
1341        // file (x-83e7 AC-error) rather than silently shipping a dead manifest.
1342        // x-8f7f added agy + opencode; x-83e7 grew the roster to full-roster parity.
1343        let synthetic = view("some scrollback\nesc to interrupt\n\u{276f} ");
1344        for agent in [
1345            "claude", "codex", "gemini", "agy", "opencode", // pre-x-83e7
1346            "amp", "cline", "cursor", "devin", "droid", "copilot", "grok", "hermes", "kilo",
1347            "kimi", "kiro", "pi", "qodercli", // x-83e7
1348        ] {
1349            let src = bundled_manifest(agent).unwrap_or_else(|| panic!("{agent} is bundled"));
1350            let m = Manifest::parse(src)
1351                .unwrap_or_else(|e| panic!("{agent}.toml failed to parse: {e:?}"));
1352            assert!(!m.rules().is_empty(), "{agent}.toml has rules");
1353            // Must not panic (regexes compiled at parse; this exercises evaluate).
1354            let _ = m.evaluate(&synthetic);
1355        }
1356        // A genuinely-unhosted harness still resolves to None (the fail-loud
1357        // guard, mirrors readiness OQ#9: no fail-open default). aider is a real
1358        // coding CLI we deliberately do not bundle a manifest for.
1359        assert!(bundled_manifest("aider").is_none(), "unknown agent -> None");
1360    }
1361
1362    // AC-E6-4: codex/gemini ported to TOML reproduce the hardcoded
1363    // CodexReadinessDetector/GeminiReadinessDetector decisions on the exact
1364    // readiness.rs test inputs, INCLUDING gemini's "Waiting for auth" false-ready.
1365    #[test]
1366    fn ac_e6_4_codex_gemini_toml_match_hardcoded_detectors() {
1367        let codex_m = bundled("codex");
1368        let gemini_m = bundled("gemini");
1369        // (input, expected ready) - mirrors readiness.rs's detector tests.
1370        let cases: &[(&str, bool)] = &[
1371            ("codex 0.130\n\n  build feature X\n\u{276f} ", true), // idle prompt
1372            ("running tool...\nEsc to interrupt\n\u{276f}", false), // busy beats glyph
1373            ("loading a 5000 byte banner of text", false),         // no glyph -> not ready
1374            ("Waiting for auth...\n\u{276f}", false),              // gemini false-ready trap
1375            ("Gemini ready\n\u{203a} ", true),                     // › idle glyph
1376            // "Working"/"Thinking" up in scrollback must NOT block (Codex P1).
1377            (
1378                "I am Working on the Thinking task you asked about.\n\
1379                 Here is a long reply that mentions Working again.\n\
1380                 filler line\nanother filler\n\u{276f} ",
1381                true,
1382            ),
1383        ];
1384        for (text, want) in cases {
1385            let trimmed = text.trim_end();
1386            let screen = view(trimmed);
1387            assert_eq!(
1388                manifest_ready(&codex_m, &screen),
1389                *want,
1390                "codex.toml readiness mismatch for {trimmed:?}"
1391            );
1392            // Cross-check against the real hardcoded detector: the TOML must
1393            // agree with the Rust it replaces, not just with `want`.
1394            assert_eq!(
1395                manifest_ready(&codex_m, &screen),
1396                CodexReadinessDetector.is_ready(&screen).unwrap(),
1397                "codex.toml diverges from CodexReadinessDetector for {trimmed:?}"
1398            );
1399            assert_eq!(
1400                manifest_ready(&gemini_m, &screen),
1401                GeminiReadinessDetector.is_ready(&screen).unwrap(),
1402                "gemini.toml diverges from GeminiReadinessDetector for {trimmed:?}"
1403            );
1404        }
1405    }
1406
1407    // AC-E6-2: claude.toml's braille-spinner osc_title_working rule badges
1408    // `working` from the title alone, with the grid showing only scrollback.
1409    #[test]
1410    fn ac_e6_2_claude_osc_title_spinner_badges_working() {
1411        let m = bundled("claude");
1412        // Grid is pure scrollback (no spinner glyph); the title carries U+280B.
1413        let screen = view_title("old output\nmore scrollback\n", "\u{280b} Compiling");
1414        let v = m.evaluate(&screen).expect("spinner title matches");
1415        assert_eq!(v.state, "working");
1416        assert_eq!(v.rule_id, "osc_title_working");
1417        // No title at all -> the title rule cannot fire (engine never guesses).
1418        assert!(m
1419            .evaluate(&view("old output\nmore scrollback"))
1420            .is_none_or(|v| v.rule_id != "osc_title_working"));
1421    }
1422
1423    // AC-E6-3: skip_state_update on claude.toml's transcript_viewer keeps a
1424    // ctrl+o transcript pager from flipping the badge to idle.
1425    #[test]
1426    fn ac_e6_3_claude_transcript_viewer_holds_state() {
1427        let m = bundled("claude");
1428        let v = m
1429            .evaluate(&view("scrollback line\nmore scrollback\n(END)"))
1430            .expect("transcript pager marker matches");
1431        assert_eq!(v.rule_id, "transcript_viewer");
1432        assert!(
1433            v.skip_state_update,
1434            "pager rule must hold state, not set idle"
1435        );
1436    }
1437
1438    // AC-E6-5: highest-priority match wins. A claude whose grid shows an idle
1439    // composer box still badges `working` when the OSC title spinner is up,
1440    // because osc_title_working (1100) out-prioritizes live_prompt_box (950) -
1441    // the title is the authority a scraped grid cannot fake.
1442    #[test]
1443    fn ac_e6_5_claude_title_spinner_outranks_idle_grid_box() {
1444        let m = bundled("claude");
1445        let grid = "\u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256e}\n\
1446                    \u{2502} \u{276f} type here \u{2502}\n\
1447                    \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256f}";
1448        // Sanity: with no title, the idle composer box wins -> idle.
1449        let v_idle = m.evaluate(&view(grid)).expect("idle box matches");
1450        assert_eq!(v_idle.state, "idle");
1451        assert_eq!(v_idle.rule_id, "live_prompt_box");
1452        // With the spinner title up, working out-prioritizes the same idle box.
1453        let v_working = m
1454            .evaluate(&view_title(grid, "\u{280b} Working"))
1455            .expect("spinner title matches");
1456        assert_eq!(v_working.state, "working");
1457        assert_eq!(v_working.rule_id, "osc_title_working");
1458    }
1459
1460    // A live permission prompt outranks an idle composer box drawn beneath it:
1461    // badging `idle` while a prompt is up would be a false-ready (forbidden).
1462    #[test]
1463    fn ac_e6_5_claude_permission_prompt_outranks_idle_box() {
1464        let m = bundled("claude");
1465        // A permission prompt with the composer box still rendered below it.
1466        let screen = "do you want to proceed?\n\
1467                      1. Yes\n\
1468                      2. No\n\
1469                      \u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256e}\n\
1470                      \u{2502} \u{276f} type \u{2502}\n\
1471                      \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256f}";
1472        let v = m.evaluate(&view(screen)).expect("a rule matches");
1473        assert_eq!(
1474            v.state, "blocked",
1475            "permission prompt must beat the idle box"
1476        );
1477        assert_eq!(v.rule_id, "permission_prompt");
1478    }
1479
1480    #[test]
1481    fn load_manifest_prefers_override_then_bundled_then_none() {
1482        // No override dir -> bundled.
1483        let m = load_manifest("claude", None)
1484            .expect("known agent")
1485            .expect("parses");
1486        assert!(!m.rules().is_empty());
1487        // Unknown agent, no override -> None (caller fails loud). hermes is now
1488        // bundled (x-83e7 full-roster parity), so use aider (a real coding CLI we
1489        // deliberately do not bundle a manifest for).
1490        assert!(load_manifest("aider", None).is_none());
1491
1492        // A readable <agent>.toml override wins over the bundled copy.
1493        let dir = tempfile::tempdir().unwrap();
1494        std::fs::write(
1495            dir.path().join("codex.toml"),
1496            "[[rule]]\nid = \"override_only\"\nstate = \"idle\"\npriority = 1\nregion = \"whole_recent\"\ngate = { contains = \"OVR\" }\n",
1497        )
1498        .unwrap();
1499        let m = load_manifest("codex", Some(dir.path()))
1500            .expect("override present")
1501            .expect("override parses");
1502        assert_eq!(m.rules().len(), 1);
1503        assert_eq!(m.rules()[0].id, "override_only");
1504        // A missing override file for another agent falls through to bundled.
1505        let m = load_manifest("gemini", Some(dir.path()))
1506            .expect("falls back to bundled")
1507            .expect("parses");
1508        assert!(m.rules().iter().any(|r| r.id == "idle_prompt"));
1509        // A present-but-malformed override surfaces the parse error (no silent
1510        // fallback to bundled - a bad hand edit must fail loud).
1511        std::fs::write(dir.path().join("claude.toml"), "this = is = not = toml").unwrap();
1512        assert!(matches!(
1513            load_manifest("claude", Some(dir.path())),
1514            Some(Err(ManifestError::Toml(_)))
1515        ));
1516        // A present override that won't read (invalid UTF-8) also fails loud as
1517        // an Io error, NOT a silent fallback to bundled (gemini review).
1518        std::fs::write(dir.path().join("gemini.toml"), [0xff, 0xfe, 0x00]).unwrap();
1519        assert!(matches!(
1520            load_manifest("gemini", Some(dir.path())),
1521            Some(Err(ManifestError::Io { .. }))
1522        ));
1523    }
1524
1525    // AC1-HP (x-8f7f): agy's manifest is authored from AgyReadinessDetector
1526    // (agy wraps Gemini, shares prompt_ready), so it badges idle/working/blocked
1527    // on the same conditions gemini does, with the never-false-ready bias
1528    // (auth_wall 980 > busy 900 > idle_prompt 100).
1529    #[test]
1530    fn x8f7f_agy_manifest_evaluates_idle_working_blocked() {
1531        let m = bundled("agy");
1532        assert_eq!(
1533            m.evaluate(&view("agy 1.0\n\u{276f} ")).unwrap().state,
1534            "idle"
1535        );
1536        assert_eq!(
1537            m.evaluate(&view("running tool...\nesc to interrupt\n\u{276f}"))
1538                .unwrap()
1539                .state,
1540            "working", // busy (900) beats the idle glyph (100)
1541        );
1542        assert_eq!(
1543            m.evaluate(&view("Waiting for auth...\n\u{276f}"))
1544                .unwrap()
1545                .state,
1546            "blocked", // auth_wall (980) is the never-false-ready guard
1547        );
1548    }
1549
1550    // AC2-HP + AC2-EDGE (x-8f7f): opencode's reference manifest, translated per
1551    // ADAPTING.md, matches the same screens the reference's rules match - including the
1552    // multi-key AND permission rule whose nesting is preserved under one gate.
1553    #[test]
1554    fn x8f7f_opencode_manifest_matches_reference_screens() {
1555        let m = bundled("opencode");
1556        // Simple blocked marker.
1557        assert_eq!(
1558            m.evaluate(&view("△ Permission required")).unwrap().state,
1559            "blocked",
1560        );
1561        // Both working markers.
1562        assert_eq!(
1563            m.evaluate(&view("thinking\nesc to interrupt"))
1564                .unwrap()
1565                .state,
1566            "working",
1567        );
1568        assert_eq!(
1569            m.evaluate(&view("progress \u{25a0}\u{25a0}\u{25a0}\u{25a0}\u{25a0}"))
1570                .unwrap()
1571                .state,
1572            "working", // progress-bar regex (■|⬝){4,}
1573        );
1574        // AC2-EDGE: the nested any/all permission branch (esc dismiss AND a
1575        // confirm hint AND a select hint) still resolves to blocked.
1576        assert_eq!(
1577            m.evaluate(&view(
1578                "esc dismiss   enter confirm   \u{2191}\u{2193} select"
1579            ))
1580            .unwrap()
1581            .state,
1582            "blocked",
1583        );
1584        // A bare model reply that merely mentions none of the markers -> no rule
1585        // fires (the engine never guesses).
1586        assert!(m.evaluate(&view("here is your answer")).is_none());
1587    }
1588
1589    // AC2-ERR (x-8f7f): an adaptation that leaves a unknown source key in the TOML
1590    // fails loud at parse (our fail-closed parser) - the bad port never ships.
1591    #[test]
1592    fn x8f7f_unknown_source_key_fails_loud() {
1593        let bad = "[[rule]]\nid = \"p\"\nstate = \"blocked\"\npriority = 1\n\
1594                   region = \"whole_recent\"\nvisible_blocker = true\n\
1595                   gate = { contains = \"x\" }\n";
1596        assert!(matches!(
1597            Manifest::parse(bad),
1598            Err(ManifestError::Field { .. })
1599        ));
1600    }
1601
1602    // AC2-FR / AC3 (x-8f7f, flipped live at x-51f6): the hosting gate is
1603    // real. opencode's manifest was BUNDLED (staged) while opencode had no
1604    // provider impl; x-51f6 added OpencodeProvider, so opencode is now both
1605    // bundled AND hostable — like agy — and its manifest can fire. aider
1606    // remains the genuinely-unhosted example (bundled nothing, hosted
1607    // nothing).
1608    #[test]
1609    fn x8f7f_staged_manifest_fires_once_hosted() {
1610        for hosted in ["opencode", "agy"] {
1611            assert!(bundled_manifest(hosted).is_some(), "{hosted} bundled");
1612            assert!(
1613                crate::provider::for_name(hosted).is_some(),
1614                "{hosted} IS hostable -> manifest can fire",
1615            );
1616        }
1617        assert!(bundled_manifest("aider").is_none(), "aider not bundled");
1618        assert!(
1619            crate::provider::for_name("aider").is_none(),
1620            "aider not hosted"
1621        );
1622    }
1623
1624    // ---- x-c929: answer grammar + fail-closed extractor ----
1625
1626    fn blocked_answer_manifest() -> Manifest {
1627        Manifest::parse(
1628            r#"
1629            [[rule]]
1630            id = "perm"
1631            state = "blocked"
1632            priority = 900
1633            region = "bottom_non_empty_lines(8)"
1634            gate = { contains = "proceed?" }
1635            [rule.answer]
1636            option = '^\s*\x{276f}?\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
1637            send = "digit"
1638            "#,
1639        )
1640        .unwrap()
1641    }
1642
1643    // AC3-HP: a clean numbered menu yields one option per line with pinned digit
1644    // keystrokes, a display-only prompt, region_lines, and a blake3 fingerprint
1645    // over the exact region text the server will re-read.
1646    #[test]
1647    fn xc929_extract_answer_clean_numbered_menu() {
1648        let m = blocked_answer_manifest();
1649        // No blank lines, so the bottom_non_empty_lines(8) region == the screen.
1650        let screen = "Do you want to proceed?\n  ❯ 1. Yes\n  2. No";
1651        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1652        assert_eq!(v.state, "blocked");
1653        let ans = ans.expect("a clean numbered menu is answerable");
1654        assert_eq!(ans.options.len(), 2);
1655        assert_eq!(ans.options[0].idx, "1");
1656        assert_eq!(ans.options[0].label, "Yes");
1657        assert_eq!(ans.options[0].keystroke, b"1");
1658        assert_eq!(ans.options[1].idx, "2");
1659        assert_eq!(ans.options[1].label, "No");
1660        assert_eq!(ans.options[1].keystroke, b"2");
1661        assert_eq!(ans.region_lines, 8);
1662        assert_eq!(ans.prompt, "Do you want to proceed?");
1663        // Fingerprint is blake3 over the region join (the server re-hashes this).
1664        assert_eq!(ans.fingerprint, *blake3::hash(screen.as_bytes()).as_bytes());
1665    }
1666
1667    #[test]
1668    fn answer_rule_can_gate_context_outside_the_answer_region() {
1669        let m = Manifest::parse(
1670            r#"
1671            [[rule]]
1672            id = "approval"
1673            state = "blocked"
1674            priority = 10
1675            region = "bottom_non_empty_lines(4)"
1676            gate = { line_regex = '^\s*\x{203a}\s*[0-9]\.\s' }
1677            context_region = "whole_recent"
1678            context_gate = { contains = "Would you like to run the following command?" }
1679            [rule.answer]
1680            option = '^\s*\x{203a}?\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
1681            send = "digit"
1682            "#,
1683        )
1684        .unwrap();
1685        let wrapped = (0..30)
1686            .map(|i| format!("wrapped command row {i}"))
1687            .collect::<Vec<_>>()
1688            .join("\n");
1689        let screen = format!(
1690            "Would you like to run the following command?\n{wrapped}\n\
1691             \u{203a} 1. Yes, proceed\n  2. Always allow\n  3. No, cancel\n  Press enter to confirm"
1692        );
1693        let (v, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
1694        assert_eq!(v.rule_id, "approval");
1695        let ans = ans.expect("context gate detects the question; bottom region extracts the menu");
1696        assert_eq!(ans.options.len(), 3);
1697        assert_eq!(ans.region_lines, 4);
1698
1699        let no_question = format!(
1700            "unrelated output\n{wrapped}\n\
1701             \u{203a} 1. Yes, proceed\n  2. Always allow\n  3. No, cancel\n  Press enter to confirm"
1702        );
1703        assert!(m.evaluate_answerable(&view(&no_question)).is_none());
1704    }
1705
1706    // AC3-ERR: duplicated indices are not a clean 1..N run -> None (fail closed).
1707    #[test]
1708    fn xc929_extract_answer_rejects_duplicate_indices() {
1709        let m = blocked_answer_manifest();
1710        let screen = "proceed?\n1. Yes\n1. No";
1711        let (_, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1712        assert!(ans.is_none(), "duplicate index -> not answerable");
1713    }
1714
1715    // AC3-EDGE: a menu whose top scrolled past the region window presents its
1716    // first captured option as "2." -> lowest index != 1 -> truncated -> None.
1717    #[test]
1718    fn xc929_extract_answer_rejects_truncated_menu() {
1719        let m = blocked_answer_manifest();
1720        let screen = "proceed?\n2. No\n3. Cancel";
1721        let (_, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1722        assert!(ans.is_none(), "menu not starting at 1 -> truncated -> None");
1723    }
1724
1725    // AC3-FR: extraction is strictly additive - an unextractable blocked prompt
1726    // still badges `blocked`; only the answer payload degrades to None.
1727    #[test]
1728    fn xc929_extraction_failure_is_additive_badge_survives() {
1729        let m = blocked_answer_manifest();
1730        let screen = "proceed?\nuse arrows to select";
1731        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1732        assert_eq!(
1733            v.state, "blocked",
1734            "detection unaffected by extraction miss"
1735        );
1736        assert!(ans.is_none(), "no numbered options -> focus-only");
1737    }
1738
1739    // A long label is kept untruncated (the client truncates for display); the
1740    // fingerprint covers the full region text (AC3-UI is a client concern, but
1741    // the extractor must not pre-truncate).
1742    #[test]
1743    fn xc929_extract_answer_keeps_full_label() {
1744        let m = blocked_answer_manifest();
1745        let long = "No, and tell Claude what to do differently (esc)";
1746        let screen = format!("proceed?\n1. Yes\n2. {long}");
1747        let (_, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
1748        assert_eq!(ans.unwrap().options[1].label, long);
1749    }
1750
1751    // `send = "digit_enter"` appends CR to the pinned keystroke.
1752    #[test]
1753    fn xc929_send_digit_enter_appends_cr() {
1754        let m = Manifest::parse(
1755            r#"
1756            [[rule]]
1757            id = "perm"
1758            state = "blocked"
1759            priority = 900
1760            region = "bottom_non_empty_lines(8)"
1761            gate = { contains = "?" }
1762            [rule.answer]
1763            option = '^\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
1764            send = "digit_enter"
1765            "#,
1766        )
1767        .unwrap();
1768        let (_, ans) = m.evaluate_answerable(&view("pick?\n1. A\n2. B")).unwrap();
1769        assert_eq!(ans.unwrap().options[0].keystroke, b"1\r");
1770    }
1771
1772    // Parse-time fail-loud: an [answer] on a non-blocked rule is a config bug.
1773    #[test]
1774    fn xc929_answer_on_non_blocked_rule_fails_loud() {
1775        let err = Manifest::parse(
1776            r#"
1777            [[rule]]
1778            id = "r"
1779            state = "idle"
1780            priority = 1
1781            region = "bottom_non_empty_lines(8)"
1782            gate = { contains = "x" }
1783            [rule.answer]
1784            option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
1785            send = "digit"
1786            "#,
1787        )
1788        .unwrap_err();
1789        assert!(
1790            matches!(err, ManifestError::Field { rule, field } if rule == "r" && field.starts_with("answer"))
1791        );
1792    }
1793
1794    // Parse-time fail-loud: an [answer] needs a bottom_non_empty_lines(N) region
1795    // so the server can re-read the same window.
1796    #[test]
1797    fn xc929_answer_on_non_bottom_n_region_fails_loud() {
1798        let err = Manifest::parse(
1799            r#"
1800            [[rule]]
1801            id = "r"
1802            state = "blocked"
1803            priority = 1
1804            region = "whole_recent"
1805            gate = { contains = "x" }
1806            [rule.answer]
1807            option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
1808            send = "digit"
1809            "#,
1810        )
1811        .unwrap_err();
1812        assert!(
1813            matches!(err, ManifestError::Field { field, .. } if field.contains("bottom_non_empty_lines"))
1814        );
1815    }
1816
1817    // Parse-time fail-loud: the option regex must name both idx and label.
1818    #[test]
1819    fn xc929_answer_missing_named_capture_fails_loud() {
1820        let err = Manifest::parse(
1821            r#"
1822            [[rule]]
1823            id = "r"
1824            state = "blocked"
1825            priority = 1
1826            region = "bottom_non_empty_lines(8)"
1827            gate = { contains = "x" }
1828            [rule.answer]
1829            option = '(?P<idx>[0-9])\.'
1830            send = "digit"
1831            "#,
1832        )
1833        .unwrap_err();
1834        assert!(matches!(err, ManifestError::Field { field, .. } if field.contains("label")));
1835    }
1836
1837    // Parse-time fail-loud: an unknown send mapping is rejected (not v1 vocab).
1838    #[test]
1839    fn xc929_answer_bad_send_fails_loud() {
1840        let err = Manifest::parse(
1841            r#"
1842            [[rule]]
1843            id = "r"
1844            state = "blocked"
1845            priority = 1
1846            region = "bottom_non_empty_lines(8)"
1847            gate = { contains = "x" }
1848            [rule.answer]
1849            option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
1850            send = "arrows"
1851            "#,
1852        )
1853        .unwrap_err();
1854        assert!(
1855            matches!(err, ManifestError::Field { field, .. } if field.starts_with("answer.send"))
1856        );
1857    }
1858
1859    // Integration: the bundled claude permission_prompt rule is answerable on a
1860    // real "Do you want to proceed? / 1. Yes / 2. No" screen.
1861    #[test]
1862    fn xc929_bundled_claude_permission_prompt_is_answerable() {
1863        let m = bundled("claude");
1864        let screen =
1865            "Do you want to proceed?\n  ❯ 1. Yes\n  2. No, and tell Claude what to do differently";
1866        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1867        assert_eq!(v.rule_id, "permission_prompt");
1868        let ans = ans.expect("claude permission prompt is answerable");
1869        assert_eq!(ans.options.len(), 2);
1870        assert_eq!(ans.options[0].keystroke, b"1");
1871        assert_eq!(ans.options[1].idx, "2");
1872    }
1873
1874    // x-5103: the bundled codex trust_prompt is answerable on a real (validated)
1875    // borderless "› 1. Yes, continue / 2. No, quit" menu; the "›" marker (U+203A)
1876    // and surrounding non-option lines don't break extraction, and send="digit".
1877    #[test]
1878    fn x5103_bundled_codex_trust_prompt_is_answerable() {
1879        let m = bundled("codex");
1880        let screen = "> You are in /tmp/foo\n  \
1881            Do you trust the contents of this directory? Trusting loads config.\n\
1882            \u{203a} 1. Yes, continue\n  2. No, quit\n\n  Press enter to continue";
1883        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1884        assert_eq!(v.rule_id, "trust_prompt");
1885        assert_eq!(v.state, "blocked");
1886        let ans = ans.expect("codex numbered trust menu is answerable");
1887        assert_eq!(ans.options.len(), 2);
1888        assert_eq!(ans.options[0].idx, "1");
1889        assert_eq!(ans.options[0].label, "Yes, continue");
1890        assert_eq!(ans.options[0].keystroke, b"1");
1891        assert_eq!(ans.options[1].idx, "2");
1892        assert_eq!(ans.options[1].keystroke, b"2");
1893    }
1894
1895    // x-5103 (codex review P2): a model reply that PRINTS "Do you trust …" plus a
1896    // plain numbered list while idle must NOT become answerable - only a live menu
1897    // draws the "›" selector before a digit. The gate's required marker is what
1898    // stops the response list from injecting "1"/"2" into the idle composer.
1899    #[test]
1900    fn x5103_codex_model_printed_list_is_not_a_false_trust_prompt() {
1901        let m = bundled("codex");
1902        let screen = "The permission flow. Do you trust the folder? Options:\n\
1903            1. Yes, it loads config\n  2. No, sandboxed\n\u{203a} ";
1904        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1905        assert_ne!(
1906            v.rule_id, "trust_prompt",
1907            "a printed list without the › selector must not fire trust_prompt"
1908        );
1909        assert!(ans.is_none(), "no live menu -> not answerable");
1910    }
1911
1912    // x-f498: Codex 0.144.1 renders command approval as a borderless numbered
1913    // menu and commits a bare digit. This screen was captured from the live TUI.
1914    #[test]
1915    fn xf498_bundled_codex_command_approval_is_answerable() {
1916        let m = bundled("codex");
1917        let screen = "Would you like to run the following command?\n\n\
1918            Environment: local\n\n\
1919            $ touch /tmp/fno-x-f498-approval-capture\n\n\
1920            \u{203a} 1. Yes, proceed (y)\n\
1921              2. Yes, and don't ask again for commands that start with `touch /tmp/fno-x-f498-approval-capture` (p)\n\
1922              3. No, and tell Codex what to do differently (esc)\n\n\
1923            Press enter to confirm or esc to cancel";
1924        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1925        assert_eq!(v.rule_id, "approval_prompt");
1926        assert_eq!(v.state, "blocked");
1927        let ans = ans.expect("codex command approval is answerable");
1928        assert_eq!(ans.options.len(), 3);
1929        assert_eq!(ans.options[0].label, "Yes, proceed (y)");
1930        assert_eq!(ans.options[0].keystroke, b"1");
1931        assert_eq!(ans.options[2].idx, "3");
1932        assert_eq!(ans.options[2].keystroke, b"3");
1933    }
1934
1935    #[test]
1936    fn xf498_codex_model_printed_list_is_not_a_false_command_approval() {
1937        let m = bundled("codex");
1938        let screen = "Here is an example. Would you like to run the following command?\n\
1939            1. Yes, proceed\n  2. No, cancel\n\u{203a} ";
1940        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1941        assert_ne!(v.rule_id, "approval_prompt");
1942        assert!(ans.is_none(), "no marked live menu -> not answerable");
1943    }
1944
1945    #[test]
1946    fn xf498_bundled_codex_edit_approval_is_answerable() {
1947        let m = bundled("codex");
1948        let screen = "Added .fno-x-f498-edit-capture (+1 -0)\n\
1949            1 +CAPTURE\n\n\
1950            Would you like to make the following edits?\n\n\
1951            \u{203a} 1. Yes, proceed (y)\n\
1952              2. Yes, and don't ask again for these files (a)\n\
1953              3. No, and tell Codex what to do differently (esc)\n\n\
1954            Press enter to confirm or esc to cancel";
1955        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1956        assert_eq!(v.rule_id, "approval_prompt");
1957        let ans = ans.expect("codex edit approval is answerable");
1958        assert_eq!(ans.options.len(), 3);
1959        assert_eq!(
1960            ans.options[1].label,
1961            "Yes, and don't ask again for these files (a)"
1962        );
1963        assert_eq!(ans.options[2].keystroke, b"3");
1964    }
1965
1966    #[test]
1967    fn xf498_codex_approval_survives_narrow_terminal_wrapping() {
1968        let m = bundled("codex");
1969        let wrapped = (0..40)
1970            .map(|i| format!("wrapped command row {i}"))
1971            .collect::<Vec<_>>()
1972            .join("\n");
1973        let screen = format!(
1974            "Would you like to run the following command?\nEnvironment: local\n{wrapped}\n\
1975             \u{203a} 1. Yes, proceed (y)\n\
1976               2. Yes, and don't ask again (p)\n\
1977               3. No, and tell Codex what to do differently (esc)\n\
1978             Press enter to confirm or esc to cancel"
1979        );
1980        let (v, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
1981        assert_eq!(v.rule_id, "approval_prompt");
1982        assert!(ans.is_some());
1983    }
1984
1985    // x-5103: the bundled gemini trust_prompt is answerable on a real (validated)
1986    // BOXED radio ("│ ● 1. Trust folder … │"); the option regex consumes the box
1987    // border (│ U+2502) + radio marker (● U+25CF) and the trailing border.
1988    #[test]
1989    fn x5103_bundled_gemini_trust_prompt_is_answerable() {
1990        let m = bundled("gemini");
1991        let screen = "\u{256d}\u{2500}\u{2500}\u{2500}\u{256e}\n\
1992            \u{2502} Do you trust the files in this folder?        \u{2502}\n\
1993            \u{2502} Trusting a folder allows Gemini CLI to load.  \u{2502}\n\
1994            \u{2502}                                               \u{2502}\n\
1995            \u{2502} \u{25cf} 1. Trust folder (foo)                     \u{2502}\n\
1996            \u{2502}   2. Trust parent folder (tmp)                \u{2502}\n\
1997            \u{2502}   3. Don't trust                              \u{2502}\n\
1998            \u{2570}\u{2500}\u{2500}\u{2500}\u{256f}";
1999        let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
2000        assert_eq!(v.rule_id, "trust_prompt");
2001        assert_eq!(v.state, "blocked");
2002        let ans = ans.expect("gemini boxed numbered menu is answerable");
2003        assert_eq!(ans.options.len(), 3);
2004        assert_eq!(ans.options[0].idx, "1");
2005        assert_eq!(ans.options[0].label, "Trust folder (foo)");
2006        assert_eq!(ans.options[0].keystroke, b"1");
2007        assert_eq!(ans.options[2].idx, "3");
2008        assert_eq!(ans.options[2].label, "Don't trust");
2009    }
2010
2011    // x-5103: agy's trust prompt is ARROW-ONLY ("> Yes … / No, exit" +
2012    // "↑/↓ Navigate") - no numbered options. No agy rule matches it, so it stays
2013    // focus-only (the documented Open Q1 no-op), never a fabricated grammar.
2014    #[test]
2015    fn x5103_bundled_agy_arrow_menu_is_focus_only() {
2016        let m = bundled("agy");
2017        let screen = "Do you trust the contents of this project?\n\
2018            Antigravity CLI requires permission to read, edit, and execute files here.\n\
2019            > Yes, I trust this folder\n  No, exit\n  \u{2191}/\u{2193} Navigate \u{b7} enter Confirm";
2020        assert!(
2021            m.evaluate_answerable(&view(screen)).is_none(),
2022            "agy arrow-only menu must not be answerable (focus-only fallback)"
2023        );
2024    }
2025}