Skip to main content

fno_agents/
loopcheck.rs

1//! `fno-agents loop-check` verb (Task 1.1, ab-d0337fbc).
2//!
3//! Single entry-point decision-maker for the target stop hook. Reads external
4//! state (manifest, transcript, git, gh, events, ledger) and returns a JSON
5//! decision object. The manifest is NEVER mutated; the only write surface is
6//! append-only event logs.
7//!
8//! Module name starts with "loop" to match the LOC-ratchet glob `crates/fno-agents/src/loop*`.
9
10use crate::{completion_output::allow_output, delivery_completion::pr_passes};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18// ── public types ──────────────────────────────────────────────────────────────
19
20/// Why the loop terminated. Serialized as the exact string enum the spec names.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum TerminationReason {
23    DonePRGreen,
24    DoneAdvisory,
25    DoneDelivery,
26    /// A batch-lane member (batch-lane Wave 2/3): its commits live on a shared
27    /// batch branch and ship via the batch PR, not its own, so there is no
28    /// per-node PR to go green. Terminal, but NOT a ship reason - the batch's
29    /// own `/pr create` graduates the plan; a member must not.
30    DoneBatched,
31    /// Work complete (PR open, mergeable, reviewed, HEAD shipped) but `done()`
32    /// fails SOLELY on CI-green because main itself is red on the same checks,
33    /// and a bg agent cannot merge. Proven pre-existing main-red (strict
34    /// check-name subset against current main HEAD) terminates the loop with a
35    /// one-shot merge-recommendation notify instead of burning to NoProgress.
36    /// Terminal, but NOT a ship reason (like DoneBatched): never merges, never
37    /// marks the node done - a human merge then the out-of-band-merge reconcile
38    /// path closes it, and DonePRGreen always wins when observable.
39    DoneAwaitingMerge,
40    /// A plan-only thread reached the plan boundary cleanly (manifest `planned`
41    /// flag + a promise). It produced planning output, not a delivery, so it is
42    /// terminal but deliberately NOT a ship reason (out of finalize.SHIP_REASONS
43    /// -> no plan stamp/graduate) and NOT a postmortem reason (a plan is not
44    /// stuck). Benign like NoWork; distinct from DoneAdvisory, which DOES
45    /// graduate. The scoreboard's `planned` bucket is keyed on the phase set,
46    /// never on this terminal.
47    DonePlanned,
48    NoWork,
49    Budget,
50    NoProgress,
51    Interrupted,
52    Aborted,
53}
54
55// ── manifest parsing ──────────────────────────────────────────────────────────
56
57/// Fields parsed from target-state.md YAML frontmatter.
58#[derive(Debug)]
59struct Manifest {
60    session_id: Option<String>,
61    created_at: Option<String>,
62    attended: bool, // default true when absent
63    advisory: bool,
64    no_ship: bool,
65    no_external: bool,
66    /// batch-lane member: commits ship via the batch PR, not a per-node PR.
67    batched: bool,
68    /// plan-only thread: reaches the plan boundary and terminates DonePlanned
69    /// (not DoneAdvisory, which would graduate the plan).
70    planned: bool,
71    /// Plan doc backing this session; source of the `done_probes` declaration.
72    plan_path: Option<String>,
73    legacy_status: Option<String>, // COMPLETE | BLOCKED | ABORTED
74    /// None = absent (unlimited). Some(Ok(v)) = valid cap. Some(Err(s)) = malformed raw value.
75    budget_wall_clock_cap_minutes: Option<Result<u64, String>>,
76    /// None = absent (unlimited). Some(Ok(v)) = valid cap. Some(Err(s)) = malformed raw value.
77    budget_cost_cap_usd: Option<Result<f64, String>>,
78}
79
80impl Default for Manifest {
81    fn default() -> Self {
82        Self {
83            session_id: None,
84            created_at: None,
85            attended: true, // spec: attended defaults to true
86            advisory: false,
87            no_ship: false,
88            no_external: false,
89            batched: false,
90            planned: false,
91            plan_path: None,
92            legacy_status: None,
93            budget_wall_clock_cap_minutes: None, // None = absent = unlimited
94            budget_cost_cap_usd: None,           // None = absent = unlimited
95        }
96    }
97}
98
99/// Read a single `^<field>: value` line from ANYWHERE in the manifest, not just
100/// the frontmatter block. `fno target init` writes the immutable frontmatter
101/// first, then APPENDS the node-claim fields (`target_claim_key/holder/ttl`)
102/// after the closing `---`, so `parse_manifest` (frontmatter-bounded) never sees
103/// them. Renewal reads them here instead (x-ba4b). Surrounding quotes stripped.
104fn scan_manifest_field(content: &str, field: &str) -> Option<String> {
105    let prefix = format!("{field}:");
106    content.lines().find_map(|line| {
107        let line = line.trim();
108        line.strip_prefix(&prefix)
109            .map(|v| v.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
110            .filter(|v| !v.is_empty())
111    })
112}
113
114/// Parse frontmatter from a `---\n...\n---\n` block at the top of a file.
115/// Returns None if the file does not start with `---`.
116/// Unknown fields are silently ignored.
117fn parse_manifest(content: &str) -> Option<Manifest> {
118    let content = content.trim_start();
119    if !content.starts_with("---") {
120        return None;
121    }
122    let after_first = &content[3..];
123    // Find closing ---
124    let end = after_first.find("\n---")?;
125    let body = &after_first[..end];
126
127    let mut m = Manifest {
128        attended: true, // default
129        ..Default::default()
130    };
131
132    for line in body.lines() {
133        let line = line.trim();
134        if line.is_empty() || line.starts_with('#') {
135            continue;
136        }
137        if let Some((k, v)) = line.split_once(':') {
138            let k = k.trim();
139            // YAML string values may be quoted; strip surrounding quotes so a
140            // quoted session_id/created_at parses identically (gemini MEDIUM).
141            let v = v.trim().trim_matches(|c| c == '"' || c == '\'');
142            match k {
143                // fno_id is canonical and wins; session_id is the one-release
144                // legacy fallback (never overwrites a resolved fno_id).
145                "fno_id" => m.session_id = Some(v.to_string()),
146                "session_id" => {
147                    if m.session_id.is_none() {
148                        m.session_id = Some(v.to_string());
149                    }
150                }
151                "created_at" => m.created_at = Some(v.to_string()),
152                "attended" => m.attended = v == "true",
153                "advisory" => m.advisory = v == "true",
154                "no_ship" => m.no_ship = v == "true",
155                "no_external" => m.no_external = v == "true",
156                "batched" => m.batched = v == "true",
157                "planned" => m.planned = v == "true",
158                "plan_path" => {
159                    if !v.is_empty() {
160                        m.plan_path = Some(v.to_string());
161                    }
162                }
163                "status" => {
164                    let upper = v.to_uppercase();
165                    if matches!(upper.as_str(), "COMPLETE" | "BLOCKED" | "ABORTED") {
166                        m.legacy_status = Some(upper);
167                    }
168                }
169                "budget_wall_clock_cap_minutes" => {
170                    // Manifests are machine-written numeric fields; tolerate a '#'-tail
171                    // (e.g. `90# Auto-merge inputs`) by truncating at the first '#'.
172                    let stripped = v
173                        .split_once('#')
174                        .map(|(before, _)| before.trim())
175                        .unwrap_or(v);
176                    m.budget_wall_clock_cap_minutes = Some(stripped.parse::<u64>().map_err(|_| {
177                        eprintln!(
178                            "loop-check: malformed budget cap 'budget_wall_clock_cap_minutes: {v}' - failing closed; fix the config"
179                        );
180                        v.to_string()
181                    }));
182                }
183                "budget_cost_cap_usd" => {
184                    let stripped = v
185                        .split_once('#')
186                        .map(|(before, _)| before.trim())
187                        .unwrap_or(v);
188                    m.budget_cost_cap_usd = Some(stripped.parse::<f64>().map_err(|_| {
189                        eprintln!(
190                            "loop-check: malformed budget cap 'budget_cost_cap_usd: {v}' - failing closed; fix the config"
191                        );
192                        v.to_string()
193                    }));
194                }
195                _ => {}
196            }
197        }
198    }
199    Some(m)
200}
201
202// ── settings parsing ──────────────────────────────────────────────────────────
203
204#[derive(Debug, Default)]
205struct Settings {
206    /// config.budget.attended.wall_clock_cap_minutes
207    /// None = absent. Some(Ok(v)) = valid. Some(Err(s)) = malformed raw value.
208    attended_wall_cap_minutes: Option<Result<u64, String>>,
209    /// config.budget.attended.cost_cap_usd
210    attended_cost_cap_usd: Option<Result<f64, String>>,
211    /// config.budget.unattended.wall_clock_cap_minutes
212    unattended_wall_cap_minutes: Option<Result<u64, String>>,
213    /// config.budget.unattended.cost_cap_usd
214    unattended_cost_cap_usd: Option<Result<f64, String>>,
215    /// flat budget_cap: (folds in ab-41b13d9d) - applies as cost cap for both modes
216    flat_budget_cap: Option<Result<f64, String>>,
217    /// config.ci.declared_none: true
218    ci_declared_none: bool,
219    /// config.external_reviewers list
220    external_reviewers: Vec<String>,
221    /// config.review.github_apps (x-4baa; the GitHub App bot logins gate).
222    /// None = key absent -> code default (empty, no gate).
223    /// Some([]) = explicitly `[]` -> declared no-review-gate path.
224    /// Some(list) = every listed login must have a completed review pass.
225    github_apps: Option<Vec<String>>,
226    /// config.review.required_bots: legacy alias for `github_apps` (a straight
227    /// rename). `github_apps` wins when both are set. Same fail-closed rules.
228    required_bots: Option<Vec<String>>,
229    /// config.review.peers: local review harnesses. Identity-free entries form
230    /// one composite, head-pinned local-attestation gate; entries with a shared
231    /// or per-entry identity retain the legacy GitHub-login gate.
232    peers: Vec<PeerEntry>,
233    /// config.review.peer_identity: the shared login peers post under.
234    peer_identity: Option<String>,
235    /// config.review.optional_apps: reviewer logins honored-if-present but NOT
236    /// required. The gate never WAITS for them (their absence never blocks -
237    /// this kills the App-bot usage-limit wedge), but a blocking finding from
238    /// one still holds the gate until addressed ("honor if present"). None =
239    /// no optional reviewers.
240    optional_apps: Option<Vec<String>>,
241    /// config.review.reviewers (x-e703, Phase 2): local reviewer names (sigma |
242    /// code-review | declare) satisfied by a head-pinned `review_attestation`
243    /// event in events.jsonl, NOT a GitHub login. Empty = no reviewers gate
244    /// (additive to the login gate; no "declared empty" distinction needed). A
245    /// leading '/' is stripped on store so `/code-review` == `code-review`.
246    /// Resolvability is validated Python-side; Rust fails closed by matching
247    /// evidence, so an unresolvable name is simply never satisfied.
248    reviewers: Vec<String>,
249    /// config.review.nudge (x-b167): per-login overrides for the bot-review
250    /// nudge, resolved against BOT_PROFILES by `resolved_nudge_configs`. Empty =
251    /// no overrides (the built-in profiles alone decide nudgeability). A
252    /// malformed entry degrades that login to non-nudgeable, never panics (AC8).
253    nudge_overrides: Vec<NudgeOverride>,
254    /// Top-level `done_probes` (x-a534): the repo-wide probe list, evaluated
255    /// alongside the plan's own. The file is FLAT, so this reads off the TOML
256    /// root, not out of a `config` table.
257    ///
258    /// None = key absent (no project gate). Some(Ok(list)) = the declaration.
259    /// Some(Err(why)) = present but not an array of strings, which maps to the
260    /// plan side's `Unparseable` and BLOCKS - a config key that degrades to
261    /// no-gate is a guardrail that disappears when you typo it.
262    done_probes: Option<Result<Vec<String>, String>>,
263}
264
265/// Normalize a config.review.reviewers entry / an event's reviewer name: strip a
266/// leading '/' so `/code-review` and `code-review` name the same reviewer
267/// (parity with the Python validator). Quote/comment stripping is the caller's.
268fn normalize_reviewer(raw: &str) -> String {
269    raw.trim().trim_start_matches('/').to_string()
270}
271
272/// Fail-closed sentinel for a structurally-malformed `reviewers:` value (e.g. a
273/// `{...}` mapping). Python raises loudly on such a value; the Rust parser must
274/// NOT silently drop it to an empty list (= no gate, fail OPEN). Instead it
275/// stores this sentinel so the gate stays active but UNSATISFIABLE - the NUL
276/// byte can never appear in an emitted `review_attestation.reviewer`, so no
277/// evidence ever clears it (codex peer review P1).
278const MALFORMED_REVIEWERS_SENTINEL: &str = "\u{0}malformed-reviewers";
279
280/// A `config.review.peers` entry. `provider` is kept for messaging and the
281/// same-model guard; `model` carries an optional `"route_provider,route_model"`
282/// route (the claude CLI as transport for a genuinely different model); the gate
283/// identity selects the legacy posting carrier; otherwise the entry contributes
284/// to the composite local-attestation gate.
285#[derive(Debug, Default, Clone)]
286struct PeerEntry {
287    provider: String,
288    model: Option<String>,
289    identity: Option<String>,
290}
291
292/// Strip a trailing YAML inline comment (` # ...`) from a raw scalar value
293/// (codex P2 on #448). YAML requires whitespace before the `#`; a value that
294/// IS a comment strips to empty. Quoted values containing '#' are out of
295/// scope for this minimal parser (no known bot login contains '#').
296fn strip_inline_comment(raw: &str) -> &str {
297    if raw.starts_with('#') {
298        return "";
299    }
300    match raw.find(" #").or_else(|| raw.find("\t#")) {
301        Some(i) => raw[..i].trim_end(),
302        None => raw,
303    }
304}
305
306/// Fail-closed sentinel for an unparseable config.toml (x-81d9 (c)). A
307/// scanner error (e.g. tab-indentation, which YAML forbids) previously caused
308/// the hand-parser to silently drop the whole config.review subtree, yielding
309/// zero required_bots and shipping the PR unreviewed. Now such a file fails
310/// CLOSED: this sentinel is placed in the login gate so it can never be
311/// satisfied (no real bot login contains a NUL), the gate blocks visibly, and a
312/// `loop_check_settings_unparseable` event records it. Distinct from
313/// MALFORMED_REVIEWERS_SENTINEL so an audit sees which gate the config tripped.
314const UNPARSEABLE_SETTINGS_SENTINEL: &str = "\u{0}unparseable-settings\u{0}";
315
316/// A bare scalar RHS (`key: value`) as a single-item login list. Used when a
317/// list key was written scalar-form: it must GATE on that one login, never
318/// silently fail open to "no gate" (codex P1 on #205). A structurally-malformed
319/// value (a `{...}` flow mapping) is NOT a login - degrade to None so both
320/// parsers agree (Python's typed reader drops a mapping to None too; codex P1 on
321/// the two-parser-agreement invariant). Empty -> None.
322fn scalar_as_singleton(rest: &str) -> Option<Vec<String>> {
323    let v = strip_inline_comment(rest.trim())
324        .trim_matches(|c| c == '"' || c == '\'')
325        .to_string();
326    if v.is_empty() || v.contains('{') || v.contains('}') {
327        None
328    } else {
329        Some(vec![v])
330    }
331}
332
333/// A TOML scalar (string / integer / float / bool) as a String; None for
334/// structured values (array / table). Numbers and bools stringify so a
335/// `required_bots = 123` or a stray bool still coerces to a login string,
336/// matching the old scalar-tolerant behavior.
337fn scalar_string(v: &toml::Value) -> Option<String> {
338    match v {
339        toml::Value::String(s) => Some(s.clone()),
340        toml::Value::Boolean(b) => Some(b.to_string()),
341        toml::Value::Integer(n) => Some(n.to_string()),
342        toml::Value::Float(f) => Some(f.to_string()),
343        _ => None,
344    }
345}
346
347/// Classify a config.review LOGIN list value (`required_bots` / `github_apps` /
348/// `optional_apps`) off a typed TOML Value, matching the Python loader:
349///   absent        -> None            (key absent; code default = no gate)
350///   array         -> Some(items)     (empty stays Some(empty) = declared no-gate)
351///   scalar        -> singleton gate  (a bare `key = "codex"` still GATES on codex)
352///   table/other   -> None            (an inline table is not a login; Python drops it)
353fn value_as_login_list(v: &toml::Value) -> Option<Vec<String>> {
354    match v {
355        toml::Value::Array(items) => Some(items.iter().filter_map(scalar_string).collect()),
356        // A bare scalar routes through scalar_as_singleton so its brace/empty
357        // semantics (and the direct unit test) stay live and Python-aligned.
358        toml::Value::String(_)
359        | toml::Value::Boolean(_)
360        | toml::Value::Integer(_)
361        | toml::Value::Float(_) => scalar_string(v).and_then(|s| scalar_as_singleton(&s)),
362        // Table / other: not a login gate -> None (Python parity).
363        _ => None,
364    }
365}
366
367/// One `[review.nudge]` per-login override (x-b167). Every field is optional in
368/// TOML; a value of the wrong type sets `malformed` so that login degrades to
369/// non-nudgeable rather than panicking - the stop gate must never panic (AC8).
370#[derive(Debug, Clone, Default)]
371struct NudgeOverride {
372    login: String,
373    review_handle: Option<String>,
374    wait_minutes: Option<i64>,
375    ceiling: Option<usize>,
376    /// Defaults to true; `enabled = false` opts a repo out (back to plain
377    /// block-and-wait, NOT a faster give-up).
378    enabled: bool,
379    /// Any field of the wrong type: the whole login drops to non-nudgeable.
380    malformed: bool,
381}
382
383/// Parse the `[review.nudge]` table (`login -> { review_handle, wait_minutes,
384/// ceiling, enabled }`). Lenient by construction, matching `value_as_login_list`:
385/// a non-table value, or any field of the wrong type / a non-positive integer,
386/// marks that login `malformed`. Never panics (AC8).
387fn value_as_nudge_overrides(v: &toml::Value) -> Vec<NudgeOverride> {
388    let Some(table) = v.as_table() else {
389        // The whole `nudge` value is not a table (scalar/list): no overrides.
390        return Vec::new();
391    };
392    let mut out = Vec::new();
393    for (login, entry) in table {
394        let mut ov = NudgeOverride {
395            login: login.clone(),
396            enabled: true,
397            ..Default::default()
398        };
399        let Some(map) = entry.as_table() else {
400            // A scalar or list where an inline table was expected (AC8).
401            ov.malformed = true;
402            out.push(ov);
403            continue;
404        };
405        if let Some(rh) = map.get("review_handle") {
406            match rh.as_str() {
407                Some(s) => ov.review_handle = Some(s.to_string()),
408                None => ov.malformed = true,
409            }
410        }
411        if let Some(wm) = map.get("wait_minutes") {
412            match wm.as_integer() {
413                // Upper-bounded so `chrono::Duration::minutes` (which panics
414                // above i64::MAX/60) can never take the stop gate down on an
415                // absurd config value; anything out of range is malformed.
416                Some(n) if (1..=MAX_NUDGE_WAIT_MINUTES).contains(&n) => ov.wait_minutes = Some(n),
417                _ => ov.malformed = true, // non-int, non-positive, or absurd (AC8)
418            }
419        }
420        if let Some(c) = map.get("ceiling") {
421            match c.as_integer() {
422                Some(n) if (1..=MAX_NUDGE_CEILING).contains(&n) => ov.ceiling = Some(n as usize),
423                _ => ov.malformed = true,
424            }
425        }
426        if let Some(en) = map.get("enabled") {
427            match en.as_bool() {
428                Some(b) => ov.enabled = b,
429                None => ov.malformed = true,
430            }
431        }
432        out.push(ov);
433    }
434    out
435}
436
437/// Classify a config.review.reviewers value (x-e703 local-attestation gate).
438/// Unlike the login lists, a structurally-wrong mapping fails CLOSED (Python
439/// raises) via the unsatisfiable sentinel, never a silent empty gate. A leading
440/// '/' is normalized off each entry.
441fn value_as_reviewers(v: &toml::Value) -> Vec<String> {
442    match v {
443        toml::Value::Array(items) => {
444            let mut out = Vec::new();
445            for it in items {
446                match scalar_string(it) {
447                    Some(s) => {
448                        let n = normalize_reviewer(&s);
449                        if !n.is_empty() {
450                            out.push(n);
451                        }
452                    }
453                    // A non-scalar item (nested table/array) is structurally
454                    // wrong; Python raises on it, so fail CLOSED with the
455                    // sentinel rather than silently dropping it (gemini medium) -
456                    // matches the top-level-table arm below.
457                    None => return vec![MALFORMED_REVIEWERS_SENTINEL.to_string()],
458                }
459            }
460            out
461        }
462        toml::Value::String(s) => {
463            let n = normalize_reviewer(s);
464            if n.is_empty() {
465                Vec::new()
466            } else {
467                vec![n]
468            }
469        }
470        // A table (or other structural shape) fails closed, not empty.
471        _ => vec![MALFORMED_REVIEWERS_SENTINEL.to_string()],
472    }
473}
474
475/// Classify a config.review.peers value into PeerEntry list. A sequence item is
476/// either a scalar (provider only) or a mapping whose `provider`/`identity` keys
477/// are read order-independently (a real map, so no hand key-order handling). A
478/// bare scalar `peers: codex` is one provider (Python's coerce_peers).
479fn value_as_peers(v: &toml::Value) -> Vec<PeerEntry> {
480    let scalar_entry = |s: String| PeerEntry {
481        provider: s,
482        model: None,
483        identity: None,
484    };
485    // One table entry -> a PeerEntry (provider/model/identity read order-independently).
486    let map_entry = |it: &toml::Value| -> Option<PeerEntry> {
487        let provider = it
488            .get("provider")
489            .and_then(scalar_string)
490            .unwrap_or_default();
491        let model = it
492            .get("model")
493            .and_then(scalar_string)
494            .filter(|s| !s.is_empty());
495        let identity = it
496            .get("identity")
497            .and_then(scalar_string)
498            .filter(|s| !s.is_empty());
499        if provider.is_empty() && identity.is_none() {
500            None
501        } else {
502            Some(PeerEntry {
503                provider,
504                model,
505                identity,
506            })
507        }
508    };
509    match v {
510        toml::Value::Array(items) => items
511            .iter()
512            .filter_map(|it| match it {
513                toml::Value::Table(_) => map_entry(it),
514                _ => scalar_string(it)
515                    .filter(|s| !s.is_empty())
516                    .map(scalar_entry),
517            })
518            .collect(),
519        toml::Value::String(s) if !s.is_empty() => vec![scalar_entry(s.clone())],
520        // A single top-level table is ONE peer - parity with Python's
521        // coerce_peers, which wraps a dict as [dict]. Dropping it to empty (as
522        // this arm did before the codex peer review) silently discards a
523        // configured peer gate -> fail-open, the class this PR removes.
524        toml::Value::Table(_) => map_entry(v).into_iter().collect(),
525        _ => Vec::new(),
526    }
527}
528
529/// Read an f64 budget cap off a typed Value: a number is Ok, a non-numeric
530/// scalar fails CLOSED as Some(Err(raw)) (so check_budget trips), an
531/// absent/null key is None (unlimited). Mirrors the manifest cap semantics.
532fn read_f64_cap(v: &toml::Value, ctx: &str) -> Option<Result<f64, String>> {
533    match v {
534        toml::Value::Integer(n) => Some(Ok(*n as f64)),
535        toml::Value::Float(f) => Some(Ok(*f)),
536        other => {
537            let raw = scalar_string(other).unwrap_or_default();
538            Some(raw.parse::<f64>().map_err(|_| {
539                eprintln!(
540                    "loop-check: malformed budget cap '{ctx}: {raw}' - failing closed; fix the config"
541                );
542                raw
543            }))
544        }
545    }
546}
547
548/// Read a u64 budget cap off a typed Value (same fail-closed rule as f64).
549fn read_u64_cap(v: &toml::Value, ctx: &str) -> Option<Result<u64, String>> {
550    match v {
551        toml::Value::Integer(n) => Some(u64::try_from(*n).map_err(|_| {
552            eprintln!(
553                "loop-check: malformed budget cap '{ctx}: {n}' - failing closed; fix the config"
554            );
555            n.to_string()
556        })),
557        other => {
558            let raw = scalar_string(other).unwrap_or_default();
559            Some(raw.parse::<u64>().map_err(|_| {
560                eprintln!(
561                    "loop-check: malformed budget cap '{ctx}: {raw}' - failing closed; fix the config"
562                );
563                raw
564            }))
565        }
566    }
567}
568
569/// Classify a top-level `done_probes` value as a probe list or a reason it is
570/// unreadable. An empty array is a legitimate "no project probes"; a mapping,
571/// a scalar, or an array holding a non-string is NOT - it is a mis-declared
572/// gate, and the Err travels to the gate so it blocks with a reason instead of
573/// silently reading as no declaration at all.
574fn value_as_probe_list(v: &toml::Value) -> Result<Vec<String>, String> {
575    let items = v
576        .as_array()
577        .ok_or_else(|| format!("it is a {}, not an array of strings", v.type_str()))?;
578    items
579        .iter()
580        .map(|i| {
581            i.as_str().map(str::to_string).ok_or_else(|| {
582                format!(
583                    "it holds a {} where a command string was expected",
584                    i.type_str()
585                )
586            })
587        })
588        .collect()
589}
590
591/// Settings with the login gate pinned unsatisfiable - the fail-closed result
592/// when config.toml cannot be parsed as TOML at all (x-81d9 (c)). The
593/// sentinel goes into BOTH github_apps and required_bots: resolved_required_bots
594/// prefers github_apps.or(required_bots), so pinning required_bots alone would
595/// be silently outranked by a parseable global file's github_apps during the
596/// global+local merge (an unparseable LOCAL file would then resolve to the
597/// global gate, re-opening the fail-open this fix removes).
598fn fail_closed_settings() -> Settings {
599    let sentinel = Some(vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]);
600    Settings {
601        github_apps: sentinel.clone(),
602        required_bots: sentinel,
603        ..Default::default()
604    }
605}
606
607/// Parse config.toml with the `toml` crate (stage 3), replacing the
608/// former hand-rolled indent state machine that derived one global indent unit
609/// and silently dropped the config.review subtree on tabs or mixed widths
610/// (x-81d9 (c)). A genuine YAML scanner error (e.g. tab indentation) returns
611/// Err so the caller can fail closed + emit an event, rather than silently
612/// zeroing the gate. The typed-Value classification preserves every semantic
613/// the old ListForm branches encoded (see the value_as_* helpers).
614fn parse_settings_result(content: &str) -> Result<Settings, String> {
615    let root: toml::Value = content.parse::<toml::Value>().map_err(|e| e.to_string())?;
616    let mut s = Settings::default();
617
618    // Top-level flat budget cap.
619    if let Some(v) = root.get("budget_cap") {
620        s.flat_budget_cap = read_f64_cap(v, "budget_cap");
621    }
622
623    // Top-level flat `done_probes` (x-a534). Presence is recorded even when the
624    // value is junk: the Err arm blocks downstream rather than degrading to
625    // "no probes declared".
626    if let Some(v) = root.get("done_probes") {
627        s.done_probes = Some(value_as_probe_list(v));
628    }
629
630    // Flat config.toml: budget / ci / external_reviewers / review are top-level
631    // blocks (no `config:` wrapper). Read them straight off root.
632    if let Some(budget) = root.get("budget") {
633        if let Some(att) = budget.get("attended") {
634            if let Some(v) = att.get("wall_clock_cap_minutes") {
635                s.attended_wall_cap_minutes = read_u64_cap(v, "attended.wall_clock_cap_minutes");
636            }
637            if let Some(v) = att.get("cost_cap_usd") {
638                s.attended_cost_cap_usd = read_f64_cap(v, "attended.cost_cap_usd");
639            }
640        }
641        if let Some(un) = budget.get("unattended") {
642            if let Some(v) = un.get("wall_clock_cap_minutes") {
643                s.unattended_wall_cap_minutes =
644                    read_u64_cap(v, "unattended.wall_clock_cap_minutes");
645            }
646            if let Some(v) = un.get("cost_cap_usd") {
647                s.unattended_cost_cap_usd = read_f64_cap(v, "unattended.cost_cap_usd");
648            }
649        }
650    }
651
652    if let Some(ci) = root.get("ci") {
653        s.ci_declared_none = ci
654            .get("declared_none")
655            .and_then(|v| v.as_bool())
656            .unwrap_or(false);
657    }
658
659    if let Some(er) = root.get("external_reviewers") {
660        if let Some(items) = er.as_array() {
661            s.external_reviewers = items.iter().filter_map(scalar_string).collect();
662        }
663    }
664
665    if let Some(review) = root.get("review") {
666        if let Some(v) = review.get("required_bots") {
667            s.required_bots = value_as_login_list(v);
668        }
669        if let Some(v) = review.get("github_apps") {
670            s.github_apps = value_as_login_list(v);
671        }
672        if let Some(v) = review.get("optional_apps") {
673            s.optional_apps = value_as_login_list(v);
674        }
675        if let Some(v) = review.get("reviewers") {
676            s.reviewers = value_as_reviewers(v);
677        }
678        if let Some(v) = review.get("nudge") {
679            s.nudge_overrides = value_as_nudge_overrides(v);
680        }
681        if let Some(v) = review.get("peers") {
682            s.peers = value_as_peers(v);
683        }
684        if let Some(v) = review.get("peer_identity") {
685            s.peer_identity = scalar_string(v).filter(|s| !s.is_empty());
686        }
687    }
688
689    Ok(s)
690}
691
692/// Infallible wrapper: an unparseable file fails CLOSED (unsatisfiable login
693/// gate) rather than silently defaulting to no gate. Test-only - production
694/// calls parse_settings_result directly so it can also emit the
695/// `loop_check_settings_unparseable` event on the Err path.
696#[cfg(test)]
697fn parse_settings(content: &str) -> Settings {
698    parse_settings_result(content).unwrap_or_else(|_| fail_closed_settings())
699}
700
701// ── ledger parsing ────────────────────────────────────────────────────────────
702
703/// Sum cost_usd for entries matching session_id. Tolerate missing/malformed as 0.
704fn session_cost_from_ledger(ledger_path: &Path, session_id: &str) -> f64 {
705    let Ok(content) = std::fs::read_to_string(ledger_path) else {
706        return 0.0;
707    };
708    let Ok(arr) = serde_json::from_str::<Value>(&content) else {
709        return 0.0;
710    };
711    let Some(entries) = arr.as_array() else {
712        return 0.0;
713    };
714    let mut total = 0.0_f64;
715    for entry in entries {
716        // Either key: new rows carry fno_id, pre-rename rows only session_id.
717        let matches = entry.get("fno_id").and_then(|v| v.as_str()) == Some(session_id)
718            || entry.get("session_id").and_then(|v| v.as_str()) == Some(session_id);
719        if matches {
720            if let Some(c) = entry.get("cost_usd").and_then(|v| v.as_f64()) {
721                total += c;
722            }
723        }
724    }
725    total
726}
727
728// ── transcript parsing ────────────────────────────────────────────────────────
729
730#[derive(Debug, PartialEq)]
731enum Intent {
732    Promise,
733    Aborted {
734        reason: String,
735    },
736    /// Agent-declared async watch (x-e2c8): it has armed a harness-tracked
737    /// watcher and wants the session to idle until that watcher fires rather
738    /// than re-blocking every stop tick. All attributes are advisory (used for
739    /// the event and the lease math), never load-bearing: external truth
740    /// decides whether idling is actually allowed.
741    Watching {
742        reason: String,
743        pr: Option<String>,
744        timeout: Option<String>,
745    },
746    None,
747}
748
749fn extract_assistant_text(val: &Value) -> String {
750    // Try /message/content as string
751    if let Some(s) = val.pointer("/message/content").and_then(|v| v.as_str()) {
752        return s.to_string();
753    }
754    // Try /message/content as array of blocks
755    if let Some(arr) = val.pointer("/message/content").and_then(|v| v.as_array()) {
756        let mut parts = Vec::new();
757        for block in arr {
758            // Only include text blocks (not tool_use, tool_result)
759            if block.get("type").and_then(|t| t.as_str()) == Some("text") {
760                if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
761                    parts.push(t.to_string());
762                }
763            }
764        }
765        return parts.join(" ");
766    }
767    // Fallback: top-level content
768    if let Some(s) = val.get("content").and_then(|v| v.as_str()) {
769        return s.to_string();
770    }
771    String::new()
772}
773
774/// Detect intent with proper attribute extraction. Precedence within one
775/// message: aborted > watching > promise (x-e2c8). aborted is the hardest stop;
776/// watching outranks promise so a session that both promises and asks to idle
777/// idles (its promise is re-evaluated on the next wake).
778fn detect_intent_from_text(text: &str) -> Intent {
779    // Look for <aborted ...> tag
780    if let Some(aborted_start) = text.find("<aborted") {
781        // Find the closing >
782        if let Some(gt) = text[aborted_start..].find('>') {
783            let tag_text = &text[aborted_start..aborted_start + gt + 1];
784            let reason = parse_xml_attr(tag_text, "reason").unwrap_or_default();
785            return Intent::Aborted { reason };
786        }
787    }
788    if let Some(w_start) = text.find("<watching") {
789        if let Some(gt) = text[w_start..].find('>') {
790            let tag_text = &text[w_start..w_start + gt + 1];
791            return Intent::Watching {
792                reason: parse_xml_attr(tag_text, "reason").unwrap_or_default(),
793                pr: parse_xml_attr(tag_text, "pr"),
794                timeout: parse_xml_attr(tag_text, "timeout"),
795            };
796        }
797    }
798    if text.contains("<promise>") {
799        return Intent::Promise;
800    }
801    Intent::None
802}
803
804fn parse_xml_attr(tag_text: &str, attr: &str) -> Option<String> {
805    let pattern = format!(r#"{attr}=""#);
806    let start = tag_text.find(&pattern)? + pattern.len();
807    let end = tag_text[start..].find('"')?;
808    Some(tag_text[start..start + end].to_string())
809}
810
811/// Extract `last_assistant_message` from the Stop-hook stdin JSON
812/// (ab-223d2dae). The harness emits it as a plain string (the stopping
813/// turn's final assistant text, blocks joined by newline and trimmed),
814/// omitted when empty. Any parse failure -> None so the caller falls back
815/// to the transcript scan.
816fn extract_last_assistant_message(hook_input: &str) -> Option<String> {
817    let val: Value = serde_json::from_str(hook_input).ok()?;
818    let s = val.get("last_assistant_message")?.as_str()?;
819    let trimmed = s.trim();
820    if trimmed.is_empty() {
821        None
822    } else {
823        Some(trimmed.to_string())
824    }
825}
826
827/// A-primary, B-fallback intent read (ab-223d2dae). A present payload is the
828/// stopping turn's final text - recomputed per fire, race-free, overwrite-
829/// proof - and is authoritative, INCLUDING its "no tag" answer. Falling
830/// through to the transcript behind a tag-less payload would resurrect the
831/// stale-promise edge the bounded scan exists to contain. Returns the intent
832/// plus its source for the loop_check event (`payload` | `transcript`).
833fn detect_intent(
834    last_assistant_message: Option<&str>,
835    transcript_path: &Path,
836) -> (Intent, &'static str) {
837    match last_assistant_message {
838        Some(text) => (detect_intent_from_text(text), "payload"),
839        None => (detect_intent_full(transcript_path), "transcript"),
840    }
841}
842
843/// Fallback transcript scan (ab-223d2dae, B): bounded lookback over the
844/// newest INTENT_LOOKBACK_ENTRIES assistant text entries instead of
845/// last-line-only. Newest tag wins; a tag-less entry no longer ends the
846/// scan, which covers the promise-overwritten-by-block-feedback shape when
847/// no payload exists. The bound is load-bearing: a stale promise from
848/// pivoted work must fall out of the window (done()'s head_shipped read is
849/// the real gate against the remainder).
850const INTENT_LOOKBACK_ENTRIES: usize = 5;
851
852fn detect_intent_full(transcript_path: &Path) -> Intent {
853    let Ok(content) = std::fs::read_to_string(transcript_path) else {
854        return Intent::None;
855    };
856
857    let lines: Vec<&str> = content.lines().collect();
858    let mut scanned: usize = 0;
859    // `watching` is honored ONLY from the single newest assistant entry
860    // (x-e2c8): a stale watch-request from earlier work must not idle a session
861    // that has since moved on. `promise`/`aborted` keep their bounded lookback.
862    let mut newest_entry = true;
863    for line in lines.iter().rev() {
864        let line = line.trim();
865        if line.is_empty() {
866            continue;
867        }
868        let Ok(val) = serde_json::from_str::<Value>(line) else {
869            continue;
870        };
871        let role = val
872            .pointer("/message/role")
873            .or_else(|| val.get("role"))
874            .and_then(|v| v.as_str())
875            .unwrap_or("");
876        if role != "assistant" {
877            continue;
878        }
879        let text = extract_assistant_text(&val);
880        if text.is_empty() {
881            continue;
882        }
883        match detect_intent_from_text(&text) {
884            Intent::None => {
885                scanned += 1;
886                if scanned >= INTENT_LOOKBACK_ENTRIES {
887                    return Intent::None;
888                }
889            }
890            // A watching tag below the newest entry is stale: skip it (counts
891            // as a scanned entry) and keep scanning for a promise/aborted.
892            Intent::Watching { .. } if !newest_entry => {
893                scanned += 1;
894                if scanned >= INTENT_LOOKBACK_ENTRIES {
895                    return Intent::None;
896                }
897            }
898            tagged => return tagged,
899        }
900        newest_entry = false;
901    }
902    Intent::None
903}
904
905// ── git / gh helpers ──────────────────────────────────────────────────────────
906
907/// PR state vocabulary (fu-4faa3d). Parsed once at the read_pr_info boundary.
908/// `as_str()` reproduces the exact legacy strings so the fingerprint (which
909/// persists across fires in events.jsonl) stays byte-identical.
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911enum PrState {
912    Open,
913    Merged,
914    Closed,
915    /// No PR, or an unrecognized gh state string (fail-closed, AC5-EDGE).
916    None,
917}
918
919impl PrState {
920    fn from_gh_str(s: &str) -> Self {
921        match s {
922            "OPEN" => PrState::Open,
923            "MERGED" => PrState::Merged,
924            "CLOSED" => PrState::Closed,
925            _ => PrState::None,
926        }
927    }
928
929    fn as_str(&self) -> &'static str {
930        match self {
931            PrState::Open => "OPEN",
932            PrState::Merged => "MERGED",
933            PrState::Closed => "CLOSED",
934            PrState::None => "none",
935        }
936    }
937
938    fn is_open_or_merged(&self) -> bool {
939        matches!(self, PrState::Open | PrState::Merged)
940    }
941}
942
943/// CI conclusion vocabulary (fu-4faa3d). `render()` reproduces the exact
944/// legacy strings ("FAILURE:{name}" carries the failing check name).
945#[derive(Debug, Clone, PartialEq, Eq)]
946enum CiConclusion {
947    Success,
948    /// Failing check name when one was identified.
949    Failure(Option<String>),
950    Pending,
951    /// CI read skipped via ci.declared_none.
952    Skipped,
953    /// No checks found (fail-closed unless declared_none).
954    None,
955}
956
957impl CiConclusion {
958    fn render(&self) -> String {
959        match self {
960            CiConclusion::Success => "SUCCESS".to_string(),
961            CiConclusion::Failure(Some(name)) => format!("FAILURE:{name}"),
962            CiConclusion::Failure(None) => "FAILURE".to_string(),
963            CiConclusion::Pending => "PENDING".to_string(),
964            CiConclusion::Skipped => "skipped".to_string(),
965            CiConclusion::None => "none".to_string(),
966        }
967    }
968
969    fn is_ok(&self) -> bool {
970        matches!(self, CiConclusion::Success | CiConclusion::Skipped)
971    }
972}
973
974#[derive(Debug)]
975struct PrInfo {
976    state: PrState,
977    number: i64,
978    /// PR head commit OID; must match local HEAD for DonePRGreen (codex P1
979    /// on #447: a green PR must not complete a session with unpushed work).
980    head_oid: String,
981    ci_conclusion: CiConclusion,
982    /// Every failing check/job name on the PR head (bucket fail|cancel), at the
983    /// same granularity as `gh pr checks .name`. Feeds the DoneAwaitingMerge
984    /// subset rule against main's failing set. Empty when CI is green/pending.
985    failing_checks: Vec<String>,
986    /// True iff any check on the PR head is still pending (a non-terminal
987    /// bucket). `ci_conclusion` reports `Failure` as soon as ONE check fails even
988    /// while others run, so the DoneAwaitingMerge terminal must consult this to
989    /// avoid firing while the session's own in-flight job could still turn red.
990    ci_has_pending: bool,
991    /// GitHub mergeable state ("MERGEABLE" | "CONFLICTING" | "UNKNOWN"). The
992    /// DoneAwaitingMerge terminal must not fire on a "CONFLICTING" PR: the human
993    /// cannot merge past main-red until the branch is rebased, and the terminal
994    /// would drop the node from retry circulation while it is un-mergeable.
995    mergeable: String,
996    /// Newest review/comment/inline-comment activity (ISO8601 or "none");
997    /// folded into the fingerprint's 4th component on done() fires.
998    latest_review_ts: String,
999    reviewed: bool, // every required bot passed AND no unaddressed blocking finding
1000    /// Required bots with no completed review pass (names the gap in the
1001    /// block message, AC1-UI).
1002    missing_bots: Vec<String>,
1003    /// Per-missing-bot nudge classification for this fire (x-b167), same order
1004    /// as `missing_bots`. Empty when the review reads were skipped or there is no
1005    /// PR. `missing_bots` stays the gate; this only changes idling and messaging.
1006    /// An EMPTY list with a non-empty `missing_bots` means "not classified" and
1007    /// is treated exactly like today (every missing bot idlable, today's string).
1008    bot_nudges: Vec<BotNudge>,
1009    /// Required bots dropped from the gate because they are rate-limited (a
1010    /// usage-limit comment, no review). Named in the terminal-allow message so
1011    /// an operator sees why the gate proceeded without them (AC1-UI).
1012    usage_limited: Vec<String>,
1013    /// Blocking inline findings (codex P1 / gemini critical|high) whose
1014    /// thread has no qualifying ack (AC2).
1015    unaddressed_findings: Vec<Finding>,
1016    /// Reads 3+4 were skipped (per-session no_external OR the repo declared
1017    /// `required_bots: []`). Recorded in loop_check events so the skip is
1018    /// observable, not silently absent (AC3-UI).
1019    review_skipped: bool,
1020    /// Configured `config.review.reviewers` with no head-pinned attestation.
1021    /// The sole failing term whenever the login gate is vacuous, and the reason
1022    /// the block message can name real local work instead of an absent bot.
1023    unattested_reviewers: Vec<UnattestedReviewer>,
1024    /// Unparseable events.jsonl lines that carry the literal
1025    /// `review_attestation`. Named in the reason so a corrupt attestation is
1026    /// not silently dropped. Not exhaustive by construction: a write torn
1027    /// before that token cannot be recognized at all.
1028    malformed_attestations: usize,
1029}
1030
1031/// The non-interactive invocation that satisfies each local reviewer, mirroring
1032/// the `invocation` field of `_RESOLVABLE_REVIEWERS` in
1033/// `cli/src/fno/config/__init__.py`. A block message that names a reviewer
1034/// without naming how to run it is only half a remedy.
1035///
1036/// Two languages, one table: kept honest by
1037/// `scripts/ci/check-reviewer-descriptor-parity.sh`, not by a comment asking a
1038/// human to remember.
1039const REVIEWER_INVOCATIONS: &[(&str, &str, bool)] = &[
1040    ("sigma", "/fno:review sigma", false),
1041    (
1042        "code-review",
1043        "/code-review, then bash skills/review/scripts/emit-attestation.sh code-review",
1044        false,
1045    ),
1046    ("declare", "/fno:review declare", true),
1047];
1048
1049/// `(invocation, is_self_cert)`. The flag mirrors the Python descriptor's
1050/// `asserts` field: a surface that names `declare` without saying it asserts
1051/// nothing invites an operator to clear the gate with no review behind it.
1052fn reviewer_invocation(name: &str) -> Option<(&'static str, bool)> {
1053    REVIEWER_INVOCATIONS
1054        .iter()
1055        .find(|(n, _, _)| *n == name)
1056        .map(|(_, inv, self_cert)| (*inv, *self_cert))
1057}
1058
1059fn git_head_sha(git_bin: &str, cwd: &Path) -> String {
1060    let out = Command::new(git_bin)
1061        .args(["rev-parse", "HEAD"])
1062        .current_dir(cwd)
1063        .output();
1064    match out {
1065        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1066        _ => "unknown".to_string(),
1067    }
1068}
1069
1070/// `gh pr view` exits 1 both when no PR exists and when gh itself fails.
1071/// "No PR" is real world-state - the fingerprint should record it and the
1072/// NoProgress backstop should keep ticking - while an outage must freeze the
1073/// streak (US4). Distinguish via gh's deterministic no-PR stderr message. If
1074/// gh ever changes the message, no-PR fires degrade to outage semantics
1075/// (freeze -> budget ceiling): safe, never a premature termination.
1076fn is_no_pr_stderr(stderr: &[u8]) -> bool {
1077    String::from_utf8_lossy(stderr)
1078        .to_lowercase()
1079        .contains("no pull requests found")
1080}
1081
1082/// Capture the last ~200 bytes of stderr as a lossy UTF-8 string.
1083fn stderr_tail(bytes: &[u8]) -> String {
1084    let s = String::from_utf8_lossy(bytes);
1085    let s = s.trim();
1086    if s.len() <= 200 {
1087        s.to_string()
1088    } else {
1089        // Byte index must land on a char boundary or the slice panics
1090        // (gemini HIGH on PR #447): walk forward to the next boundary.
1091        let mut start = s.len() - 200;
1092        while start < s.len() && !s.is_char_boundary(start) {
1093            start += 1;
1094        }
1095        s[start..].to_string()
1096    }
1097}
1098
1099/// A configured local reviewer with no head-pinned `pass` attestation.
1100#[derive(Debug, Clone, PartialEq)]
1101struct UnattestedReviewer {
1102    name: String,
1103    /// A head this reviewer DID attest at, which is no longer HEAD. Always a
1104    /// PASS and never empty - normalized at construction so `Some` means
1105    /// "there is a real prior pass to name", not "check is_empty() first".
1106    /// Without it the block message reads as "you never ran sigma" to a session
1107    /// that ran sigma and then pushed a commit, losing turns twice.
1108    superseded_head: Option<String>,
1109    /// This reviewer DID attest at the current head, and the verdict was not
1110    /// `pass`. "No attestation exists" would be a lie to a session that ran the
1111    /// reviewer and was told no.
1112    failed_at_head: bool,
1113}
1114
1115/// The `config.review.reviewers` entries NOT satisfied by a head-pinned
1116/// `review_attestation` event (x-e703 Phase 2; list form added by x-cdc7). A
1117/// reviewer is satisfied when events.jsonl carries a line with
1118/// `type == "review_attestation"`, `data.reviewer` matching (leading '/'
1119/// stripped on both sides), `data.head_sha == head_sha`, and
1120/// `data.verdict == "pass"`.
1121///
1122/// The gate reads `.is_empty()` and the block message reads the names, so the
1123/// decision and the explanation come from ONE scan. When they came from two,
1124/// the message told sessions to wait on a bot that was never required.
1125///
1126/// Fail closed everywhere: an empty/unreadable events file, a stale head_sha
1127/// (attestation for a prior commit), or a `fail` verdict leaves the reviewer
1128/// UNSATISFIED, mirroring how a missing bot review holds the login gate. An
1129/// empty reviewer list is vacuously satisfied (no reviewers gate).
1130/// `unattested_reviewers` plus the count of unparseable lines that LOOK like
1131/// attestations. A torn write leaves a corrupt `review_attestation` in the file
1132/// and the gate then reports "no head-pinned review_attestation", which is the
1133/// same class of lie this node exists to delete - so the count is surfaced in
1134/// the reason. Mirrors `open_review_findings`, which already does this for
1135/// `review_finding`.
1136fn unattested_reviewers_scan(
1137    events_path: &Path,
1138    reviewers: &[String],
1139    head_sha: &str,
1140) -> (Vec<UnattestedReviewer>, usize) {
1141    let unsatisfied_all = || -> Vec<UnattestedReviewer> {
1142        reviewers
1143            .iter()
1144            .map(|r| UnattestedReviewer {
1145                name: r.trim_start_matches('/').to_string(),
1146                superseded_head: None,
1147                failed_at_head: false,
1148            })
1149            .collect()
1150    };
1151    if reviewers.is_empty() {
1152        return (Vec::new(), 0);
1153    }
1154    let Ok(content) = std::fs::read_to_string(events_path) else {
1155        // no evidence file -> gate unmet (fail closed)
1156        return (unsatisfied_all(), 0);
1157    };
1158    let mut malformed = 0usize;
1159    // Single pass (gemini review): record the LATEST verdict per reviewer at the
1160    // current head. events.jsonl is append-ordered, so a later attestation
1161    // supersedes an earlier one for the same reviewer - a `fail` posted after a
1162    // `pass` must revoke it, and a re-run `pass` after a `fail` must restore it
1163    // (codex peer review P1: a later fail was previously ignored). A reviewer is
1164    // satisfied iff its latest head-pinned verdict is exactly `pass`. O(lines).
1165    let mut latest_pass: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
1166    // reviewer -> every OLD head it attested at, in first-seen order, each
1167    // carrying that head's LATEST verdict. A single-entry "most recent pass"
1168    // map cannot survive a retraction: `pass A, pass B, fail B` overwrites A
1169    // with B and then drops B, reporting no prior pass while A is still a real
1170    // one (codex P2 on this PR). Multi-round review/fix cycles produce exactly
1171    // that sequence.
1172    let mut other_heads: std::collections::HashMap<String, Vec<(String, bool)>> =
1173        std::collections::HashMap::new();
1174    for line in content.lines() {
1175        let Ok(val) = serde_json::from_str::<Value>(line) else {
1176            if line.contains("review_attestation") {
1177                malformed += 1;
1178            }
1179            continue;
1180        };
1181        if val.get("type").and_then(|v| v.as_str()) != Some("review_attestation") {
1182            continue;
1183        }
1184        let Some(r) = val.pointer("/data/reviewer").and_then(|v| v.as_str()) else {
1185            continue;
1186        };
1187        let r = r.trim_start_matches('/').to_string();
1188        // An event with no `head_sha` is not head-pinned evidence and is
1189        // skipped outright. Defaulting it to "" would make it MATCH a caller
1190        // whose own head_sha is "", turning unpinned data into a pass (codex
1191        // P1 on this PR).
1192        let Some(line_head) = val.pointer("/data/head_sha").and_then(|v| v.as_str()) else {
1193            continue;
1194        };
1195        let is_pass = val.pointer("/data/verdict").and_then(|v| v.as_str()) == Some("pass");
1196        if line_head != head_sha {
1197            // Empty is not a head; recording it would put a `Some` in the
1198            // message with nothing to print.
1199            if line_head.is_empty() {
1200                continue;
1201            }
1202            let seen = other_heads.entry(r).or_default();
1203            match seen.iter().position(|(h, _)| h == line_head) {
1204                Some(i) => seen[i].1 = is_pass, // latest verdict wins for that head
1205                None => seen.push((line_head.to_string(), is_pass)),
1206            }
1207            continue;
1208        }
1209        latest_pass.insert(r, is_pass);
1210    }
1211    let out = reviewers
1212        .iter()
1213        .map(|entry| entry.trim_start_matches('/'))
1214        .filter(|name| latest_pass.get(*name) != Some(&true))
1215        .map(|name| UnattestedReviewer {
1216            name: name.to_string(),
1217            // An old head whose LATEST verdict is still a pass. Heads keep
1218            // first-seen order, so a head re-attested later keeps its original
1219            // slot and this may name a slightly older one - both are real
1220            // passes, so the line stays true either way. Only a pass is worth
1221            // naming: an old-head `fail` rendered as "passed at X, superseded"
1222            // would imply a successful review that never happened.
1223            superseded_head: other_heads
1224                .get(name)
1225                .and_then(|heads| heads.iter().rev().find(|(_, ok)| *ok))
1226                .map(|(h, _)| h.clone()),
1227            failed_at_head: latest_pass.get(name) == Some(&false),
1228        })
1229        .collect();
1230    (out, malformed)
1231}
1232
1233/// An operator review finding (x-f8d4) still open: a `review_finding` event for
1234/// the node with no later `review_finding_resolved` for the same id.
1235#[derive(Debug, Clone)]
1236struct OpenFinding {
1237    id: String,
1238    first_line: String,
1239}
1240
1241/// Scan events.jsonl for OPEN operator review findings scoped to `node`.
1242///
1243/// Returns `(open findings sorted by id, malformed-line count)`. A finding is
1244/// open until an explicit `review_finding_resolved` clears it - node-scoped and
1245/// NOT head-pinned, so a new commit never auto-clears an operator's comment
1246/// (Locked Decision 2). Malformed finding lines notice-not-block (AC3-FR): a
1247/// line that is unparseable JSON but carries the literal `review_finding`, or a
1248/// parsed `review_finding` missing its id, is our own writer's corrupted output;
1249/// it is counted for the deny/audit notice but NEVER holds the gate. Any read
1250/// failure yields no findings (the gate is only ADDED by evidence, never
1251/// invented from an unreadable file).
1252fn open_review_findings(events_path: &Path, node: &str) -> (Vec<OpenFinding>, usize) {
1253    let Ok(content) = std::fs::read_to_string(events_path) else {
1254        return (Vec::new(), 0);
1255    };
1256    // Preserve first-seen order via a Vec of (id, first_line); a later duplicate
1257    // id (shouldn't happen - ids are minted) just refreshes the first_line.
1258    let mut findings: Vec<(String, String)> = Vec::new();
1259    let mut resolved: std::collections::HashSet<String> = std::collections::HashSet::new();
1260    let mut malformed = 0usize;
1261    for line in content.lines() {
1262        let line = line.trim();
1263        if line.is_empty() {
1264            continue;
1265        }
1266        let Ok(val) = serde_json::from_str::<Value>(line) else {
1267            // Only OUR corrupted output counts toward the notice; unrelated
1268            // corruption from another writer is not a finding concern.
1269            if line.contains("review_finding") {
1270                malformed += 1;
1271            }
1272            continue;
1273        };
1274        match val.get("type").and_then(|v| v.as_str()) {
1275            Some("review_finding") => {
1276                if val.pointer("/data/node").and_then(|v| v.as_str()) != Some(node) {
1277                    continue;
1278                }
1279                match val.pointer("/data/finding_id").and_then(|v| v.as_str()) {
1280                    Some(id) => {
1281                        let first = val
1282                            .pointer("/data/text")
1283                            .and_then(|v| v.as_str())
1284                            .unwrap_or("")
1285                            .lines()
1286                            .next()
1287                            .unwrap_or("")
1288                            .to_string();
1289                        if let Some(slot) = findings.iter_mut().find(|(fid, _)| fid == id) {
1290                            slot.1 = first;
1291                        } else {
1292                            findings.push((id.to_string(), first));
1293                        }
1294                    }
1295                    None => malformed += 1, // review_finding without an id
1296                }
1297            }
1298            Some("review_finding_resolved") => {
1299                if let Some(id) = val.pointer("/data/finding_id").and_then(|v| v.as_str()) {
1300                    resolved.insert(id.to_string());
1301                }
1302            }
1303            _ => {}
1304        }
1305    }
1306    let mut open: Vec<OpenFinding> = findings
1307        .into_iter()
1308        .filter(|(id, _)| !resolved.contains(id))
1309        .map(|(id, first_line)| OpenFinding { id, first_line })
1310        .collect();
1311    open.sort_by(|a, b| a.id.cmp(&b.id)); // deterministic deny reason
1312    (open, malformed)
1313}
1314
1315/// Deny reason for an open-finding gate: quote the first finding (id + first
1316/// line) + the resolve remedy, plus a `[+N more]` count and any malformed-line
1317/// notice so nothing vanishes silently.
1318fn build_findings_block_reason(open: &[OpenFinding], malformed: usize) -> String {
1319    let f = &open[0];
1320    let more = if open.len() > 1 {
1321        format!(" [+{} more]", open.len() - 1)
1322    } else {
1323        String::new()
1324    };
1325    let notice = if malformed > 0 {
1326        format!(" ({malformed} malformed finding line(s) ignored)")
1327    } else {
1328        String::new()
1329    };
1330    format!(
1331        "open review finding {}: {} - address it, then `fno annotate resolve {}`{}{}",
1332        f.id, f.first_line, f.id, more, notice
1333    )
1334}
1335
1336/// Run done() reads. Returns Ok(PrInfo) or Err((read_name, stderr_tail)) on gh failure.
1337#[allow(clippy::too_many_arguments)]
1338fn read_pr_info(
1339    gh_bin: &str,
1340    cwd: &Path,
1341    ci_declared_none: bool,
1342    no_external: bool,
1343    required_bots: &[String],
1344    optional_bots: &[String],
1345    external_reviewers: &[String],
1346    reviewers: &[String],
1347    nudge_configs: &[NudgeConfig],
1348    head_sha: &str,
1349    events_path: &Path,
1350) -> Result<PrInfo, (String, String)> {
1351    // Read 1: PR state + number + head OID + mergeability
1352    let pr_view_out = Command::new(gh_bin)
1353        .args([
1354            "pr",
1355            "view",
1356            "--json",
1357            "state,number,headRefName,headRefOid,mergeable",
1358        ])
1359        .current_dir(cwd)
1360        .output()
1361        .map_err(|e| ("pr_view".to_string(), e.to_string()))?;
1362
1363    if !pr_view_out.status.success() {
1364        if is_no_pr_stderr(&pr_view_out.stderr) {
1365            // No PR yet: world-state, not an error. done() is simply false
1366            // ("no PR for HEAD"), and the backstop can resolve a stuck
1367            // no-PR session as NoProgress rather than freezing forever.
1368            return Ok(PrInfo {
1369                state: PrState::None,
1370                number: 0,
1371                head_oid: String::new(),
1372                ci_conclusion: CiConclusion::None,
1373                failing_checks: Vec::new(),
1374                ci_has_pending: false,
1375                mergeable: "UNKNOWN".to_string(),
1376                latest_review_ts: "none".to_string(),
1377                reviewed: false,
1378                missing_bots: Vec::new(),
1379                bot_nudges: Vec::new(),
1380                usage_limited: Vec::new(),
1381                unaddressed_findings: Vec::new(),
1382                review_skipped: false,
1383                unattested_reviewers: Vec::new(),
1384                malformed_attestations: 0,
1385            });
1386        }
1387        return Err(("pr_view".to_string(), stderr_tail(&pr_view_out.stderr)));
1388    }
1389
1390    let pr_json: Value = serde_json::from_slice(&pr_view_out.stdout)
1391        .map_err(|_| ("pr_view_parse".to_string(), String::new()))?;
1392
1393    let state = PrState::from_gh_str(
1394        pr_json
1395            .get("state")
1396            .and_then(|v| v.as_str())
1397            .unwrap_or("none"),
1398    );
1399    let number = pr_json.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
1400    let head_oid = pr_json
1401        .get("headRefOid")
1402        .and_then(|v| v.as_str())
1403        .unwrap_or("")
1404        .to_string();
1405    // GitHub's mergeable state: "MERGEABLE" | "CONFLICTING" | "UNKNOWN" (still
1406    // computing). Only "CONFLICTING" is a definitive no; UNKNOWN must not hold
1407    // the terminal (it clears on its own). Missing field -> "UNKNOWN".
1408    let mergeable = pr_json
1409        .get("mergeable")
1410        .and_then(|v| v.as_str())
1411        .unwrap_or("UNKNOWN")
1412        .to_string();
1413
1414    // x-8b64 (E): a MERGED PR is terminal. A PR merged out-of-band (GitHub
1415    // web/mobile, or `gh pr merge`) is done regardless of whether the required
1416    // bot ever reviewed it or whether CI is still green post-merge - the merge
1417    // IS the authority. Short-circuit the now-irrelevant CI + review polls
1418    // (which also avoids a transient gh blip on those reads re-blocking a
1419    // finished session). The single merge signal is `state` from the same
1420    // `gh pr view` call that `reconcile`/`fno pr verify` read - one signal, not
1421    // two independently-polled sources. done()'s `head_shipped` guard still
1422    // applies downstream: an unpushed commit on top of a merged PR stays
1423    // unshipped work.
1424    if state == PrState::Merged {
1425        return Ok(PrInfo {
1426            state,
1427            number,
1428            head_oid,
1429            ci_conclusion: CiConclusion::Skipped,
1430            failing_checks: Vec::new(),
1431            ci_has_pending: false,
1432            mergeable,
1433            latest_review_ts: "none".to_string(),
1434            reviewed: true,
1435            missing_bots: Vec::new(),
1436            bot_nudges: Vec::new(),
1437            usage_limited: Vec::new(),
1438            unaddressed_findings: Vec::new(),
1439            review_skipped: true,
1440            unattested_reviewers: Vec::new(),
1441            malformed_attestations: 0,
1442        });
1443    }
1444
1445    // Read 2: CI checks. Compute the conclusion, the full failing-check-name set,
1446    // AND whether any check is still pending from the same payload (the set feeds
1447    // the DoneAwaitingMerge subset rule; the pending flag gates that terminal so
1448    // it never fires on partial CI).
1449    let no_hosted_ci =
1450        crate::verify_evidence::hosted_ci_not_configured(ci_declared_none, cwd, head_sha);
1451    let (ci_conclusion, failing_checks, ci_has_pending) = if no_hosted_ci {
1452        (CiConclusion::Skipped, Vec::new(), false)
1453    } else {
1454        let checks_out = Command::new(gh_bin)
1455            .args(["pr", "checks", "--json", "name,state,bucket"])
1456            .current_dir(cwd)
1457            .output()
1458            .map_err(|e| ("pr_checks".to_string(), e.to_string()))?;
1459
1460        if !checks_out.status.success() {
1461            return Err(("pr_checks".to_string(), stderr_tail(&checks_out.stderr)));
1462        }
1463
1464        let checks: Value = serde_json::from_slice(&checks_out.stdout)
1465            .map_err(|_| ("pr_checks_parse".to_string(), String::new()))?;
1466
1467        let failing = failing_check_names(&checks);
1468        let has_pending = ci_has_pending_checks(&checks);
1469        (
1470            compute_ci_conclusion(&checks).map_err(|e| (e, String::new()))?,
1471            failing,
1472            has_pending,
1473        )
1474    };
1475
1476    // Reads 3+4: reviews + inline findings. Skipped when the session declares
1477    // no_external OR the repo declares `required_bots: []` (the no-review-gate
1478    // path, US3 - mirrors ci.declared_none; PR + CI carry the gate). The two
1479    // skips are orthogonal: one is per-session, the other repo config.
1480    // Skip the review reads only when there is NOTHING to honor: no required
1481    // login AND no optional login. An optional-only gate still reads (to catch
1482    // an optional blocking finding), but its presence is never required.
1483    // x-e703: the gate is a strict conjunction over the union of GitHub-login
1484    // evidence (github_apps/peers via optional_bots+required_bots) AND the
1485    // local-attestation `reviewers`. Each satisfied by its own evidence source,
1486    // so the two skips are INDEPENDENT: `no_external` (and an empty login set)
1487    // skips only the EXTERNAL GitHub-login reads - it is scoped to external
1488    // review (control-plane-loop.md step 2), NOT the local attestation gate. A
1489    // repo that pins `reviewers: [sigma]` still requires that local pass even
1490    // when a session runs `--no-external` to skip usage-wedged App bots
1491    // (fixes a fail-open the sigma review caught). `reviewers` is empty for
1492    // every pre-x-e703 config, so `reviewers_all_attested` is vacuously true
1493    // there and this changes nothing for them.
1494    let login_gate_active = !required_bots.is_empty() || !optional_bots.is_empty();
1495    let login_skipped = no_external || !login_gate_active;
1496    // One scan feeds both the gate and its explanation, so the two cannot
1497    // disagree the way the decision and the message did on PR #618.
1498    let (unattested, malformed_attestations) =
1499        unattested_reviewers_scan(events_path, reviewers, head_sha);
1500    let reviewers_ok = unattested.is_empty();
1501    let (latest_review_ts, reviewed, missing_bots, bot_nudges, usage_limited, unaddressed_findings) =
1502        if login_skipped {
1503            // No GitHub logins to poll (nothing configured, or no_external): skip
1504            // the gh review reads entirely (fewer calls + no spurious gh-error
1505            // block). The local attestation gate still applies - reviewers_ok is
1506            // true when unconfigured, so a login-only or no-gate config is
1507            // unaffected.
1508            (
1509                "none".to_string(),
1510                reviewers_ok,
1511                Vec::new(),
1512                Vec::new(),
1513                Vec::new(),
1514                Vec::new(),
1515            )
1516        } else {
1517            // Read 3: top-level reviews + issue comments
1518            let reviews_out = Command::new(gh_bin)
1519                .args(["pr", "view", "--json", "reviews,comments"])
1520                .current_dir(cwd)
1521                .output()
1522                .map_err(|e| ("pr_reviews".to_string(), e.to_string()))?;
1523
1524            if !reviews_out.status.success() {
1525                return Err(("pr_reviews".to_string(), stderr_tail(&reviews_out.stderr)));
1526            }
1527
1528            let reviews_json: Value = serde_json::from_slice(&reviews_out.stdout)
1529                .map_err(|_| ("pr_reviews_parse".to_string(), String::new()))?;
1530
1531            // PRESENCE is required-only: an optional login's absence must never
1532            // create a missing_bot (never wait for it). FINDINGS honor the union:
1533            // an optional login's blocking P1 still holds the gate ("honor if
1534            // present"). A dedup keeps a login that is in both lists counted once.
1535            let info = compute_review_info(&reviews_json, required_bots);
1536            // Per-missing-bot nudge classification (x-b167), computed AFTER the
1537            // usage-limit retain (which happened inside compute_review_info) so
1538            // the two give-up paths never compose (AC6): a usage_limited bot is
1539            // already out of missing_bots and is never classified here. Derived
1540            // from the same issue-comment list, fresh every fire.
1541            let now = Utc::now();
1542            let review_comments = reviews_json
1543                .get("comments")
1544                .and_then(|v| v.as_array())
1545                .map(|v| v.as_slice())
1546                .unwrap_or(&[]);
1547            let bot_nudges: Vec<BotNudge> = info
1548                .missing_bots
1549                .iter()
1550                .map(|bot| {
1551                    classify_bot_nudge(
1552                        bot,
1553                        review_comments,
1554                        nudge_config_for(nudge_configs, bot),
1555                        now,
1556                    )
1557                })
1558                .collect();
1559            // The "empty bot_nudges = not classified = status quo" contract that
1560            // async_wait_class and build_block_reason rely on holds only because
1561            // this is an all-or-nothing map: bot_nudges is either empty or 1:1
1562            // with missing_bots. A future partial classification would silently
1563            // mis-idle, so pin the invariant here rather than let it drift.
1564            debug_assert_eq!(bot_nudges.len(), info.missing_bots.len());
1565            let mut findings_bots: Vec<String> = required_bots.to_vec();
1566            for b in optional_bots {
1567                if !findings_bots.iter().any(|x| x == b) {
1568                    findings_bots.push(b.clone());
1569                }
1570            }
1571
1572            // Read 4: inline review comments (NEW in step 2). Codex's P1s land on
1573            // the /pulls/N/comments REST endpoint, which `gh pr view --json
1574            // comments` does NOT return (verified on PR #447). --paginate may
1575            // emit CONCATENATED JSON arrays (one per page), so parse as a stream.
1576            let comments_out = Command::new(gh_bin)
1577                .args([
1578                    "api",
1579                    &format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments"),
1580                    "--paginate",
1581                ])
1582                .current_dir(cwd)
1583                .output()
1584                .map_err(|e| ("pulls_comments".to_string(), e.to_string()))?;
1585
1586            if !comments_out.status.success() {
1587                return Err((
1588                    "pulls_comments".to_string(),
1589                    stderr_tail(&comments_out.stderr),
1590                ));
1591            }
1592
1593            let mut inline_comments: Vec<Value> = Vec::new();
1594            for page in
1595                serde_json::Deserializer::from_slice(&comments_out.stdout).into_iter::<Value>()
1596            {
1597                let page = page.map_err(|_| ("pulls_comments_parse".to_string(), String::new()))?;
1598                match page.as_array() {
1599                    Some(arr) => inline_comments.extend(arr.iter().cloned()),
1600                    None => return Err(("pulls_comments_parse".to_string(), String::new())),
1601                }
1602            }
1603
1604            // Commit timestamps feed the commit-after arm of "addressed". Only
1605            // fetched when a blocking candidate could exist (cheap pre-scan).
1606            let has_blocking_candidate = inline_comments.iter().any(|c| {
1607                c.get("in_reply_to_id").and_then(|v| v.as_i64()).is_none()
1608                    && blocking_severity(c.get("body").and_then(|v| v.as_str()).unwrap_or(""))
1609                        .is_some()
1610            });
1611            let commit_dates: Vec<String> = if has_blocking_candidate {
1612                let commits_out = Command::new(gh_bin)
1613                    .args(["pr", "view", "--json", "commits"])
1614                    .current_dir(cwd)
1615                    .output()
1616                    .map_err(|e| ("pr_commits".to_string(), e.to_string()))?;
1617                if !commits_out.status.success() {
1618                    return Err(("pr_commits".to_string(), stderr_tail(&commits_out.stderr)));
1619                }
1620                let commits_json: Value = serde_json::from_slice(&commits_out.stdout)
1621                    .map_err(|_| ("pr_commits_parse".to_string(), String::new()))?;
1622                commits_json
1623                    .get("commits")
1624                    .and_then(|v| v.as_array())
1625                    .map(|arr| {
1626                        arr.iter()
1627                            .filter_map(|c| {
1628                                c.get("committedDate")
1629                                    .and_then(|v| v.as_str())
1630                                    .map(|s| s.to_string())
1631                            })
1632                            .collect()
1633                    })
1634                    .unwrap_or_default()
1635            } else {
1636                Vec::new()
1637            };
1638
1639            let (inline_ts, unaddressed) = compute_unaddressed_findings(
1640                &inline_comments,
1641                &commit_dates,
1642                &findings_bots,
1643                external_reviewers,
1644            );
1645
1646            // Read 4's newest comment timestamp joins the activity timestamp so
1647            // inline-only review traffic advances the fingerprint (closes the
1648            // false-NoProgress hole).
1649            let activity_ts = max_ts(&info.latest_ts, &inline_ts);
1650            // x-e703: the login gate AND the local-attestation reviewers gate must
1651            // both clear. reviewers is usually empty (vacuously true) so this is a
1652            // no-op for login-only configs.
1653            let reviewed = info.all_required_passed() && unaddressed.is_empty() && reviewers_ok;
1654            // (a) Record the rate-limit drop so a post-hoc audit sees why the gate
1655            // proceeded without a required bot (AC1-UI). append_loop_event, not
1656            // Branch-B emit: these are target-stream events (see the doc comment on
1657            // append_loop_event), deliberately unregistered in KNOWN_EVENT_KINDS.
1658            if !info.usage_limited.is_empty() {
1659                append_loop_event(
1660                    events_path,
1661                    "review_gate_bot_usage_limited",
1662                    serde_json::json!({"pr": number, "bots": info.usage_limited.clone()}),
1663                );
1664            }
1665            (
1666                activity_ts,
1667                reviewed,
1668                info.missing_bots,
1669                bot_nudges,
1670                info.usage_limited,
1671                unaddressed,
1672            )
1673        };
1674
1675    Ok(PrInfo {
1676        state,
1677        number,
1678        head_oid,
1679        ci_conclusion,
1680        failing_checks,
1681        ci_has_pending,
1682        mergeable,
1683        latest_review_ts,
1684        reviewed,
1685        missing_bots,
1686        bot_nudges,
1687        usage_limited,
1688        unaddressed_findings,
1689        // Telemetry only (no decision reads this): "no review gate of any kind
1690        // applied" = the login reads were skipped AND no local reviewers gate.
1691        // A reviewers-only config did gate, so it is NOT review_skipped.
1692        review_skipped: login_skipped && reviewers.is_empty(),
1693        unattested_reviewers: unattested,
1694        malformed_attestations,
1695    })
1696}
1697
1698fn compute_ci_conclusion(checks: &Value) -> Result<CiConclusion, String> {
1699    let arr = match checks.as_array() {
1700        Some(a) => a,
1701        None => return Err("pr_checks_parse".to_string()),
1702    };
1703
1704    if arr.is_empty() {
1705        // No checks configured and no declared_none -> fail closed
1706        return Ok(CiConclusion::None);
1707    }
1708
1709    // `gh pr checks --json` classifies each check into a rollup `bucket`:
1710    // pass | fail | pending | skipping | cancel. (`conclusion` is NOT an
1711    // available field on this subcommand; requesting it errored the read on
1712    // every fire - ab-610d2ee3 follow-on, previously masked by the budget
1713    // bug terminating sessions before this read ran.) Unknown or missing
1714    // buckets fail closed as Pending - never green.
1715    let bucket_of = |check: &Value| -> String {
1716        check
1717            .get("bucket")
1718            .and_then(|v| v.as_str())
1719            .unwrap_or("")
1720            .to_lowercase()
1721    };
1722
1723    if let Some(failing) = arr
1724        .iter()
1725        .find(|c| matches!(bucket_of(c).as_str(), "fail" | "cancel"))
1726    {
1727        let name = failing
1728            .get("name")
1729            .and_then(|v| v.as_str())
1730            .unwrap_or("unknown");
1731        return Ok(CiConclusion::Failure(Some(name.to_string())));
1732    }
1733    if arr
1734        .iter()
1735        .any(|c| !matches!(bucket_of(c).as_str(), "pass" | "skipping"))
1736    {
1737        return Ok(CiConclusion::Pending);
1738    }
1739    Ok(CiConclusion::Success)
1740}
1741
1742// ── DoneAwaitingMerge classifier ───────────────────────────────────────────────
1743//
1744// When done() fails SOLELY on CI-green (PR open+mergeable, reviewed, HEAD
1745// shipped) the loop would burn to NoProgress while a bg agent waits on a merge
1746// it cannot perform - but only pathologically so when main ITSELF is red on the
1747// same checks. `pre_existing_main_red` proves that condition mechanically:
1748// every failing PR check name must also be failing on current main HEAD (strict
1749// subset, check-name granularity so the mux flakes rotating test names between
1750// runs stay matched). Any PR-unique red, or any gh uncertainty, holds as today.
1751
1752/// How many latest completed main runs to scan. `main_head_failing_checks` keeps
1753/// only the runs whose headSha equals the newest run's (the current main HEAD),
1754/// so this bound just needs to comfortably cover ONE commit's workflow fan-out
1755/// (this repo fires ~4-5 workflow runs per push); a value above that is harmless
1756/// because the headSha scope discards any older commit's runs. Bounded so the
1757/// per-fire gh cost stays constant.
1758const MAIN_RUN_LOOKBACK: usize = 10;
1759
1760/// Failing check/job names on a `gh pr checks --json name,bucket` payload
1761/// (bucket fail|cancel), the same granularity a main-HEAD job carries. Non-fail
1762/// buckets (pass|pending|skipping) are ignored. Malformed entries are skipped.
1763fn failing_check_names(checks: &Value) -> Vec<String> {
1764    let Some(arr) = checks.as_array() else {
1765        return Vec::new();
1766    };
1767    arr.iter()
1768        .filter(|c| {
1769            let bucket = c
1770                .get("bucket")
1771                .and_then(|v| v.as_str())
1772                .unwrap_or("")
1773                .to_lowercase();
1774            matches!(bucket.as_str(), "fail" | "cancel")
1775        })
1776        .filter_map(|c| c.get("name").and_then(|v| v.as_str()).map(str::to_string))
1777        .collect()
1778}
1779
1780/// True iff any check is still in a non-terminal bucket (`pending`, or an
1781/// unrecognized bucket that is not one of pass|fail|cancel|skipping). The
1782/// DoneAwaitingMerge terminal must not fire while any check is unresolved: a
1783/// still-running check (e.g. the session's own new job) could turn red, so a
1784/// partial `Failure` is not yet proof that the ONLY problem is pre-existing
1785/// main-red.
1786fn ci_has_pending_checks(checks: &Value) -> bool {
1787    let Some(arr) = checks.as_array() else {
1788        return false;
1789    };
1790    arr.iter().any(|c| {
1791        let bucket = c
1792            .get("bucket")
1793            .and_then(|v| v.as_str())
1794            .unwrap_or("")
1795            .to_lowercase();
1796        !matches!(bucket.as_str(), "pass" | "fail" | "cancel" | "skipping")
1797    })
1798}
1799
1800/// databaseIds of failed workflow runs from a `gh run list --json
1801/// databaseId,conclusion,headSha` payload, scoped to a single `head_sha`. Only
1802/// conclusion=="failure" runs whose headSha equals the current main HEAD count
1803/// (a cancelled or in-progress run is not proof; a run from an OLDER main commit
1804/// that has since been fixed is not proof of CURRENT main-red).
1805fn parse_failing_run_ids(run_list: &Value, head_sha: &str) -> Vec<i64> {
1806    let Some(arr) = run_list.as_array() else {
1807        return Vec::new();
1808    };
1809    arr.iter()
1810        .filter(|r| r.get("conclusion").and_then(|v| v.as_str()) == Some("failure"))
1811        .filter(|r| r.get("headSha").and_then(|v| v.as_str()) == Some(head_sha))
1812        .filter_map(|r| r.get("databaseId").and_then(|v| v.as_i64()))
1813        .collect()
1814}
1815
1816/// Failing job names from a `gh run view <id> --json jobs` payload. The `jobs`
1817/// `.name` field is the same namespace as `gh pr checks .name` (both are the
1818/// check-run/job name), so a name from here matches a PR failing-check name.
1819fn parse_failing_job_names(jobs_json: &Value) -> Vec<String> {
1820    let Some(jobs) = jobs_json.get("jobs").and_then(|v| v.as_array()) else {
1821        return Vec::new();
1822    };
1823    jobs.iter()
1824        .filter(|j| j.get("conclusion").and_then(|v| v.as_str()) == Some("failure"))
1825        .filter_map(|j| j.get("name").and_then(|v| v.as_str()).map(str::to_string))
1826        .collect()
1827}
1828
1829/// The strict subset rule: main's failing set must COVER every failing PR check.
1830/// Empty PR-failing is never eligible (that is the DonePRGreen path, not here);
1831/// any PR-unique failing check blocks the terminal (the session's own breakage).
1832fn is_pre_existing_main_red(pr_failing: &[String], main_failing: &[String]) -> bool {
1833    if pr_failing.is_empty() {
1834        return false;
1835    }
1836    pr_failing.iter().all(|c| main_failing.contains(c))
1837}
1838
1839/// Union of failing job names on the CURRENT main HEAD commit, scanning the
1840/// latest N completed runs on `--branch main` and keeping only those whose
1841/// headSha matches the newest run's (i.e. the current main HEAD). N is sized to
1842/// cover one commit's workflow fan-out with margin; scoping by headSha means a
1843/// larger N never pulls in a stale older commit's failures. Fail-CLOSED: any gh
1844/// error, non-zero exit, malformed JSON, ZERO completed runs, or a missing
1845/// headSha returns `None` (unknown -> the caller holds as today). A clean read
1846/// with no failures on HEAD returns `Some(empty)` -> the subset rule then fails
1847/// and the caller holds; only positive proof fires the terminal.
1848fn main_head_failing_checks(gh_bin: &str, cwd: &Path, n: usize) -> Option<Vec<String>> {
1849    let list_out = Command::new(gh_bin)
1850        .args([
1851            "run",
1852            "list",
1853            "--branch",
1854            "main",
1855            "--status",
1856            "completed",
1857            "--limit",
1858            &n.to_string(),
1859            "--json",
1860            "databaseId,conclusion,headSha",
1861        ])
1862        .current_dir(cwd)
1863        .output()
1864        .ok()?;
1865    if !list_out.status.success() {
1866        return None; // gh error -> unknown -> hold
1867    }
1868    let list: Value = serde_json::from_slice(&list_out.stdout).ok()?;
1869    let arr = list.as_array()?;
1870    // Zero completed runs (new/quiet repo) is not proof -> unknown.
1871    // The newest run's headSha IS the current main HEAD; classify against only
1872    // that commit's runs so a failure fixed on a later commit never counts.
1873    let head_sha = arr
1874        .first()
1875        .and_then(|r| r.get("headSha"))
1876        .and_then(|v| v.as_str())
1877        .filter(|s| !s.is_empty())?;
1878    let failing_run_ids = parse_failing_run_ids(&list, head_sha);
1879
1880    let mut names: Vec<String> = Vec::new();
1881    for id in failing_run_ids {
1882        let view_out = Command::new(gh_bin)
1883            .args(["run", "view", &id.to_string(), "--json", "jobs"])
1884            .current_dir(cwd)
1885            .output()
1886            .ok()?;
1887        if !view_out.status.success() {
1888            return None; // any per-run gh error -> unknown -> hold (fail closed)
1889        }
1890        let view: Value = serde_json::from_slice(&view_out.stdout).ok()?;
1891        for name in parse_failing_job_names(&view) {
1892            if !names.contains(&name) {
1893                names.push(name);
1894            }
1895        }
1896    }
1897    Some(names)
1898}
1899
1900/// Idempotency guard (Concurrency AC): true iff a prior `termination` event with
1901/// reason `DoneAwaitingMerge` for this session already exists, so a re-evaluation
1902/// (crash restart, or the two consumers racing) does not double-emit or
1903/// double-notify. Fail-open (false) on an unreadable events file: at worst one
1904/// extra notify, never a silent skip of the terminal.
1905fn already_emitted_awaiting_merge(events_path: &Path, session_id: &str) -> bool {
1906    let Ok(content) = std::fs::read_to_string(events_path) else {
1907        return false;
1908    };
1909    content.lines().any(|line| {
1910        let Ok(val) = serde_json::from_str::<Value>(line) else {
1911            return false;
1912        };
1913        val.get("type").and_then(|v| v.as_str()) == Some("termination")
1914            && val.pointer("/data/session_id").and_then(|v| v.as_str()) == Some(session_id)
1915            && val.pointer("/data/reason").and_then(|v| v.as_str()) == Some("DoneAwaitingMerge")
1916    })
1917}
1918
1919/// Best-effort `fno notify TITLE BODY`. Spawned detached and never waited on;
1920/// any failure (missing binary, non-zero exit) is non-fatal - the terminal
1921/// completes on the durable event row alone (AC2-FR). Suppressed under
1922/// `FNO_LOOPCHECK_NO_NOTIFY=1` so unit tests never spawn a real notifier.
1923fn best_effort_notify(title: &str, body: &str) {
1924    if std::env::var("FNO_LOOPCHECK_NO_NOTIFY").as_deref() == Ok("1") {
1925        return;
1926    }
1927    // var_os avoids a lossy UTF-8 conversion on a path/binary env value and
1928    // hands the raw OsString straight to Command (gemini review).
1929    let fno_bin = std::env::var_os("FNO_LOOPCHECK_FNO_BIN").unwrap_or_else(|| "fno".into());
1930    let _ = Command::new(fno_bin).args(["notify", title, body]).spawn();
1931}
1932
1933/// Post a bot's review trigger to the PR once, returning true on success (x-b167
1934/// section 5). `FNO_LOOPCHECK_NO_COMMENT=1` suppresses the post so the test suite
1935/// never comments on a real PR, mirroring `FNO_LOOPCHECK_NO_NOTIFY`.
1936///
1937/// Idempotency is the PR itself, not a counter: this fires only on a NeedsNudge
1938/// classification, which means zero qualifying mentions exist within the wait
1939/// window - the same read every participant makes. A sibling worktree, a
1940/// `/fno:pr check` cron, a human, and a restarted-after-compaction session all
1941/// see the same PR and reach the same decision, so there is nothing to double.
1942fn post_nudge_comment(gh_bin: &str, cwd: &Path, pr_number: i64, review_handle: &str) -> bool {
1943    if std::env::var("FNO_LOOPCHECK_NO_COMMENT").as_deref() == Ok("1") {
1944        return false;
1945    }
1946    Command::new(gh_bin)
1947        .args([
1948            "pr",
1949            "comment",
1950            &pr_number.to_string(),
1951            "--body",
1952            review_handle,
1953        ])
1954        .current_dir(cwd)
1955        .output()
1956        .map(|o| o.status.success())
1957        .unwrap_or(false)
1958}
1959
1960/// The first missing bot that has been nudged to its ceiling and gone silent, if
1961/// any. The NoProgress backstop names it instead of a bare fingerprint streak.
1962fn unresponsive_bot(pr: &PrInfo) -> Option<&BotNudge> {
1963    pr.bot_nudges
1964        .iter()
1965        .find(|n| n.class == NudgeClass::Unresponsive)
1966}
1967
1968/// The give-up line for an unresponsive nudged bot (x-b167 AC13): the operator's
1969/// two questions ("will it finish, must I act") answered in one line.
1970fn nudge_giveup_message(n: &BotNudge) -> String {
1971    format!(
1972        "{} did not review after {} nudges over {}m; giving up (NoProgress). \
1973         Move it to config.review.optional_apps or review by hand.",
1974        n.login, n.nudges, n.span_min
1975    )
1976}
1977
1978/// Per-bot knowledge, login-keyed: the ONE table the review-gate code reads for
1979/// "what is this bot and how do we reach it". Replaces the scattered `KNOWN_BOTS`
1980/// membership list and the `USAGE_LIMIT_MARKERS` body-string list.
1981///
1982/// One bot wears three names and they are NOT interchangeable at the three sites
1983/// that use them:
1984///   - `login`         the review author, what `login_matches_bot` compares against
1985///   - `review_handle` what a PR comment must CONTAIN to trigger a fresh review
1986///   - `reply_handle`  what an in-thread reply must ADDRESS to reach the bot
1987/// A `github-app` reviewer that reviews on mention (not on push) is `nudgeable`:
1988/// footnote may post its `review_handle` to un-stick a required gate that nobody
1989/// mentioned (x-b167). Nudge timing (`wait_minutes`, `ceiling`, `enabled`) is
1990/// config, not code - see `[review.nudge]` / `resolved_nudge_configs`.
1991struct BotProfile {
1992    login: &'static str,
1993    review_handle: &'static str,
1994    reply_handle: &'static str,
1995    /// ISSUE-comment body markers this bot posts when it is rate-limited and will
1996    /// never post a review object (PR #214). Empty for a bot never seen to do so.
1997    usage_markers: &'static [&'static str],
1998    nudgeable: bool,
1999}
2000
2001/// The shipped bot table. `chatgpt-codex-connector` is characterized from PR #618
2002/// (mention-triggered, ~4-7m latency, 5/5 mentions answered); `gemini-code-assist`
2003/// stays `nudgeable: false` with an empty `review_handle` until its trigger is
2004/// characterized (Evidence Gaps), which is strictly more than the old lists knew.
2005const BOT_PROFILES: &[BotProfile] = &[
2006    BotProfile {
2007        login: "chatgpt-codex-connector",
2008        review_handle: "@codex review",
2009        reply_handle: "@chatgpt-codex-connector",
2010        usage_markers: &["usage limits for code reviews", "codex usage limits"],
2011        nudgeable: true,
2012    },
2013    BotProfile {
2014        login: "gemini-code-assist",
2015        review_handle: "",
2016        reply_handle: "@gemini-code-assist",
2017        usage_markers: &[],
2018        nudgeable: false,
2019    },
2020];
2021
2022/// The profile for an actual review/comment AUTHOR login (may carry gh's `[bot]`
2023/// suffix or be the full login): the profile login is a substring of the author,
2024/// matching `login_matches_bot(author, profile.login)`. Used to reach a finding
2025/// author's `reply_handle` (x-b167 AC14).
2026fn profile_by_author(author: &str) -> Option<&'static BotProfile> {
2027    BOT_PROFILES
2028        .iter()
2029        .find(|p| login_matches_bot(author, p.login))
2030}
2031
2032/// Two login strings name the same bot when either is a case-insensitive
2033/// substring of the other (so a config short name "codex", a full login, and a
2034/// "[bot]"-suffixed author all correspond). Symmetric superset of
2035/// `login_matches_bot`.
2036fn logins_correspond(a: &str, b: &str) -> bool {
2037    login_matches_bot(a, b) || login_matches_bot(b, a)
2038}
2039
2040/// Default nudge cadence (x-b167). 15 minutes is the observed 6m55s worst-case
2041/// latency on PR #618 with headroom, not a guess; 3 nudges bounds the give-up at
2042/// ~45 minutes of *asked-for* waiting versus the unbounded budget burn today.
2043const DEFAULT_NUDGE_WAIT_MINUTES: i64 = 15;
2044const DEFAULT_NUDGE_CEILING: usize = 3;
2045
2046/// Sanity ceilings for `[review.nudge]` override integers (x-b167). A value
2047/// beyond these is a typo, not a cadence: `wait_minutes` is bounded well under
2048/// `i64::MAX/60` so `chrono::Duration::minutes` can never overflow-panic in the
2049/// stop gate, and a nudge cadence past a week / 1000 asks is meaningless anyway.
2050const MAX_NUDGE_WAIT_MINUTES: i64 = 7 * 24 * 60; // one week
2051const MAX_NUDGE_CEILING: i64 = 1000;
2052
2053/// A nudgeable bot login with its resolved cadence: BOT_PROFILES defaults
2054/// overlaid with `[review.nudge]` overrides. ONLY nudgeable logins appear here
2055/// (enabled, non-empty review_handle, not malformed); any other missing bot
2056/// classifies `NotNudgeable`.
2057#[derive(Debug, Clone)]
2058struct NudgeConfig {
2059    login: String,
2060    review_handle: String,
2061    wait_minutes: i64,
2062    ceiling: usize,
2063}
2064
2065/// Resolve the nudgeable-bot set for this repo: the built-in profiles, then the
2066/// `[review.nudge]` overrides. A malformed or `enabled = false` override REMOVES
2067/// its login from the set (opting out is never opting into a faster give-up);
2068/// an override with no resolvable `review_handle` (neither its own nor a base
2069/// profile's) is likewise dropped, since there is nothing to post.
2070fn resolved_nudge_configs(settings: &Settings) -> Vec<NudgeConfig> {
2071    let mut out: Vec<NudgeConfig> = BOT_PROFILES
2072        .iter()
2073        .filter(|p| p.nudgeable && !p.review_handle.is_empty())
2074        .map(|p| NudgeConfig {
2075            login: p.login.to_string(),
2076            review_handle: p.review_handle.to_string(),
2077            wait_minutes: DEFAULT_NUDGE_WAIT_MINUTES,
2078            ceiling: DEFAULT_NUDGE_CEILING,
2079        })
2080        .collect();
2081
2082    for ov in &settings.nudge_overrides {
2083        let base = out
2084            .iter()
2085            .find(|c| logins_correspond(&c.login, &ov.login))
2086            .cloned();
2087        // Drop first so an override always replaces (or removes) its login.
2088        out.retain(|c| !logins_correspond(&c.login, &ov.login));
2089        if ov.malformed || !ov.enabled {
2090            continue; // opt-out / bad entry -> non-nudgeable
2091        }
2092        let handle = ov
2093            .review_handle
2094            .clone()
2095            .or_else(|| base.as_ref().map(|b| b.review_handle.clone()))
2096            .filter(|h| !h.is_empty());
2097        let Some(review_handle) = handle else {
2098            continue; // no trigger to post -> not nudgeable
2099        };
2100        out.push(NudgeConfig {
2101            login: ov.login.clone(),
2102            review_handle,
2103            wait_minutes: ov
2104                .wait_minutes
2105                .or_else(|| base.as_ref().map(|b| b.wait_minutes))
2106                .unwrap_or(DEFAULT_NUDGE_WAIT_MINUTES),
2107            ceiling: ov
2108                .ceiling
2109                .or_else(|| base.as_ref().map(|b| b.ceiling))
2110                .unwrap_or(DEFAULT_NUDGE_CEILING),
2111        });
2112    }
2113    out
2114}
2115
2116/// The nudge config for a configured missing-bot login, or None (non-nudgeable).
2117fn nudge_config_for<'a>(configs: &'a [NudgeConfig], bot: &str) -> Option<&'a NudgeConfig> {
2118    configs.iter().find(|c| logins_correspond(&c.login, bot))
2119}
2120
2121/// A missing bot's nudge classification for this fire (x-b167). Derived fresh
2122/// from PR comments every fire - no durable counter - so a mention posted by a
2123/// human, `/fno:pr check`, or a sibling worktree counts identically and
2124/// self-heals across restart / compaction / handoff.
2125#[derive(Debug, Clone, PartialEq)]
2126enum NudgeClass {
2127    /// No mention within the wait window: work to DO (post the trigger). Never
2128    /// idlable.
2129    NeedsNudge,
2130    /// Newest mention still inside the wait window: a genuine async wait. The
2131    /// only idlable nudge state.
2132    Awaiting,
2133    /// Ceiling reached and the newest mention timed out: nobody will end this
2134    /// wait. Never idlable, so the NoProgress backstop reaps it.
2135    Unresponsive,
2136    /// Login footnote cannot nudge (no profile/override, disabled, or a peer
2137    /// sentinel): today's block-and-wait behavior, unchanged. Idlable (status
2138    /// quo).
2139    NotNudgeable,
2140}
2141
2142/// One missing bot's classification plus the facts the block message renders.
2143#[derive(Debug, Clone)]
2144struct BotNudge {
2145    login: String,
2146    class: NudgeClass,
2147    /// The trigger to post; "" when NotNudgeable.
2148    review_handle: String,
2149    ceiling: usize,
2150    /// Mention count on the PR (every issue comment containing review_handle).
2151    nudges: usize,
2152    /// Minutes since the newest mention (0 when there is none).
2153    newest_age_min: i64,
2154    /// Minutes from the oldest mention to now (0 when there is none), for the
2155    /// "did not review after N nudges over Mm" give-up line.
2156    span_min: i64,
2157}
2158
2159impl BotNudge {
2160    fn not_nudgeable(login: &str) -> Self {
2161        BotNudge {
2162            login: login.to_string(),
2163            class: NudgeClass::NotNudgeable,
2164            review_handle: String::new(),
2165            ceiling: 0,
2166            nudges: 0,
2167            newest_age_min: 0,
2168            span_min: 0,
2169        }
2170    }
2171}
2172
2173/// Whether this state may idle on a `<watching>` tag: only a genuine async wait
2174/// (Awaiting) or a login we never nudge (NotNudgeable, status quo). NeedsNudge is
2175/// work to do; Unresponsive is a wait nobody ends.
2176fn nudge_class_idlable(class: &NudgeClass) -> bool {
2177    matches!(class, NudgeClass::Awaiting | NudgeClass::NotNudgeable)
2178}
2179
2180/// Classify one missing bot against the PR's issue comments. A mention is every
2181/// issue comment whose body contains the trigger handle, author unrestricted (a
2182/// mention is a request from anyone; only a usage-limit *claim* is scoped to the
2183/// bot's own login). Reads NO review timestamp and NO `reviews[].commit`: the
2184/// bot gate is PR-lifetime, and touching either silently re-pins it to head.
2185fn classify_bot_nudge(
2186    login: &str,
2187    comments: &[Value],
2188    cfg: Option<&NudgeConfig>,
2189    now: DateTime<Utc>,
2190) -> BotNudge {
2191    let Some(cfg) = cfg else {
2192        return BotNudge::not_nudgeable(login);
2193    };
2194    if cfg.review_handle.is_empty() {
2195        return BotNudge::not_nudgeable(login);
2196    }
2197    let mut total = 0usize;
2198    let mut times: Vec<DateTime<Utc>> = Vec::new();
2199    for c in comments {
2200        let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
2201        if !body.contains(&cfg.review_handle) {
2202            continue;
2203        }
2204        total += 1;
2205        // A malformed/missing createdAt must NOT push toward Unresponsive:
2206        // giving up on a parse error is not reversible, asking again is (AC-ERR).
2207        if let Some(dt) = c
2208            .get("createdAt")
2209            .and_then(|v| v.as_str())
2210            .and_then(|s| s.parse::<DateTime<Utc>>().ok())
2211        {
2212            times.push(dt);
2213        }
2214    }
2215    if total == 0 {
2216        return BotNudge {
2217            login: login.to_string(),
2218            class: NudgeClass::NeedsNudge,
2219            review_handle: cfg.review_handle.clone(),
2220            ceiling: cfg.ceiling,
2221            nudges: 0,
2222            newest_age_min: 0,
2223            span_min: 0,
2224        };
2225    }
2226    let (Some(newest), Some(oldest)) = (times.iter().max().copied(), times.iter().min().copied())
2227    else {
2228        // Mentions exist but none carried a usable timestamp: ask again (cheap).
2229        return BotNudge {
2230            login: login.to_string(),
2231            class: NudgeClass::NeedsNudge,
2232            review_handle: cfg.review_handle.clone(),
2233            ceiling: cfg.ceiling,
2234            nudges: total,
2235            newest_age_min: 0,
2236            span_min: 0,
2237        };
2238    };
2239    let newest_age_min = (now - newest).num_minutes().max(0);
2240    let span_min = (now - oldest).num_minutes().max(0);
2241    let class = if (now - newest) < chrono::Duration::minutes(cfg.wait_minutes) {
2242        NudgeClass::Awaiting
2243    } else if total >= cfg.ceiling {
2244        NudgeClass::Unresponsive
2245    } else {
2246        NudgeClass::NeedsNudge // previous mention timed out; ask again
2247    };
2248    BotNudge {
2249        login: login.to_string(),
2250        class,
2251        review_handle: cfg.review_handle.clone(),
2252        ceiling: cfg.ceiling,
2253        nudges: total,
2254        newest_age_min,
2255        span_min,
2256    }
2257}
2258
2259/// Default must-have-reviewed list when config.review.github_apps is absent.
2260/// EMPTY for fresh installs: a clone with no review configuration completes on
2261/// PR + CI green without hanging on a review bot it has never set up (a fresh
2262/// `/target` otherwise runs to the budget cap waiting for a codex review that
2263/// never arrives). Maintainers who want an external-review gate pin it
2264/// explicitly via config.review.github_apps (e.g. ["chatgpt-codex-connector"]).
2265const DEFAULT_REQUIRED_BOTS: &[&str] = &[];
2266
2267/// Stable reviewer key emitted by every identity-free peer. Multiple configured
2268/// peer harnesses are alternatives for one composite gate, not N required votes.
2269const LOCAL_PEER_REVIEWER: &str = "peer";
2270
2271/// An unmatchable reviewer key used when every identity-free peer is the
2272/// author's own model family. It keeps the local gate fail-closed independently
2273/// of the producer and is rendered as an actionable same-model refusal.
2274const SAME_MODEL_LOCAL_PEER_SENTINEL: &str = "\u{0}fno-peer-same-model-local\u{0}";
2275
2276/// A login no real GitHub account can equal, pushed when a required peer login is
2277/// backed ONLY by peers whose model is the author's own (same-model guard). It
2278/// REPLACES the clearable login so a same-model review can never satisfy the
2279/// cross-model gate.
2280const SAME_MODEL_PEER_SENTINEL: &str = "\u{0}fno-peer-same-model\u{0}";
2281
2282/// Model family of a harness or provider name - the same-model guard's proxy for
2283/// "which model". The author's family is its invoking harness's family
2284/// (claude->anthropic, codex->openai, gemini->google); a peer's family is its
2285/// route provider (else its bare provider). An unknown name is None and so never
2286/// equals any author family (fail open per-peer). A routed-transport author
2287/// (claude CLI over GLM) still reads as anthropic here - a known limitation that
2288/// errs toward HOLDING the gate, never wrongly clearing it.
2289fn harness_family(name: &str) -> Option<&'static str> {
2290    match name.trim().to_ascii_lowercase().as_str() {
2291        "claude" | "anthropic" => Some("anthropic"),
2292        "codex" | "openai" => Some("openai"),
2293        "gemini" | "google" => Some("google"),
2294        _ => None,
2295    }
2296}
2297
2298/// The route provider of a peers `model` route: `"route_provider,route_model"`
2299/// -> `route_provider`. None unless there are exactly two non-empty comma parts,
2300/// matching the loader's parse rule (config/__init__.py coerce_peers), so a
2301/// malformed route falls back to the bare provider.
2302fn route_provider(model: &str) -> Option<&str> {
2303    let mut parts = model.split(',').map(str::trim);
2304    match (parts.next(), parts.next(), parts.next()) {
2305        (Some(prov), Some(rest), None) if !prov.is_empty() && !rest.is_empty() => Some(prov),
2306        _ => None,
2307    }
2308}
2309
2310/// A peer's effective model family: its route provider's family when it names a
2311/// valid route, else its bare provider's family. A `model` route is only honored
2312/// for a **claude** peer, because only the claude transport actually executes a
2313/// route (`claude -p` over the routed model); codex/gemini dispatch ignores the
2314/// route and runs the bare provider, so trusting a codex/gemini route would
2315/// classify a same-model review as cross-model and re-open the bypass this guard
2316/// exists to close. Matches the loader, which validates routes for claude only.
2317fn peer_family(peer: &PeerEntry) -> Option<&'static str> {
2318    let effective = peer
2319        .model
2320        .as_deref()
2321        .filter(|_| peer.provider.trim().eq_ignore_ascii_case("claude"))
2322        .and_then(route_provider)
2323        .unwrap_or(peer.provider.as_str());
2324    harness_family(effective)
2325}
2326
2327/// Thin wrapper: resolve the must-have-reviewed login set with NO author-harness
2328/// awareness (the same-model guard is inert). Test-only convenience so existing
2329/// tests stay byte-identical; production passes the resolved harness via
2330/// [`resolved_required_bots_for_author`].
2331#[cfg(test)]
2332fn resolved_required_bots(settings: &Settings) -> Vec<String> {
2333    resolved_required_bots_for_author(settings, None)
2334}
2335
2336/// The set of expected review logins that must have passed for the gate to
2337/// clear (x-4baa): `github_apps` (or its legacy `required_bots` alias) UNION
2338/// the resolved posting identity of each identity-backed `peers` entry.
2339/// Identity-free peers are resolved separately into local reviewer evidence.
2340///
2341/// `author_harness` is the invoking harness (`claude`/`codex`/`gemini`), resolved
2342/// from the ambient env markers by the caller. When it resolves to a model
2343/// family, the same-model guard (x-c2e7) replaces any peer login backed ONLY by
2344/// the author's own model with SAME_MODEL_PEER_SENTINEL, so a codex-authored run
2345/// with `peers: [codex]` can no longer review its own work and clear the gate.
2346/// `None` (unknown authorship) leaves the login set byte-identical - fail open.
2347fn resolved_required_bots_for_author(
2348    settings: &Settings,
2349    author_harness: Option<&str>,
2350) -> Vec<String> {
2351    // github_apps wins over the legacy required_bots alias when both are set.
2352    if settings.github_apps.is_some() && settings.required_bots.is_some() {
2353        eprintln!(
2354            "loop-check: both config.review.github_apps and required_bots set - using github_apps"
2355        );
2356    }
2357    let mut logins: Vec<String> = match settings
2358        .github_apps
2359        .as_ref()
2360        .or(settings.required_bots.as_ref())
2361    {
2362        Some(list) => list.clone(),
2363        None => DEFAULT_REQUIRED_BOTS
2364            .iter()
2365            .map(|s| s.to_string())
2366            .collect(),
2367    };
2368
2369    // Only identity-backed peers contribute to the expected-login set. Shared
2370    // identity collapses to one login; per-peer identities each add their own.
2371    // Identity-free peers are not missing logins: they use local attestations.
2372    for peer in &settings.peers {
2373        let id = peer
2374            .identity
2375            .clone()
2376            .or_else(|| settings.peer_identity.clone());
2377        match id {
2378            Some(id) if !logins.iter().any(|l| l == &id) => logins.push(id),
2379            Some(_) => {} // already present (shared identity)
2380            None => {}    // local-attestation carrier
2381        }
2382    }
2383
2384    // Same-model guard (x-c2e7): a peer login backed ONLY by the author's own
2385    // model cannot honestly satisfy the cross-model gate. Inert unless the
2386    // author harness resolves to a family (fail open on unknown authorship, so
2387    // the block above stays byte-identical). The GITHUB_APPS base set is never
2388    // touched - only logins contributed by `peers` are eligible.
2389    if let Some(author) = author_harness.filter(|_| !settings.peers.is_empty()) {
2390        if let Some(author_fam) = harness_family(author) {
2391            apply_same_model_guard(&mut logins, settings, author, author_fam);
2392        }
2393    }
2394    logins
2395}
2396
2397/// Resolve all identity-free peers into one local reviewer requirement.
2398///
2399/// Any cross-model option makes the composite gate satisfiable by a `peer`
2400/// attestation. When the author is known and every option is same-model, return
2401/// an unmatchable sentinel so even a forged `peer: pass` cannot self-review the
2402/// change. Unknown peer families remain eligible, matching the existing
2403/// identity-backed guard's conservative compatibility rule.
2404fn resolved_local_peer_reviewers_for_author(
2405    settings: &Settings,
2406    author_harness: Option<&str>,
2407) -> Vec<String> {
2408    if settings.peer_identity.is_some() {
2409        return Vec::new();
2410    }
2411    let local: Vec<&PeerEntry> = settings
2412        .peers
2413        .iter()
2414        .filter(|peer| peer.identity.is_none())
2415        .collect();
2416    if local.is_empty() {
2417        return Vec::new();
2418    }
2419    let Some(author_fam) = author_harness.and_then(harness_family) else {
2420        return vec![LOCAL_PEER_REVIEWER.to_string()];
2421    };
2422    if local
2423        .iter()
2424        .any(|peer| peer_family(peer) != Some(author_fam))
2425    {
2426        vec![LOCAL_PEER_REVIEWER.to_string()]
2427    } else {
2428        eprintln!(
2429            "loop-check: every identity-free peer is the author's own model - configure a cross-model peer or routed model"
2430        );
2431        vec![SAME_MODEL_LOCAL_PEER_SENTINEL.to_string()]
2432    }
2433}
2434
2435/// Replace every peer-contributed login backed ONLY by same-model peers with
2436/// SAME_MODEL_PEER_SENTINEL and print one loud line per such login. A login with
2437/// >=1 cross-model peer (a different family, or an unknown provider) is left
2438/// alone. When a same-model peer login COLLIDES with a github_apps/required_bots
2439/// base login (`peer_identity` == an App login), the base login is kept (its App
2440/// requirement is not loosened) AND the sentinel is appended, so a same-model
2441/// review posted under that shared login can never be the thing that clears the
2442/// gate - the collision is a fail-closed hold, not an exemption (codex peer
2443/// review on PR #375). Peers are walked in config order so output is deterministic.
2444fn apply_same_model_guard(
2445    logins: &mut Vec<String>,
2446    settings: &Settings,
2447    author_harness: &str,
2448    author_fam: &str,
2449) {
2450    let base_set = settings
2451        .github_apps
2452        .as_ref()
2453        .or(settings.required_bots.as_ref());
2454
2455    // Per distinct peer login, in first-seen order: does any backing peer differ
2456    // in model family, and the first same-model provider (for the message)?
2457    let mut seen: Vec<(String, bool, String)> = Vec::new();
2458    for peer in &settings.peers {
2459        let Some(login) = peer
2460            .identity
2461            .as_deref()
2462            .or(settings.peer_identity.as_deref())
2463        else {
2464            continue;
2465        };
2466        let cross = peer_family(peer) != Some(author_fam);
2467        match seen.iter_mut().find(|(l, _, _)| l.as_str() == login) {
2468            Some(entry) => entry.1 = entry.1 || cross,
2469            None => seen.push((login.to_string(), cross, peer.provider.clone())),
2470        }
2471    }
2472
2473    for (login, any_cross, provider) in seen {
2474        if any_cross {
2475            continue;
2476        }
2477        if base_set.is_some_and(|set| set.contains(&login)) {
2478            // Collision: the peer posts under a required App login. Keep the App
2479            // requirement, but add the sentinel so this same-model login can't be
2480            // what clears the gate (never an exemption - fail closed).
2481            if !logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL) {
2482                logins.push(SAME_MODEL_PEER_SENTINEL.to_string());
2483            }
2484        } else if let Some(slot) = logins.iter_mut().find(|l| **l == login) {
2485            // Peer-only login: replace it with the sentinel.
2486            *slot = SAME_MODEL_PEER_SENTINEL.to_string();
2487        }
2488        eprintln!(
2489            "loop-check: peer '{provider}' is the author's own model ({author_harness}-authored run) - the cross-model gate cannot be satisfied by it; configure a cross-model peer or a model route"
2490        );
2491    }
2492}
2493
2494/// The OPTIONAL reviewer logins (config.review.optional_apps): honored-if-
2495/// present but never required. Their blocking findings hold the gate, but their
2496/// absence never does (x-4baa "honor if present"). Empty when unset.
2497fn resolved_optional_bots(settings: &Settings) -> Vec<String> {
2498    settings.optional_apps.clone().unwrap_or_default()
2499}
2500
2501/// Case-insensitive substring match so a configured short name ("codex") or a
2502/// full login both match the review author, including gh's `[bot]`-suffixed
2503/// form (reference_gh_bot_login_suffix_polling_trap).
2504pub(crate) fn login_matches_bot(login: &str, bot: &str) -> bool {
2505    !bot.is_empty() && login.to_lowercase().contains(&bot.to_lowercase())
2506}
2507
2508fn is_bot_reviewer(login: &str, external_reviewers: &[String]) -> bool {
2509    if !external_reviewers.is_empty() {
2510        let login_lower = login.to_lowercase();
2511        // Case-insensitive substring match: "gemini" matches "gemini-code-assist[bot]"
2512        if external_reviewers
2513            .iter()
2514            .any(|r| login_lower.contains(&r.to_lowercase()))
2515        {
2516            return true;
2517        }
2518        // Configured list present but no entry matched: fall back to bot heuristic
2519        // so a configured-but-partial list doesn't make reviewed unreachable.
2520    }
2521    // Default: endswith [bot] or a known profile login
2522    login.ends_with("[bot]") || BOT_PROFILES.iter().any(|p| login.contains(p.login))
2523}
2524
2525/// Every usage-limit marker across all bot profiles, unioned. A rate-limited
2526/// review bot posts one of these as an ISSUE comment when it never posts a review
2527/// object (PR #214). Matched case-insensitively via `contains` against a
2528/// lowercased body, mirroring the pinned-string approach in `blocking_severity`.
2529/// Unioned rather than scoped per-login to stay byte-identical to the old flat
2530/// `USAGE_LIMIT_MARKERS` const it replaced: an under-match degrades to the safe
2531/// old block behavior; an over-match risks a false drop.
2532pub(crate) fn body_is_usage_limit(body: &str) -> bool {
2533    BOT_PROFILES
2534        .iter()
2535        .flat_map(|p| p.usage_markers.iter())
2536        .any(|m| body.contains(m))
2537}
2538
2539/// Per-required-bot review verdict (grilled decision 5 / step 2).
2540#[derive(Debug)]
2541struct ReviewInfo {
2542    /// Latest review/comment activity timestamp, or "none".
2543    latest_ts: String,
2544    /// Required bots with no completed review pass. A pass is a top-level
2545    /// review with any non-empty state on ANY commit - in practice COMMENTED
2546    /// (verified on PR #447; codex reviews once per PR and never re-reviews,
2547    /// so requiring a pass on HEAD would make the gate unsatisfiable).
2548    missing_bots: Vec<String>,
2549    /// Required bots dropped from `missing_bots` because they are env-blocked
2550    /// (rate-limited): they posted only a usage-limit comment, never a review.
2551    /// Keeping them in `missing_bots` wedged the gate until budget death
2552    /// (PR #214); dropping them lets the gate proceed on remaining evidence
2553    /// while the caller records the drop (AC1-UI). A bot is never in both
2554    /// lists - it is scanned only while still in `missing_bots`.
2555    usage_limited: Vec<String>,
2556}
2557
2558impl ReviewInfo {
2559    /// Every required bot has at least one completed pass.
2560    fn all_required_passed(&self) -> bool {
2561        self.missing_bots.is_empty()
2562    }
2563}
2564
2565fn compute_review_info(reviews_json: &Value, required_bots: &[String]) -> ReviewInfo {
2566    let reviews = reviews_json
2567        .get("reviews")
2568        .and_then(|v| v.as_array())
2569        .map(|v| v.as_slice())
2570        .unwrap_or(&[]);
2571    let comments = reviews_json
2572        .get("comments")
2573        .and_then(|v| v.as_array())
2574        .map(|v| v.as_slice())
2575        .unwrap_or(&[]);
2576
2577    let mut latest_ts = String::new(); // empty; "none" returned if no activity found
2578    let mut passed: Vec<bool> = vec![false; required_bots.len()];
2579
2580    for r in reviews {
2581        let login = r
2582            .pointer("/author/login")
2583            .and_then(|v| v.as_str())
2584            .unwrap_or("");
2585        let submitted_at = r.get("submittedAt").and_then(|v| v.as_str()).unwrap_or("");
2586        let state = r.get("state").and_then(|v| v.as_str()).unwrap_or("");
2587
2588        if !submitted_at.is_empty() && submitted_at > latest_ts.as_str() {
2589            latest_ts = submitted_at.to_string();
2590        }
2591
2592        if !state.is_empty() {
2593            for (i, bot) in required_bots.iter().enumerate() {
2594                if login_matches_bot(login, bot) {
2595                    passed[i] = true;
2596                }
2597            }
2598        }
2599    }
2600
2601    for c in comments {
2602        let created_at = c.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
2603        if !created_at.is_empty() && created_at > latest_ts.as_str() {
2604            latest_ts = created_at.to_string();
2605        }
2606    }
2607
2608    let final_ts = if latest_ts.is_empty() {
2609        "none".to_string()
2610    } else {
2611        latest_ts
2612    };
2613
2614    let mut missing_bots: Vec<String> = required_bots
2615        .iter()
2616        .zip(passed.iter())
2617        .filter(|(_, ok)| !**ok)
2618        .map(|(bot, _)| bot.clone())
2619        .collect();
2620
2621    // (a) Usage-limit detection. A still-missing required bot that authored a
2622    // comment carrying a pinned usage-limit marker is env-blocked, not
2623    // hasn't-reviewed-yet: it will never post a review, so leaving it in
2624    // missing_bots blocks every fire until the budget cap kills the session
2625    // (PR #214). Move it OUT of missing_bots into usage_limited so the gate
2626    // proceeds on remaining evidence (the caller logs the drop + names the bot,
2627    // and the merge stays human-gated). Scoped to the bot's OWN author.login so
2628    // a stranger's comment never drops a required bot (AC1-ERR). Only
2629    // still-missing bots are scanned, so a bot that actually reviewed is never
2630    // usage-limited-dropped (AC1-EDGE).
2631    let mut usage_limited: Vec<String> = Vec::new();
2632    missing_bots.retain(|bot| {
2633        let rate_limited = comments.iter().any(|c| {
2634            let login = c
2635                .pointer("/author/login")
2636                .and_then(|v| v.as_str())
2637                .unwrap_or("");
2638            if !login_matches_bot(login, bot) {
2639                return false;
2640            }
2641            let body = c
2642                .get("body")
2643                .and_then(|v| v.as_str())
2644                .unwrap_or("")
2645                .to_lowercase();
2646            body_is_usage_limit(&body)
2647        });
2648        if rate_limited {
2649            usage_limited.push(bot.clone());
2650            false
2651        } else {
2652            true
2653        }
2654    });
2655
2656    ReviewInfo {
2657        latest_ts: final_ts,
2658        missing_bots,
2659        usage_limited,
2660    }
2661}
2662
2663// ── inline findings (Read 4, step 2 / US2) ────────────────────────────────────
2664
2665/// A blocking inline finding: a root review comment (in_reply_to_id == null)
2666/// authored by a required bot whose body carries a blocking severity badge.
2667#[derive(Debug, Clone)]
2668struct Finding {
2669    id: i64,
2670    /// Bot login that posted the finding (REST `user.login`).
2671    author: String,
2672    path: String,
2673    line: i64,
2674    created_at: String,
2675    /// Parsed severity label (P1 / critical / high).
2676    severity: &'static str,
2677}
2678
2679/// Parse a blocking severity from the bot's own badge markup. The exact
2680/// strings are pinned from PR #447 ground truth; both the alt-text and the
2681/// badge-URL forms are matched so a partial render still classifies:
2682///   codex:  `![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)`
2683///   gemini: `![high](https://www.gstatic.com/codereviewagent/high-priority.svg)`
2684/// Anything unparseable is advisory, never blocking (locked decision 4:
2685/// under-blocking is the only safe failure - the agent cannot edit a bot's
2686/// comment, and PR history is the post-hoc backstop).
2687fn blocking_severity(body: &str) -> Option<&'static str> {
2688    if body.contains("![P1 Badge]") || body.contains("badge/P1-") {
2689        return Some("P1");
2690    }
2691    if body.contains("![critical]") || body.contains("critical-priority.svg") {
2692        return Some("critical");
2693    }
2694    if body.contains("![high]") || body.contains("high-priority.svg") {
2695        return Some("high");
2696    }
2697    None
2698}
2699
2700/// Max of two timestamp strings, treating "none"/"" as the lowest value.
2701/// Both sides are compared chronologically when they parse (gemini HIGH on
2702/// #448: an offset-suffixed timestamp can sort above a Zulu one
2703/// lexicographically while being earlier in UTC); the returned value is
2704/// always one of the ORIGINAL strings so the fingerprint stays byte-stable.
2705/// Unparseable-but-real strings fall back to lexicographic comparison.
2706fn max_ts(a: &str, b: &str) -> String {
2707    if let (Ok(da), Ok(db)) = (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
2708        return if da >= db {
2709            a.to_string()
2710        } else {
2711            b.to_string()
2712        };
2713    }
2714    let a_real = !a.is_empty() && a != "none";
2715    let b_real = !b.is_empty() && b != "none";
2716    match (a_real, b_real) {
2717        (true, true) => {
2718            if a >= b {
2719                a.to_string()
2720            } else {
2721                b.to_string()
2722            }
2723        }
2724        (true, false) => a.to_string(),
2725        (false, true) => b.to_string(),
2726        (false, false) => "none".to_string(),
2727    }
2728}
2729
2730/// The `wontfix:` decline marker (documented in skills/check-pr). Matched
2731/// case-insensitively in a non-bot reply body.
2732const WONTFIX_MARKER: &str = "wontfix:";
2733
2734/// True iff `a` is strictly after `b`. Both sides parse as RFC3339; an
2735/// unparseable timestamp returns false, so a blocking finding is never
2736/// cleared on garbage data. Raw string comparison is NOT used here because
2737/// offset-suffixed and Z-suffixed forms mis-order lexicographically
2738/// (e.g. "...T23:30:00+13:00" sorts above "...T11:00:00Z" as a string but
2739/// is 30 minutes EARLIER in UTC).
2740fn ts_after(a: &str, b: &str) -> bool {
2741    match (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
2742        (Ok(da), Ok(db)) => da > db,
2743        _ => false,
2744    }
2745}
2746
2747/// Walk the `/pulls/N/comments` array (REST shape: `user.login`,
2748/// `in_reply_to_id`, `created_at`). Returns the newest comment timestamp
2749/// (fingerprint contribution) and the UNADDRESSED blocking findings.
2750///
2751/// A blocking finding is addressed iff its thread has a non-bot reply AND
2752/// (a commit landed after the finding's created_at OR a non-bot reply body
2753/// carries `wontfix:`). The reply is mandatory: a commit alone must not
2754/// silently clear a P1 (anti-gaming, locked decision 3).
2755fn compute_unaddressed_findings(
2756    comments: &[Value],
2757    commit_dates: &[String],
2758    required_bots: &[String],
2759    external_reviewers: &[String],
2760) -> (String, Vec<Finding>) {
2761    let mut latest_ts = String::new();
2762    let mut candidates: Vec<Finding> = Vec::new();
2763    // finding id -> non-bot replies' bodies
2764    let mut replies: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
2765
2766    for c in comments {
2767        let created_at = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
2768        if !created_at.is_empty() && created_at > latest_ts.as_str() {
2769            latest_ts = created_at.to_string();
2770        }
2771
2772        let login = c
2773            .pointer("/user/login")
2774            .and_then(|v| v.as_str())
2775            .unwrap_or("");
2776        let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
2777        let in_reply_to = c.get("in_reply_to_id").and_then(|v| v.as_i64());
2778
2779        match in_reply_to {
2780            Some(parent_id) => {
2781                // A reply. Only non-bot replies count as the agent's ack.
2782                if !is_bot_reviewer(login, external_reviewers) {
2783                    replies.entry(parent_id).or_default().push(body.to_string());
2784                }
2785            }
2786            None => {
2787                // A root comment: a finding when a required bot posted it
2788                // with a blocking badge.
2789                let by_required_bot = required_bots
2790                    .iter()
2791                    .any(|bot| login_matches_bot(login, bot));
2792                if by_required_bot {
2793                    if let Some(severity) = blocking_severity(body) {
2794                        // A REST comment always carries an integer id; a row
2795                        // without one is schema drift. Skip it rather than
2796                        // pooling id-less findings on a shared default bucket
2797                        // where a single stray reply could mark them all
2798                        // addressed (under-blocking is the safe direction per
2799                        // locked decision 4; PR history is the backstop).
2800                        let Some(id) = c.get("id").and_then(|v| v.as_i64()) else {
2801                            eprintln!(
2802                                "loop-check: skipping blocking finding with missing id (author={login})"
2803                            );
2804                            continue;
2805                        };
2806                        candidates.push(Finding {
2807                            id,
2808                            author: login.to_string(),
2809                            path: c
2810                                .get("path")
2811                                .and_then(|v| v.as_str())
2812                                .unwrap_or("unknown")
2813                                .to_string(),
2814                            line: c
2815                                .get("line")
2816                                .and_then(|v| v.as_i64())
2817                                .or_else(|| c.get("original_line").and_then(|v| v.as_i64()))
2818                                .unwrap_or(0),
2819                            created_at: created_at.to_string(),
2820                            severity,
2821                        });
2822                    }
2823                }
2824            }
2825        }
2826    }
2827
2828    let unaddressed: Vec<Finding> = candidates
2829        .into_iter()
2830        .filter(|f| {
2831            let non_bot_replies = replies.get(&f.id);
2832            let has_reply = non_bot_replies.map(|r| !r.is_empty()).unwrap_or(false);
2833            if !has_reply {
2834                return true; // no ack -> unaddressed
2835            }
2836            let commit_after = commit_dates.iter().any(|d| ts_after(d, &f.created_at));
2837            let wontfix = non_bot_replies
2838                .map(|rs| rs.iter().any(|b| b.to_lowercase().contains(WONTFIX_MARKER)))
2839                .unwrap_or(false);
2840            !(commit_after || wontfix)
2841        })
2842        .collect();
2843
2844    let final_ts = if latest_ts.is_empty() {
2845        "none".to_string()
2846    } else {
2847        latest_ts
2848    };
2849    (final_ts, unaddressed)
2850}
2851
2852// ── fingerprint + fire history ────────────────────────────────────────────────
2853
2854fn make_fingerprint(
2855    head_sha: &str,
2856    pr_state: &str,
2857    ci_conclusion: &str,
2858    latest_ts: &str,
2859) -> String {
2860    format!("{head_sha}|{pr_state}|{ci_conclusion}|{latest_ts}")
2861}
2862
2863/// Default debounce window: an unchanged fingerprint seen again inside this many
2864/// seconds is the SAME observation, not a new one. The streak counts independent
2865/// observations of an unchanged world, not stop-hook fires -- a session taking
2866/// short turns used to burn a 5-fire backstop in 109 seconds while its CI run
2867/// still had 7 minutes to go, which no external wait can outrun. The effective
2868/// floor becomes `(backstop_n - 1) * gap`: 10 minutes unattended, 20 attended.
2869/// Override with `FNO_LOOPCHECK_MIN_FIRE_GAP_SECS` (0 restores fire counting).
2870const MIN_FIRE_GAP_SECS: i64 = 300;
2871
2872/// Resolve the debounce window from the env seam, falling back to the default.
2873/// Mirrors the `FNO_LOOPCHECK_GH_BIN` / `_NO_NOTIFY` / `_NO_COMMENT` seams.
2874fn min_fire_gap_secs() -> i64 {
2875    std::env::var("FNO_LOOPCHECK_MIN_FIRE_GAP_SECS")
2876        .ok()
2877        .and_then(|s| s.trim().parse::<i64>().ok())
2878        .unwrap_or(MIN_FIRE_GAP_SECS)
2879}
2880
2881/// Count prior loop_check events for this session_id in the project events file.
2882/// Returns (total_fires, consecutive_unchanged_count, last_fingerprint_in_log,
2883/// streak_window_secs).
2884///
2885/// `current_fp` is the fingerprint computed this fire (used for streak matching).
2886/// `last_fp` is the most recent fingerprint recorded in the events log for this
2887/// session -- used for carry-forward when the gh pre-read fails this fire.
2888/// `streak_window_secs` is the span from the oldest COUNTED fire to `now`; it is
2889/// what makes a streak count falsifiable from the events log.
2890///
2891/// The streak is debounced by `min_gap_secs`: walking backwards from `now`, a
2892/// matching fire closer than the gap to the last counted one is skipped
2893/// TRANSPARENTLY and does not advance the cursor, so a burst collapses to a
2894/// single observation. The asymmetry is deliberate and load-bearing: a CHANGED
2895/// fingerprint breaks the streak at any spacing, because real progress is real
2896/// progress at any speed -- only the *absence* of change needs time to be
2897/// credible.
2898fn read_prior_fires(
2899    events_path: &Path,
2900    session_id: &str,
2901    current_fp: &str,
2902    now: DateTime<Utc>,
2903    min_gap_secs: i64,
2904) -> (u64, u64, Option<String>, i64) {
2905    let Ok(content) = std::fs::read_to_string(events_path) else {
2906        return (0, 0, None, 0);
2907    };
2908
2909    let mut total: u64 = 0;
2910
2911    for line in content.lines() {
2912        let Ok(val) = serde_json::from_str::<Value>(line) else {
2913            continue;
2914        };
2915        if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
2916            continue;
2917        }
2918        if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
2919            continue;
2920        }
2921        total += 1;
2922    }
2923
2924    // Calculate consecutive streak from the end (how many recent fires share current_fp)
2925    // and capture the most recent fp recorded. `next_ts` is the cursor: it starts
2926    // at `now` and only moves to a fire that was COUNTED, which is what collapses
2927    // a rapid burst into one observation.
2928    let mut consecutive: u64 = 0;
2929    let mut last_fp: Option<String> = None;
2930    let mut next_ts = now;
2931    let mut oldest_counted_ts: Option<DateTime<Utc>> = None;
2932    for line in content.lines().rev() {
2933        let Ok(val) = serde_json::from_str::<Value>(line) else {
2934            continue;
2935        };
2936        if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
2937            continue;
2938        }
2939        if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
2940            continue;
2941        }
2942        // US4: gh-errored fires are TRANSPARENT to the streak - they neither
2943        // advance nor reset the consecutive count (their recorded fp is just
2944        // a carry-forward, not an observation). After an outage clears, the
2945        // streak resumes from its pre-outage value (AC4-FR).
2946        if val
2947            .pointer("/data/fp_read_failed")
2948            .and_then(|v| v.as_bool())
2949            == Some(true)
2950        {
2951            continue;
2952        }
2953        let fp = val
2954            .pointer("/data/fingerprint")
2955            .and_then(|v| v.as_str())
2956            .unwrap_or("");
2957        // Capture the most recent fp (first match in reverse order)
2958        if last_fp.is_none() && !fp.is_empty() {
2959            last_fp = Some(fp.to_string());
2960        }
2961        // A CHANGED fingerprint breaks the streak at ANY spacing - progress is
2962        // never debounced. This check precedes the gap check on purpose.
2963        if fp != current_fp {
2964            break;
2965        }
2966        // Debounce. A fire we cannot place in time is skipped transparently
2967        // rather than counted: giving up on a parse error must fail AWAY from
2968        // an irreversible NoProgress, matching classify_bot_nudge's precedent.
2969        let Some(ts) = val
2970            .get("ts")
2971            .and_then(|v| v.as_str())
2972            .and_then(|s| s.parse::<DateTime<Utc>>().ok())
2973        else {
2974            continue;
2975        };
2976        let gap = (next_ts - ts).num_seconds();
2977        // gap < 0 means clock skew (a fire stamped after `now`); count it rather
2978        // than invent a debounce from a bad clock - status quo, no crash.
2979        if gap < 0 || gap >= min_gap_secs {
2980            consecutive += 1;
2981            next_ts = ts;
2982            oldest_counted_ts = Some(ts);
2983        }
2984        // else: same observation seen twice; skip WITHOUT advancing next_ts.
2985    }
2986
2987    let streak_window_secs = oldest_counted_ts
2988        .map(|t| (now - t).num_seconds().max(0))
2989        .unwrap_or(0);
2990
2991    (total, consecutive, last_fp, streak_window_secs)
2992}
2993
2994// ── event emission ────────────────────────────────────────────────────────────
2995
2996/// Envelope struct for target-stream events. Field order ts,type,source,data is
2997/// preserved because serde_json serializes struct fields in declaration order.
2998/// Method is named `append_loop_event` (NOT .emit / .emit_fields) so the
2999/// production-emit scanner test in lib.rs does not capture it and force
3000/// registration in KNOWN_EVENT_KINDS (which is the Branch B / fno-agents
3001/// daemon stream, not the target stream that these events belong to).
3002#[derive(Debug, Serialize)]
3003struct LoopEventEnvelope<'a> {
3004    ts: String,
3005    #[serde(rename = "type")]
3006    event_type: &'a str,
3007    source: &'static str,
3008    data: serde_json::Value,
3009}
3010
3011// pub(crate): the `finalize` verb (step 6, ab-f8e5f214) reuses this so its
3012// `session_finalized` events carry the identical RFC3339 timestamp shape.
3013pub(crate) fn now_rfc3339_utc() -> String {
3014    // Seconds precision, Z suffix, as required by the envelope spec.
3015    let now = chrono::Utc::now();
3016    now.format("%Y-%m-%dT%H:%M:%SZ").to_string()
3017}
3018
3019/// Append a target-stream event to a file (O_APPEND, create if missing).
3020/// Failure is loud on stderr but never fatal to the decision.
3021fn append_loop_event(path: &Path, event_type: &str, data: serde_json::Value) {
3022    let env = LoopEventEnvelope {
3023        ts: now_rfc3339_utc(),
3024        event_type,
3025        source: "hook",
3026        data,
3027    };
3028    let Ok(mut line) = serde_json::to_string(&env) else {
3029        eprintln!("loop-check: failed to serialize event {event_type}");
3030        return;
3031    };
3032    line.push('\n');
3033
3034    // Create parent dirs
3035    if let Some(parent) = path.parent() {
3036        let _ = std::fs::create_dir_all(parent);
3037    }
3038
3039    match std::fs::OpenOptions::new()
3040        .create(true)
3041        .append(true)
3042        .open(path)
3043    {
3044        Ok(mut f) => {
3045            if let Err(e) = f.write_all(line.as_bytes()) {
3046                eprintln!(
3047                    "loop-check: failed to write event {event_type} to {}: {e}",
3048                    path.display()
3049                );
3050            }
3051        }
3052        Err(e) => {
3053            eprintln!(
3054                "loop-check: failed to open events file {}: {e}",
3055                path.display()
3056            );
3057        }
3058    }
3059}
3060
3061/// Append to both project and global event logs.
3062///
3063/// pub(crate): the `finalize` verb (step 6, ab-f8e5f214) emits its
3064/// `session_finalized` / `session_finalize_failed` events through the same
3065/// writer so they land in both logs with the identical `{ts,type,source,data}`
3066/// envelope loop-check uses.
3067pub(crate) fn emit_to_both(
3068    project_events: &Path,
3069    global_events: &Path,
3070    event_type: &str,
3071    data: serde_json::Value,
3072) {
3073    append_loop_event(project_events, event_type, data.clone());
3074    if project_events != global_events {
3075        append_loop_event(global_events, event_type, data);
3076    }
3077}
3078
3079// ── cancel sentinel ───────────────────────────────────────────────────────────
3080
3081fn check_cancel_sentinel(cwd: &Path, created_at: &Option<String>) -> bool {
3082    let sentinel = cwd.join(".fno/.target-cancelled");
3083    let tombstone = cwd.join(".fno/.target-cancelled-final");
3084
3085    for path in &[&tombstone, &sentinel] {
3086        if !path.exists() {
3087            continue;
3088        }
3089        // Check mtime >= created_at
3090        if let Some(ca) = created_at {
3091            if let Ok(parsed_ca) = ca.parse::<DateTime<Utc>>() {
3092                if let Ok(meta) = std::fs::metadata(path) {
3093                    if let Ok(modified) = meta.modified() {
3094                        let sentinel_time: DateTime<Utc> = modified.into();
3095                        if sentinel_time >= parsed_ca {
3096                            return true;
3097                        }
3098                        // Stale sentinel (older than created_at) -> ignore
3099                        continue;
3100                    }
3101                }
3102            }
3103            // Can't read mtime -> treat as present (fail-closed)
3104            return true;
3105        }
3106        return true;
3107    }
3108    false
3109}
3110
3111// ── budget check ──────────────────────────────────────────────────────────────
3112
3113#[derive(Debug, PartialEq)]
3114enum BudgetTrip {
3115    WallClock,
3116    Cost,
3117}
3118
3119/// Resolve an `Option<Result<T, String>>` budget cap for use in check_budget.
3120/// - None => absent (no cap)
3121/// - Some(Ok(v)) => valid cap value
3122/// - Some(Err(raw)) => malformed: fail-closed, treat as cap exceeded immediately
3123enum ResolvedCap<T> {
3124    Absent,
3125    Valid(T),
3126    Malformed(String),
3127}
3128
3129fn resolve_cap<T: Copy>(cap: &Option<Result<T, String>>) -> ResolvedCap<T> {
3130    match cap {
3131        None => ResolvedCap::Absent,
3132        Some(Ok(v)) => ResolvedCap::Valid(*v),
3133        Some(Err(raw)) => ResolvedCap::Malformed(raw.clone()),
3134    }
3135}
3136
3137fn check_budget(
3138    manifest: &Manifest,
3139    settings: &Settings,
3140    now: &DateTime<Utc>,
3141    ledger_path: &Path,
3142) -> Option<BudgetTrip> {
3143    let attended = manifest.attended;
3144
3145    // Wall-clock cap: prefer manifest value, then settings
3146    let wall_cap = match resolve_cap(&manifest.budget_wall_clock_cap_minutes) {
3147        ResolvedCap::Absent => {
3148            if attended {
3149                resolve_cap(&settings.attended_wall_cap_minutes)
3150            } else {
3151                resolve_cap(&settings.unattended_wall_cap_minutes)
3152            }
3153        }
3154        other => other,
3155    };
3156
3157    match wall_cap {
3158        ResolvedCap::Malformed(raw) => {
3159            eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
3160            return Some(BudgetTrip::WallClock);
3161        }
3162        ResolvedCap::Valid(cap) => {
3163            if let Some(ca_str) = &manifest.created_at {
3164                if let Ok(created) = ca_str.parse::<DateTime<Utc>>() {
3165                    // Guard against negative elapsed (clock skew / future created_at)
3166                    let duration = now.signed_duration_since(created);
3167                    let elapsed_min = if duration.num_minutes() < 0 {
3168                        0u64
3169                    } else {
3170                        duration.num_minutes() as u64
3171                    };
3172                    if elapsed_min >= cap {
3173                        return Some(BudgetTrip::WallClock);
3174                    }
3175                }
3176            }
3177        }
3178        ResolvedCap::Absent => {}
3179    }
3180
3181    // Cost cap: prefer manifest value, then nested settings, then flat budget_cap
3182    let cost_cap = match resolve_cap(&manifest.budget_cost_cap_usd) {
3183        ResolvedCap::Absent => {
3184            let nested = if attended {
3185                resolve_cap(&settings.attended_cost_cap_usd)
3186            } else {
3187                resolve_cap(&settings.unattended_cost_cap_usd)
3188            };
3189            match nested {
3190                ResolvedCap::Absent => resolve_cap(&settings.flat_budget_cap),
3191                other => other,
3192            }
3193        }
3194        other => other,
3195    };
3196
3197    match cost_cap {
3198        ResolvedCap::Malformed(raw) => {
3199            eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
3200            Some(BudgetTrip::Cost)
3201        }
3202        ResolvedCap::Valid(cap) => {
3203            if let Some(session_id) = &manifest.session_id {
3204                let cost = session_cost_from_ledger(ledger_path, session_id);
3205                if cost >= cap {
3206                    return Some(BudgetTrip::Cost);
3207                }
3208            }
3209            None
3210        }
3211        ResolvedCap::Absent => None,
3212    }
3213}
3214
3215// ── main decision function ────────────────────────────────────────────────────
3216
3217/// CLI flags parsed for `loop-check`. The three required paths are
3218/// non-optional by construction (fu-4faa3d): `parse_args` validates them and
3219/// returns `Err` on absence, so downstream code cannot forget to check.
3220#[derive(Debug)]
3221struct LoopCheckArgs {
3222    state_path: PathBuf,
3223    transcript_path: PathBuf,
3224    cwd: PathBuf,
3225    /// Override for the GLOBAL settings file (default $HOME/.fno/
3226    /// settings.yaml). Tests point it at a nonexistent path for hermeticity.
3227    global_settings_path: Option<PathBuf>,
3228    events_path: Option<PathBuf>,
3229    global_events_path: Option<PathBuf>,
3230    settings_path: Option<PathBuf>,
3231    ledger_path: Option<PathBuf>,
3232    now_override: Option<String>,
3233    gh_bin: String,
3234    git_bin: String,
3235    /// When set, the full Stop-hook JSON payload is read from stdin so
3236    /// `last_assistant_message` becomes the primary intent channel
3237    /// (ab-223d2dae). Flag-gated so manual terminal invocations never hang
3238    /// on a stdin read.
3239    hook_input_stdin: bool,
3240}
3241
3242fn parse_args(args: &[String]) -> Result<LoopCheckArgs, String> {
3243    let mut state_path: Option<PathBuf> = None;
3244    let mut transcript_path: Option<PathBuf> = None;
3245    let mut cwd: Option<PathBuf> = None;
3246    let mut global_settings_path: Option<PathBuf> = None;
3247    let mut events_path: Option<PathBuf> = None;
3248    let mut global_events_path: Option<PathBuf> = None;
3249    let mut settings_path: Option<PathBuf> = None;
3250    let mut ledger_path: Option<PathBuf> = None;
3251    let mut now_override: Option<String> = None;
3252    let mut gh_bin = std::env::var("FNO_LOOPCHECK_GH_BIN").unwrap_or_else(|_| "gh".to_string());
3253    let mut git_bin = std::env::var("FNO_LOOPCHECK_GIT_BIN").unwrap_or_else(|_| "git".to_string());
3254    let mut hook_input_stdin = false;
3255
3256    // Skip the "loop-check" verb itself if present
3257    let args = if args.first().map(|s| s.as_str()) == Some("loop-check") {
3258        &args[1..]
3259    } else {
3260        args
3261    };
3262
3263    let mut i = 0;
3264    while i < args.len() {
3265        let arg = &args[i];
3266        // Support both --flag value and --flag=value forms. Unknown flags are
3267        // tolerated (AC5-FR: forward-compat for the shim).
3268        if let Some(val) = try_flag_value(arg, "--state", args, &mut i) {
3269            state_path = Some(PathBuf::from(val));
3270        } else if let Some(val) = try_flag_value(arg, "--transcript", args, &mut i) {
3271            transcript_path = Some(PathBuf::from(val));
3272        } else if let Some(val) = try_flag_value(arg, "--cwd", args, &mut i) {
3273            cwd = Some(PathBuf::from(val));
3274        } else if let Some(val) = try_flag_value(arg, "--events", args, &mut i) {
3275            events_path = Some(PathBuf::from(val));
3276        } else if let Some(val) = try_flag_value(arg, "--global-events", args, &mut i) {
3277            global_events_path = Some(PathBuf::from(val));
3278        } else if let Some(val) = try_flag_value(arg, "--settings", args, &mut i) {
3279            settings_path = Some(PathBuf::from(val));
3280        } else if let Some(val) = try_flag_value(arg, "--global-settings", args, &mut i) {
3281            global_settings_path = Some(PathBuf::from(val));
3282        } else if let Some(val) = try_flag_value(arg, "--ledger", args, &mut i) {
3283            ledger_path = Some(PathBuf::from(val));
3284        } else if let Some(val) = try_flag_value(arg, "--now", args, &mut i) {
3285            now_override = Some(val);
3286        } else if let Some(val) = try_flag_value(arg, "--gh-bin", args, &mut i) {
3287            gh_bin = val;
3288        } else if let Some(val) = try_flag_value(arg, "--git-bin", args, &mut i) {
3289            git_bin = val;
3290        } else if arg == "--hook-input-stdin" {
3291            // Bare boolean flag (no value): try_flag_value would consume the
3292            // next token as a value, so it is matched directly (ab-223d2dae).
3293            hook_input_stdin = true;
3294        }
3295        i += 1;
3296    }
3297
3298    // Required-flag validation lives here (AC5-ERR), not downstream in decide().
3299    let state_path = state_path.ok_or_else(|| "--state is required".to_string())?;
3300    let transcript_path = transcript_path.ok_or_else(|| "--transcript is required".to_string())?;
3301    let cwd = cwd.ok_or_else(|| "--cwd is required".to_string())?;
3302
3303    Ok(LoopCheckArgs {
3304        state_path,
3305        transcript_path,
3306        cwd,
3307        global_settings_path,
3308        events_path,
3309        global_events_path,
3310        settings_path,
3311        ledger_path,
3312        now_override,
3313        gh_bin,
3314        git_bin,
3315        hook_input_stdin,
3316    })
3317}
3318
3319fn try_flag_value(arg: &str, flag: &str, args: &[String], i: &mut usize) -> Option<String> {
3320    if arg == flag {
3321        *i += 1;
3322        args.get(*i).cloned()
3323    } else if let Some(val) = arg.strip_prefix(&format!("{flag}=")) {
3324        Some(val.to_string())
3325    } else {
3326        None
3327    }
3328}
3329
3330/// Core decision logic. Returns (exit_code, json_output).
3331/// Exit 0 always for allow/block; non-zero only for internal/CLI errors.
3332pub fn decide(args: &[String]) -> (i32, String) {
3333    // Missing required flags are CLI misuse: exit 2 with the same JSON error
3334    // shape the pre-refactor inline checks emitted (AC5-ERR).
3335    let parsed = match parse_args(args) {
3336        Ok(p) => p,
3337        Err(e) => {
3338            let out = serde_json::json!({ "error": e });
3339            return (2, out.to_string());
3340        }
3341    };
3342
3343    let state_path = parsed.state_path.clone();
3344    let transcript_path = parsed.transcript_path.clone();
3345    let cwd = parsed.cwd.clone();
3346
3347    // ab-223d2dae (A): the shim feeds the full Stop-hook JSON via stdin so
3348    // the stopping turn's final text (`last_assistant_message`, recomputed
3349    // per fire) is readable without racing the transcript flush. Read or
3350    // parse failures degrade to None (transcript fallback), never an error -
3351    // but a genuine I/O error is named on stderr (-> the shim's
3352    // loop-check.stderr.log) so a sustained stdin failure is separable from
3353    // an ordinary transcript-channel fire in the forensic trail.
3354    let last_assistant_message: Option<String> = if parsed.hook_input_stdin {
3355        match std::io::read_to_string(std::io::stdin()) {
3356            Ok(s) => extract_last_assistant_message(&s),
3357            Err(e) => {
3358                eprintln!(
3359                    "loop-check: failed to read hook input from stdin: {e}; falling back to transcript scan"
3360                );
3361                None
3362            }
3363        }
3364    } else {
3365        None
3366    };
3367
3368    // Parse manifest
3369    let manifest_content = match std::fs::read_to_string(&state_path) {
3370        Ok(c) => c,
3371        Err(e) => {
3372            eprintln!(
3373                "loop-check: cannot read state file {}: {e}",
3374                state_path.display()
3375            );
3376            let out = allow_output(
3377                "allow",
3378                None,
3379                "corrupt/missing manifest; allowing exit",
3380                0,
3381                None,
3382            );
3383            return (0, out);
3384        }
3385    };
3386
3387    let manifest = match parse_manifest(&manifest_content) {
3388        Some(m) => m,
3389        None => {
3390            eprintln!("loop-check: corrupt manifest (no frontmatter)");
3391            let out = allow_output(
3392                "allow",
3393                None,
3394                "corrupt manifest (no frontmatter); allowing exit",
3395                0,
3396                None,
3397            );
3398            return (0, out);
3399        }
3400    };
3401
3402    // Lease renewal (x-ba4b): keep this session's node claim fresh on every
3403    // stop, so a worker whose supervisor pid died mid-run (and now runs under a
3404    // new pid) never loses its claim to TTL expiry. Best-effort and non-fatal:
3405    // renew only bumps expires_at when the on-disk holder still matches, so it
3406    // can never steal, and any failure is a warning that just shortens the lease
3407    // (the loop never blocks on it). The claim key/holder/ttl are APPENDED after
3408    // the frontmatter by `fno target init`, so scan the whole manifest for them
3409    // (parse_manifest is frontmatter-bounded and would miss them). Root=None
3410    // routes node:<id> to the global claims root inside renew.
3411    if let (Some(key), Some(holder)) = (
3412        scan_manifest_field(&manifest_content, "target_claim_key"),
3413        scan_manifest_field(&manifest_content, "target_claim_holder"),
3414    ) {
3415        // Renew for the SAME window the claim was acquired with (default 2h,
3416        // matching init's `_CLAIM_TTL`), so the deadline never grows.
3417        let ttl_ms = scan_manifest_field(&manifest_content, "target_claim_ttl")
3418            .and_then(|s| crate::claims::parse_ttl_ms(&s))
3419            .unwrap_or(7_200_000);
3420        match crate::claims::renew(&key, &holder, ttl_ms, None) {
3421            Ok(_) => {}
3422            Err(e) => eprintln!("loop-check: lease renewal for {key} failed (non-fatal): {e}"),
3423        }
3424    }
3425
3426    // Resolve paths
3427    let project_events = parsed
3428        .events_path
3429        .clone()
3430        .unwrap_or_else(|| cwd.join(".fno/events.jsonl"));
3431
3432    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
3433    let global_events = parsed
3434        .global_events_path
3435        .clone()
3436        .unwrap_or_else(|| PathBuf::from(&home).join(".fno/events.jsonl"));
3437
3438    let ledger_path = parsed
3439        .ledger_path
3440        .clone()
3441        .unwrap_or_else(|| cwd.join(".fno/ledger.json"));
3442
3443    // Parse settings: GLOBAL first, then overlay the project-local file's
3444    // populated fields (codex P1 on #447: budgets normally live in the
3445    // global file; a project-local settings.yaml with unrelated content
3446    // must not silently uncap the session). An explicit --settings path
3447    // replaces the merge entirely (tests rely on full isolation).
3448    //
3449    // x-81d9 (c): a genuinely unparseable settings.yaml fails CLOSED (the login
3450    // gate is pinned unsatisfiable) and emits loop_check_settings_unparseable,
3451    // rather than silently zeroing the required bots and shipping unreviewed.
3452    let parse_or_emit = |content: &str, path: &Path| -> Settings {
3453        match parse_settings_result(content) {
3454            Ok(s) => s,
3455            Err(e) => {
3456                eprintln!(
3457                    "loop-check: config.toml unparseable ({}): {e} - failing the login gate closed",
3458                    path.display()
3459                );
3460                emit_to_both(
3461                    &project_events,
3462                    &global_events,
3463                    "loop_check_settings_unparseable",
3464                    serde_json::json!({"path": path.display().to_string(), "error": e}),
3465                );
3466                fail_closed_settings()
3467            }
3468        }
3469    };
3470    let settings = if let Some(ref explicit) = parsed.settings_path {
3471        if let Ok(sc) = std::fs::read_to_string(explicit) {
3472            parse_or_emit(&sc, explicit)
3473        } else {
3474            Settings::default()
3475        }
3476    } else {
3477        let global_path = parsed
3478            .global_settings_path
3479            .clone()
3480            .unwrap_or_else(|| PathBuf::from(&home).join(".fno/config.toml"));
3481        let mut merged = std::fs::read_to_string(&global_path)
3482            .map(|sc| parse_or_emit(&sc, &global_path))
3483            .unwrap_or_default();
3484        let local_path = cwd.join(".fno/config.toml");
3485        if let Ok(sc) = std::fs::read_to_string(&local_path) {
3486            let local = parse_or_emit(&sc, &local_path);
3487            if local.attended_wall_cap_minutes.is_some() {
3488                merged.attended_wall_cap_minutes = local.attended_wall_cap_minutes;
3489            }
3490            if local.attended_cost_cap_usd.is_some() {
3491                merged.attended_cost_cap_usd = local.attended_cost_cap_usd;
3492            }
3493            if local.unattended_wall_cap_minutes.is_some() {
3494                merged.unattended_wall_cap_minutes = local.unattended_wall_cap_minutes;
3495            }
3496            if local.unattended_cost_cap_usd.is_some() {
3497                merged.unattended_cost_cap_usd = local.unattended_cost_cap_usd;
3498            }
3499            if local.flat_budget_cap.is_some() {
3500                merged.flat_budget_cap = local.flat_budget_cap;
3501            }
3502            if local.ci_declared_none {
3503                merged.ci_declared_none = true;
3504            }
3505            if !local.external_reviewers.is_empty() {
3506                merged.external_reviewers = local.external_reviewers;
3507            }
3508            if local.required_bots.is_some() {
3509                // Some([]) is a meaningful project-local override (declared
3510                // no-review-gate), so presence - not non-emptiness - wins.
3511                merged.required_bots = local.required_bots;
3512            }
3513            if local.github_apps.is_some() {
3514                merged.github_apps = local.github_apps;
3515            }
3516            if local.optional_apps.is_some() {
3517                merged.optional_apps = local.optional_apps;
3518            }
3519            if !local.reviewers.is_empty() {
3520                merged.reviewers = local.reviewers;
3521            }
3522            if !local.nudge_overrides.is_empty() {
3523                // Without this line a project-local `[review.nudge]` (including
3524                // `enabled = false`) is read from the GLOBAL file only and the
3525                // repo's own overrides vanish - loop-check would post a nudge a
3526                // repo explicitly opted out of. Same per-field-overlay trap the
3527                // done_probes line below documents.
3528                merged.nudge_overrides = local.nudge_overrides;
3529            }
3530            if !local.peers.is_empty() {
3531                merged.peers = local.peers;
3532            }
3533            if local.peer_identity.is_some() {
3534                merged.peer_identity = local.peer_identity;
3535            }
3536            if local.done_probes.is_some() {
3537                // Presence, not non-emptiness: a project-local `done_probes = []`
3538                // is a deliberate "this repo declares none", same rule as
3539                // required_bots. Omitting this line entirely is the silent
3540                // guardrail bypass this list keeps re-inviting - the field would
3541                // be read from the GLOBAL file only and the project's own gate
3542                // would never run.
3543                merged.done_probes = local.done_probes;
3544            }
3545        }
3546        merged
3547    };
3548
3549    // Resolve the must-have-reviewed list once (code default when unset). The
3550    // author harness (from the ambient env markers, shared with claims.rs) drives
3551    // the same-model peer guard (x-c2e7); None leaves the set unchanged.
3552    let author_harness = crate::claims::resolve_harness();
3553    let required_bots = resolved_required_bots_for_author(&settings, author_harness.as_deref());
3554    let mut required_reviewers = settings.reviewers.clone();
3555    for reviewer in resolved_local_peer_reviewers_for_author(&settings, author_harness.as_deref()) {
3556        if !required_reviewers.contains(&reviewer) {
3557            required_reviewers.push(reviewer);
3558        }
3559    }
3560    let optional_bots = resolved_optional_bots(&settings);
3561    let nudge_configs = resolved_nudge_configs(&settings);
3562
3563    // Now timestamp
3564    let now: DateTime<Utc> = if let Some(ref s) = parsed.now_override {
3565        s.parse().unwrap_or_else(|_| Utc::now())
3566    } else {
3567        Utc::now()
3568    };
3569
3570    let session_id = manifest
3571        .session_id
3572        .clone()
3573        .unwrap_or_else(|| "unknown".to_string());
3574    let emit = |event_type: &str, data: serde_json::Value| {
3575        emit_to_both(&project_events, &global_events, event_type, data);
3576    };
3577
3578    // ── Step 1: cancel sentinel ───────────────────────────────────────────────
3579    if check_cancel_sentinel(&cwd, &manifest.created_at) {
3580        emit(
3581            "termination",
3582            serde_json::json!({
3583                "session_id": session_id,
3584                "reason": "Interrupted",
3585                "message": "cancel sentinel present"
3586            }),
3587        );
3588        return (
3589            0,
3590            allow_output(
3591                "allow",
3592                Some(TerminationReason::Interrupted),
3593                "cancel sentinel present; exiting",
3594                0,
3595                None,
3596            ),
3597        );
3598    }
3599
3600    // ── Step 2: legacy terminal status ───────────────────────────────────────
3601    if let Some(ref status) = manifest.legacy_status {
3602        emit(
3603            "loop_check_legacy_manifest",
3604            serde_json::json!({
3605                "session_id": session_id,
3606                "status": status
3607            }),
3608        );
3609        return (
3610            0,
3611            allow_output(
3612                "allow",
3613                None,
3614                &format!("legacy manifest status={status}; allowing exit"),
3615                0,
3616                None,
3617            ),
3618        );
3619    }
3620
3621    // ── Step 3: budget check ──────────────────────────────────────────────────
3622    if let Some(trip) = check_budget(&manifest, &settings, &now, &ledger_path) {
3623        let axis = match &trip {
3624            BudgetTrip::WallClock => "wall_clock",
3625            BudgetTrip::Cost => "cost",
3626        };
3627        emit(
3628            "termination",
3629            serde_json::json!({
3630                "session_id": session_id,
3631                "reason": "Budget",
3632                "axis": axis,
3633                "message": format!("budget exceeded (axis={axis})")
3634            }),
3635        );
3636        return (
3637            0,
3638            allow_output(
3639                "allow",
3640                Some(TerminationReason::Budget),
3641                &format!("budget exceeded (axis={axis})"),
3642                0,
3643                None,
3644            ),
3645        );
3646    }
3647
3648    let generic = crate::delivery_completion::evaluate_manifest(
3649        &cwd,
3650        manifest.plan_path.as_deref(),
3651        &project_events,
3652    );
3653    // ── Check gh binary availability ──────────────────────────────────────────
3654    // Probe by attempting to spawn; if the binary doesn't exist at all (NotFound
3655    // error kind), treat as absent. Exit-code failures from valid gh commands
3656    // are handled per-read below as transient failures, not absence.
3657    let gh_bin = &parsed.gh_bin;
3658    let gh_available = {
3659        // Use a harmless read-only probe: `gh auth status` exits non-zero when
3660        // not logged in, but the binary IS present. We only care about
3661        // NotFound (binary missing from path entirely).
3662        match Command::new(gh_bin).arg("--version").output() {
3663            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
3664            Err(_) => false,
3665            Ok(_) => true, // any exit code: binary exists
3666        }
3667    };
3668
3669    if !gh_available
3670        && matches!(
3671            generic,
3672            crate::delivery_completion::DeliveryCompletion::Inactive
3673        )
3674    {
3675        if !manifest.attended && !manifest.advisory {
3676            // Unattended + no advisory + no gh -> Interrupted
3677            emit(
3678                "termination",
3679                serde_json::json!({
3680                    "session_id": session_id,
3681                    "reason": "Interrupted",
3682                    "message": "gh binary not found; unattended sessions require gh"
3683                }),
3684            );
3685            return (
3686                0,
3687                allow_output(
3688                    "allow",
3689                    Some(TerminationReason::Interrupted),
3690                    "gh binary not found; unattended sessions require gh",
3691                    0,
3692                    None,
3693                ),
3694            );
3695        }
3696        // Attended or declared advisory -> advisory mode (promise + budget only).
3697        // Budget was already checked above; honor intent here so a promise can
3698        // terminate an advisory session (AC5-ERR) - gh reads are impossible, so
3699        // the promise alone is the completion signal.
3700        emit(
3701            "loop_advisory_mode",
3702            serde_json::json!({
3703                "session_id": session_id,
3704                "attended": manifest.attended
3705            }),
3706        );
3707        let (advisory_intent, _advisory_intent_source) =
3708            detect_intent(last_assistant_message.as_deref(), &transcript_path);
3709        if let Intent::Aborted { ref reason } = advisory_intent {
3710            emit(
3711                "termination",
3712                serde_json::json!({
3713                    "session_id": session_id,
3714                    "reason": "Aborted",
3715                    "message": reason
3716                }),
3717            );
3718            return (
3719                0,
3720                allow_output(
3721                    "allow",
3722                    Some(TerminationReason::Aborted),
3723                    "aborted tag detected (advisory mode)",
3724                    0,
3725                    None,
3726                ),
3727            );
3728        }
3729        if advisory_intent == Intent::Promise {
3730            emit(
3731                "termination",
3732                serde_json::json!({
3733                    "session_id": session_id,
3734                    "reason": "DoneAdvisory",
3735                    "message": "promise accepted in advisory mode (gh unavailable)"
3736                }),
3737            );
3738            return (
3739                0,
3740                allow_output(
3741                    "allow",
3742                    Some(TerminationReason::DoneAdvisory),
3743                    "promise accepted in advisory mode (gh unavailable)",
3744                    0,
3745                    None,
3746                ),
3747            );
3748        }
3749        return (
3750            0,
3751            allow_output(
3752                "block",
3753                None,
3754                "gh binary not found; running in advisory mode (promise + budget only)",
3755                0,
3756                None,
3757            ),
3758        );
3759    }
3760
3761    // ── Step 4: intent + backstop ─────────────────────────────────────────────
3762    let (intent, intent_source) =
3763        detect_intent(last_assistant_message.as_deref(), &transcript_path);
3764    let git_bin = &parsed.git_bin;
3765    let head_sha = git_head_sha(git_bin, &cwd);
3766
3767    // Compute fingerprint from a quick PR state read (or "none" if no PR)
3768    // We do a lightweight fingerprint computation even when intent is None,
3769    // to check backstop.
3770    let backstop_n: u64 = if manifest.attended { 5 } else { 3 };
3771
3772    // Read PR info for fingerprint.
3773    // On a hard gh failure (spawn error, non-zero exit, unparseable JSON), carry
3774    // forward the most recent prior fingerprint so the consecutive-unchanged streak
3775    // continues instead of resetting to "none|none|none" which would mask NoProgress.
3776    // fp_read_failed is recorded in the event payload for observability.
3777    let fp_read_result = Command::new(gh_bin)
3778        .args(["pr", "view", "--json", "state,number,headRefName"])
3779        .current_dir(&cwd)
3780        .output();
3781    let (fp_pr_state, fp_ci, fp_review_ts, fp_read_failed) = match fp_read_result {
3782        Ok(o) if o.status.success() => {
3783            let pv: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
3784            let state =
3785                PrState::from_gh_str(pv.get("state").and_then(|v| v.as_str()).unwrap_or("none"));
3786
3787            // Get CI
3788            let ci = match Command::new(gh_bin)
3789                .args(["pr", "checks", "--json", "name,state,bucket"])
3790                .current_dir(&cwd)
3791                .output()
3792            {
3793                Ok(co) if co.status.success() => {
3794                    let cv: Value = serde_json::from_slice(&co.stdout).unwrap_or(Value::Null);
3795                    compute_ci_conclusion(&cv).unwrap_or(CiConclusion::None)
3796                }
3797                _ => CiConclusion::None,
3798            };
3799
3800            // Get review ts (skipped for no_external sessions and declared
3801            // no-review repos, matching the done() Read 3/4 skip)
3802            let rv_ts = if !manifest.no_external && !required_bots.is_empty() {
3803                match Command::new(gh_bin)
3804                    .args(["pr", "view", "--json", "reviews,comments"])
3805                    .current_dir(&cwd)
3806                    .output()
3807                {
3808                    Ok(ro) if ro.status.success() => {
3809                        let rv: Value = serde_json::from_slice(&ro.stdout).unwrap_or(Value::Null);
3810                        compute_review_info(&rv, &required_bots).latest_ts
3811                    }
3812                    _ => "none".to_string(),
3813                }
3814            } else {
3815                "none".to_string()
3816            };
3817
3818            (state, ci, rv_ts, false)
3819        }
3820        // No PR yet: a healthy fire with a "none" fingerprint (world-state,
3821        // not an outage) - the backstop keeps ticking for a session that
3822        // never ships a PR.
3823        Ok(o) if is_no_pr_stderr(&o.stderr) => {
3824            (PrState::None, CiConclusion::None, "none".to_string(), false)
3825        }
3826        // Hard gh failure (spawn error OR non-zero exit): mark as failed; we will
3827        // carry forward the prior fingerprint after reading the events log.
3828        _ => (PrState::None, CiConclusion::None, "none".to_string(), true),
3829    };
3830
3831    // Build a tentative fingerprint from this fire's gh reads.
3832    let tentative_fp = generic.delivery_fingerprint(make_fingerprint(
3833        &head_sha,
3834        fp_pr_state.as_str(),
3835        &fp_ci.render(),
3836        &fp_review_ts,
3837    ));
3838
3839    // Read prior fires. We pass the tentative_fp for streak counting; if the gh
3840    // read failed we'll override the fingerprint with the carried-forward value below.
3841    let min_fire_gap = min_fire_gap_secs();
3842    let (prior_fires, consecutive_unchanged, last_recorded_fp, streak_window) = read_prior_fires(
3843        &project_events,
3844        &session_id,
3845        &tentative_fp,
3846        now,
3847        min_fire_gap,
3848    );
3849
3850    // If the pre-read gh call hard-failed, carry forward the prior fingerprint
3851    // (so the streak continues) rather than resetting to "none|none|none".
3852    let fingerprint = if fp_read_failed && !generic.is_active() {
3853        last_recorded_fp.unwrap_or(tentative_fp)
3854    } else {
3855        tentative_fp
3856    };
3857
3858    // Recount consecutive streak with the (possibly carried-forward) fingerprint.
3859    // We already counted against the tentative_fp; if different, recount from the log.
3860    let (consecutive_unchanged, streak_window) = if fp_read_failed && !generic.is_active() {
3861        // Re-read the streak against the carried-forward fingerprint.
3862        let (_, streak, _, window) = read_prior_fires(
3863            &project_events,
3864            &session_id,
3865            &fingerprint,
3866            now,
3867            min_fire_gap,
3868        );
3869        (streak, window)
3870    } else {
3871        (consecutive_unchanged, streak_window)
3872    };
3873
3874    let this_fire = prior_fires + 1;
3875    // consecutive_unchanged counts prior identical fires; adding this fire.
3876    // US4: a gh-errored fire is itself transparent - the count holds at its
3877    // prior value instead of advancing (AC4-HP).
3878    let consecutive_after = if fp_read_failed {
3879        consecutive_unchanged
3880    } else {
3881        consecutive_unchanged + 1
3882    };
3883
3884    let backstop_tripped = consecutive_after >= backstop_n;
3885
3886    // D (ab-223d2dae): probe done() after MUTE_PROBE_N unchanged mute fires
3887    // instead of waiting out the full backstop streak. A done-but-mute
3888    // session (all reads pass, no promise as final text) now resolves as a
3889    // late DonePRGreen in ~2 fires instead of 5/3 - the post-wedge events
3890    // audit counted 337 backstop fires, i.e. ~1000 no-op confirmation laps.
3891    // NoProgress still requires the full backstop_n streak (unchanged below),
3892    // so the grilled-9 backstop semantics are intact; a probed fire whose
3893    // done() fails simply blocks with the named reason.
3894    const MUTE_PROBE_N: u64 = 2;
3895
3896    let node_id = scan_manifest_field(&manifest_content, "graph_node_id").or_else(|| {
3897        scan_manifest_field(&manifest_content, "target_claim_key")
3898            .and_then(|k| k.strip_prefix("node:").map(|s| s.to_string()))
3899    });
3900    let (open_findings, malformed_findings) = match node_id.as_deref() {
3901        Some(n) => open_review_findings(&project_events, n),
3902        None => (Vec::new(), 0),
3903    };
3904    if malformed_findings > 0 {
3905        emit(
3906            "loop_check_malformed_finding",
3907            serde_json::json!({
3908                "session_id": session_id,
3909                "node": node_id,
3910                "malformed_lines": malformed_findings
3911            }),
3912        );
3913    }
3914
3915    // Run done() on active generic delivery, intent, backstop, or mute-probe; malformed findings cannot block.
3916    if generic.is_active()
3917        || intent != Intent::None
3918        || backstop_tripped
3919        || consecutive_after >= MUTE_PROBE_N
3920    {
3921        // Handle aborted first
3922        if let Intent::Aborted { ref reason } = intent {
3923            emit(
3924                "termination",
3925                serde_json::json!({
3926                    "session_id": session_id,
3927                    "reason": "Aborted",
3928                    "message": reason
3929                }),
3930            );
3931            emit(
3932                "loop_check",
3933                serde_json::json!({
3934                    "session_id": session_id,
3935                    "fingerprint": fingerprint,
3936                    "fires": this_fire,
3937                    "consecutive_unchanged": consecutive_after,
3938                    "streak_window_secs": streak_window,
3939                    "decision": "allow",
3940                    "intent": "aborted",
3941                    "intent_source": intent_source,
3942                    "pr_state": fp_pr_state.as_str(),
3943                    "ci": fp_ci.render(),
3944                    "reviewed": false,
3945                    "fp_read_failed": fp_read_failed
3946                }),
3947            );
3948            return (
3949                0,
3950                allow_output(
3951                    "allow",
3952                    Some(TerminationReason::Aborted),
3953                    "aborted tag detected",
3954                    this_fire,
3955                    Some(fingerprint),
3956                ),
3957            );
3958        }
3959
3960        // Operator review-finding gate (x-f8d4, Locked Decision 3): an open
3961        // review_finding for this node HOLDS every success terminal-allow
3962        // (DonePlanned / DoneAdvisory / DoneDelivery / DoneBatched / DonePRGreen) until an
3963        // explicit resolve - a promise cannot self-authorize past an operator's
3964        // open comment. Placed AFTER the Aborted arm and gated on
3965        // `!backstop_tripped` so the anti-wedge safety valves still win: an
3966        // Aborted tag exits, and once the NoProgress backstop streak is reached
3967        // the session gives up rather than looping forever on an unresolved
3968        // finding. Fires on a promise OR a mute-probe (the paths that would
3969        // otherwise terminate-allow), never on an ordinary working fire.
3970        if !open_findings.is_empty()
3971            && !backstop_tripped
3972            && (intent == Intent::Promise || consecutive_after >= MUTE_PROBE_N)
3973        {
3974            let reason = build_findings_block_reason(&open_findings, malformed_findings);
3975            emit(
3976                "loop_check",
3977                serde_json::json!({
3978                    "session_id": session_id,
3979                    "fingerprint": fingerprint,
3980                    "fires": this_fire,
3981                    "consecutive_unchanged": consecutive_after,
3982                    "streak_window_secs": streak_window,
3983                    "decision": "block",
3984                    "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
3985                    "intent_source": intent_source,
3986                    "pr_state": fp_pr_state.as_str(),
3987                    "ci": fp_ci.render(),
3988                    "reviewed": false,
3989                    "open_findings": open_findings.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
3990                    "malformed_findings": malformed_findings,
3991                    "fp_read_failed": fp_read_failed
3992                }),
3993            );
3994            return (
3995                0,
3996                allow_output("block", None, &reason, this_fire, Some(fingerprint)),
3997            );
3998        }
3999
4000        if let Some(output) = crate::delivery_completion::gate_output(
4001            &generic,
4002            intent == Intent::Promise,
4003            &project_events,
4004            &global_events,
4005            &session_id,
4006            manifest.session_id.as_deref(),
4007            node_id.as_deref(),
4008            intent_source,
4009            &fingerprint,
4010            this_fire,
4011            backstop_tripped,
4012            consecutive_after,
4013            streak_window,
4014            fp_pr_state.as_str(),
4015            &fp_ci.render(),
4016        ) {
4017            return (0, output);
4018        }
4019
4020        // Plan-only unit: a plan-only thread reached the plan boundary. Checked
4021        // BEFORE the advisory unit because DoneAdvisory is a ship reason (it
4022        // graduates the plan) and a plan-only thread must not graduate its own
4023        // plan. DonePlanned is benign: not a ship reason, not a postmortem.
4024        if manifest.planned && intent == Intent::Promise {
4025            emit(
4026                "termination",
4027                serde_json::json!({
4028                    "session_id": session_id,
4029                    "reason": "DonePlanned",
4030                    "message": "promise in plan-only unit"
4031                }),
4032            );
4033            emit(
4034                "loop_check",
4035                serde_json::json!({
4036                    "session_id": session_id,
4037                    "fingerprint": fingerprint,
4038                    "fires": this_fire,
4039                    "consecutive_unchanged": consecutive_after,
4040                    "streak_window_secs": streak_window,
4041                    "decision": "allow",
4042                    "intent": "promise",
4043                    "intent_source": intent_source,
4044                    "pr_state": fp_pr_state.as_str(),
4045                    "ci": fp_ci.render(),
4046                    "reviewed": true,
4047                    "fp_read_failed": fp_read_failed
4048                }),
4049            );
4050            return (
4051                0,
4052                allow_output(
4053                    "allow",
4054                    Some(TerminationReason::DonePlanned),
4055                    "promise + plan-only unit; done",
4056                    this_fire,
4057                    Some(fingerprint),
4058                ),
4059            );
4060        }
4061
4062        // Advisory unit (no_ship or manifest advisory)
4063        if (manifest.no_ship || manifest.advisory) && intent == Intent::Promise {
4064            emit(
4065                "termination",
4066                serde_json::json!({
4067                    "session_id": session_id,
4068                    "reason": "DoneAdvisory",
4069                    "message": "promise in advisory/no_ship unit"
4070                }),
4071            );
4072            emit(
4073                "loop_check",
4074                serde_json::json!({
4075                    "session_id": session_id,
4076                    "fingerprint": fingerprint,
4077                    "fires": this_fire,
4078                    "consecutive_unchanged": consecutive_after,
4079                    "streak_window_secs": streak_window,
4080                    "decision": "allow",
4081                    "intent": "promise",
4082                    "intent_source": intent_source,
4083                    "pr_state": fp_pr_state.as_str(),
4084                    "ci": fp_ci.render(),
4085                    "reviewed": true,
4086                    "fp_read_failed": fp_read_failed
4087                }),
4088            );
4089            return (
4090                0,
4091                allow_output(
4092                    "allow",
4093                    Some(TerminationReason::DoneAdvisory),
4094                    "promise + advisory unit; done",
4095                    this_fire,
4096                    Some(fingerprint),
4097                ),
4098            );
4099        }
4100
4101        // Batched unit (batch-lane Wave 2/3): the node's commits live on a
4102        // shared batch branch and ship via the batch PR, not its own, so
4103        // run_done() below would block forever waiting for a per-node PR that
4104        // never comes. The daemon set `batched: true` at dispatch; a promise
4105        // here means the member finished committing to the shared branch.
4106        // Terminal as DoneBatched - deliberately NOT a ship reason, so finalize
4107        // records the ledger entry but does NOT stamp/graduate the plan (the
4108        // batch's own `/pr create` graduates it once, for all members). Comes
4109        // AFTER the advisory arm (a batched unit is not advisory: it sets
4110        // neither no_ship nor advisory) and BEFORE run_done so no PR is polled.
4111        if manifest.batched && intent == Intent::Promise {
4112            emit(
4113                "termination",
4114                serde_json::json!({
4115                    "session_id": session_id,
4116                    "reason": "DoneBatched",
4117                    "message": "promise in batched unit; commit landed on shared branch"
4118                }),
4119            );
4120            emit(
4121                "loop_check",
4122                serde_json::json!({
4123                    "session_id": session_id,
4124                    "fingerprint": fingerprint,
4125                    "fires": this_fire,
4126                    "consecutive_unchanged": consecutive_after,
4127                    "streak_window_secs": streak_window,
4128                    "decision": "allow",
4129                    "intent": "promise",
4130                    "intent_source": intent_source,
4131                    "pr_state": fp_pr_state.as_str(),
4132                    "ci": fp_ci.render(),
4133                    "reviewed": true,
4134                    "fp_read_failed": fp_read_failed
4135                }),
4136            );
4137            return (
4138                0,
4139                allow_output(
4140                    "allow",
4141                    Some(TerminationReason::DoneBatched),
4142                    "promise + batched unit; commit on shared branch, batch PR ships it",
4143                    this_fire,
4144                    Some(fingerprint),
4145                ),
4146            );
4147        }
4148
4149        // Run done() for code units
4150        let done_result = run_done(
4151            gh_bin,
4152            &cwd,
4153            settings.ci_declared_none,
4154            manifest.no_external,
4155            &required_bots,
4156            &optional_bots,
4157            &settings.external_reviewers,
4158            &required_reviewers,
4159            &nudge_configs,
4160            &head_sha,
4161            &project_events,
4162        );
4163
4164        match done_result {
4165            Ok(mut pr_info) => {
4166                // Read 4's newest activity timestamp folds into the
4167                // fingerprint's 4th component: a late inline finding advances
4168                // the fingerprint (re-block, not NoProgress - the codex
4169                // findings-minutes-after-summary shape). State/CI components
4170                // stay on the pre-read basis so quiet fires stay comparable.
4171                // Skipped entirely when the pre-read failed: its stale
4172                // none|none components would leak into done_fp and manufacture
4173                // a fingerprint change on a fire US4 declares transparent
4174                // (sigma-review finding on this branch).
4175                let (fingerprint, consecutive_after, streak_window) = if !fp_read_failed {
4176                    let done_fp = make_fingerprint(
4177                        &head_sha,
4178                        fp_pr_state.as_str(),
4179                        &fp_ci.render(),
4180                        &max_ts(&fp_review_ts, &pr_info.latest_review_ts),
4181                    );
4182                    if done_fp != fingerprint {
4183                        let (_, streak, _, window) = read_prior_fires(
4184                            &project_events,
4185                            &session_id,
4186                            &done_fp,
4187                            now,
4188                            min_fire_gap,
4189                        );
4190                        (done_fp, streak + 1, window)
4191                    } else {
4192                        (fingerprint, consecutive_after, streak_window)
4193                    }
4194                } else {
4195                    (fingerprint, consecutive_after, streak_window)
4196                };
4197                let backstop_tripped = consecutive_after >= backstop_n;
4198
4199                // x-b167 section 5: post the trigger for any NeedsNudge bot ONCE,
4200                // then treat it as Awaiting for this fire's messaging + idle read.
4201                // A NeedsNudge state means !reviewed, so no terminal below can
4202                // fire (they require reviewed=true); posting here is safe. A
4203                // failed post keeps NeedsNudge so the block message tells the
4204                // agent to post by hand (AC11) and the count is unchanged - a
4205                // failed post is never counted as a nudge.
4206                let nudge_pr_number = pr_info.number;
4207                for n in pr_info.bot_nudges.iter_mut() {
4208                    if n.class != NudgeClass::NeedsNudge {
4209                        continue;
4210                    }
4211                    if post_nudge_comment(gh_bin, &cwd, nudge_pr_number, &n.review_handle) {
4212                        emit(
4213                            "loop_check_nudge_posted",
4214                            serde_json::json!({
4215                                "session_id": session_id,
4216                                "pr": nudge_pr_number,
4217                                "bot": n.login,
4218                                "handle": n.review_handle,
4219                                "nudge": n.nudges + 1,
4220                                "ceiling": n.ceiling
4221                            }),
4222                        );
4223                        n.nudges += 1;
4224                        n.newest_age_min = 0;
4225                        n.class = NudgeClass::Awaiting;
4226                    } else {
4227                        emit(
4228                            "loop_check_nudge_post_failed",
4229                            serde_json::json!({
4230                                "session_id": session_id,
4231                                "pr": nudge_pr_number,
4232                                "bot": n.login,
4233                                "handle": n.review_handle
4234                            }),
4235                        );
4236                    }
4237                }
4238
4239                let ci_ok = pr_info.ci_conclusion.is_ok();
4240                let pr_open = pr_info.state.is_open_or_merged();
4241                // codex P1 on #447: a green PR must also contain the local
4242                // HEAD - otherwise unpushed work terminates as DonePRGreen
4243                // without ever shipping. MERGED PRs are exempt only when the
4244                // local HEAD matches too; an unpushed commit on top of a
4245                // merged PR is still unshipped work.
4246                let head_shipped = !pr_info.head_oid.is_empty() && pr_info.head_oid == head_sha;
4247
4248                // done_probes: the FINAL DonePRGreen conjunct. Gated on
4249                // every other conjunct already holding, so a plan with no probes
4250                // spawns no subprocess and a red/unreviewed PR never pays for one.
4251                let (mut probe_block, mut probe_results) = (None, Value::Null);
4252                if pr_open && ci_ok && pr_info.reviewed && head_shipped {
4253                    match evaluate_done_probes(
4254                        manifest.plan_path.as_deref(),
4255                        settings.done_probes.as_ref(),
4256                        &cwd,
4257                        &project_events,
4258                        &session_id,
4259                        PROBE_TIMEOUT,
4260                    ) {
4261                        ProbeGate::Absent => {}
4262                        ProbeGate::Pass(results) => probe_results = results,
4263                        ProbeGate::Fail { reason, results } => {
4264                            probe_block = Some(reason);
4265                            probe_results = results;
4266                        }
4267                    }
4268                }
4269
4270                let (reviewed, probes_passed) = (pr_info.reviewed, probe_block.is_none());
4271                if pr_passes(pr_open, ci_ok, reviewed, head_shipped, probes_passed) {
4272                    // AC1-UI: name any rate-limited bot the gate proceeded
4273                    // without, so the terminal message and the emitted event
4274                    // agree on why a required bot is absent from the evidence.
4275                    let done_msg = if pr_info.usage_limited.is_empty() {
4276                        format!("PR #{} is green and reviewed", pr_info.number)
4277                    } else {
4278                        format!(
4279                            "PR #{} is green and reviewed (rate-limited, dropped from gate: {})",
4280                            pr_info.number,
4281                            pr_info.usage_limited.join(", ")
4282                        )
4283                    };
4284                    emit(
4285                        "termination",
4286                        serde_json::json!({
4287                            "session_id": session_id,
4288                            "reason": "DonePRGreen",
4289                            "message": done_msg.clone()
4290                        }),
4291                    );
4292                    emit(
4293                        "loop_check",
4294                        serde_json::json!({
4295                            "session_id": session_id,
4296                            "fingerprint": fingerprint,
4297                            "fires": this_fire,
4298                            "consecutive_unchanged": consecutive_after,
4299                            "streak_window_secs": streak_window,
4300                            "decision": "allow",
4301                            "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
4302                            "intent_source": intent_source,
4303                            "pr_state": pr_info.state.as_str(),
4304                            "ci": pr_info.ci_conclusion.render(),
4305                            "reviewed": pr_info.reviewed,
4306                            "review_skipped": pr_info.review_skipped,
4307                            "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4308                            "fp_read_failed": fp_read_failed,
4309                            "done_probes": probe_results
4310                        }),
4311                    );
4312                    return (
4313                        0,
4314                        allow_output(
4315                            "allow",
4316                            Some(TerminationReason::DonePRGreen),
4317                            &done_msg,
4318                            this_fire,
4319                            Some(fingerprint),
4320                        ),
4321                    );
4322                }
4323
4324                // DoneAwaitingMerge: done() failed SOLELY on CI-green
4325                // (PR open, reviewed, HEAD shipped, but CI red). Reached only
4326                // when !ci_ok because the DonePRGreen arm above returned - so
4327                // DonePRGreen precedence holds, and a merge that flipped the PR
4328                // green would have been caught by the fresh run_done this fire
4329                // (AC1-FR). If current main HEAD is red on the SAME checks
4330                // (strict subset, check-name granularity), a bg agent cannot
4331                // merge past it: terminate clean with a one-shot notify instead
4332                // of burning to NoProgress. Any PR-unique red or any gh
4333                // uncertainty falls through to the hold below (fail closed).
4334                //
4335                // `!pr_info.ci_has_pending` is load-bearing: ci_conclusion
4336                // reports Failure as soon as ONE check fails while others still
4337                // run, so without this guard the terminal could fire on a
4338                // partial-CI fire where the session's OWN new job is still
4339                // pending and about to turn red. The terminal must see fully
4340                // settled-red CI, never partial.
4341                //
4342                // `mergeable != "CONFLICTING"` guards a reviewed PR whose branch
4343                // conflicts with main: the human cannot merge past main-red until
4344                // it is rebased, so terminating here would drop the node from
4345                // retry circulation while it is un-mergeable. UNKNOWN (still
4346                // computing) is allowed - it clears on its own.
4347                if pr_open
4348                    && pr_info.reviewed
4349                    && head_shipped
4350                    && !ci_ok
4351                    && !pr_info.ci_has_pending
4352                    && pr_info.mergeable != "CONFLICTING"
4353                {
4354                    if let Some(main_failing) =
4355                        main_head_failing_checks(gh_bin, &cwd, MAIN_RUN_LOOKBACK)
4356                    {
4357                        if is_pre_existing_main_red(&pr_info.failing_checks, &main_failing) {
4358                            let proof = format!(
4359                                "same checks red on main (last {} completed runs): {}",
4360                                MAIN_RUN_LOOKBACK,
4361                                pr_info.failing_checks.join(", ")
4362                            );
4363                            let msg = format!(
4364                                "PR #{} complete and reviewed; awaiting merge past pre-existing main-red ({proof})",
4365                                pr_info.number
4366                            );
4367                            // Idempotency (Concurrency AC): emit + notify at most
4368                            // once per session; a re-eval or the two consumers
4369                            // racing still returns the terminal but does not
4370                            // double-notify.
4371                            if !already_emitted_awaiting_merge(&project_events, &session_id) {
4372                                emit(
4373                                    "termination",
4374                                    serde_json::json!({
4375                                        "session_id": session_id,
4376                                        "reason": "DoneAwaitingMerge",
4377                                        "message": msg.clone()
4378                                    }),
4379                                );
4380                                emit(
4381                                    "loop_check",
4382                                    serde_json::json!({
4383                                        "session_id": session_id,
4384                                        "fingerprint": fingerprint,
4385                                        "fires": this_fire,
4386                                        "consecutive_unchanged": consecutive_after,
4387                                        "streak_window_secs": streak_window,
4388                                        "decision": "allow",
4389                                        "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
4390                                        "intent_source": intent_source,
4391                                        "pr_state": pr_info.state.as_str(),
4392                                        "ci": pr_info.ci_conclusion.render(),
4393                                        "reviewed": pr_info.reviewed,
4394                                        "review_skipped": pr_info.review_skipped,
4395                                        "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4396                                        "fp_read_failed": fp_read_failed
4397                                    }),
4398                                );
4399                                best_effort_notify(
4400                                    &format!(
4401                                        "PR #{} ready - merge past pre-existing main-red",
4402                                        pr_info.number
4403                                    ),
4404                                    &msg,
4405                                );
4406                            }
4407                            return (
4408                                0,
4409                                allow_output(
4410                                    "allow",
4411                                    Some(TerminationReason::DoneAwaitingMerge),
4412                                    &msg,
4413                                    this_fire,
4414                                    Some(fingerprint),
4415                                ),
4416                            );
4417                        }
4418                    }
4419                }
4420
4421                // ── Watching idle-allow (x-e2c8) ─────────────────────────────
4422                // A verified async wait (CI pending or awaiting a bot review,
4423                // head pushed, zero unaddressed findings) plus an agent-armed
4424                // <watching> tag idles NON-terminally: the harness re-invokes the
4425                // model when the agent's watcher task exits, so re-blocking every
4426                // ~90s tick until then is pure no-op overhead. done() and every
4427                // terminal above already ran (a terminal always beats an idle),
4428                // and this sits BEFORE the NoProgress backstop so a long watched
4429                // wait degrades to budget/claim-expiry, never a spurious kill.
4430                if let Intent::Watching {
4431                    ref reason,
4432                    ref timeout,
4433                    ..
4434                } = intent
4435                {
4436                    // Harness + substrate gate: only a Claude session self-wakes
4437                    // on a background-task exit, and a `fno-agents loop run` child
4438                    // (FNO_DRIVER_LIB, the same discriminator terminal_stop.rs
4439                    // uses) exits on allow. codex/gemini keep today's block
4440                    // behavior until their daemon-consumer waker ships (AC1-EDGE).
4441                    let blocker = if harness_can_idle(
4442                        author_harness.as_deref(),
4443                        std::env::var("FNO_DRIVER_LIB").is_ok(),
4444                    ) {
4445                        async_wait_class(&pr_info, &head_sha, open_findings.is_empty())
4446                    } else {
4447                        None
4448                    };
4449                    if let Some(blocker) = blocker {
4450                        // Extend the node claim to cover the watch window BEFORE
4451                        // idling, or the idle opens a dispatcher-stampede gap.
4452                        // Renewal MUST pass an explicit --ttl (a default refresh
4453                        // shrinks the lease to 1min) and MUST return Ok(true)
4454                        // (holder match); anything else blocks (AC3-ERR).
4455                        let window_ms = watch_window_ms(timeout.as_deref());
4456                        let renewed = match (
4457                            scan_manifest_field(&manifest_content, "target_claim_key"),
4458                            scan_manifest_field(&manifest_content, "target_claim_holder"),
4459                        ) {
4460                            (Some(key), Some(holder)) => matches!(
4461                                crate::claims::renew(&key, &holder, window_ms, None),
4462                                Ok(true)
4463                            ),
4464                            _ => false,
4465                        };
4466                        if renewed {
4467                            emit(
4468                                "loop_check_watch_idle",
4469                                serde_json::json!({
4470                                    "session_id": session_id,
4471                                    "pr": pr_info.number,
4472                                    "blocker": blocker,
4473                                    "declared_timeout": timeout.clone().unwrap_or_default(),
4474                                    "reason": reason,
4475                                    "lease_ms": window_ms
4476                                }),
4477                            );
4478                            emit(
4479                                "loop_check",
4480                                serde_json::json!({
4481                                    "session_id": session_id,
4482                                    "fingerprint": fingerprint,
4483                                    "fires": this_fire,
4484                                    "consecutive_unchanged": consecutive_after,
4485                                    "streak_window_secs": streak_window,
4486                                    "decision": "allow",
4487                                    "intent": "watching",
4488                                    "intent_source": intent_source,
4489                                    "pr_state": pr_info.state.as_str(),
4490                                    "ci": pr_info.ci_conclusion.render(),
4491                                    "reviewed": pr_info.reviewed,
4492                                    "review_skipped": pr_info.review_skipped,
4493                                    "fp_read_failed": fp_read_failed
4494                                }),
4495                            );
4496                            let msg = format!(
4497                                "watching: idling until watcher fires (PR #{}, {blocker} pending)",
4498                                pr_info.number
4499                            );
4500                            return (
4501                                0,
4502                                allow_output("allow", None, &msg, this_fire, Some(fingerprint)),
4503                            );
4504                        }
4505                        // renewal failed / holder mismatch -> fall through to the
4506                        // block below (AC3-ERR): never idle without a lease.
4507                    }
4508                    // not async-wait class, or a loop-run child -> fall through:
4509                    // build_block_reason names the real blocker (AC1-ERR CI red,
4510                    // AC2-ERR head mismatch / finding).
4511                }
4512
4513                // x-b167: a freshly-posted nudge sits in Awaiting until
4514                // wait_minutes elapses. On a harness that cannot idle on a
4515                // `<watching>` tag (a loop-run child, codex/gemini, or a failed
4516                // lease renewal) the fingerprint is stable, so without this guard
4517                // the generic backstop reaps the wait after backstop_n fires -
4518                // before the nudge cycle reaches its ceiling, terminating with a
4519                // generic NoProgress instead of the named give-up. Suppress the
4520                // backstop ONLY when the sole unmet condition is a live Awaiting
4521                // nudge: it is self-limiting (Awaiting -> Unresponsive after
4522                // wait_minutes, when this guard clears and the backstop reaps it
4523                // naming the bot), and the narrow scope keeps CI red, a finding,
4524                // an unattested reviewer, or a failed probe tripping it as before.
4525                let sole_blocker_is_awaiting = pr_open
4526                    && ci_ok
4527                    && probe_block.is_none()
4528                    && !pr_info.reviewed
4529                    && pr_info.unattested_reviewers.is_empty()
4530                    && pr_info.unaddressed_findings.is_empty()
4531                    && pr_info
4532                        .bot_nudges
4533                        .iter()
4534                        .any(|n| n.class == NudgeClass::Awaiting);
4535                // `probe_block.is_some()` keeps a probe that can never pass in
4536                // this environment on the NoProgress escape rather than looping
4537                // to the budget ceiling: PR+CI+review all hold, so without it
4538                // none of the other disjuncts can ever fire.
4539                if backstop_tripped
4540                    && (!pr_open || !ci_ok || !pr_info.reviewed || probe_block.is_some())
4541                    && !sole_blocker_is_awaiting
4542                {
4543                    // Backstop tripped + done() false -> NoProgress. x-b167 AC13:
4544                    // when a nudged bot never answered, the operator's question is
4545                    // "is this going to finish, and must I do something" - so name
4546                    // the bot + nudge count + elapsed instead of a bare fingerprint
4547                    // streak, and reach the operator (who is not watching the pane)
4548                    // with exactly one notification.
4549                    let nudge_giveup = unresponsive_bot(&pr_info);
4550                    let noprogress_msg = match nudge_giveup {
4551                        Some(n) => nudge_giveup_message(n),
4552                        None => format!(
4553                            "fingerprint unchanged for {} consecutive fires over {}m; PR not done",
4554                            consecutive_after,
4555                            streak_window / 60
4556                        ),
4557                    };
4558                    if let Some(n) = nudge_giveup {
4559                        best_effort_notify(
4560                            "target: bot review gave up",
4561                            &format!(
4562                                "PR #{}: {} did not review after {} nudges over {}m",
4563                                pr_info.number, n.login, n.nudges, n.span_min
4564                            ),
4565                        );
4566                    }
4567                    // Backstop tripped + done() false -> NoProgress
4568                    emit(
4569                        "termination",
4570                        serde_json::json!({
4571                            "session_id": session_id,
4572                            "reason": "NoProgress",
4573                            "message": noprogress_msg
4574                        }),
4575                    );
4576                    emit(
4577                        "loop_check",
4578                        serde_json::json!({
4579                            "session_id": session_id,
4580                            "fingerprint": fingerprint,
4581                            "fires": this_fire,
4582                            "consecutive_unchanged": consecutive_after,
4583                            "streak_window_secs": streak_window,
4584                            "decision": "allow",
4585                            "intent": "backstop",
4586                            "intent_source": intent_source,
4587                            "pr_state": pr_info.state.as_str(),
4588                            "ci": pr_info.ci_conclusion.render(),
4589                            "reviewed": pr_info.reviewed,
4590                            "review_skipped": pr_info.review_skipped,
4591                            "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4592                            "fp_read_failed": fp_read_failed,
4593                            "done_probes": probe_results
4594                        }),
4595                    );
4596                    let return_msg = match nudge_giveup {
4597                        Some(_) => noprogress_msg.clone(),
4598                        None => format!(
4599                            "fingerprint unchanged for {} fires over {}m; HEAD={}, PR={}, CI={}, reviewed={}",
4600                            consecutive_after,
4601                            streak_window / 60,
4602                            short_sha(&head_sha),
4603                            pr_info.state.as_str(),
4604                            pr_info.ci_conclusion.render(),
4605                            pr_info.reviewed
4606                        ),
4607                    };
4608                    return (
4609                        0,
4610                        allow_output(
4611                            "allow",
4612                            Some(TerminationReason::NoProgress),
4613                            &return_msg,
4614                            this_fire,
4615                            Some(fingerprint),
4616                        ),
4617                    );
4618                }
4619
4620                // done() false on promise -> block with named reason. P2
4621                // (ab-098967b4): enrich with a loop-boundary inbox nudge.
4622                // A failed probe IS the blocker when everything else is green;
4623                // build_block_reason would otherwise report a healthy PR.
4624                let reason = crate::nudge::append_inbox_nudge(
4625                    &probe_block.clone().unwrap_or_else(|| {
4626                        build_block_reason(&pr_info, &head_sha, open_findings.is_empty())
4627                    }),
4628                    &cwd,
4629                    &session_id,
4630                );
4631                emit(
4632                    "loop_check",
4633                    serde_json::json!({
4634                        "session_id": session_id,
4635                        "fingerprint": fingerprint,
4636                        "fires": this_fire,
4637                        "consecutive_unchanged": consecutive_after,
4638                        "streak_window_secs": streak_window,
4639                        "decision": "block",
4640                        "intent": if intent == Intent::Promise { "promise" } else { "none" },
4641                        "intent_source": intent_source,
4642                        "pr_state": pr_info.state.as_str(),
4643                        "ci": pr_info.ci_conclusion.render(),
4644                        "reviewed": pr_info.reviewed,
4645                        "review_skipped": pr_info.review_skipped,
4646                        "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4647                        "fp_read_failed": fp_read_failed,
4648                        "done_probes": probe_results
4649                    }),
4650                );
4651                return (
4652                    0,
4653                    allow_output("block", None, &reason, this_fire, Some(fingerprint)),
4654                );
4655            }
4656            Err((failed_read, failed_stderr)) => {
4657                // US4 (locked decision 6, REVERSES the wedge's behavior): a
4658                // gh-errored done() read NEVER terminates NoProgress, even
4659                // with the backstop tripped - a healthy session must not be
4660                // killed because GitHub blipped. The fire blocks-and-retries
4661                // and is recorded fp_read_failed=true, keeping it transparent
4662                // to the streak. Budget remains the sole ceiling during a
4663                // sustained outage (AC4-EDGE; budget is checked before any
4664                // gh read, so the outage never makes a session immortal).
4665                emit(
4666                    "loop_check_gh_error",
4667                    serde_json::json!({
4668                        "session_id": session_id,
4669                        "read": failed_read,
4670                        "stderr_tail": failed_stderr
4671                    }),
4672                );
4673                emit(
4674                    "loop_check",
4675                    serde_json::json!({
4676                        "session_id": session_id,
4677                        "fingerprint": fingerprint,
4678                        "fires": this_fire,
4679                        "consecutive_unchanged": consecutive_after,
4680                        "streak_window_secs": streak_window,
4681                        "decision": "block",
4682                        "intent": if intent == Intent::Promise { "promise" } else { "none" },
4683                        "intent_source": intent_source,
4684                        "pr_state": "unknown",
4685                        "ci": "unknown",
4686                        "reviewed": false,
4687                        "fp_read_failed": true
4688                    }),
4689                );
4690                return (
4691                    0,
4692                    allow_output(
4693                        "block",
4694                        None,
4695                        &format!("gh read '{failed_read}' failed; retrying next fire"),
4696                        this_fire,
4697                        Some(fingerprint),
4698                    ),
4699                );
4700            }
4701        }
4702    }
4703
4704    // ── Step 5: no intent, no backstop -> block, record fingerprint ───────────
4705    emit(
4706        "loop_check",
4707        serde_json::json!({
4708            "session_id": session_id,
4709            "fingerprint": fingerprint,
4710            "fires": this_fire,
4711            "consecutive_unchanged": consecutive_after,
4712            "streak_window_secs": streak_window,
4713            "decision": "block",
4714            "intent": "none",
4715            "intent_source": intent_source,
4716            "pr_state": fp_pr_state.as_str(),
4717            "ci": fp_ci.render(),
4718            "reviewed": false,
4719            "fp_read_failed": fp_read_failed
4720        }),
4721    );
4722
4723    // P2 (ab-098967b4): the dominant loop-yield boundary. Enrich the continue
4724    // message with a one-line inbox nudge so an autonomous loop surfaces mail.
4725    let continue_msg = crate::nudge::append_inbox_nudge(
4726        "continue working; no completion signal. If you are only waiting on an async check (CI/review) with nothing to do, arm a harness-tracked watcher with a hard timeout (e.g. background Bash `gh pr checks <N> --watch & w=$!; (sleep 1800; kill $w 2>/dev/null) & wait $w`) and end your turn with `<watching reason=\"ci|review\" pr=\"<N>\" timeout=\"30m\">` - the session idles until the watcher exits instead of re-waking every tick.",
4727        &cwd,
4728        &session_id,
4729    );
4730    (
4731        0,
4732        allow_output("block", None, &continue_msg, this_fire, Some(fingerprint)),
4733    )
4734}
4735
4736#[allow(clippy::too_many_arguments)]
4737fn run_done(
4738    gh_bin: &str,
4739    cwd: &Path,
4740    ci_declared_none: bool,
4741    no_external: bool,
4742    required_bots: &[String],
4743    optional_bots: &[String],
4744    external_reviewers: &[String],
4745    reviewers: &[String],
4746    nudge_configs: &[NudgeConfig],
4747    head_sha: &str,
4748    events_path: &Path,
4749) -> Result<PrInfo, (String, String)> {
4750    read_pr_info(
4751        gh_bin,
4752        cwd,
4753        ci_declared_none,
4754        no_external,
4755        required_bots,
4756        optional_bots,
4757        external_reviewers,
4758        reviewers,
4759        nudge_configs,
4760        head_sha,
4761        events_path,
4762    )
4763}
4764
4765/// Slack added beyond the declared watch window so the claim lease outlives the
4766/// agent's watcher (x-e2c8): a watcher that fires right at its timeout must not
4767/// race claim expiry.
4768const WATCH_SLACK_MS: i64 = 12 * 60_000;
4769
4770/// Lease window for an idle watch: the declared timeout clamped to [5m, 2h]
4771/// (never trust the tag for an unbounded hold) plus slack. Defaults to 30m when
4772/// the tag omits or mangles `timeout`, giving the ~40m default lease.
4773fn watch_window_ms(timeout: Option<&str>) -> i64 {
4774    let declared = timeout
4775        .and_then(crate::claims::parse_ttl_ms)
4776        .unwrap_or(30 * 60_000);
4777    declared.clamp(5 * 60_000, 2 * 3_600_000) + WATCH_SLACK_MS
4778}
4779
4780/// Whether a session's harness + substrate can park-and-wake on a `<watching>`
4781/// idle (x-e2c8). Only a Claude session's harness-tracked background/Monitor
4782/// tasks re-invoke the model when they exit, so only Claude may idle. A
4783/// `fno-agents loop run` child exits on allow (FNO_DRIVER_LIB set), and
4784/// codex/gemini have no self-wake on background-task exit - their waker is the
4785/// fno-agents daemon consuming the watch event, shipped as a separate
4786/// live-verified follow-up - so all of those keep today's block behavior rather
4787/// than idling with nothing to wake them (a dead watch). This is the design's
4788/// "unroutable harness -> status quo, never a dead watch" degradation.
4789fn harness_can_idle(author_harness: Option<&str>, is_loop_run_child: bool) -> bool {
4790    author_harness == Some("claude") && !is_loop_run_child
4791}
4792
4793/// Whether the PR is in the async-wait class a `<watching>` tag may idle on
4794/// (x-e2c8): PR open, local HEAD pushed, no unaddressed findings (inline OR
4795/// operator), and the sole remaining blocker is CI still pending or an
4796/// outstanding bot review. Returns the blocker label, or None if anything else
4797/// blocks. External truth only - the tag is a request, this is the authority.
4798fn async_wait_class(
4799    pr: &PrInfo,
4800    local_head: &str,
4801    open_findings_empty: bool,
4802) -> Option<&'static str> {
4803    let head_shipped = !pr.head_oid.is_empty() && pr.head_oid == local_head;
4804    if pr.state != PrState::Open
4805        || !head_shipped
4806        || !pr.unaddressed_findings.is_empty()
4807        || !open_findings_empty
4808    {
4809        return None;
4810    }
4811    // CI still pending AND nothing has concluded red yet: idle on CI. If a
4812    // check has ALREADY failed while others run, do NOT idle - the agent should
4813    // start debugging the failure now rather than wait out the rest (gemini).
4814    if pr.ci_has_pending && !matches!(pr.ci_conclusion, CiConclusion::Failure(_)) {
4815        return Some("ci");
4816    }
4817    // Awaiting an EXTERNAL bot review: a real GitHub login WILL post it, so
4818    // idling until it does is correct. `reviewed == false` with an EMPTY
4819    // missing_bots is instead a LOCAL-attestation gate (config.review.reviewers,
4820    // e.g. sigma) or an unaddressed finding - work the agent must DO, and no
4821    // GitHub reviewer will ever appear to wake it, so idling would park the
4822    // session forever. Require an outstanding bot (codex P1).
4823    //
4824    // An outstanding LOCAL reviewer disqualifies the wait even when a bot is
4825    // also outstanding (codex review of x-cdc7): the session has work it can do
4826    // right now, and if the bot never posts, idling means that work never
4827    // happens and the run dies on budget with the gate still unmet.
4828    //
4829    // x-b167: idle ONLY when every missing bot is in an idlable nudge state
4830    // (Awaiting, a genuine async wait; or NotNudgeable, today's status quo). A
4831    // NeedsNudge bot is work to DO (post its trigger) and an Unresponsive bot is
4832    // a wait nobody ends - idling on either parks the session. This is the same
4833    // rule x-cdc7 gave unattested_reviewers. An empty bot_nudges (not classified)
4834    // means every-bot-idlable vacuously, preserving pre-x-b167 behavior.
4835    if pr.ci_conclusion.is_ok()
4836        && !pr.reviewed
4837        && !pr.review_skipped
4838        && !pr.missing_bots.is_empty()
4839        && pr.unattested_reviewers.is_empty()
4840        && pr.bot_nudges.iter().all(|n| nudge_class_idlable(&n.class))
4841    {
4842        return Some("review");
4843    }
4844    None
4845}
4846
4847/// First 8 chars of a sha, never bytes. `&s[..8]` panics when byte offset 8
4848/// lands inside a multibyte character, and one of these strings comes from a
4849/// user-writable events.jsonl - a panic there takes the whole stop gate down.
4850fn short_sha(s: &str) -> String {
4851    s.chars().take(8).collect()
4852}
4853
4854/// The arm-and-tag ritual (x-e2c8, US3) that converts an unwatched async wait
4855/// into a single idle turn. Supersedes the old "wait silently" prose: waiting
4856/// silently still costs a full model invocation every ~90s tick, whereas arming
4857/// a harness-tracked watcher and emitting `<watching>` idles the session to ZERO
4858/// invocations until the watcher fires. The `gh pr checks` shape is a template
4859/// (gh's `--watch` exit varies by version); the design depends only on the task
4860/// EXITING, never on its exit code.
4861///
4862/// The bound uses shell builtins, never `timeout(1)`: stock macOS has neither
4863/// it nor `gtimeout`, so naming it makes the watcher no-op and the session idle
4864/// forever on a wait that never started. The watchdog is reaped once the wait
4865/// returns - left alive, it wakes 30m later and kills whatever now holds that
4866/// recycled pid (codex P1).
4867fn arm_watch_hint(pr_number: i64, blocker: &str) -> String {
4868    // The watcher must WAIT on the actual blocker. `gh pr checks --watch` exits
4869    // the instant CI has no pending checks, so on a review wait (CI already
4870    // green) it returns immediately and the session just re-blocks - the review
4871    // path needs a watcher that polls REVIEW state, not checks (codex P2).
4872    let watcher = if blocker == "review" {
4873        format!(
4874            "background Bash `n=$(gh pr view {pr_number} --json reviews --jq '.reviews|length'); i=0; while [ $i -lt 30 ]; do sleep 60; [ \"$(gh pr view {pr_number} --json reviews --jq '.reviews|length')\" -gt \"$n\" ] && break; i=$((i+1)); done` (wakes when a new review posts, or after ~30m)"
4875        )
4876    } else {
4877        format!(
4878            "background Bash `gh pr checks {pr_number} --watch & w=$!; (sleep 1800; kill $w 2>/dev/null) & k=$!; wait $w; kill $k 2>/dev/null`"
4879        )
4880    };
4881    format!(
4882        " Arm a harness-tracked watcher with a hard timeout (e.g. {watcher}), then end your turn with `<watching reason=\"{blocker}\" pr=\"{pr_number}\" timeout=\"30m\">` and nothing else - the session then idles until the watcher exits."
4883    )
4884}
4885
4886// ── done_probes ──────────────────────────────────────────────────────
4887//
4888// A plan may declare `done_probes` in its frontmatter: runnable commands whose
4889// success is the operational evidence that the shipped thing actually RUNS.
4890// DonePRGreen measures artifacts (PR + CI + review), which operational silence
4891// cannot falsify - grooming shipped three times without ever running. Probes are
4892// the enforcement arm: the gate refuses done until the declared observation
4893// holds, forcing the session to perform the last mile before claiming done.
4894
4895/// Wall-clock ceiling per probe. The host has no `timeout` binary (and no
4896/// gtimeout), so the bound is native: spawn, poll `try_wait`, kill.
4897const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
4898
4899/// A probe list is a gate, not a test suite.
4900const PROBE_CAP: usize = 3;
4901
4902/// Probe stderr is quoted back in the block reason so the agent knows which
4903/// last-mile action to perform; cap it so the reason stays readable.
4904const PROBE_STDERR_CAP: usize = 500;
4905
4906enum ProbeOutcome {
4907    Pass,
4908    Fail { code: Option<i32>, stderr: String },
4909    Timeout,
4910}
4911
4912impl ProbeOutcome {
4913    /// Event rendering: `pass` | `fail:<code>` | `timeout`.
4914    fn render(&self) -> String {
4915        match self {
4916            ProbeOutcome::Pass => "pass".to_string(),
4917            ProbeOutcome::Fail { code: Some(c), .. } => format!("fail:{c}"),
4918            ProbeOutcome::Fail { code: None, .. } => "fail:signal".to_string(),
4919            ProbeOutcome::Timeout => "timeout".to_string(),
4920        }
4921    }
4922}
4923
4924enum ProbeGate {
4925    /// No declaration: zero subprocesses, gate behavior byte-identical to before.
4926    Absent,
4927    Pass(Value),
4928    Fail {
4929        reason: String,
4930        results: Value,
4931    },
4932}
4933
4934/// Unwrap a YAML scalar to the string a YAML parser would produce.
4935///
4936/// Decoding escapes is not cosmetic: the recommended block form routinely
4937/// carries an inner quote (`- "test -n \"$(cmd)\""`). Leaving the backslashes in
4938/// would hand `sh -c` literal `\"` characters - a DIFFERENT command than the
4939/// plan declared, whose result the gate would then trust - and would also key
4940/// the event by a string the PyYAML-side grader never matches.
4941fn unquote_scalar(s: &str) -> String {
4942    let s = s.trim();
4943    if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
4944        let inner = &s[1..s.len() - 1];
4945        let mut out = String::with_capacity(inner.len());
4946        let mut chars = inner.chars();
4947        while let Some(c) = chars.next() {
4948            if c != '\\' {
4949                out.push(c);
4950                continue;
4951            }
4952            match chars.next() {
4953                Some('n') => out.push('\n'),
4954                Some('t') => out.push('\t'),
4955                Some('r') => out.push('\r'),
4956                Some('0') => out.push('\0'),
4957                // `\"`, `\\`, `\/` and anything else: keep the escaped char.
4958                Some(other) => out.push(other),
4959                None => out.push('\\'),
4960            }
4961        }
4962        return out;
4963    }
4964    if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
4965        // YAML single-quoted scalars escape only the quote, by doubling it.
4966        return s[1..s.len() - 1].replace("''", "'");
4967    }
4968    s.to_string()
4969}
4970
4971/// Split a YAML inline list body. Quoted segments win over comma-splitting
4972/// because probe commands routinely contain commas (`--jq '.a,.b'`); only an
4973/// unquoted body falls back to a naive split.
4974fn split_inline_list(body: &str) -> Vec<String> {
4975    let mut out = Vec::new();
4976    let mut chars = body.chars().peekable();
4977    while let Some(c) = chars.next() {
4978        if c == '"' || c == '\'' {
4979            let mut item = String::new();
4980            let mut escaped = false;
4981            for c2 in chars.by_ref() {
4982                if escaped {
4983                    item.push(c2);
4984                    escaped = false;
4985                } else if c2 == '\\' {
4986                    escaped = true;
4987                } else if c2 == c {
4988                    break;
4989                } else {
4990                    item.push(c2);
4991                }
4992            }
4993            out.push(item);
4994        }
4995    }
4996    if out.is_empty() {
4997        out = body
4998            .split(',')
4999            .map(unquote_scalar)
5000            .filter(|s| !s.is_empty())
5001            .collect();
5002    }
5003    out
5004}
5005
5006/// What a plan doc's frontmatter says about `done_probes`.
5007#[derive(Debug, PartialEq)]
5008enum ProbeDecl {
5009    /// No `done_probes` key, or explicitly `[]` - both mean "no gate".
5010    None,
5011    Probes(Vec<String>),
5012    /// The key is present but no probes could be recovered from it. This is
5013    /// NEVER treated as "no probes": a declaration this parser cannot read is
5014    /// the vacuous-pass shape the whole feature exists to prevent, so it fails
5015    /// closed and asks a human to look.
5016    Unparseable,
5017}
5018
5019/// Read `done_probes` from a plan doc's frontmatter. Accepts the block form
5020/// (`done_probes:\n  - "cmd"`) and the single-line inline form
5021/// (`done_probes: ["cmd"]`); anything else declared is `Unparseable`.
5022fn parse_done_probes(content: &str) -> ProbeDecl {
5023    let content = content.trim_start();
5024    if !content.starts_with("---") {
5025        return ProbeDecl::None;
5026    }
5027    let after_first = &content[3..];
5028    let Some(end) = after_first.find("\n---") else {
5029        return ProbeDecl::None;
5030    };
5031
5032    let mut out = Vec::new();
5033    let mut declared = false;
5034    let mut in_block = false;
5035    for line in after_first[..end].lines() {
5036        let trimmed = line.trim();
5037        if !in_block {
5038            let Some(rest) = trimmed.strip_prefix("done_probes:") else {
5039                continue;
5040            };
5041            declared = true;
5042            let rest = rest.trim();
5043            if rest == "[]" {
5044                return ProbeDecl::None;
5045            }
5046            if let Some(inner) = rest.strip_prefix('[') {
5047                // strip_suffix, not trim_end_matches: the latter eats EVERY
5048                // trailing ']' (mangling a command that ends in one) and would
5049                // silently accept an unterminated list.
5050                let Some(inner) = inner.strip_suffix(']') else {
5051                    return ProbeDecl::Unparseable;
5052                };
5053                let items = split_inline_list(inner);
5054                // An empty result means a multi-line inline list (items live on
5055                // following lines) - unrecoverable here, so refuse rather than
5056                // report the declaration as absent.
5057                return if items.is_empty() {
5058                    ProbeDecl::Unparseable
5059                } else {
5060                    ProbeDecl::Probes(items)
5061                };
5062            }
5063            in_block = true;
5064            continue;
5065        }
5066        // Inside the block: a comment is not the end of it (treating one as a
5067        // terminator would silently drop every probe below it).
5068        if trimmed.is_empty() || trimmed.starts_with('#') {
5069            continue;
5070        }
5071        let Some(item) = trimmed.strip_prefix("- ") else {
5072            break; // the next frontmatter key ends the block
5073        };
5074        let item = unquote_scalar(item);
5075        if !item.is_empty() {
5076            out.push(item);
5077        }
5078    }
5079
5080    match (declared, out.is_empty()) {
5081        (false, _) => ProbeDecl::None,
5082        (true, true) => ProbeDecl::Unparseable,
5083        (true, false) => ProbeDecl::Probes(out),
5084    }
5085}
5086
5087/// Keep at most the LAST `cap` bytes, without splitting a UTF-8 character.
5088///
5089/// The tail, not the head: a failing command's real error is almost always its
5090/// last line, so keeping the prefix would routinely drop the one diagnostic the
5091/// block reason exists to surface. Char-boundary aware because `String::drain`
5092/// and `truncate` panic mid-character, and probe stderr regularly carries
5093/// arrows, box-drawing, and accented words.
5094fn keep_last_on_char_boundary(s: &mut String, cap: usize) {
5095    if s.len() <= cap {
5096        return;
5097    }
5098    let start = s.len() - cap;
5099    let cut = (start..=s.len())
5100        .find(|i| s.is_char_boundary(*i))
5101        .unwrap_or(s.len());
5102    s.drain(..cut);
5103}
5104
5105/// SIGKILL a process group, ignoring "already gone".
5106fn killpg(pgid: i32) {
5107    if pgid <= 0 {
5108        return;
5109    }
5110    // SAFETY: pgid is our own spawned group leader's pid; ESRCH is expected
5111    // once every member has exited and is deliberately ignored.
5112    unsafe {
5113        libc::killpg(pgid, libc::SIGKILL);
5114    }
5115}
5116
5117/// Run one probe under a native timeout.
5118///
5119/// Two things here are load-bearing rather than defensive. stderr is drained by
5120/// a reader thread because reading a piped stderr only after exit deadlocks any
5121/// probe that writes past the pipe buffer. And the child leads its own process
5122/// group, which is killed on EVERY exit path - not just the timeout.
5123///
5124/// The group kill has to cover normal exit too, because `sh` is not the only
5125/// process holding the stderr write end. A pipeline (`... | grep -q x`) forks,
5126/// and a probe that backgrounds anything (`sleep 3600 &`, or any command that
5127/// daemonizes) lets `sh` exit IMMEDIATELY while the descendant keeps the pipe
5128/// open. `try_wait` then reports success and leaves the timeout loop, so the
5129/// timer is never consulted again and the drain join blocks for the
5130/// descendant's whole lifetime - wedging the stop hook well past the 60s the
5131/// gate promises. Killing the group closes the pipe and bounds the join.
5132fn run_probe(cmd: &str, cwd: &Path, timeout: std::time::Duration) -> ProbeOutcome {
5133    use std::os::unix::process::CommandExt;
5134
5135    let spawned = Command::new("sh")
5136        .arg("-c")
5137        .arg(cmd)
5138        .current_dir(cwd)
5139        .stdin(Stdio::null())
5140        .stdout(Stdio::null())
5141        .stderr(Stdio::piped())
5142        .process_group(0)
5143        .spawn();
5144
5145    let mut child = match spawned {
5146        Ok(c) => c,
5147        Err(e) => {
5148            return ProbeOutcome::Fail {
5149                code: Some(127),
5150                stderr: format!("probe spawn failed: {e}"),
5151            }
5152        }
5153    };
5154
5155    // Capture the pgid before any wait() can reap the leader.
5156    let pgid = child.id() as i32;
5157
5158    let mut pipe = child.stderr.take();
5159    let drain = std::thread::spawn(move || {
5160        let mut buf = String::new();
5161        if let Some(ref mut p) = pipe {
5162            let _ = p.read_to_string(&mut buf);
5163        }
5164        buf
5165    });
5166
5167    let start = std::time::Instant::now();
5168    let outcome = loop {
5169        match child.try_wait() {
5170            Ok(Some(status)) => {
5171                break if status.success() {
5172                    ProbeOutcome::Pass
5173                } else {
5174                    ProbeOutcome::Fail {
5175                        code: status.code(),
5176                        stderr: String::new(),
5177                    }
5178                };
5179            }
5180            Ok(None) => {
5181                if start.elapsed() >= timeout {
5182                    kill_process_group(&mut child);
5183                    break ProbeOutcome::Timeout;
5184                }
5185                std::thread::sleep(std::time::Duration::from_millis(50));
5186            }
5187            Err(e) => {
5188                kill_process_group(&mut child);
5189                break ProbeOutcome::Fail {
5190                    code: None,
5191                    stderr: format!("probe wait failed: {e}"),
5192                };
5193            }
5194        }
5195    };
5196
5197    // Reap any descendant still holding the stderr write end, so the drain sees
5198    // EOF. Without this a backgrounding probe blocks the join indefinitely even
5199    // though the shell itself exited cleanly.
5200    killpg(pgid);
5201
5202    // On timeout the stderr tail is worthless (the reason names the timeout) and
5203    // joining risks the very hang we just escaped if anything outlived the group
5204    // kill. Drop the handle instead: the thread ends when the pipe closes.
5205    if matches!(outcome, ProbeOutcome::Timeout) {
5206        return outcome;
5207    }
5208
5209    let mut stderr = drain.join().unwrap_or_default();
5210    keep_last_on_char_boundary(&mut stderr, PROBE_STDERR_CAP);
5211    match outcome {
5212        ProbeOutcome::Fail { code, stderr: s } if s.is_empty() => {
5213            ProbeOutcome::Fail { code, stderr }
5214        }
5215        other => other,
5216    }
5217}
5218
5219/// SIGKILL the child's whole process group, then reap it. A probe pipeline's
5220/// grandchildren hold the stderr pipe open; killing only the direct child would
5221/// leave the drain thread blocked on a pipe that never reaches EOF.
5222fn kill_process_group(child: &mut std::process::Child) {
5223    killpg(child.id() as i32);
5224    let _ = child.kill();
5225    let _ = child.wait();
5226}
5227
5228/// Event payload for a refusal where probes were DECLARED but none ran (plan
5229/// unreadable, unparseable, over cap). It must be a non-empty object: recording
5230/// a bare null would make the refusal invisible to `prior_fires_declared_probes`,
5231/// so a plan that tripped the cap and then went missing would silently degrade
5232/// to "no gate" - the exact fail-open this records history to prevent. The key
5233/// is underscore-prefixed so it cannot collide with a probe command string.
5234fn undeterminable_marker(cause: &str) -> Value {
5235    serde_json::json!({ "_undeterminable": cause })
5236}
5237
5238/// True when any prior loop_check fire for this session recorded probe results.
5239/// Used to fail closed on an unreadable plan only when probes are known to have
5240/// existed - a probe-less session with a stale plan_path keeps today's behavior.
5241fn prior_fires_declared_probes(events_path: &Path, session_id: &str) -> bool {
5242    let Ok(content) = std::fs::read_to_string(events_path) else {
5243        return false;
5244    };
5245    content.lines().any(|line| {
5246        let Ok(val) = serde_json::from_str::<Value>(line) else {
5247            return false;
5248        };
5249        val.get("type").and_then(|v| v.as_str()) == Some("loop_check")
5250            && val.pointer("/data/session_id").and_then(|v| v.as_str()) == Some(session_id)
5251            && val
5252                .pointer("/data/done_probes")
5253                .and_then(|v| v.as_object())
5254                .is_some_and(|m| !m.is_empty())
5255    })
5256}
5257
5258/// Resolve the PLAN source to its probe list, or the gate that must block.
5259///
5260/// Split out from `evaluate_done_probes` so the project source can be resolved
5261/// independently: a plan that declares nothing (or whose doc is missing on a
5262/// probe-less session) must still let the project's own probes run, which a
5263/// single early-return-Absent path cannot express.
5264fn plan_declared_probes(
5265    plan_path: Option<&str>,
5266    cwd: &Path,
5267    events_path: &Path,
5268    session_id: &str,
5269) -> Result<Vec<String>, ProbeGate> {
5270    // Resolve a relative plan_path against the session's cwd, not the process
5271    // cwd: plan_path is repo-relative in practice, and reading nothing here
5272    // would degrade to Absent - a silent gate bypass.
5273    let plan = plan_path.and_then(|p| {
5274        // A plan_path may carry a `#wave-1`-style fragment; the Python plan
5275        // readers strip it, and reading the literal name would fail, which on
5276        // the first fire (no probe history) degrades to Absent - a silent
5277        // bypass of a gate the plan actually declared.
5278        let p = Path::new(p.split('#').next().unwrap_or(p));
5279        let abs = if p.is_absolute() {
5280            p.to_path_buf()
5281        } else {
5282            cwd.join(p)
5283        };
5284        std::fs::read_to_string(abs).ok()
5285    });
5286    let Some(plan) = plan else {
5287        // Fail closed only when probes were observed before; otherwise a stale
5288        // plan_path on a probe-less session must not start refusing done.
5289        if prior_fires_declared_probes(events_path, session_id) {
5290            return Err(ProbeGate::Fail {
5291                reason: format!(
5292                    "done_probes undeterminable: plan {} is unreadable but a prior fire declared probes; restore the plan doc",
5293                    plan_path.unwrap_or("(unset)")
5294                ),
5295                results: undeterminable_marker("plan-unreadable"),
5296            });
5297        }
5298        return Ok(Vec::new());
5299    };
5300
5301    let probes = match parse_done_probes(&plan) {
5302        ProbeDecl::None => return Ok(Vec::new()),
5303        ProbeDecl::Unparseable => {
5304            return Err(ProbeGate::Fail {
5305                reason: format!(
5306                    "done_probes undeterminable: plan {} declares the field but no probe could be read from it (use a block list, or a single-line inline list)",
5307                    plan_path.unwrap_or("(unset)")
5308                ),
5309                results: undeterminable_marker("unparseable-declaration"),
5310            })
5311        }
5312        ProbeDecl::Probes(p) => p,
5313    };
5314    if probes.len() > PROBE_CAP {
5315        return Err(ProbeGate::Fail {
5316            reason: format!(
5317                "plan declares {} done_probes; the cap is {PROBE_CAP} per source (a probe list is a gate, not a test suite)",
5318                probes.len()
5319            ),
5320            results: undeterminable_marker("over-cap"),
5321        });
5322    }
5323    Ok(probes)
5324}
5325
5326/// Evaluate the probe conjunct across BOTH sources. Called ONLY once every
5327/// other DonePRGreen conjunct already holds, so probes run at most once per
5328/// would-be-done fire.
5329///
5330/// `config_probes` is the repo-wide `done_probes` off config.toml. Both lists
5331/// run and both must pass: a plan can ADD guardrails and can never silence the
5332/// project's, including via an explicit `done_probes: []`. A repo-wide guard a
5333/// plan doc can switch off is a guard on one of two paths, which is decorative.
5334fn evaluate_done_probes(
5335    plan_path: Option<&str>,
5336    config_probes: Option<&Result<Vec<String>, String>>,
5337    cwd: &Path,
5338    events_path: &Path,
5339    session_id: &str,
5340    timeout: std::time::Duration,
5341) -> ProbeGate {
5342    // The project source resolves first: a declaration this parser cannot read
5343    // must block before anything runs, in the same vocabulary the plan side
5344    // uses. A config key that degrades to no-gate is a guardrail that
5345    // disappears when you typo it.
5346    let project = match config_probes {
5347        None => Vec::new(),
5348        Some(Err(why)) => {
5349            return ProbeGate::Fail {
5350                reason: format!(
5351                    "done_probes undeterminable: config.toml declares `done_probes` but {why}"
5352                ),
5353                results: undeterminable_marker("unparseable-config-declaration"),
5354            }
5355        }
5356        Some(Ok(p)) => p.clone(),
5357    };
5358    // PROBE_CAP applies PER SOURCE, not to the union. The cap encodes
5359    // per-declaration discipline ("a gate, not a test suite"); one shared
5360    // budget would instead make two independent authors compete for one number
5361    // and let a project's policy eat a plan's operational probes.
5362    if project.len() > PROBE_CAP {
5363        return ProbeGate::Fail {
5364            reason: format!(
5365                "config.toml declares {} done_probes; the cap is {PROBE_CAP} per source (a probe list is a gate, not a test suite)",
5366                project.len()
5367            ),
5368            results: undeterminable_marker("over-cap"),
5369        };
5370    }
5371
5372    let plan_probes = match plan_declared_probes(plan_path, cwd, events_path, session_id) {
5373        Ok(p) => p,
5374        Err(gate) => return gate,
5375    };
5376
5377    if project.is_empty() && plan_probes.is_empty() {
5378        return ProbeGate::Absent;
5379    }
5380
5381    let mut results = serde_json::Map::new();
5382    let mut failures = Vec::new();
5383    for (source, cmd) in project
5384        .iter()
5385        .map(|c| ("project", c))
5386        .chain(plan_probes.iter().map(|c| ("plan", c)))
5387    {
5388        let outcome = run_probe(cmd, cwd, timeout);
5389        // Keyed by the BARE command: cli/src/fno/scoreboard/fold.py joins this
5390        // map back to the plan's frontmatter by exact command string, so the
5391        // source label belongs in the failure reason - which is what the
5392        // operator reads to know which file to edit - and never in the key.
5393        results.insert(cmd.clone(), Value::String(outcome.render()));
5394        match &outcome {
5395            ProbeOutcome::Pass => {}
5396            ProbeOutcome::Timeout => failures.push(format!(
5397                "{source} probe `{cmd}` timed out after {}s (killed)",
5398                timeout.as_secs()
5399            )),
5400            ProbeOutcome::Fail { code, stderr } => {
5401                let code = code.map(|c| c.to_string()).unwrap_or("signal".to_string());
5402                let tail = if stderr.trim().is_empty() {
5403                    String::new()
5404                } else {
5405                    format!(": {}", stderr.trim())
5406                };
5407                failures.push(format!("{source} probe `{cmd}` exited {code}{tail}"));
5408            }
5409        }
5410    }
5411
5412    let results = Value::Object(results);
5413    if failures.is_empty() {
5414        ProbeGate::Pass(results)
5415    } else {
5416        ProbeGate::Fail {
5417            reason: format!(
5418                "done_probes failed - the shipped thing has no evidence of running: {}",
5419                failures.join("; ")
5420            ),
5421            results,
5422        }
5423    }
5424}
5425
5426fn build_block_reason(pr: &PrInfo, local_head: &str, open_findings_empty: bool) -> String {
5427    // The ONE predicate. A message that prescribes the arm-and-tag ritual for a
5428    // blocker `async_wait_class` refuses to idle is the code contradicting
5429    // itself, and a session complied with exactly that roughly ten times on
5430    // #618. Deriving the hint from the classifier makes the two agree by
5431    // construction rather than by two hand-kept branch orders.
5432    let idlable = async_wait_class(pr, local_head, open_findings_empty);
5433    let hint = |blocker: &str| -> String {
5434        if idlable == Some(blocker) {
5435            arm_watch_hint(pr.number, blocker)
5436        } else {
5437            String::new()
5438        }
5439    };
5440    if !pr.state.is_open_or_merged() {
5441        return format!(
5442            "no PR for HEAD (pr_state={}); keep working",
5443            pr.state.as_str()
5444        );
5445    }
5446
5447    if !pr.head_oid.is_empty() && pr.head_oid != local_head {
5448        return format!(
5449            "PR #{} head {} != local HEAD {}: push the latest commits before completing",
5450            pr.number,
5451            short_sha(&pr.head_oid),
5452            short_sha(local_head)
5453        );
5454    }
5455
5456    if !pr.ci_conclusion.is_ok() {
5457        if pr.ci_conclusion == CiConclusion::None {
5458            return format!(
5459                "no CI checks found on PR #{}; declare ci.declared_none: true in settings if intentional",
5460                pr.number
5461            );
5462        }
5463        // Pending is "not green YET", not red. The MUTE_PROBE_N probe
5464        // (ab-223d2dae) runs done() while CI is commonly still in flight,
5465        // so a "CI failed" message here would mislead the blocked agent
5466        // into debugging a nonexistent failure on every quiet fire.
5467        if pr.ci_conclusion == CiConclusion::Pending {
5468            return format!("CI still running on PR #{}.{}", pr.number, hint("ci"));
5469        }
5470        let check_name = match &pr.ci_conclusion {
5471            CiConclusion::Failure(Some(name)) => name.as_str(),
5472            _ => "CI",
5473        };
5474        return format!("CI red on PR #{}: {} failed", pr.number, check_name);
5475    }
5476
5477    if !pr.reviewed {
5478        // Order: work you can do now, cheapest-to-invalidate first, then the
5479        // async wait. An unaddressed finding leads because addressing it MOVES
5480        // HEAD, which supersedes any attestation produced before it - naming
5481        // the reviewer first would make a session run sigma twice. The bot
5482        // wait comes last: naming only the bot hides the half the session can
5483        // act on now, and if the bot never posts, the local work never happens
5484        // and the run dies on budget with the gate still unmet.
5485        if !pr.unaddressed_findings.is_empty() {
5486            // AC2-UI: name the specific finding (path:line) and the remedy.
5487            let f = &pr.unaddressed_findings[0];
5488            let more = if pr.unaddressed_findings.len() > 1 {
5489                format!(" [+{} more]", pr.unaddressed_findings.len() - 1)
5490            } else {
5491                String::new()
5492            };
5493            // x-b167 AC14: "reply in-thread" alone is a half-remedy - a reply
5494            // that does not address the bot by its full login never reaches it.
5495            // Name the handle when the finding author is a known bot.
5496            let reply_to = profile_by_author(&f.author)
5497                .map(|p| format!(" addressed to {}", p.reply_handle))
5498                .unwrap_or_default();
5499            return format!(
5500                "PR #{}: {} {} at {}:{} unaddressed (reply in-thread{} or wontfix:){}",
5501                pr.number, f.author, f.severity, f.path, f.line, reply_to, more
5502            );
5503        }
5504        if !pr.unattested_reviewers.is_empty() {
5505            // The branch that was missing (x-cdc7). Without it a local-only
5506            // reviewers gate fell through to the generic string below and told
5507            // the session to wait on a bot that was never required.
5508            //
5509            // No arm_watch_hint here, deliberately: `async_wait_class` has
5510            // ALREADY excluded this blocker from idling, because no GitHub
5511            // reviewer will ever post the attestation and the session would park
5512            // forever. Emitting the arm-and-tag ritual on a blocker the same
5513            // file refuses to idle is the code contradicting itself, and a
5514            // session did comply with it roughly ten times.
5515            let head = short_sha(local_head);
5516            let items: Vec<String> = pr
5517                .unattested_reviewers
5518                .iter()
5519                .map(|r| {
5520                    // "no attestation" is a lie to a session that ran the
5521                    // reviewer and got told no; name that case separately.
5522                    let state = if r.failed_at_head {
5523                        " (attested at this head, verdict NOT pass)".to_string()
5524                    } else {
5525                        match &r.superseded_head {
5526                            Some(h) => {
5527                                format!(" (passed at {}, superseded by this head)", short_sha(h))
5528                            }
5529                            None => String::new(),
5530                        }
5531                    };
5532                    if r.name == SAME_MODEL_LOCAL_PEER_SENTINEL {
5533                        return format!(
5534                            "peer{} -> configure a cross-model peer or routed model",
5535                            state
5536                        );
5537                    }
5538                    if r.name == LOCAL_PEER_REVIEWER {
5539                        return format!("peer{} -> run `/fno:review peer --attest`", state);
5540                    }
5541                    match reviewer_invocation(&r.name) {
5542                        Some((inv, self_cert)) => {
5543                            let mark = if self_cert {
5544                                " [self-cert: asserts no review evidence]"
5545                            } else {
5546                                ""
5547                            };
5548                            format!("{}{} -> run `{}`{}", r.name, state, inv, mark)
5549                        }
5550                        None => format!("{}{}", r.name, state),
5551                    }
5552                })
5553                .collect();
5554            let corrupt = match pr.malformed_attestations {
5555                0 => String::new(),
5556                n => format!(" ({n} unparseable attestation line(s) ignored)"),
5557            };
5558            return format!(
5559                "PR #{}: reviewers gate unmet - no head-pinned review_attestation at {} for {}{}. \
5560                 This is local work to DO, not a wait: no GitHub reviewer posts these, \
5561                 so do not arm a watcher.",
5562                pr.number,
5563                head,
5564                items.join("; "),
5565                corrupt
5566            );
5567        }
5568        if !pr.missing_bots.is_empty() {
5569            // x-b167: render per nudge state. `hint("review")` is derived from
5570            // async_wait_class, so it is EMPTY for NeedsNudge/Unresponsive (both
5571            // non-idlable) and PRESENT for Awaiting/NotNudgeable by construction -
5572            // the arm-and-tag ritual can never appear on a blocker the same file
5573            // refuses to idle (the contradiction x-cdc7 removed). NeedsNudge and
5574            // Unresponsive lead because they are work/decisions, not waits.
5575            if let Some(n) = pr
5576                .bot_nudges
5577                .iter()
5578                .find(|n| n.class == NudgeClass::NeedsNudge)
5579            {
5580                return format!(
5581                    "PR #{}: {} reviews on mention, not on push, and has not been asked. Run:\n  \
5582                     gh pr comment {} --body \"{}\"\nthen arm a watcher (nudge {} of {}).{}",
5583                    pr.number,
5584                    n.login,
5585                    pr.number,
5586                    n.review_handle,
5587                    n.nudges + 1,
5588                    n.ceiling,
5589                    hint("review")
5590                );
5591            }
5592            if let Some(n) = pr
5593                .bot_nudges
5594                .iter()
5595                .find(|n| n.class == NudgeClass::Unresponsive)
5596            {
5597                return format!(
5598                    "PR #{}: {} did not review after {} nudges over {}m. Nothing further \
5599                     will arrive on its own. Either post the review by hand, or move this \
5600                     login to config.review.optional_apps (honored-if-present, never waited \
5601                     on). Not a wait: do not arm a watcher.{}",
5602                    pr.number,
5603                    n.login,
5604                    n.nudges,
5605                    n.span_min,
5606                    hint("review")
5607                );
5608            }
5609            if let Some(n) = pr
5610                .bot_nudges
5611                .iter()
5612                .find(|n| n.class == NudgeClass::Awaiting)
5613            {
5614                return format!(
5615                    "PR #{}: {} nudged {}m ago ({} of {}), awaiting review.{}",
5616                    pr.number,
5617                    n.login,
5618                    n.newest_age_min,
5619                    n.nudges,
5620                    n.ceiling,
5621                    hint("review")
5622                );
5623            }
5624            // All NotNudgeable (or not classified): today's exact string + hint
5625            // (AC5 - a non-nudgeable required bot keeps the pre-x-b167 behavior).
5626            return format!(
5627                "PR #{}: {} has not reviewed.{}",
5628                pr.number,
5629                pr.missing_bots.join(", "),
5630                hint("review")
5631            );
5632        }
5633        // Reaching here means missing_bots is empty, which `async_wait_class`
5634        // treats as non-idlable, so this must not teach the arm-and-tag ritual
5635        // either (the two must never disagree about whether a wait is valid).
5636        return format!(
5637            "PR #{} not yet reviewed and no reviewer is outstanding; \
5638             re-check config.review (required_bots / reviewers) - nothing here will \
5639             arrive on its own.",
5640            pr.number
5641        );
5642    }
5643
5644    format!("PR #{} done() returned false (unknown reason)", pr.number)
5645}
5646
5647// ── public entry points ───────────────────────────────────────────────────────
5648
5649/// Entry point called from `bin/client.rs` direct dispatch.
5650/// Prints JSON to stdout, returns exit code.
5651pub fn run_loop_check(args: &[String]) -> i32 {
5652    let (code, json) = decide(args);
5653    println!("{json}");
5654    code
5655}
5656
5657/// Test-friendly variant that returns (exit_code, json_string) without printing.
5658/// Used by integration tests in tests/loop_check.rs.
5659pub fn run_loop_check_capture(args: &[String]) -> (i32, String) {
5660    decide(args)
5661}
5662
5663// ── unit tests ────────────────────────────────────────────────────────────────
5664
5665#[cfg(test)]
5666mod tests {
5667    use super::*;
5668
5669    /// The list half of the scan. Production reads the count too, so this
5670    /// wrapper lives here rather than as an unused function in the binary.
5671    fn unattested_reviewers(
5672        events_path: &Path,
5673        reviewers: &[String],
5674        head_sha: &str,
5675    ) -> Vec<UnattestedReviewer> {
5676        unattested_reviewers_scan(events_path, reviewers, head_sha).0
5677    }
5678
5679    /// The gate's boolean view of `unattested_reviewers`, exactly as
5680    /// `read_pr_info` derives it. The pre-x-cdc7 predicate tests below are
5681    /// unchanged on purpose: promoting the return value to a list must not
5682    /// move the gate.
5683    fn reviewers_all_attested(events_path: &Path, reviewers: &[String], head_sha: &str) -> bool {
5684        unattested_reviewers(events_path, reviewers, head_sha).is_empty()
5685    }
5686
5687    // ── streak debounce (x-6231) ─────────────────────────────────────────────
5688    //
5689    // These drive `read_prior_fires` with an explicit `now` and gap, so they need
5690    // no env var and are parallel-safe -- unlike the integration suite, which
5691    // pins FNO_LOOPCHECK_MIN_FIRE_GAP_SECS=0 process-wide.
5692
5693    const FP: &str = "FP";
5694    const NOW: &str = "2026-06-05T12:00:00Z";
5695
5696    fn at(ts: &str) -> DateTime<Utc> {
5697        ts.parse().unwrap()
5698    }
5699
5700    /// Write a loop_check events log from (ts, fingerprint) pairs, oldest first.
5701    fn write_fire_log(path: &Path, fires: &[(String, &str)]) {
5702        let mut out = String::new();
5703        for (ts, fp) in fires {
5704            out.push_str(
5705                &serde_json::json!({
5706                    "ts": ts, "type": "loop_check", "source": "hook",
5707                    "data": { "session_id": "sess", "fingerprint": fp },
5708                })
5709                .to_string(),
5710            );
5711            out.push('\n');
5712        }
5713        std::fs::write(path, out).unwrap();
5714    }
5715
5716    /// Count the streak over prior fires given as SECONDS BEFORE `now`, oldest
5717    /// first, all sharing FP. Returns (streak, streak_window_secs).
5718    fn streak_ago(secs_before_now: &[i64], gap: i64) -> (u64, i64) {
5719        let now = at(NOW);
5720        let fires: Vec<(String, &str)> = secs_before_now
5721            .iter()
5722            .map(|s| {
5723                (
5724                    (now - chrono::Duration::seconds(*s))
5725                        .format("%Y-%m-%dT%H:%M:%SZ")
5726                        .to_string(),
5727                    FP,
5728                )
5729            })
5730            .collect();
5731        let dir = tempfile::TempDir::new().unwrap();
5732        let p = dir.path().join("events.jsonl");
5733        write_fire_log(&p, &fires);
5734        let (_, streak, _, window) = read_prior_fires(&p, "sess", FP, now, gap);
5735        (streak, window)
5736    }
5737
5738    /// The streak rules. `consecutive_after` is streak + 1, so a streak of 4 is
5739    /// what trips the attended backstop of 5.
5740    #[test]
5741    fn debounce_streak_counting_rules() {
5742        // (case, prior fires as seconds before now (oldest first), gap, streak, window)
5743        #[rustfmt::skip]
5744        let cases: &[(&str, &[i64], i64, u64, i64)] = &[
5745            // AC1-HP: the triggering shape - four fires inside 60s are ONE
5746            // observation (the current fire), nowhere near backstop_n.
5747            ("rapid burst collapses to one observation", &[49, 33, 16, 0], 300, 0, 0),
5748            // AC2-HP: a genuinely stalled session is still reaped.
5749            ("fires 6 minutes apart still trip the backstop", &[1440, 1080, 720, 360], 300, 4, 1440),
5750            // AC3-FR: a skip must NOT advance the cursor. This fire is 330s
5751            // before `now` but only 270s before the burst's oldest member, so it
5752            // counts ONLY because the burst left the cursor parked at `now`.
5753            ("a skip does not advance the cursor", &[330, 60, 30, 10], 300, 1, 330),
5754            // AC6-FR: gap 0 is byte-identical to the old fire counting, which is
5755            // what lets the integration suite pin the seam and keep every
5756            // backstop assertion it already had.
5757            ("gap 0 restores fire counting exactly", &[49, 33, 16], 0, 3, 49),
5758            // Clock skew must not invent a debounce from a bad clock.
5759            ("a fire stamped after `now` counts, not crashes", &[1200, -600], 300, 2, 1200),
5760            // AC8-REG: the recorded sequence behind the false terminal - session
5761            // 20260727T203203Z, five fires in 109 seconds with CI still PENDING.
5762            ("the false-NoProgress incident now blocks", &[109, 93, 76, 17], 300, 0, 0),
5763        ];
5764        for (case, fires, gap, want_streak, want_window) in cases {
5765            let (streak, window) = streak_ago(fires, *gap);
5766            assert_eq!(streak, *want_streak, "streak: {case}");
5767            assert_eq!(window, *want_window, "window: {case}");
5768        }
5769    }
5770
5771    /// AC4-CON: progress is never debounced - a CHANGED fingerprint breaks the
5772    /// streak however fast it arrived.
5773    #[test]
5774    fn debounce_changed_fingerprint_breaks_streak_at_any_speed() {
5775        let now = at(NOW);
5776        let dir = tempfile::TempDir::new().unwrap();
5777        let p = dir.path().join("events.jsonl");
5778        write_fire_log(
5779            &p,
5780            &[
5781                ("2026-06-05T11:40:00Z".to_string(), FP),
5782                ("2026-06-05T11:50:00Z".to_string(), FP),
5783                ("2026-06-05T11:59:58Z".to_string(), "DIFFERENT"),
5784            ],
5785        );
5786        let (_, streak, _, _) = read_prior_fires(&p, "sess", FP, now, 300);
5787        assert_eq!(streak, 0, "a 2-second-old change still resets the streak");
5788    }
5789
5790    /// AC5-ERR: a fire we cannot place in time is transparent - it neither counts
5791    /// toward nor breaks the streak, and never panics. Failing this way biases
5792    /// away from an irreversible NoProgress.
5793    #[test]
5794    fn debounce_untimestamped_fire_is_transparent() {
5795        let dir = tempfile::TempDir::new().unwrap();
5796        let p = dir.path().join("events.jsonl");
5797        let lines = [
5798            r#"{"ts":"2026-06-05T11:40:00Z","type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
5799            r#"{"ts":"not-a-timestamp","type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
5800            r#"{"type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
5801        ];
5802        std::fs::write(&p, lines.join("\n") + "\n").unwrap();
5803
5804        let (_, streak, last_fp, _) = read_prior_fires(&p, "sess", FP, at(NOW), 300);
5805        assert_eq!(
5806            streak, 1,
5807            "unplaceable fires skip; the good one still counts"
5808        );
5809        assert_eq!(
5810            last_fp.as_deref(),
5811            Some(FP),
5812            "carry-forward still reads the newest recorded fp"
5813        );
5814    }
5815
5816    #[test]
5817    fn parse_manifest_minimal() {
5818        let content =
5819            "---\nsession_id: abc\ncreated_at: 2026-06-05T00:00:00Z\nattended: true\n---\n";
5820        let m = parse_manifest(content).unwrap();
5821        assert_eq!(m.session_id.as_deref(), Some("abc"));
5822        assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
5823        assert!(m.attended);
5824        assert!(m.legacy_status.is_none());
5825    }
5826
5827    #[test]
5828    fn scan_manifest_field_reads_claim_fields_after_frontmatter() {
5829        // x-ba4b regression: `fno target init` APPENDS the node-claim fields
5830        // AFTER the closing `---`, so the frontmatter-bounded parse_manifest must
5831        // NOT be relied on for them - the whole-file scanner is what drives
5832        // renewal. Mirrors init's real manifest shape.
5833        let content = "---\nsession_id: s1\nattended: false\n---\n\
5834                       Immutable session manifest.\n\
5835                       target_claim_key: \"node:x-ba4b\"\n\
5836                       target_claim_holder: \"target-session:s1\"\n\
5837                       target_claim_ttl: \"2h\"\n";
5838        // parse_manifest (frontmatter-bounded) never sees the appended fields.
5839        let m = parse_manifest(content).unwrap();
5840        assert_eq!(m.session_id.as_deref(), Some("s1"));
5841        // The whole-file scanner does.
5842        assert_eq!(
5843            scan_manifest_field(content, "target_claim_key").as_deref(),
5844            Some("node:x-ba4b")
5845        );
5846        assert_eq!(
5847            scan_manifest_field(content, "target_claim_holder").as_deref(),
5848            Some("target-session:s1")
5849        );
5850        assert_eq!(
5851            scan_manifest_field(content, "target_claim_ttl")
5852                .as_deref()
5853                .and_then(crate::claims::parse_ttl_ms),
5854            Some(7_200_000)
5855        );
5856        assert_eq!(scan_manifest_field(content, "nonexistent_field"), None);
5857    }
5858
5859    #[test]
5860    fn parse_manifest_legacy_complete() {
5861        let content =
5862            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: COMPLETE\n---\n";
5863        let m = parse_manifest(content).unwrap();
5864        assert_eq!(m.legacy_status.as_deref(), Some("COMPLETE"));
5865    }
5866
5867    #[test]
5868    fn parse_manifest_legacy_blocked() {
5869        let content =
5870            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: BLOCKED\n---\n";
5871        let m = parse_manifest(content).unwrap();
5872        assert_eq!(m.legacy_status.as_deref(), Some("BLOCKED"));
5873    }
5874
5875    #[test]
5876    fn parse_manifest_no_ship() {
5877        let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nno_ship: true\n---\n";
5878        let m = parse_manifest(content).unwrap();
5879        assert!(m.no_ship);
5880        assert!(!m.no_external);
5881    }
5882
5883    #[test]
5884    fn parse_manifest_planned() {
5885        let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nplanned: true\n---\n";
5886        let m = parse_manifest(content).unwrap();
5887        assert!(m.planned);
5888        assert!(!m.advisory); // planned is distinct from advisory (which graduates)
5889    }
5890
5891    #[test]
5892    fn parse_manifest_strips_quotes() {
5893        // gemini MEDIUM on #447: quoted YAML values must parse identically.
5894        let content = "---\nsession_id: \"s-quoted\"\ncreated_at: '2026-06-05T00:00:00Z'\n---\n";
5895        let m = parse_manifest(content).unwrap();
5896        assert_eq!(m.session_id.as_deref(), Some("s-quoted"));
5897        assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
5898    }
5899
5900    #[test]
5901    fn parse_settings_nested_budget_and_ci() {
5902        // Flat config.toml: budget / ci are top-level tables (no config: wrapper).
5903        let cfg = "[budget.unattended]\ncost_cap_usd = 7.5\n\n[ci]\ndeclared_none = true\n";
5904        let s = parse_settings(cfg);
5905        assert_eq!(s.unattended_cost_cap_usd, Some(Ok(7.5)));
5906        assert!(s.ci_declared_none);
5907    }
5908
5909    #[test]
5910    fn stderr_tail_multibyte_boundary_no_panic() {
5911        // gemini HIGH on #447: tail slice must land on a char boundary.
5912        let mut payload = String::new();
5913        while payload.len() < 300 {
5914            payload.push('\u{00e9}'); // 2-byte char so len-200 can split one
5915        }
5916        let tail = stderr_tail(payload.as_bytes());
5917        assert!(tail.len() <= 200);
5918        assert!(!tail.is_empty());
5919    }
5920
5921    #[test]
5922    fn parse_manifest_attended_default_true() {
5923        let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\n---\n";
5924        let m = parse_manifest(content).unwrap();
5925        assert!(m.attended, "attended should default to true when absent");
5926    }
5927
5928    #[test]
5929    fn parse_manifest_budget_caps() {
5930        let content =
5931            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: 120\nbudget_cost_cap_usd: 5.0\n---\n";
5932        let m = parse_manifest(content).unwrap();
5933        assert_eq!(m.budget_wall_clock_cap_minutes, Some(Ok(120)));
5934        assert_eq!(m.budget_cost_cap_usd, Some(Ok(5.0)));
5935    }
5936
5937    #[test]
5938    fn parse_manifest_no_frontmatter_returns_none() {
5939        let content = "no frontmatter here";
5940        assert!(parse_manifest(content).is_none());
5941    }
5942
5943    #[test]
5944    fn parse_settings_flat_budget_cap() {
5945        let cfg = "budget_cap = 2.5\n";
5946        let s = parse_settings(cfg);
5947        assert_eq!(s.flat_budget_cap, Some(Ok(2.5)));
5948    }
5949
5950    #[test]
5951    fn parse_settings_nested_budget() {
5952        let cfg = "[budget.attended]\nwall_clock_cap_minutes = 90\ncost_cap_usd = 10.0\n\n[budget.unattended]\nwall_clock_cap_minutes = 60\ncost_cap_usd = 5.0\n";
5953        let s = parse_settings(cfg);
5954        assert_eq!(s.attended_wall_cap_minutes, Some(Ok(90)));
5955        assert_eq!(s.attended_cost_cap_usd, Some(Ok(10.0)));
5956        assert_eq!(s.unattended_wall_cap_minutes, Some(Ok(60)));
5957        assert_eq!(s.unattended_cost_cap_usd, Some(Ok(5.0)));
5958    }
5959
5960    #[test]
5961    fn parse_settings_ci_declared_none() {
5962        let cfg = "[ci]\ndeclared_none = true\n";
5963        let s = parse_settings(cfg);
5964        assert!(s.ci_declared_none);
5965    }
5966
5967    #[test]
5968    fn parse_settings_comments_ignored() {
5969        let cfg =
5970            "# top comment\nbudget_cap = 1.0\n# another\n[ci]\n# inner\ndeclared_none = true\n";
5971        let s = parse_settings(cfg);
5972        assert_eq!(s.flat_budget_cap, Some(Ok(1.0)));
5973        assert!(s.ci_declared_none);
5974    }
5975
5976    #[test]
5977    fn detect_intent_promise() {
5978        let tmp = tempfile::tempdir().unwrap();
5979        let path = tmp.path().join("t.jsonl");
5980        let line = serde_json::json!({
5981            "message": {"role": "assistant", "content": "done <promise>COMPLETE</promise>"}
5982        });
5983        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
5984        assert_eq!(detect_intent_full(&path), Intent::Promise);
5985    }
5986
5987    #[test]
5988    fn detect_intent_aborted_beats_promise() {
5989        let tmp = tempfile::tempdir().unwrap();
5990        let path = tmp.path().join("t.jsonl");
5991        // Last line has aborted (even if earlier had promise, aborted in same msg wins)
5992        let line = serde_json::json!({
5993            "message": {"role": "assistant", "content": "<aborted reason=\"user\">done</aborted>"}
5994        });
5995        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
5996        assert!(matches!(detect_intent_full(&path), Intent::Aborted { .. }));
5997    }
5998
5999    #[test]
6000    fn detect_intent_tool_result_ignored() {
6001        // Tool result content with promise-like text should not trigger
6002        let tmp = tempfile::tempdir().unwrap();
6003        let path = tmp.path().join("t.jsonl");
6004        let user_line = serde_json::json!({
6005            "message": {"role": "user", "content": "<promise>fake</promise>"}
6006        });
6007        std::fs::write(&path, serde_json::to_string(&user_line).unwrap() + "\n").unwrap();
6008        assert_eq!(detect_intent_full(&path), Intent::None);
6009    }
6010
6011    #[test]
6012    fn detect_intent_none_when_no_assistant() {
6013        let tmp = tempfile::tempdir().unwrap();
6014        let path = tmp.path().join("t.jsonl");
6015        let line = serde_json::json!({"message": {"role": "user", "content": "go"}});
6016        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6017        assert_eq!(detect_intent_full(&path), Intent::None);
6018    }
6019
6020    #[test]
6021    fn detect_intent_array_content_blocks() {
6022        let tmp = tempfile::tempdir().unwrap();
6023        let path = tmp.path().join("t.jsonl");
6024        let line = serde_json::json!({
6025            "message": {
6026                "role": "assistant",
6027                "content": [
6028                    {"type": "text", "text": "<promise>done</promise>"},
6029                    {"type": "tool_use", "name": "Bash"}
6030                ]
6031            }
6032        });
6033        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6034        assert_eq!(detect_intent_full(&path), Intent::Promise);
6035    }
6036
6037    #[test]
6038    fn extract_last_assistant_message_plain_string() {
6039        let payload = r#"{"transcript_path":"/t.jsonl","last_assistant_message":"  done <promise>MISSION COMPLETE: x</promise>  "}"#;
6040        assert_eq!(
6041            extract_last_assistant_message(payload).as_deref(),
6042            Some("done <promise>MISSION COMPLETE: x</promise>")
6043        );
6044    }
6045
6046    #[test]
6047    fn extract_last_assistant_message_degrades_to_none() {
6048        // Missing field, malformed JSON, non-string value, and empty/blank
6049        // strings all degrade to None (transcript fallback), never an error.
6050        assert_eq!(
6051            extract_last_assistant_message(r#"{"transcript_path":"/t.jsonl"}"#),
6052            None
6053        );
6054        assert_eq!(extract_last_assistant_message("not json {"), None);
6055        assert_eq!(
6056            extract_last_assistant_message(r#"{"last_assistant_message":{"text":"obj"}}"#),
6057            None
6058        );
6059        assert_eq!(
6060            extract_last_assistant_message(r#"{"last_assistant_message":"   "}"#),
6061            None
6062        );
6063    }
6064
6065    #[test]
6066    fn detect_intent_payload_promise_wins_over_stale_transcript() {
6067        // AC2-HP: at the promise turn's own fire the transcript does NOT yet
6068        // contain the final message; the payload alone must carry the intent.
6069        let tmp = tempfile::tempdir().unwrap();
6070        let path = tmp.path().join("t.jsonl");
6071        let line = serde_json::json!({
6072            "message": {"role": "assistant", "content": "still working on it"}
6073        });
6074        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6075        let (intent, source) =
6076            detect_intent(Some("<promise>MISSION COMPLETE: done</promise>"), &path);
6077        assert_eq!(intent, Intent::Promise);
6078        assert_eq!(source, "payload");
6079    }
6080
6081    #[test]
6082    fn detect_intent_payload_no_tag_is_authoritative() {
6083        // A tag-less payload is the stopping turn's final text; it must NOT
6084        // fall through to the transcript (stale-promise containment).
6085        let tmp = tempfile::tempdir().unwrap();
6086        let path = tmp.path().join("t.jsonl");
6087        let line = serde_json::json!({
6088            "message": {"role": "assistant", "content": "<promise>old stale promise</promise>"}
6089        });
6090        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6091        let (intent, source) = detect_intent(Some("moving on to other work"), &path);
6092        assert_eq!(intent, Intent::None);
6093        assert_eq!(source, "payload");
6094    }
6095
6096    #[test]
6097    fn detect_intent_payload_aborted_beats_promise() {
6098        let (intent, source) = detect_intent(
6099            Some("<promise>done</promise> <aborted reason=\"kill\">stop</aborted>"),
6100            Path::new("/nonexistent"),
6101        );
6102        assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
6103        assert_eq!(source, "payload");
6104    }
6105
6106    #[test]
6107    fn watching_intent_parses_all_attrs() {
6108        let (intent, source) = detect_intent(
6109            Some("waiting <watching reason=\"ci\" pr=\"404\" timeout=\"30m\">"),
6110            Path::new("/nonexistent"),
6111        );
6112        assert_eq!(source, "payload");
6113        assert_eq!(
6114            intent,
6115            Intent::Watching {
6116                reason: "ci".into(),
6117                pr: Some("404".into()),
6118                timeout: Some("30m".into()),
6119            }
6120        );
6121    }
6122
6123    #[test]
6124    fn watching_intent_malformed_attrs_default_to_absent() {
6125        // A bare tag: attributes absent, not an error; lease math applies its
6126        // own default window downstream.
6127        let (intent, _) = detect_intent(Some("<watching>"), Path::new("/nonexistent"));
6128        assert_eq!(
6129            intent,
6130            Intent::Watching {
6131                reason: String::new(),
6132                pr: None,
6133                timeout: None,
6134            }
6135        );
6136    }
6137
6138    #[test]
6139    fn watching_intent_aborted_beats_watching() {
6140        let (intent, _) = detect_intent(
6141            Some("<watching reason=\"ci\" pr=\"1\"> <aborted reason=\"kill\">"),
6142            Path::new("/nonexistent"),
6143        );
6144        assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
6145    }
6146
6147    #[test]
6148    fn watching_intent_beats_promise() {
6149        let (intent, _) = detect_intent(
6150            Some("<promise>done</promise> <watching reason=\"review\" pr=\"9\">"),
6151            Path::new("/nonexistent"),
6152        );
6153        assert!(matches!(intent, Intent::Watching { .. }));
6154    }
6155
6156    #[test]
6157    fn watching_intent_newest_transcript_entry_honored() {
6158        let tmp = tempfile::tempdir().unwrap();
6159        let path = tmp.path().join("t.jsonl");
6160        let line = serde_json::json!({
6161            "message": {"role": "assistant", "content": "<watching reason=\"ci\" pr=\"7\">"}
6162        });
6163        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6164        assert!(matches!(detect_intent_full(&path), Intent::Watching { .. }));
6165    }
6166
6167    #[test]
6168    fn watching_intent_stale_transcript_not_honored() {
6169        // AC3-EDGE: a watching tag 2 entries back with a tag-less newest entry
6170        // must NOT resurrect as Watching (payload-or-newest-entry rule).
6171        let tmp = tempfile::tempdir().unwrap();
6172        let path = tmp.path().join("t.jsonl");
6173        let mut content = String::new();
6174        for text in [
6175            "<watching reason=\"ci\" pr=\"3\">", // oldest
6176            "still going",
6177            "moving on to unrelated work", // newest
6178        ] {
6179            let line = serde_json::json!({"message": {"role": "assistant", "content": text}});
6180            content.push_str(&serde_json::to_string(&line).unwrap());
6181            content.push('\n');
6182        }
6183        std::fs::write(&path, content).unwrap();
6184        assert_eq!(detect_intent_full(&path), Intent::None);
6185    }
6186
6187    #[test]
6188    fn watching_intent_stale_watch_does_not_shadow_deeper_promise() {
6189        // A stale watching in the newest-but-one entry is skipped, and a real
6190        // promise deeper in the lookback window still wins.
6191        let tmp = tempfile::tempdir().unwrap();
6192        let path = tmp.path().join("t.jsonl");
6193        let mut content = String::new();
6194        for text in [
6195            "<promise>MISSION COMPLETE: shipped</promise>", // oldest, real
6196            "<watching reason=\"ci\" pr=\"3\">",            // stale (not newest)
6197            "tag-less newest",                              // newest
6198        ] {
6199            let line = serde_json::json!({"message": {"role": "assistant", "content": text}});
6200            content.push_str(&serde_json::to_string(&line).unwrap());
6201            content.push('\n');
6202        }
6203        std::fs::write(&path, content).unwrap();
6204        assert_eq!(detect_intent_full(&path), Intent::Promise);
6205    }
6206
6207    #[test]
6208    fn detect_intent_absent_payload_falls_back_to_transcript() {
6209        let tmp = tempfile::tempdir().unwrap();
6210        let path = tmp.path().join("t.jsonl");
6211        let line = serde_json::json!({
6212            "message": {"role": "assistant", "content": "<promise>COMPLETE</promise>"}
6213        });
6214        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6215        let (intent, source) = detect_intent(None, &path);
6216        assert_eq!(intent, Intent::Promise);
6217        assert_eq!(source, "transcript");
6218    }
6219
6220    #[test]
6221    fn detect_intent_lookback_finds_promise_behind_block_feedback() {
6222        // AC2-EDGE ("the block destroys the evidence"): promise 3 assistant
6223        // text entries back - block feedback reply + a follow-up on top -
6224        // must still be detected by the bounded fallback scan.
6225        let tmp = tempfile::tempdir().unwrap();
6226        let path = tmp.path().join("t.jsonl");
6227        let mut content = String::new();
6228        for text in [
6229            "<promise>MISSION COMPLETE: shipped</promise>",
6230            "acknowledged the block; checking CI",
6231            "CI is still pending, waiting",
6232        ] {
6233            let line = serde_json::json!({
6234                "message": {"role": "assistant", "content": text}
6235            });
6236            content.push_str(&serde_json::to_string(&line).unwrap());
6237            content.push('\n');
6238        }
6239        std::fs::write(&path, content).unwrap();
6240        assert_eq!(detect_intent_full(&path), Intent::Promise);
6241    }
6242
6243    #[test]
6244    fn detect_intent_lookback_bound_holds() {
6245        // AC2-EDGE ("grill the stale-promise edge"): a promise older than
6246        // INTENT_LOOKBACK_ENTRIES assistant text entries must NOT ride the
6247        // window.
6248        let tmp = tempfile::tempdir().unwrap();
6249        let path = tmp.path().join("t.jsonl");
6250        let mut content = String::new();
6251        let line = serde_json::json!({
6252            "message": {"role": "assistant", "content": "<promise>stale</promise>"}
6253        });
6254        content.push_str(&serde_json::to_string(&line).unwrap());
6255        content.push('\n');
6256        for i in 0..INTENT_LOOKBACK_ENTRIES {
6257            let line = serde_json::json!({
6258                "message": {"role": "assistant", "content": format!("pivoted work step {i}")}
6259            });
6260            content.push_str(&serde_json::to_string(&line).unwrap());
6261            content.push('\n');
6262        }
6263        std::fs::write(&path, content).unwrap();
6264        assert_eq!(detect_intent_full(&path), Intent::None);
6265    }
6266
6267    #[test]
6268    fn parse_args_hook_input_stdin_flag() {
6269        let args: Vec<String> = [
6270            "loop-check",
6271            "--state",
6272            "/s.md",
6273            "--transcript",
6274            "/t.jsonl",
6275            "--cwd",
6276            "/w",
6277            "--hook-input-stdin",
6278        ]
6279        .iter()
6280        .map(|s| s.to_string())
6281        .collect();
6282        let parsed = parse_args(&args).unwrap();
6283        assert!(parsed.hook_input_stdin);
6284        // Bare flag must not swallow a following flag as its value.
6285        assert_eq!(parsed.cwd, PathBuf::from("/w"));
6286    }
6287
6288    #[test]
6289    fn block_reason_pending_ci_is_not_red() {
6290        // The MUTE_PROBE_N probe runs done() while CI is often still in
6291        // flight; a Pending conclusion must read as "still running", never
6292        // as the misleading "CI red ... failed" (observed live on PR #455).
6293        let pr = PrInfo {
6294            state: PrState::Open,
6295            number: 455,
6296            head_oid: "abc".to_string(),
6297            ci_conclusion: CiConclusion::Pending,
6298            failing_checks: vec![],
6299            ci_has_pending: false,
6300            mergeable: "UNKNOWN".to_string(),
6301            latest_review_ts: "none".to_string(),
6302            reviewed: false,
6303            missing_bots: vec![],
6304            bot_nudges: vec![],
6305            usage_limited: vec![],
6306            unaddressed_findings: vec![],
6307            review_skipped: false,
6308            unattested_reviewers: vec![],
6309            malformed_attestations: 0,
6310        };
6311        let reason = build_block_reason(&pr, "abc", true);
6312        assert!(
6313            reason.contains("still running"),
6314            "pending CI must not read as red; got: {reason}"
6315        );
6316        assert!(!reason.contains("failed"), "got: {reason}");
6317    }
6318
6319    #[test]
6320    fn unwatched_async_nudge_ci_pending_teaches_arm_and_tag() {
6321        // AC3-HP: the CI-pending block message must instruct arming a
6322        // harness-tracked watcher with a timeout and emitting <watching>,
6323        // replacing the old "wait silently" prose.
6324        let pr = PrInfo {
6325            ci_conclusion: CiConclusion::Pending,
6326            ci_has_pending: true,
6327            ..watch_pr()
6328        };
6329        let reason = build_block_reason(&pr, "abc", true);
6330        assert!(reason.contains("<watching"), "got: {reason}");
6331        assert!(reason.contains("timeout"), "got: {reason}");
6332        assert!(reason.contains("gh pr checks"), "got: {reason}");
6333        assert!(!reason.contains("wait silently"), "got: {reason}");
6334    }
6335
6336    #[test]
6337    fn no_hint_prescribes_the_timeout_binary() {
6338        // File-wide, so a future hint cannot reintroduce `timeout(1)` at a site
6339        // this test does not name. The needle is built at runtime so the test
6340        // does not match its own source.
6341        let needle = ["timeout", " "].concat();
6342        for tail in include_str!("loopcheck.rs").split(&needle).skip(1) {
6343            assert!(
6344                !tail.trim_start().starts_with(|c: char| c.is_ascii_digit()),
6345                "bare timeout invocation: ...{}",
6346                tail.chars().take(60).collect::<String>()
6347            );
6348        }
6349    }
6350
6351    #[test]
6352    fn unwatched_async_nudge_missing_review_teaches_arm_and_tag() {
6353        let pr = PrInfo {
6354            ci_conclusion: CiConclusion::Success,
6355            ci_has_pending: false,
6356            reviewed: false,
6357            missing_bots: vec!["chatgpt-codex-connector".into()],
6358            bot_nudges: vec![],
6359            ..watch_pr()
6360        };
6361        let reason = build_block_reason(&pr, "abc", true);
6362        assert!(reason.contains("chatgpt-codex-connector"), "got: {reason}");
6363        assert!(reason.contains("<watching"), "got: {reason}");
6364    }
6365
6366    // ── Watching idle-allow classification (x-e2c8) ───────────────────────
6367    /// An open PR whose head matches local HEAD, CI still pending, no findings.
6368    fn watch_pr() -> PrInfo {
6369        PrInfo {
6370            state: PrState::Open,
6371            number: 404,
6372            head_oid: "abc".to_string(),
6373            ci_conclusion: CiConclusion::Pending,
6374            failing_checks: vec![],
6375            ci_has_pending: true,
6376            mergeable: "UNKNOWN".to_string(),
6377            latest_review_ts: "none".to_string(),
6378            reviewed: false,
6379            missing_bots: vec![],
6380            bot_nudges: vec![],
6381            usage_limited: vec![],
6382            unaddressed_findings: vec![],
6383            review_skipped: false,
6384            unattested_reviewers: vec![],
6385            malformed_attestations: 0,
6386        }
6387    }
6388
6389    #[test]
6390    fn watch_idle_classifies_pending_ci() {
6391        assert_eq!(async_wait_class(&watch_pr(), "abc", true), Some("ci"));
6392    }
6393
6394    #[test]
6395    fn codex_watch_harness_gate_is_claude_only() {
6396        // Only Claude self-wakes on a background-task exit, so only Claude idles.
6397        assert!(harness_can_idle(Some("claude"), false));
6398        // A loop-run child (FNO_DRIVER_LIB) exits on allow -> never idles.
6399        assert!(!harness_can_idle(Some("claude"), true));
6400        // codex/gemini have no self-wake; their daemon-consumer waker ships
6401        // separately, so until then they keep today's block behavior.
6402        assert!(!harness_can_idle(Some("codex"), false));
6403        assert!(!harness_can_idle(Some("gemini"), false));
6404        // Unknown harness (bare shell / daemon): conservative block.
6405        assert!(!harness_can_idle(None, false));
6406    }
6407
6408    #[test]
6409    fn watch_idle_classifies_awaiting_review() {
6410        // CI green, no pending checks, and a required GitHub bot has not reviewed.
6411        let pr = PrInfo {
6412            ci_conclusion: CiConclusion::Success,
6413            ci_has_pending: false,
6414            reviewed: false,
6415            review_skipped: false,
6416            missing_bots: vec!["chatgpt-codex-connector".into()],
6417            bot_nudges: vec![],
6418            ..watch_pr()
6419        };
6420        assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6421    }
6422
6423    #[test]
6424    fn watch_idle_rejects_ci_pending_with_a_failure() {
6425        // gemini finding: a check has ALREADY concluded red while others run.
6426        // The agent should debug now, not idle out the remaining pending checks.
6427        let pr = PrInfo {
6428            ci_conclusion: CiConclusion::Failure(Some("unit".into())),
6429            ci_has_pending: true,
6430            ..watch_pr()
6431        };
6432        assert_eq!(async_wait_class(&pr, "abc", true), None);
6433    }
6434
6435    #[test]
6436    fn watch_idle_rejects_local_attestation_review_gate() {
6437        // codex P1: reviewed=false with an EMPTY missing_bots is a local
6438        // attestation (sigma) or unaddressed-finding gate - no GitHub reviewer
6439        // will ever post to wake the session, so idling would park it forever.
6440        let pr = PrInfo {
6441            ci_conclusion: CiConclusion::Success,
6442            ci_has_pending: false,
6443            reviewed: false,
6444            review_skipped: false,
6445            missing_bots: vec![],
6446            bot_nudges: vec![],
6447            ..watch_pr()
6448        };
6449        assert_eq!(async_wait_class(&pr, "abc", true), None);
6450    }
6451
6452    // ── x-b167 idle rule + message rendering ──────────────────────────────────
6453
6454    fn bn(login: &str, class: NudgeClass, nudges: usize, newest: i64, span: i64) -> BotNudge {
6455        BotNudge {
6456            login: login.into(),
6457            class,
6458            review_handle: "@codex review".into(),
6459            ceiling: 3,
6460            nudges,
6461            newest_age_min: newest,
6462            span_min: span,
6463        }
6464    }
6465    fn bot_review_pr(login: &str, nudges: Vec<BotNudge>) -> PrInfo {
6466        PrInfo {
6467            number: 618,
6468            ci_conclusion: CiConclusion::Success,
6469            ci_has_pending: false,
6470            reviewed: false,
6471            review_skipped: false,
6472            missing_bots: vec![login.into()],
6473            bot_nudges: nudges,
6474            ..watch_pr()
6475        }
6476    }
6477
6478    #[test]
6479    fn nudge_needs_nudge_blocks_and_names_the_command() {
6480        // AC1: not idlable; reason gives the exact gh command; no arm-and-tag hint.
6481        let pr = bot_review_pr(
6482            "chatgpt-codex-connector",
6483            vec![bn(
6484                "chatgpt-codex-connector",
6485                NudgeClass::NeedsNudge,
6486                0,
6487                0,
6488                0,
6489            )],
6490        );
6491        assert_eq!(async_wait_class(&pr, "abc", true), None);
6492        let reason = build_block_reason(&pr, "abc", true);
6493        assert!(
6494            reason.contains("gh pr comment 618 --body \"@codex review\""),
6495            "{reason}"
6496        );
6497        assert!(
6498            !reason.contains("harness-tracked watcher"),
6499            "no arm hint: {reason}"
6500        );
6501    }
6502
6503    #[test]
6504    fn nudge_awaiting_idles_with_the_arm_hint() {
6505        // AC2: a genuine async wait - idlable, message says nudged + awaiting,
6506        // and the arm-and-tag ritual is present.
6507        let pr = bot_review_pr(
6508            "chatgpt-codex-connector",
6509            vec![bn("chatgpt-codex-connector", NudgeClass::Awaiting, 1, 3, 3)],
6510        );
6511        assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6512        let reason = build_block_reason(&pr, "abc", true);
6513        assert!(reason.contains("nudged"), "{reason}");
6514        assert!(reason.contains("awaiting"), "{reason}");
6515        assert!(
6516            reason.contains("harness-tracked watcher"),
6517            "arm hint present: {reason}"
6518        );
6519    }
6520
6521    #[test]
6522    fn nudge_unresponsive_blocks_and_names_optional_apps() {
6523        // AC3: not idlable; names the give-up + optional_apps; no arm-and-tag hint.
6524        let pr = bot_review_pr(
6525            "chatgpt-codex-connector",
6526            vec![bn(
6527                "chatgpt-codex-connector",
6528                NudgeClass::Unresponsive,
6529                3,
6530                20,
6531                47,
6532            )],
6533        );
6534        assert_eq!(async_wait_class(&pr, "abc", true), None);
6535        let reason = build_block_reason(&pr, "abc", true);
6536        assert!(
6537            reason.contains("did not review after 3 nudges over 47m"),
6538            "{reason}"
6539        );
6540        assert!(reason.contains("config.review.optional_apps"), "{reason}");
6541        assert!(reason.contains("do not arm a watcher"), "{reason}");
6542        assert!(
6543            !reason.contains("harness-tracked watcher"),
6544            "no arm hint: {reason}"
6545        );
6546    }
6547
6548    #[test]
6549    fn nudge_not_nudgeable_keeps_todays_behavior() {
6550        // AC5: a non-nudgeable required bot keeps today's string + arm hint and
6551        // stays idlable, regardless of comment history.
6552        let pr = bot_review_pr(
6553            "gemini-code-assist",
6554            vec![bn("gemini-code-assist", NudgeClass::NotNudgeable, 0, 0, 0)],
6555        );
6556        assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6557        let reason = build_block_reason(&pr, "abc", true);
6558        assert!(
6559            reason.contains("gemini-code-assist has not reviewed"),
6560            "{reason}"
6561        );
6562        assert!(
6563            reason.contains("harness-tracked watcher"),
6564            "arm hint present: {reason}"
6565        );
6566    }
6567
6568    #[test]
6569    fn nudge_empty_classification_is_status_quo() {
6570        // A non-empty missing_bots with an EMPTY bot_nudges (not classified)
6571        // behaves exactly as pre-x-b167: idlable, today's string.
6572        let pr = bot_review_pr("chatgpt-codex-connector", vec![]);
6573        assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6574        let reason = build_block_reason(&pr, "abc", true);
6575        assert!(reason.contains("has not reviewed"), "{reason}");
6576    }
6577
6578    #[test]
6579    fn finding_block_reason_names_the_reply_handle() {
6580        // AC14: an unaddressed finding by a known bot names the handle a reply
6581        // must address, not just "reply in-thread".
6582        let pr = PrInfo {
6583            ci_conclusion: CiConclusion::Success,
6584            ci_has_pending: false,
6585            reviewed: false,
6586            unaddressed_findings: vec![Finding {
6587                id: 1,
6588                author: "chatgpt-codex-connector".into(),
6589                path: "a.rs".into(),
6590                line: 10,
6591                created_at: "2026-07-06T01:00:00Z".into(),
6592                severity: "P1",
6593            }],
6594            ..watch_pr()
6595        };
6596        let reason = build_block_reason(&pr, "abc", true);
6597        assert!(reason.contains("@chatgpt-codex-connector"), "{reason}");
6598    }
6599
6600    #[test]
6601    fn nudge_post_is_suppressed_by_the_escape_hatch() {
6602        // The test suite must never comment on a real PR. With the guard set,
6603        // post_nudge_comment returns false without spawning gh (a bogus bin here
6604        // would otherwise error, not silently succeed).
6605        std::env::set_var("FNO_LOOPCHECK_NO_COMMENT", "1");
6606        let posted = post_nudge_comment(
6607            "/nonexistent/gh",
6608            std::path::Path::new("/tmp"),
6609            618,
6610            "@codex review",
6611        );
6612        std::env::remove_var("FNO_LOOPCHECK_NO_COMMENT");
6613        assert!(!posted);
6614    }
6615
6616    #[test]
6617    fn unresponsive_bot_drives_the_giveup_message() {
6618        // AC13: the NoProgress message names the bot, the nudge count, and the
6619        // elapsed time instead of a bare fingerprint streak.
6620        let pr = bot_review_pr(
6621            "chatgpt-codex-connector",
6622            vec![bn(
6623                "chatgpt-codex-connector",
6624                NudgeClass::Unresponsive,
6625                3,
6626                20,
6627                47,
6628            )],
6629        );
6630        let n = unresponsive_bot(&pr).expect("an unresponsive bot");
6631        let msg = nudge_giveup_message(n);
6632        assert!(msg.contains("chatgpt-codex-connector"), "{msg}");
6633        assert!(msg.contains("3 nudges over 47m"), "{msg}");
6634        assert!(msg.contains("config.review.optional_apps"), "{msg}");
6635    }
6636
6637    #[test]
6638    fn no_giveup_for_an_awaiting_bot() {
6639        let pr = bot_review_pr(
6640            "chatgpt-codex-connector",
6641            vec![bn("chatgpt-codex-connector", NudgeClass::Awaiting, 1, 3, 3)],
6642        );
6643        assert!(unresponsive_bot(&pr).is_none());
6644    }
6645
6646    /// The exact state PR #618 sat in for ~15 turns: CI green, no required
6647    /// bots, no unaddressed findings, and a `reviewers: [sigma]` gate with no
6648    /// head-pinned attestation. `reviewers_ok` was the sole failing term.
6649    fn reviewers_gate_pr() -> PrInfo {
6650        PrInfo {
6651            ci_conclusion: CiConclusion::Success,
6652            ci_has_pending: false,
6653            reviewed: false,
6654            review_skipped: false,
6655            missing_bots: vec![],
6656            bot_nudges: vec![],
6657            unaddressed_findings: vec![],
6658            unattested_reviewers: vec![UnattestedReviewer {
6659                name: "sigma".to_string(),
6660                superseded_head: None,
6661                failed_at_head: false,
6662            }],
6663            ..watch_pr()
6664        }
6665    }
6666
6667    #[test]
6668    fn block_reason_names_the_reviewers_gate_not_a_bot() {
6669        // AC2: the old string claimed a bot had not reviewed while
6670        // required_bots was empty and the real blocker was local.
6671        let reason = build_block_reason(&reviewers_gate_pr(), "abc", true);
6672        assert!(reason.contains("reviewers gate unmet"), "got: {reason}");
6673        assert!(reason.contains("sigma"), "got: {reason}");
6674        assert!(reason.contains("/fno:review sigma"), "got: {reason}");
6675        assert!(!reason.contains("bot reviewer"), "got: {reason}");
6676    }
6677
6678    #[test]
6679    fn block_reason_names_the_local_peer_invocation() {
6680        let mut pr = reviewers_gate_pr();
6681        pr.unattested_reviewers[0].name = LOCAL_PEER_REVIEWER.to_string();
6682        let reason = build_block_reason(&pr, "abc", true);
6683        assert!(
6684            reason.contains("/fno:review peer --attest"),
6685            "got: {reason}"
6686        );
6687        assert!(
6688            !reason.contains("wait on a GitHub reviewer"),
6689            "got: {reason}"
6690        );
6691    }
6692
6693    #[test]
6694    fn block_reason_explains_same_model_local_peer_refusal() {
6695        let mut pr = reviewers_gate_pr();
6696        pr.unattested_reviewers[0].name = SAME_MODEL_LOCAL_PEER_SENTINEL.to_string();
6697        let reason = build_block_reason(&pr, "abc", true);
6698        assert!(
6699            reason.contains("configure a cross-model peer"),
6700            "got: {reason}"
6701        );
6702        assert!(
6703            !reason.contains(SAME_MODEL_LOCAL_PEER_SENTINEL),
6704            "got: {reason}"
6705        );
6706    }
6707
6708    #[test]
6709    fn block_reason_reviewers_gate_emits_no_idle_ritual() {
6710        // AC3: async_wait_class already excluded this blocker from idling
6711        // (watch_idle_rejects_local_attestation_review_gate), so prescribing
6712        // the arm-and-tag ritual here is the code contradicting itself.
6713        let pr = reviewers_gate_pr();
6714        assert_eq!(async_wait_class(&pr, "abc", true), None);
6715        let reason = build_block_reason(&pr, "abc", true);
6716        assert!(!reason.contains("<watching"), "got: {reason}");
6717        assert!(
6718            !reason.contains("Arm a harness-tracked watcher"),
6719            "got: {reason}"
6720        );
6721        assert!(!reason.contains("gh pr checks"), "got: {reason}");
6722    }
6723
6724    #[test]
6725    fn block_reason_names_a_superseded_attestation_head() {
6726        // A session that ran sigma and then pushed must not read "you never
6727        // ran sigma"; name the head the pass is pinned to.
6728        let pr = PrInfo {
6729            unattested_reviewers: vec![UnattestedReviewer {
6730                name: "sigma".to_string(),
6731                superseded_head: Some("0123456789abcdef".to_string()),
6732                failed_at_head: false,
6733            }],
6734            ..reviewers_gate_pr()
6735        };
6736        let reason = build_block_reason(&pr, "abc", true);
6737        assert!(reason.contains("01234567"), "got: {reason}");
6738        assert!(reason.contains("superseded"), "got: {reason}");
6739    }
6740
6741    #[test]
6742    fn block_reason_generic_review_fallback_has_no_idle_ritual() {
6743        // The fallback is only reachable with an EMPTY missing_bots, which
6744        // async_wait_class refuses to idle. It must not teach the ritual either.
6745        let pr = PrInfo {
6746            unattested_reviewers: vec![],
6747            ..reviewers_gate_pr()
6748        };
6749        let reason = build_block_reason(&pr, "abc", true);
6750        assert!(!reason.contains("<watching"), "got: {reason}");
6751        assert!(!reason.contains("bot reviewer"), "got: {reason}");
6752    }
6753
6754    #[test]
6755    fn block_reason_missing_bot_still_teaches_the_ritual() {
6756        // AC7-adjacent regression: a REAL outstanding GitHub bot, and nothing
6757        // local outstanding, is a valid async wait and keeps today's message.
6758        let pr = PrInfo {
6759            missing_bots: vec!["chatgpt-codex-connector".into()],
6760            bot_nudges: vec![],
6761            unattested_reviewers: vec![],
6762            ..reviewers_gate_pr()
6763        };
6764        let reason = build_block_reason(&pr, "abc", true);
6765        assert!(reason.contains("chatgpt-codex-connector"), "got: {reason}");
6766        assert!(reason.contains("<watching"), "got: {reason}");
6767    }
6768
6769    #[test]
6770    fn block_reason_local_work_outranks_a_bot_wait() {
6771        // Codex review of this PR: with a bot AND a local reviewer both
6772        // outstanding, naming only the bot hides the half the session can act
6773        // on now. Worse, if the bot never posts, the local work never happens
6774        // and the run dies on budget with the gate still unmet - the #618 shape
6775        // this node exists to delete.
6776        let pr = PrInfo {
6777            missing_bots: vec!["chatgpt-codex-connector".into()],
6778            bot_nudges: vec![],
6779            ..reviewers_gate_pr()
6780        };
6781        let reason = build_block_reason(&pr, "abc", true);
6782        assert!(reason.contains("reviewers gate unmet"), "got: {reason}");
6783        assert!(!reason.contains("<watching"), "got: {reason}");
6784        // Once the local half is attested, the bot wait is the sole blocker and
6785        // the arm-and-tag message returns.
6786        let after = PrInfo {
6787            unattested_reviewers: vec![],
6788            ..pr
6789        };
6790        assert!(build_block_reason(&after, "abc", true).contains("<watching"));
6791    }
6792
6793    #[test]
6794    fn reviewers_gate_stays_fail_closed() {
6795        // AC7: promoting the predicate's return value to a list must not move
6796        // the gate. Missing file, missing event, stale head, and a `fail`
6797        // verdict all still leave the reviewer unsatisfied.
6798        let tmp = tempfile::tempdir().unwrap();
6799        let missing = tmp.path().join("absent.jsonl");
6800        let sigma = vec!["sigma".to_string()];
6801        assert!(!unattested_reviewers(&missing, &sigma, "h").is_empty());
6802
6803        let stale = tmp.path().join("stale.jsonl");
6804        std::fs::write(
6805            &stale,
6806            r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6807        )
6808        .unwrap();
6809        let out = unattested_reviewers(&stale, &sigma, "NEW");
6810        assert_eq!(out.len(), 1);
6811        assert_eq!(out[0].superseded_head.as_deref(), Some("OLD"));
6812        assert!(!out[0].failed_at_head);
6813
6814        let failed = tmp.path().join("fail.jsonl");
6815        std::fs::write(
6816            &failed,
6817            r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
6818        )
6819        .unwrap();
6820        let out = unattested_reviewers(&failed, &sigma, "h");
6821        assert_eq!(out.len(), 1);
6822        // A head-pinned fail is not a superseded pass; do not offer a stale head.
6823        assert_eq!(out[0].superseded_head, None);
6824        // ...but it IS an attestation at this head, and the message says so
6825        // rather than claiming none exists. Pinned at the PARSER: the message
6826        // test hand-builds the struct and never exercises this derivation, so
6827        // `failed_at_head: false` survived the whole suite before this line.
6828        assert!(
6829            out[0].failed_at_head,
6830            "a fail at HEAD must be reported as such"
6831        );
6832    }
6833
6834    #[test]
6835    fn unpinned_attestation_never_counts_as_evidence() {
6836        // codex P1 on this PR: defaulting a missing head_sha to "" made an
6837        // unpinned event MATCH a caller whose own head_sha is "", turning
6838        // no-evidence into a pass.
6839        let tmp = tempfile::tempdir().unwrap();
6840        let p = tmp.path().join("e.jsonl");
6841        std::fs::write(
6842            &p,
6843            r#"{"type":"review_attestation","data":{"reviewer":"sigma","verdict":"pass"}}"#,
6844        )
6845        .unwrap();
6846        let out = unattested_reviewers(&p, &["sigma".to_string()], "");
6847        assert_eq!(out.len(), 1, "unpinned evidence must not satisfy the gate");
6848        assert_eq!(out[0].superseded_head, None);
6849    }
6850
6851    #[test]
6852    fn a_failed_old_head_is_not_reported_as_superseded() {
6853        // codex P2: "attested at X, superseded" implies a prior PASS. An
6854        // old-head fail rendered that way invents a review that never passed.
6855        let tmp = tempfile::tempdir().unwrap();
6856        let p = tmp.path().join("e.jsonl");
6857        std::fs::write(
6858            &p,
6859            r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
6860        )
6861        .unwrap();
6862        let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
6863        assert_eq!(out.len(), 1);
6864        assert_eq!(out[0].superseded_head, None);
6865    }
6866
6867    #[test]
6868    fn a_corrupt_attestation_line_is_counted_and_named() {
6869        // A torn write leaves an unparseable review_attestation in the file.
6870        // The gate must still fail closed, but reporting "no head-pinned
6871        // review_attestation" over a corrupt one is the same class of lie this
6872        // node deletes. The sibling review_finding scanner already counts its
6873        // malformed lines; this one did not.
6874        let tmp = tempfile::tempdir().unwrap();
6875        let p = tmp.path().join("e.jsonl");
6876        std::fs::write(
6877            &p,
6878            concat!(
6879                r#"{"type":"review_attestation","data":{"reviewer":"sigma","hea"#,
6880                "\n",
6881                r#"{"type":"loop_check","data":{}}"#,
6882            ),
6883        )
6884        .unwrap();
6885        let (out, malformed) = unattested_reviewers_scan(&p, &["sigma".to_string()], "h");
6886        assert_eq!(out.len(), 1, "a corrupt line never satisfies the gate");
6887        assert_eq!(malformed, 1, "and it is counted, not silently dropped");
6888
6889        let pr = PrInfo {
6890            malformed_attestations: malformed,
6891            ..reviewers_gate_pr()
6892        };
6893        let reason = build_block_reason(&pr, "abc", true);
6894        assert!(
6895            reason.contains("unparseable attestation line"),
6896            "got: {reason}"
6897        );
6898
6899        // A clean file adds nothing to the message.
6900        std::fs::write(&p, r#"{"type":"loop_check","data":{}}"#).unwrap();
6901        assert_eq!(
6902            unattested_reviewers_scan(&p, &["sigma".to_string()], "h").1,
6903            0
6904        );
6905        assert!(!build_block_reason(&reviewers_gate_pr(), "abc", true)
6906            .contains("unparseable attestation line"));
6907    }
6908
6909    #[test]
6910    fn a_revoked_pass_falls_back_to_an_older_passing_head() {
6911        // codex P2: `pass A, pass B, fail B` with HEAD C. A single "most recent
6912        // pass" entry overwrites A with B and then drops B, so the message
6913        // claims no prior pass while A is still a real one - the misleading
6914        // guidance this whole node exists to delete, reappearing in exactly the
6915        // multi-round review/fix cycle that produces this sequence.
6916        let tmp = tempfile::tempdir().unwrap();
6917        let p = tmp.path().join("e.jsonl");
6918        let line = |head: &str, verdict: &str| {
6919            format!(
6920                r#"{{"type":"review_attestation","data":{{"reviewer":"sigma","head_sha":"{head}","verdict":"{verdict}"}}}}"#
6921            )
6922        };
6923        std::fs::write(
6924            &p,
6925            [
6926                line("AAA", "pass"),
6927                line("BBB", "pass"),
6928                line("BBB", "fail"),
6929            ]
6930            .join("\n"),
6931        )
6932        .unwrap();
6933        let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
6934        assert_eq!(out.len(), 1);
6935        assert_eq!(
6936            out[0].superseded_head.as_deref(),
6937            Some("AAA"),
6938            "a still-valid older pass must survive a newer head's retraction"
6939        );
6940
6941        // The newest STILL-PASSING head wins when several are valid.
6942        std::fs::write(&p, [line("AAA", "pass"), line("BBB", "pass")].join("\n")).unwrap();
6943        let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
6944        assert_eq!(out[0].superseded_head.as_deref(), Some("BBB"));
6945
6946        // Every old head retracted -> nothing to name.
6947        std::fs::write(
6948            &p,
6949            [
6950                line("AAA", "pass"),
6951                line("BBB", "pass"),
6952                line("BBB", "fail"),
6953                line("AAA", "fail"),
6954            ]
6955            .join("\n"),
6956        )
6957        .unwrap();
6958        let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
6959        assert_eq!(out[0].superseded_head, None);
6960    }
6961
6962    #[test]
6963    fn a_later_fail_revokes_the_superseded_pass_for_that_head() {
6964        // Append-ordered pass-then-fail on the SAME old head. The pass was
6965        // recorded as superseded and the fail merely skipped, so the message
6966        // kept claiming that head was successfully attested after its latest
6967        // verdict retracted exactly that (codex P2 on this PR).
6968        let tmp = tempfile::tempdir().unwrap();
6969        let p = tmp.path().join("e.jsonl");
6970        std::fs::write(
6971            &p,
6972            concat!(
6973                r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6974                "\n",
6975                r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
6976            ),
6977        )
6978        .unwrap();
6979        let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
6980        assert_eq!(out.len(), 1);
6981        assert_eq!(
6982            out[0].superseded_head, None,
6983            "a retracted pass is not evidence"
6984        );
6985
6986        // A re-run pass after the fail restores it: revocation is latest-wins,
6987        // not a one-way latch.
6988        std::fs::write(
6989            &p,
6990            concat!(
6991                r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6992                "\n",
6993                r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
6994                "\n",
6995                r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6996            ),
6997        )
6998        .unwrap();
6999        let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
7000        assert_eq!(out[0].superseded_head.as_deref(), Some("OLD"));
7001    }
7002
7003    #[test]
7004    fn short_sha_never_panics_on_multibyte() {
7005        // codex P2: `&s[..8]` panics when byte 8 lands inside a character, and
7006        // superseded_head comes from a user-writable events.jsonl.
7007        assert_eq!(short_sha("0123456789ab"), "01234567");
7008        assert_eq!(short_sha("abc"), "abc");
7009        assert_eq!(short_sha(""), "");
7010        assert_eq!(short_sha("1234567\u{e9}xyz"), "1234567\u{e9}");
7011        let pr = PrInfo {
7012            unattested_reviewers: vec![UnattestedReviewer {
7013                name: "sigma".to_string(),
7014                superseded_head: Some("1234567\u{e9}abc".to_string()),
7015                failed_at_head: false,
7016            }],
7017            ..reviewers_gate_pr()
7018        };
7019        build_block_reason(&pr, "1234567\u{e9}abc", true);
7020    }
7021
7022    #[test]
7023    fn watcher_hint_never_contradicts_the_idle_classifier() {
7024        // codex P1: the missing-bot branch emitted the ritual unconditionally,
7025        // including for states async_wait_class refuses to idle (an unaddressed
7026        // finding, or an open operator finding). The hint is now derived from
7027        // that same classifier, so the two agree by construction.
7028        let bot_only = PrInfo {
7029            missing_bots: vec!["chatgpt-codex-connector".into()],
7030            bot_nudges: vec![],
7031            unattested_reviewers: vec![],
7032            ..reviewers_gate_pr()
7033        };
7034        for (label, pr, open_empty) in [
7035            // Reaches the FINDINGS branch, not the bot branch: unaddressed
7036            // findings render first. Kept because the invariant under test is
7037            // "no hint for a non-idlable state", which holds branch-wide - but
7038            // it is the case below that reaches `missing_bots`, so that one is
7039            // what would catch a reintroduced unconditional `arm_watch_hint`
7040            // there. A sigma round caught this test silently losing its teeth
7041            // when the branch order moved out from under it.
7042            (
7043                "bot + unaddressed finding (renders as the finding)",
7044                PrInfo {
7045                    missing_bots: vec!["chatgpt-codex-connector".into()],
7046                    bot_nudges: vec![],
7047                    unattested_reviewers: vec![],
7048                    unaddressed_findings: vec![Finding {
7049                        id: 1,
7050                        author: "codex".into(),
7051                        path: "a.rs".into(),
7052                        line: 1,
7053                        created_at: "2026-07-27T00:00:00Z".into(),
7054                        severity: "P1",
7055                    }],
7056                    ..reviewers_gate_pr()
7057                },
7058                true,
7059            ),
7060            (
7061                // The one that DOES reach `missing_bots` while non-idlable.
7062                "bot + open operator finding",
7063                PrInfo {
7064                    missing_bots: vec!["chatgpt-codex-connector".into()],
7065                    bot_nudges: vec![],
7066                    unattested_reviewers: vec![],
7067                    ..reviewers_gate_pr()
7068                },
7069                false,
7070            ),
7071        ] {
7072            let reason = build_block_reason(&pr, "abc", open_empty);
7073            assert_eq!(async_wait_class(&pr, "abc", open_empty), None, "{label}");
7074            assert!(!reason.contains("<watching"), "{label}: {reason}");
7075        }
7076        // The genuinely idlable state keeps the ritual.
7077        assert_eq!(async_wait_class(&bot_only, "abc", true), Some("review"));
7078        assert!(build_block_reason(&bot_only, "abc", true).contains("<watching"));
7079    }
7080
7081    #[test]
7082    fn an_unaddressed_finding_is_named_before_the_reviewers_gate() {
7083        // Sigma review of this PR: addressing an inline finding MOVES HEAD,
7084        // which supersedes any attestation produced first. Naming the reviewer
7085        // first would make the session run the panel twice.
7086        let pr = PrInfo {
7087            unaddressed_findings: vec![Finding {
7088                id: 1,
7089                author: "codex".into(),
7090                path: "a.rs".into(),
7091                line: 7,
7092                created_at: "2026-07-27T00:00:00Z".into(),
7093                severity: "P1",
7094            }],
7095            ..reviewers_gate_pr()
7096        };
7097        let reason = build_block_reason(&pr, "abc", true);
7098        assert!(reason.contains("unaddressed"), "got: {reason}");
7099        assert!(!reason.contains("reviewers gate unmet"), "got: {reason}");
7100        // With the finding cleared, the reviewers gate is what is named.
7101        let after = PrInfo {
7102            unaddressed_findings: vec![],
7103            ..pr
7104        };
7105        assert!(build_block_reason(&after, "abc", true).contains("reviewers gate unmet"));
7106    }
7107
7108    #[test]
7109    fn a_failed_attestation_at_this_head_is_not_reported_as_absent() {
7110        // "no head-pinned review_attestation" reads as "you never ran it" to a
7111        // session that ran the reviewer and was told no.
7112        let pr = PrInfo {
7113            unattested_reviewers: vec![UnattestedReviewer {
7114                name: "sigma".to_string(),
7115                superseded_head: None,
7116                failed_at_head: true,
7117            }],
7118            ..reviewers_gate_pr()
7119        };
7120        let reason = build_block_reason(&pr, "abc", true);
7121        assert!(reason.contains("verdict NOT pass"), "got: {reason}");
7122    }
7123
7124    #[test]
7125    fn the_stop_gate_marks_declare_as_a_self_cert() {
7126        // AC5: every surface that prints `declare` says it asserts nothing.
7127        // The Rust block message is such a surface.
7128        let pr = PrInfo {
7129            unattested_reviewers: vec![UnattestedReviewer {
7130                name: "declare".to_string(),
7131                superseded_head: None,
7132                failed_at_head: false,
7133            }],
7134            ..reviewers_gate_pr()
7135        };
7136        let reason = build_block_reason(&pr, "abc", true);
7137        assert!(reason.contains("self-cert"), "got: {reason}");
7138        assert!(
7139            reason.contains("asserts no review evidence"),
7140            "got: {reason}"
7141        );
7142        // A real reviewer carries no such mark.
7143        assert!(!build_block_reason(&reviewers_gate_pr(), "abc", true).contains("self-cert"));
7144    }
7145
7146    #[test]
7147    fn an_empty_head_sha_never_becomes_a_superseded_head() {
7148        // Option<String> cannot say "non-empty", so normalize at construction
7149        // rather than leaving is_empty() as a convention every reader re-derives.
7150        let tmp = tempfile::tempdir().unwrap();
7151        let p = tmp.path().join("e.jsonl");
7152        std::fs::write(
7153            &p,
7154            r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"","verdict":"pass"}}"#,
7155        )
7156        .unwrap();
7157        let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
7158        assert_eq!(out.len(), 1);
7159        assert_eq!(out[0].superseded_head, None);
7160    }
7161
7162    #[test]
7163    fn an_outstanding_local_reviewer_is_never_an_idlable_wait() {
7164        // The classifier half of the same fix: with a bot AND a local reviewer
7165        // outstanding, a stray <watching> tag must not park the session on work
7166        // it could do now.
7167        let pr = PrInfo {
7168            missing_bots: vec!["chatgpt-codex-connector".into()],
7169            bot_nudges: vec![],
7170            ..reviewers_gate_pr()
7171        };
7172        assert_eq!(async_wait_class(&pr, "abc", true), None);
7173    }
7174
7175    #[test]
7176    fn reviewer_invocations_cover_the_descriptor_table() {
7177        // The parity script enforces this against the Python side in CI; this
7178        // keeps the Rust half self-consistent at unit-test speed.
7179        for (name, inv, self_cert) in REVIEWER_INVOCATIONS {
7180            assert!(!inv.is_empty(), "{name} has no invocation");
7181            assert_eq!(reviewer_invocation(name), Some((*inv, *self_cert)));
7182        }
7183        assert_eq!(reviewer_invocation("teleport"), None);
7184        // AC5: the ONE self-cert must stay visibly marked on this surface too.
7185        assert_eq!(reviewer_invocation("declare").map(|(_, sc)| sc), Some(true));
7186        assert_eq!(reviewer_invocation("sigma").map(|(_, sc)| sc), Some(false));
7187    }
7188
7189    #[test]
7190    fn unwatched_async_nudge_review_uses_review_aware_watcher() {
7191        // codex P2: the review-wait watcher must poll REVIEW state, not
7192        // `gh pr checks --watch` (which exits instantly when CI is green).
7193        let hint = arm_watch_hint(404, "review");
7194        assert!(hint.contains("--json reviews"), "got: {hint}");
7195        assert!(!hint.contains("gh pr checks"), "got: {hint}");
7196        // The CI-wait watcher still uses checks --watch.
7197        let ci_hint = arm_watch_hint(404, "ci");
7198        assert!(ci_hint.contains("gh pr checks"), "got: {ci_hint}");
7199    }
7200
7201    #[test]
7202    fn watch_idle_rejects_head_mismatch() {
7203        // AC2-ERR: unpushed work (PR head != local HEAD) is never async-wait.
7204        assert_eq!(async_wait_class(&watch_pr(), "def", true), None);
7205    }
7206
7207    #[test]
7208    fn watch_idle_rejects_ci_red() {
7209        // AC1-ERR: settled-red CI (no pending) blocks, never idles.
7210        let pr = PrInfo {
7211            ci_conclusion: CiConclusion::Failure(Some("unit".into())),
7212            ci_has_pending: false,
7213            ..watch_pr()
7214        };
7215        assert_eq!(async_wait_class(&pr, "abc", true), None);
7216    }
7217
7218    #[test]
7219    fn watch_idle_rejects_unaddressed_finding() {
7220        // AC2-ERR: an unaddressed blocking inline finding is not async-wait.
7221        let pr = PrInfo {
7222            unaddressed_findings: vec![Finding {
7223                id: 1,
7224                author: "codex".into(),
7225                path: "a.rs".into(),
7226                line: 1,
7227                created_at: "none".into(),
7228                severity: "P1",
7229            }],
7230            ..watch_pr()
7231        };
7232        assert_eq!(async_wait_class(&pr, "abc", true), None);
7233    }
7234
7235    #[test]
7236    fn watch_idle_rejects_open_operator_finding() {
7237        // An open operator review_finding for the node also blocks idling.
7238        assert_eq!(async_wait_class(&watch_pr(), "abc", false), None);
7239    }
7240
7241    #[test]
7242    fn watch_idle_rejects_non_open_pr() {
7243        // A merged/closed PR is not an async wait (green+merged is DonePRGreen).
7244        let pr = PrInfo {
7245            state: PrState::Merged,
7246            ..watch_pr()
7247        };
7248        assert_eq!(async_wait_class(&pr, "abc", true), None);
7249    }
7250
7251    #[test]
7252    fn watch_idle_window_defaults_clamps_and_slacks() {
7253        // Default (no tag timeout): 30m + 12m slack.
7254        assert_eq!(watch_window_ms(None), 30 * 60_000 + WATCH_SLACK_MS);
7255        // Honored within range.
7256        assert_eq!(watch_window_ms(Some("30m")), 30 * 60_000 + WATCH_SLACK_MS);
7257        // Below the 5m floor clamps up.
7258        assert_eq!(watch_window_ms(Some("1m")), 5 * 60_000 + WATCH_SLACK_MS);
7259        // Above the 2h ceiling clamps down.
7260        assert_eq!(watch_window_ms(Some("5h")), 2 * 3_600_000 + WATCH_SLACK_MS);
7261        // Garbage falls back to the default.
7262        assert_eq!(watch_window_ms(Some("soon")), 30 * 60_000 + WATCH_SLACK_MS);
7263    }
7264
7265    #[test]
7266    fn fingerprint_format() {
7267        let fp = make_fingerprint("sha123", "OPEN", "SUCCESS", "2026-06-05T01:00:00Z");
7268        assert_eq!(fp, "sha123|OPEN|SUCCESS|2026-06-05T01:00:00Z");
7269    }
7270
7271    #[test]
7272    fn ci_conclusion_failure_extracts_name() {
7273        let checks = serde_json::json!([
7274            {"name": "unit-tests", "state": "FAILURE", "bucket": "fail"}
7275        ]);
7276        let result = compute_ci_conclusion(&checks).unwrap();
7277        assert_eq!(
7278            result,
7279            CiConclusion::Failure(Some("unit-tests".to_string()))
7280        );
7281        let rendered = result.render();
7282        assert!(rendered.starts_with("FAILURE:"), "got: {rendered}");
7283        assert!(rendered.contains("unit-tests"), "got: {rendered}");
7284    }
7285
7286    /// A cancelled check is a failure, and a skipping sibling never masks it.
7287    #[test]
7288    fn ci_conclusion_cancel_is_failure() {
7289        let checks = serde_json::json!([
7290            {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
7291            {"name": "deploy", "state": "CANCELLED", "bucket": "cancel"}
7292        ]);
7293        assert_eq!(
7294            compute_ci_conclusion(&checks).unwrap(),
7295            CiConclusion::Failure(Some("deploy".to_string()))
7296        );
7297    }
7298
7299    /// pass + skipping rolls up green; a pending bucket blocks it.
7300    #[test]
7301    fn ci_conclusion_bucket_vocabulary() {
7302        let green = serde_json::json!([
7303            {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
7304            {"name": "publish", "state": "SKIPPED", "bucket": "skipping"}
7305        ]);
7306        assert_eq!(
7307            compute_ci_conclusion(&green).unwrap(),
7308            CiConclusion::Success
7309        );
7310
7311        let pending = serde_json::json!([
7312            {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
7313            {"name": "smoke", "state": "IN_PROGRESS", "bucket": "pending"}
7314        ]);
7315        assert_eq!(
7316            compute_ci_conclusion(&pending).unwrap(),
7317            CiConclusion::Pending
7318        );
7319    }
7320
7321    /// An unknown or missing bucket fails closed as Pending, never green.
7322    #[test]
7323    fn ci_conclusion_unknown_bucket_fails_closed() {
7324        let unknown = serde_json::json!([
7325            {"name": "ci", "state": "SUCCESS", "bucket": "mystery"}
7326        ]);
7327        assert_eq!(
7328            compute_ci_conclusion(&unknown).unwrap(),
7329            CiConclusion::Pending
7330        );
7331
7332        let missing = serde_json::json!([{"name": "ci", "state": "SUCCESS"}]);
7333        assert_eq!(
7334            compute_ci_conclusion(&missing).unwrap(),
7335            CiConclusion::Pending
7336        );
7337    }
7338
7339    #[test]
7340    fn ci_conclusion_empty_returns_none() {
7341        let checks = serde_json::json!([]);
7342        let result = compute_ci_conclusion(&checks).unwrap();
7343        assert_eq!(result, CiConclusion::None);
7344        assert_eq!(result.render(), "none");
7345    }
7346
7347    #[test]
7348    fn ci_conclusion_all_success() {
7349        let checks = serde_json::json!([
7350            {"name": "ci", "state": "SUCCESS", "bucket": "pass"}
7351        ]);
7352        let result = compute_ci_conclusion(&checks).unwrap();
7353        assert_eq!(result, CiConclusion::Success);
7354        assert_eq!(result.render(), "SUCCESS");
7355    }
7356
7357    // ── DoneAwaitingMerge classifier ───────────────────────────────────────
7358
7359    #[test]
7360    fn failing_check_names_collects_fail_and_cancel_only() {
7361        let checks = serde_json::json!([
7362            {"name": "smoke",        "bucket": "fail"},
7363            {"name": "loc-ratchet",  "bucket": "pass"},
7364            {"name": "prompt-drift", "bucket": "cancel"},
7365            {"name": "self-test",    "bucket": "pending"},
7366            {"name": "doc-colo",     "bucket": "skipping"},
7367        ]);
7368        let mut got = failing_check_names(&checks);
7369        got.sort();
7370        assert_eq!(got, vec!["prompt-drift".to_string(), "smoke".to_string()]);
7371    }
7372
7373    #[test]
7374    fn failing_check_names_empty_when_all_green() {
7375        let checks = serde_json::json!([{"name": "smoke", "bucket": "pass"}]);
7376        assert!(failing_check_names(&checks).is_empty());
7377        // Malformed input never panics, yields empty.
7378        assert!(failing_check_names(&serde_json::json!({})).is_empty());
7379    }
7380
7381    #[test]
7382    fn ci_has_pending_gates_partial_ci() {
7383        // One check failed while another still runs -> pending (must hold, not
7384        // terminate: the pending job could be the session's own new red).
7385        let partial = serde_json::json!([
7386            {"name": "smoke",   "bucket": "fail"},
7387            {"name": "rust-ci", "bucket": "pending"},
7388        ]);
7389        assert!(ci_has_pending_checks(&partial));
7390        // Fully settled red -> no pending -> eligible for the terminal.
7391        let settled = serde_json::json!([
7392            {"name": "smoke",   "bucket": "fail"},
7393            {"name": "rust-ci", "bucket": "pass"},
7394            {"name": "doc",     "bucket": "skipping"},
7395        ]);
7396        assert!(!ci_has_pending_checks(&settled));
7397        // Unrecognized bucket is treated as pending (fail safe).
7398        let unknown = serde_json::json!([{"name": "x", "bucket": "queued"}]);
7399        assert!(ci_has_pending_checks(&unknown));
7400        // Malformed input never panics.
7401        assert!(!ci_has_pending_checks(&serde_json::json!({})));
7402    }
7403
7404    #[test]
7405    fn parse_failing_run_ids_only_failures_on_head_sha() {
7406        // Only failures whose headSha matches the current main HEAD count. Run 4
7407        // failed but belongs to an OLDER commit (headSha "old"), so a check it
7408        // failed that main HEAD has since fixed must NOT be classified pre-existing.
7409        let list = serde_json::json!([
7410            {"databaseId": 1, "conclusion": "failure", "headSha": "head"},
7411            {"databaseId": 2, "conclusion": "success", "headSha": "head"},
7412            {"databaseId": 3, "conclusion": "cancelled", "headSha": "head"},
7413            {"databaseId": 4, "conclusion": "failure", "headSha": "old"},
7414            {"databaseId": 5, "conclusion": "failure", "headSha": "head"},
7415        ]);
7416        assert_eq!(parse_failing_run_ids(&list, "head"), vec![1, 5]);
7417        // A different HEAD sha selects that commit's failures only.
7418        assert_eq!(parse_failing_run_ids(&list, "old"), vec![4]);
7419    }
7420
7421    #[test]
7422    fn parse_failing_job_names_only_failed_jobs() {
7423        let view = serde_json::json!({
7424            "jobs": [
7425                {"name": "codex",   "conclusion": "success"},
7426                {"name": "cargo test + schema parity", "conclusion": "failure"},
7427                {"name": "gemini",  "conclusion": "failure"},
7428            ]
7429        });
7430        let mut got = parse_failing_job_names(&view);
7431        got.sort();
7432        assert_eq!(
7433            got,
7434            vec![
7435                "cargo test + schema parity".to_string(),
7436                "gemini".to_string()
7437            ]
7438        );
7439        // No jobs key -> empty, never panics.
7440        assert!(parse_failing_job_names(&serde_json::json!({})).is_empty());
7441    }
7442
7443    /// AC1-HP: the core shape - PR fails only the one check main also fails.
7444    #[test]
7445    fn subset_rule_pr_failing_is_covered_by_main() {
7446        let pr = vec!["cargo test + schema parity".to_string()];
7447        let main = vec![
7448            "cargo test + schema parity".to_string(),
7449            "some other main-only red".to_string(),
7450        ];
7451        assert!(is_pre_existing_main_red(&pr, &main));
7452    }
7453
7454    /// AC1-EDGE: a PR-unique failing check (its own breakage) blocks the terminal.
7455    #[test]
7456    fn subset_rule_pr_unique_red_blocks() {
7457        let pr = vec![
7458            "cargo test + schema parity".to_string(),
7459            "fmt gate".to_string(), // the session's own breakage
7460        ];
7461        let main = vec!["cargo test + schema parity".to_string()];
7462        assert!(!is_pre_existing_main_red(&pr, &main));
7463    }
7464
7465    #[test]
7466    fn subset_rule_empty_pr_failing_never_eligible() {
7467        // Empty PR-failing is the DonePRGreen path, not this one.
7468        assert!(!is_pre_existing_main_red(&[], &["x".to_string()]));
7469        // Non-empty PR vs green main (empty) -> hold.
7470        assert!(!is_pre_existing_main_red(&["x".to_string()], &[]));
7471    }
7472
7473    #[test]
7474    fn already_emitted_awaiting_merge_detects_prior_and_absence() {
7475        let dir = tempfile::tempdir().unwrap();
7476        let events = dir.path().join("events.jsonl");
7477        // Absent file -> false (fail open).
7478        assert!(!already_emitted_awaiting_merge(&events, "sess-A"));
7479        // A DonePRGreen termination for the same session must NOT count.
7480        std::fs::write(
7481            &events,
7482            "{\"type\":\"termination\",\"data\":{\"session_id\":\"sess-A\",\"reason\":\"DonePRGreen\"}}\n",
7483        )
7484        .unwrap();
7485        assert!(!already_emitted_awaiting_merge(&events, "sess-A"));
7486        // A prior DoneAwaitingMerge for sess-A counts; a different session does not.
7487        std::fs::write(
7488            &events,
7489            "{\"type\":\"termination\",\"data\":{\"session_id\":\"sess-A\",\"reason\":\"DoneAwaitingMerge\"}}\n",
7490        )
7491        .unwrap();
7492        assert!(already_emitted_awaiting_merge(&events, "sess-A"));
7493        assert!(!already_emitted_awaiting_merge(&events, "sess-B"));
7494    }
7495
7496    /// AC5-HP: enums parse known gh strings.
7497    #[test]
7498    fn pr_state_parses_known_gh_strings() {
7499        assert_eq!(PrState::from_gh_str("OPEN"), PrState::Open);
7500        assert_eq!(PrState::from_gh_str("MERGED"), PrState::Merged);
7501        assert_eq!(PrState::from_gh_str("CLOSED"), PrState::Closed);
7502        assert_eq!(PrState::from_gh_str("none"), PrState::None);
7503    }
7504
7505    /// AC5-EDGE: an unexpected gh state string maps to PrState::None
7506    /// (fail-closed), never panics.
7507    #[test]
7508    fn pr_state_unknown_string_fails_closed() {
7509        assert_eq!(PrState::from_gh_str("DRAFT"), PrState::None);
7510        assert_eq!(PrState::from_gh_str(""), PrState::None);
7511        assert_eq!(PrState::from_gh_str("open"), PrState::None);
7512    }
7513
7514    /// AC5-UI: as_str/render reproduce the exact legacy fingerprint vocabulary.
7515    #[test]
7516    fn enum_rendering_byte_identical_to_legacy_strings() {
7517        assert_eq!(PrState::Open.as_str(), "OPEN");
7518        assert_eq!(PrState::Merged.as_str(), "MERGED");
7519        assert_eq!(PrState::Closed.as_str(), "CLOSED");
7520        assert_eq!(PrState::None.as_str(), "none");
7521        assert_eq!(CiConclusion::Success.render(), "SUCCESS");
7522        assert_eq!(
7523            CiConclusion::Failure(Some("lint".into())).render(),
7524            "FAILURE:lint"
7525        );
7526        assert_eq!(CiConclusion::Failure(None).render(), "FAILURE");
7527        assert_eq!(CiConclusion::Pending.render(), "PENDING");
7528        assert_eq!(CiConclusion::Skipped.render(), "skipped");
7529        assert_eq!(CiConclusion::None.render(), "none");
7530    }
7531
7532    /// AC5-ERR: required flags validated in parse_args, which returns Err.
7533    #[test]
7534    fn parse_args_missing_required_flags_err() {
7535        let no_state: Vec<String> = vec![
7536            "loop-check".into(),
7537            "--transcript".into(),
7538            "/t".into(),
7539            "--cwd".into(),
7540            "/c".into(),
7541        ];
7542        assert_eq!(
7543            parse_args(&no_state).unwrap_err(),
7544            "--state is required".to_string()
7545        );
7546
7547        let no_transcript: Vec<String> = vec!["loop-check".into(), "--state".into(), "/s".into()];
7548        assert_eq!(
7549            parse_args(&no_transcript).unwrap_err(),
7550            "--transcript is required".to_string()
7551        );
7552
7553        let no_cwd: Vec<String> = vec![
7554            "loop-check".into(),
7555            "--state".into(),
7556            "/s".into(),
7557            "--transcript".into(),
7558            "/t".into(),
7559        ];
7560        assert_eq!(
7561            parse_args(&no_cwd).unwrap_err(),
7562            "--cwd is required".to_string()
7563        );
7564    }
7565
7566    /// AC5-FR: an unknown flag is tolerated (forward-compat for the shim).
7567    #[test]
7568    fn parse_args_unknown_flag_tolerated() {
7569        let args: Vec<String> = vec![
7570            "loop-check".into(),
7571            "--state".into(),
7572            "/s".into(),
7573            "--transcript".into(),
7574            "/t".into(),
7575            "--cwd".into(),
7576            "/c".into(),
7577            "--future-flag=whatever".into(),
7578            "--another-unknown".into(),
7579            "value".into(),
7580        ];
7581        let parsed = parse_args(&args).expect("unknown flags must be ignored");
7582        assert_eq!(parsed.state_path, PathBuf::from("/s"));
7583        assert_eq!(parsed.transcript_path, PathBuf::from("/t"));
7584        assert_eq!(parsed.cwd, PathBuf::from("/c"));
7585    }
7586
7587    #[test]
7588    fn budget_flat_key_enforces_cost_cap_ab41b13d9d() {
7589        // Prove the flat budget_cap key enforces as cost cap for BOTH attended and
7590        // unattended - this is the ab-41b13d9d fold-in test.
7591        let settings_cfg = "budget_cap = 0.10\n";
7592        let settings = parse_settings(settings_cfg);
7593        assert_eq!(settings.flat_budget_cap, Some(Ok(0.10)));
7594        // No nested blocks configured
7595        assert!(settings.attended_cost_cap_usd.is_none());
7596        assert!(settings.unattended_cost_cap_usd.is_none());
7597        // The budget resolver picks flat_budget_cap as cost cap fallback
7598        // for both attended=true and attended=false (tested in check_budget)
7599
7600        let manifest_att = Manifest {
7601            session_id: Some("s1".into()),
7602            created_at: Some("2026-06-05T00:00:00Z".into()),
7603            attended: true,
7604            ..Default::default()
7605        };
7606        let manifest_unatt = Manifest {
7607            session_id: Some("s1".into()),
7608            created_at: Some("2026-06-05T00:00:00Z".into()),
7609            attended: false,
7610            ..Default::default()
7611        };
7612
7613        // Ledger with cost > 0.10
7614        let tmp = tempfile::tempdir().unwrap();
7615        let ledger = tmp.path().join("ledger.json");
7616        std::fs::write(&ledger, r#"[{"session_id":"s1","cost_usd":0.50}]"#).unwrap();
7617
7618        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7619
7620        assert_eq!(
7621            check_budget(&manifest_att, &settings, &now, &ledger),
7622            Some(BudgetTrip::Cost),
7623            "flat budget_cap must enforce for attended"
7624        );
7625        assert_eq!(
7626            check_budget(&manifest_unatt, &settings, &now, &ledger),
7627            Some(BudgetTrip::Cost),
7628            "flat budget_cap must enforce for unattended"
7629        );
7630    }
7631
7632    #[test]
7633    fn is_bot_reviewer_known_patterns() {
7634        assert!(is_bot_reviewer("gemini-code-assist[bot]", &[]));
7635        assert!(is_bot_reviewer("chatgpt-codex-connector", &[]));
7636        assert!(is_bot_reviewer("some-bot[bot]", &[]));
7637        assert!(!is_bot_reviewer("human-reviewer", &[]));
7638    }
7639
7640    #[test]
7641    fn is_bot_reviewer_with_external_list() {
7642        let external = vec!["my-bot".to_string()];
7643        // "my-bot" is a substring of "my-bot" -> match via configured list
7644        assert!(is_bot_reviewer("my-bot", &external));
7645        // "other-bot[bot]" doesn't match "my-bot" substring, but falls back to
7646        // the [bot] suffix heuristic (configured list must not make reviewed unreachable)
7647        assert!(is_bot_reviewer("other-bot[bot]", &external));
7648    }
7649
7650    #[test]
7651    fn session_cost_from_ledger_sums_session_only() {
7652        let tmp = tempfile::tempdir().unwrap();
7653        let ledger = tmp.path().join("l.json");
7654        std::fs::write(
7655            &ledger,
7656            r#"[{"session_id":"a","cost_usd":1.0},{"session_id":"b","cost_usd":0.5},{"session_id":"a","cost_usd":0.25}]"#,
7657        )
7658        .unwrap();
7659        let cost = session_cost_from_ledger(&ledger, "a");
7660        assert!((cost - 1.25).abs() < 0.001, "expected 1.25, got {cost}");
7661    }
7662
7663    #[test]
7664    fn session_cost_missing_ledger_returns_zero() {
7665        let cost = session_cost_from_ledger(Path::new("/nonexistent/l.json"), "s");
7666        assert_eq!(cost, 0.0);
7667    }
7668
7669    #[test]
7670    fn allow_output_serializes_correctly() {
7671        let json = allow_output(
7672            "allow",
7673            Some(TerminationReason::DonePRGreen),
7674            "done",
7675            3,
7676            Some("fp".into()),
7677        );
7678        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
7679        assert_eq!(v["decision"], "allow");
7680        // Verify variant names serialize byte-identically to the spec strings.
7681        assert_eq!(v["termination_reason"], "DonePRGreen");
7682        assert_eq!(v["fires"], 3);
7683        assert_eq!(v["fingerprint"], "fp");
7684    }
7685
7686    #[test]
7687    fn allow_output_null_termination_reason() {
7688        let json = allow_output("block", None, "continue", 1, None);
7689        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
7690        assert!(v["termination_reason"].is_null());
7691        assert!(v["fingerprint"].is_null());
7692    }
7693
7694    #[test]
7695    fn watch_idle_event_is_non_terminal_allow() {
7696        // AC1-HP invariant: the idle branch emits allow + null termination, so
7697        // the stop-hook shim (which runs finalize only on a NON-null
7698        // termination_reason) never invokes finalize / stamps the ledger /
7699        // graduates a plan on an idle fire. This is the exact output shape the
7700        // idle branch returns.
7701        let json = allow_output(
7702            "allow",
7703            None,
7704            "watching: idling until watcher fires (PR #404, ci pending)",
7705            3,
7706            Some("sha|OPEN|PENDING|none".to_string()),
7707        );
7708        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
7709        assert_eq!(v["decision"], "allow");
7710        assert!(
7711            v["termination_reason"].is_null(),
7712            "idle-allow MUST be non-terminal or finalize would run"
7713        );
7714        assert!(v["message"].as_str().unwrap().contains("watching"));
7715    }
7716
7717    #[test]
7718    fn termination_reason_variant_names_byte_identical() {
7719        // Fix 6: all TerminationReason variants must serialize to the exact strings
7720        // the spec names - no rename attributes applied.
7721        let cases = [
7722            (TerminationReason::DonePRGreen, "DonePRGreen"),
7723            (TerminationReason::DoneAdvisory, "DoneAdvisory"),
7724            (TerminationReason::NoWork, "NoWork"),
7725            (TerminationReason::Budget, "Budget"),
7726            (TerminationReason::NoProgress, "NoProgress"),
7727            (TerminationReason::Interrupted, "Interrupted"),
7728            (TerminationReason::Aborted, "Aborted"),
7729        ];
7730        for (variant, expected) in cases {
7731            let json = serde_json::to_string(&variant).unwrap();
7732            // serde serializes enum unit variants as "\"VariantName\""
7733            assert_eq!(
7734                json,
7735                format!("\"{expected}\""),
7736                "variant {expected} serialized incorrectly"
7737            );
7738        }
7739    }
7740
7741    #[test]
7742    fn manifest_default_attended_is_true() {
7743        // Fix 7: manual Default impl must set attended=true (derive would give false)
7744        let m = Manifest::default();
7745        assert!(m.attended, "Manifest::default() must have attended=true");
7746        assert!(!m.advisory);
7747        assert!(!m.no_ship);
7748        assert!(!m.no_external);
7749        assert!(m.session_id.is_none());
7750        assert!(m.budget_cost_cap_usd.is_none());
7751        assert!(m.budget_wall_clock_cap_minutes.is_none());
7752    }
7753
7754    #[test]
7755    fn parse_manifest_malformed_cost_cap_fail_closed() {
7756        // Fix 2: a present but unparseable cost cap must be Err (fail-closed)
7757        let content =
7758            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_cost_cap_usd: 5.OO\n---\n";
7759        let m = parse_manifest(content).unwrap();
7760        assert!(
7761            matches!(m.budget_cost_cap_usd, Some(Err(_))),
7762            "malformed cost cap must be Some(Err(...))"
7763        );
7764    }
7765
7766    #[test]
7767    fn parse_manifest_malformed_wall_cap_fail_closed() {
7768        let content =
7769            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: abc\n---\n";
7770        let m = parse_manifest(content).unwrap();
7771        assert!(
7772            matches!(m.budget_wall_clock_cap_minutes, Some(Err(_))),
7773            "malformed wall cap must be Some(Err(...))"
7774        );
7775    }
7776
7777    #[test]
7778    fn parse_settings_malformed_flat_cap_fail_closed() {
7779        let cfg = "budget_cap = \"not_a_number\"\n";
7780        let s = parse_settings(cfg);
7781        assert!(
7782            matches!(s.flat_budget_cap, Some(Err(_))),
7783            "malformed flat_budget_cap must be Some(Err(...))"
7784        );
7785    }
7786
7787    #[test]
7788    fn check_budget_malformed_cost_cap_trips_budget() {
7789        // Fix 2: malformed cap in manifest -> Budget termination (fail-closed)
7790        let m = Manifest {
7791            session_id: Some("s".into()),
7792            created_at: Some("2026-06-05T00:00:00Z".into()),
7793            budget_cost_cap_usd: Some(Err("5.OO".into())),
7794            ..Default::default()
7795        };
7796        let s = Settings::default();
7797        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7798        let tmp = tempfile::tempdir().unwrap();
7799        let ledger = tmp.path().join("ledger.json");
7800        std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":0.0}]"#).unwrap();
7801        assert_eq!(
7802            check_budget(&m, &s, &now, &ledger),
7803            Some(BudgetTrip::Cost),
7804            "malformed cost cap must fail closed"
7805        );
7806    }
7807
7808    #[test]
7809    fn check_budget_absent_cap_is_unlimited() {
7810        // ABSENT caps stay unlimited - must not trip
7811        let m = Manifest {
7812            session_id: Some("s".into()),
7813            created_at: Some("2026-06-05T00:00:00Z".into()),
7814            ..Default::default()
7815        };
7816        let s = Settings::default();
7817        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7818        let tmp = tempfile::tempdir().unwrap();
7819        let ledger = tmp.path().join("ledger.json");
7820        std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":9999.0}]"#).unwrap();
7821        assert_eq!(
7822            check_budget(&m, &s, &now, &ledger),
7823            None,
7824            "absent cap must be unlimited"
7825        );
7826    }
7827
7828    #[test]
7829    fn check_budget_negative_elapsed_no_trip() {
7830        // Fix 3: created_at in the future (clock skew) -> elapsed=0 -> no wall-clock trip
7831        let m = Manifest {
7832            session_id: Some("s".into()),
7833            // created_at is 1 hour in the future
7834            created_at: Some("2026-06-05T02:00:00Z".into()),
7835            budget_wall_clock_cap_minutes: Some(Ok(30)),
7836            ..Default::default()
7837        };
7838        let s = Settings::default();
7839        // now is earlier than created_at
7840        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7841        let tmp = tempfile::tempdir().unwrap();
7842        let ledger = tmp.path().join("ledger.json");
7843        std::fs::write(&ledger, "[]").unwrap();
7844        assert_eq!(
7845            check_budget(&m, &s, &now, &ledger),
7846            None,
7847            "negative elapsed (future created_at) must not trip wall clock cap"
7848        );
7849    }
7850
7851    #[test]
7852    fn is_bot_reviewer_configured_short_names_match_real_logins() {
7853        // Fix 1: configured entries use substring matching.
7854        // "gemini" (short config name) must match "gemini-code-assist[bot]"
7855        // "codex" must match "chatgpt-codex-connector"
7856        let external = vec!["gemini".to_string(), "codex".to_string()];
7857        assert!(
7858            is_bot_reviewer("gemini-code-assist[bot]", &external),
7859            "gemini short name must substring-match gemini-code-assist[bot]"
7860        );
7861        assert!(
7862            is_bot_reviewer("chatgpt-codex-connector", &external),
7863            "codex short name must substring-match chatgpt-codex-connector"
7864        );
7865    }
7866
7867    #[test]
7868    fn is_bot_reviewer_configured_list_falls_back_to_bot_heuristic() {
7869        // Fix 1: when configured list has [some-human] but a bot review arrives,
7870        // fallback to endswith-[bot] heuristic so reviewed remains reachable.
7871        let external = vec!["some-human".to_string()];
7872        assert!(
7873            is_bot_reviewer("gemini-code-assist[bot]", &external),
7874            "configured list with no match must still fall back to [bot] heuristic"
7875        );
7876    }
7877
7878    #[test]
7879    fn is_bot_reviewer_empty_config_human_only_returns_false() {
7880        // Fix 1: empty config + human-only review -> false
7881        assert!(
7882            !is_bot_reviewer("alice-the-human", &[]),
7883            "human reviewer with empty config must return false"
7884        );
7885    }
7886
7887    // ── step 2: required_bots parsing + resolution (US1/US3) ────────────────
7888
7889    #[test]
7890    fn parse_settings_required_bots_block_list() {
7891        let cfg = "[review]\nrequired_bots = [\n  \"chatgpt-codex-connector\",\n  \"gemini-code-assist\",\n]\n";
7892        let s = parse_settings(cfg);
7893        assert_eq!(
7894            s.required_bots,
7895            Some(vec![
7896                "chatgpt-codex-connector".to_string(),
7897                "gemini-code-assist".to_string()
7898            ])
7899        );
7900    }
7901
7902    #[test]
7903    fn parse_settings_required_bots_inline_empty_is_declared_empty() {
7904        // The explicit [] form is the ONLY way to declare the no-review-gate
7905        // path (US3, locked decision 2).
7906        let cfg = "[review]\nrequired_bots = []\n";
7907        let s = parse_settings(cfg);
7908        assert_eq!(s.required_bots, Some(Vec::new()));
7909    }
7910
7911    #[test]
7912    fn parse_settings_required_bots_inline_list() {
7913        let cfg = "[review]\nrequired_bots = [\"codex\", \"gemini\"]\n";
7914        let s = parse_settings(cfg);
7915        assert_eq!(
7916            s.required_bots,
7917            Some(vec!["codex".to_string(), "gemini".to_string()])
7918        );
7919    }
7920
7921    /// A bare scalar `required_bots = "gemini"` GATES on that one login (parity
7922    /// with peers + Python), rather than failing OPEN to no-gate on a
7923    /// bracket-less typo (codex P1 on #205).
7924    #[test]
7925    fn parse_settings_required_bots_scalar_is_singleton() {
7926        let cfg = "[review]\nrequired_bots = \"gemini\"\n";
7927        let s = parse_settings(cfg);
7928        assert_eq!(s.required_bots, Some(vec!["gemini".to_string()]));
7929        // github_apps behaves identically.
7930        let g = parse_settings("[review]\ngithub_apps = \"chatgpt-codex-connector\"\n");
7931        assert_eq!(
7932            g.github_apps,
7933            Some(vec!["chatgpt-codex-connector".to_string()])
7934        );
7935    }
7936
7937    /// An ABSENT required_bots key resolves to the default (no gate), and a
7938    /// following block still parses.
7939    #[test]
7940    fn parse_settings_absent_required_bots_defaults() {
7941        let cfg = "[review]\ngithub_apps = []\n\n[ci]\ndeclared_none = true\n";
7942        let s = parse_settings(cfg);
7943        assert_eq!(
7944            s.required_bots, None,
7945            "absent key resolves to the no-gate default"
7946        );
7947        assert!(s.ci_declared_none, "following blocks still parse");
7948    }
7949
7950    /// TOML strips inline comments natively - a `required_bots = []  # note` is
7951    /// still the declared empty form, and commented list forms still parse.
7952    #[test]
7953    fn parse_settings_required_bots_inline_comments_stripped() {
7954        let empty = parse_settings("[review]\nrequired_bots = []  # no review gate\n");
7955        assert_eq!(empty.required_bots, Some(Vec::new()));
7956
7957        let inline =
7958            parse_settings("[review]\nrequired_bots = [\"chatgpt-codex-connector\"] # required\n");
7959        assert_eq!(
7960            inline.required_bots,
7961            Some(vec!["chatgpt-codex-connector".to_string()])
7962        );
7963
7964        let block = parse_settings(
7965            "[review]\nrequired_bots = [ # the gate\n  \"chatgpt-codex-connector\", # codex\n]\n",
7966        );
7967        assert_eq!(
7968            block.required_bots,
7969            Some(vec!["chatgpt-codex-connector".to_string()])
7970        );
7971
7972        // A scalar (with a trailing comment stripped) coerces to a single-login
7973        // gate, not no-gate (codex P1 on #205).
7974        let scalar = parse_settings("[review]\nrequired_bots = \"gemini\" # oops\n");
7975        assert_eq!(scalar.required_bots, Some(vec!["gemini".to_string()]));
7976    }
7977
7978    #[test]
7979    fn parse_settings_required_bots_multiline_array() {
7980        let cfg = "[review]\nrequired_bots = [\n  \"chatgpt-codex-connector\",\n]\n";
7981        let s = parse_settings(cfg);
7982        assert_eq!(
7983            s.required_bots,
7984            Some(vec!["chatgpt-codex-connector".to_string()])
7985        );
7986    }
7987
7988    #[test]
7989    fn parse_settings_required_bots_reads_under_review_table() {
7990        // required_bots lives under the flat [review] table (no config: wrapper).
7991        let cfg = "[review]\nrequired_bots = [\"chatgpt-codex-connector\"]\n";
7992        let s = parse_settings(cfg);
7993        assert_eq!(
7994            s.required_bots,
7995            Some(vec!["chatgpt-codex-connector".to_string()])
7996        );
7997    }
7998
7999    #[test]
8000    fn parse_settings_malformed_fails_closed_not_zeroed() {
8001        // A malformed config.toml must NOT silently zero the gate (the old
8002        // fail-open); it fails CLOSED with an unsatisfiable sentinel so the ship
8003        // gate blocks visibly. Here: an unclosed table header.
8004        let cfg = "[review\nrequired_bots = []\n";
8005        assert!(
8006            parse_settings_result(cfg).is_err(),
8007            "malformed TOML must be a parse error"
8008        );
8009        let s = parse_settings(cfg);
8010        assert_eq!(
8011            s.required_bots,
8012            Some(vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]),
8013            "a malformed file must fail closed, not zero the gate"
8014        );
8015        // The sentinel can never be satisfied by a real bot login.
8016        assert!(!login_matches_bot(
8017            "chatgpt-codex-connector",
8018            UNPARSEABLE_SETTINGS_SENTINEL
8019        ));
8020    }
8021
8022    #[test]
8023    fn parse_settings_unparseable_fails_closed() {
8024        // AC3-UI: a genuinely malformed config file leaves the login gate
8025        // unsatisfiable (fail closed), never a silent no-gate. The production
8026        // caller additionally emits loop_check_settings_unparseable.
8027        let cfg = "[review]\nrequired_bots = [1, 2, 3\n"; // unclosed array
8028        assert!(parse_settings_result(cfg).is_err());
8029        let s = parse_settings(cfg);
8030        assert_eq!(
8031            resolved_required_bots(&s),
8032            vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]
8033        );
8034    }
8035
8036    #[test]
8037    fn resolved_required_bots_default_is_empty() {
8038        // Fresh-install default: no required review bot, so a clone with no
8039        // review configuration is not blocked waiting for a bot it never set up.
8040        let s = Settings::default();
8041        assert!(
8042            resolved_required_bots(&s).is_empty(),
8043            "absent required_bots config must resolve to no review gate"
8044        );
8045    }
8046
8047    #[test]
8048    fn resolved_required_bots_explicit_list_wins() {
8049        let s = Settings {
8050            required_bots: Some(vec!["my-bot".to_string()]),
8051            ..Default::default()
8052        };
8053        assert_eq!(resolved_required_bots(&s), vec!["my-bot".to_string()]);
8054        let empty = Settings {
8055            required_bots: Some(Vec::new()),
8056            ..Default::default()
8057        };
8058        assert!(resolved_required_bots(&empty).is_empty());
8059    }
8060
8061    // --- github_apps rename + required_bots alias (x-4baa US3/US4) ---
8062
8063    // --- optional_apps: honored-if-present, never required (x-4baa) ---
8064
8065    #[test]
8066    fn parse_settings_structural_scalar_degrades_like_python() {
8067        // A `{...}` flow-mapping value is not a login: scalar_as_singleton
8068        // returns None so the Rust reader agrees with Python's typed reader
8069        // (which drops a mapping to None), honoring the two-parser invariant
8070        // (codex P1 on #205). A numeric scalar stays a singleton (parity too).
8071        assert_eq!(scalar_as_singleton(" {login: codex}"), None);
8072        assert_eq!(scalar_as_singleton(" 123"), Some(vec!["123".to_string()]));
8073        let g = parse_settings("[review]\ngithub_apps = {login = \"codex\"}\n");
8074        assert_eq!(g.github_apps, None, "an inline table is not a login gate");
8075        let o = parse_settings("[review]\noptional_apps = {a = \"b\"}\n");
8076        assert_eq!(o.optional_apps, None);
8077    }
8078
8079    #[test]
8080    fn parse_settings_optional_apps_forms() {
8081        // Inline, multi-line, and bare-scalar all parse.
8082        let inline = parse_settings("[review]\noptional_apps = [\"chatgpt-codex-connector\"]\n");
8083        assert_eq!(
8084            inline.optional_apps,
8085            Some(vec!["chatgpt-codex-connector".to_string()])
8086        );
8087        let block =
8088            parse_settings("[review]\noptional_apps = [\n  \"chatgpt-codex-connector\",\n]\n");
8089        assert_eq!(
8090            block.optional_apps,
8091            Some(vec!["chatgpt-codex-connector".to_string()])
8092        );
8093        let scalar = parse_settings("[review]\noptional_apps = \"chatgpt-codex-connector\"\n");
8094        assert_eq!(
8095            scalar.optional_apps,
8096            Some(vec!["chatgpt-codex-connector".to_string()])
8097        );
8098    }
8099
8100    // --- reviewers: local-attestation gate (x-e703, Phase 2) ---
8101
8102    #[test]
8103    fn parse_settings_reviewers_forms() {
8104        // Inline, block-under, key-aligned (PyYAML), bare scalar all parse; a
8105        // leading '/' is normalized off (parity with the Python validator).
8106        let inline = parse_settings("[review]\nreviewers = [\"sigma\", \"/code-review\"]\n");
8107        assert_eq!(
8108            inline.reviewers,
8109            vec!["sigma".to_string(), "code-review".to_string()]
8110        );
8111        let block = parse_settings("[review]\nreviewers = [\n  \"sigma\",\n]\n");
8112        assert_eq!(block.reviewers, vec!["sigma".to_string()]);
8113        let scalar = parse_settings("[review]\nreviewers = \"/code-review\"\n");
8114        assert_eq!(scalar.reviewers, vec!["code-review".to_string()]);
8115        let absent = parse_settings("[review]\ngithub_apps = []\n");
8116        assert!(absent.reviewers.is_empty());
8117    }
8118
8119    #[test]
8120    fn parse_settings_reviewers_distinct_from_external_reviewers() {
8121        // Top-level external_reviewers and review.reviewers must not
8122        // cross-contaminate their list items.
8123        let cfg = "external_reviewers = [\"gemini\"]\n\n[review]\nreviewers = [\"sigma\"]\n";
8124        let s = parse_settings(cfg);
8125        assert_eq!(s.external_reviewers, vec!["gemini".to_string()]);
8126        assert_eq!(s.reviewers, vec!["sigma".to_string()]);
8127    }
8128
8129    fn write_events(dir: &Path, lines: &[&str]) -> std::path::PathBuf {
8130        let p = dir.join("events.jsonl");
8131        std::fs::write(&p, lines.join("\n")).unwrap();
8132        p
8133    }
8134
8135    #[test]
8136    fn reviewers_all_attested_empty_is_vacuously_true() {
8137        let tmp = tempfile::tempdir().unwrap();
8138        let p = tmp.path().join("nonexistent.jsonl");
8139        assert!(reviewers_all_attested(&p, &[], "abc"));
8140    }
8141
8142    #[test]
8143    fn reviewers_all_attested_head_pinned_pass() {
8144        let tmp = tempfile::tempdir().unwrap();
8145        let p = write_events(
8146            tmp.path(),
8147            &[
8148                r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"abc123","verdict":"pass"}}"#,
8149            ],
8150        );
8151        assert!(reviewers_all_attested(&p, &["sigma".to_string()], "abc123"));
8152    }
8153
8154    #[test]
8155    fn reviewers_all_attested_stale_head_is_unsatisfied() {
8156        // Head-pin: a pass for a PRIOR commit must not satisfy the current HEAD
8157        // (AC1-EDGE / AC8-HP). A new commit invalidates the old attestation.
8158        let tmp = tempfile::tempdir().unwrap();
8159        let p = write_events(
8160            tmp.path(),
8161            &[
8162                r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
8163            ],
8164        );
8165        assert!(!reviewers_all_attested(&p, &["sigma".to_string()], "NEW"));
8166    }
8167
8168    #[test]
8169    fn reviewers_all_attested_fail_and_missing_are_unsatisfied() {
8170        let tmp = tempfile::tempdir().unwrap();
8171        // fail verdict -> unsatisfied
8172        let fail = write_events(
8173            tmp.path(),
8174            &[
8175                r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
8176            ],
8177        );
8178        assert!(!reviewers_all_attested(&fail, &["sigma".to_string()], "h"));
8179        // missing file -> fail closed
8180        let gone = tmp.path().join("gone.jsonl");
8181        assert!(!reviewers_all_attested(&gone, &["sigma".to_string()], "h"));
8182    }
8183
8184    #[test]
8185    fn reviewers_all_attested_conjunction_and_slash_normalized() {
8186        // Every reviewer must be attested (strict conjunction); a '/'-prefixed
8187        // config entry matches an event that emits the bare name and vice-versa.
8188        let tmp = tempfile::tempdir().unwrap();
8189        let p = write_events(
8190            tmp.path(),
8191            &[
8192                r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
8193                r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"code-review","head_sha":"h","verdict":"pass"}}"#,
8194            ],
8195        );
8196        // Both present -> satisfied ('/code-review' config vs 'code-review' event).
8197        assert!(reviewers_all_attested(
8198            &p,
8199            &["sigma".to_string(), "/code-review".to_string()],
8200            "h"
8201        ));
8202        // One missing -> unsatisfied.
8203        assert!(!reviewers_all_attested(
8204            &p,
8205            &["sigma".to_string(), "declare".to_string()],
8206            "h"
8207        ));
8208    }
8209
8210    #[test]
8211    fn parse_settings_reviewers_malformed_mapping_fails_closed() {
8212        // A `{...}` mapping value must NOT drop to no-gate (Python raises here);
8213        // Rust stores an unsatisfiable sentinel so the gate stays active but can
8214        // never clear (codex peer review P1).
8215        let s = parse_settings("[review]\nreviewers = {a = \"b\"}\n");
8216        assert_eq!(s.reviewers, vec![MALFORMED_REVIEWERS_SENTINEL.to_string()]);
8217        let tmp = tempfile::tempdir().unwrap();
8218        let p = write_events(tmp.path(), &[]);
8219        assert!(
8220            !reviewers_all_attested(&p, &s.reviewers, "h"),
8221            "a malformed-reviewers sentinel must never be satisfiable"
8222        );
8223    }
8224
8225    #[test]
8226    fn parse_settings_reviewers_seq_with_nonscalar_fails_closed() {
8227        // gemini medium: a non-scalar item INSIDE the reviewers list (Python
8228        // raises on it) must fail CLOSED with the sentinel, not silently drop
8229        // the entry and gate on the survivors.
8230        let bad = parse_settings("[review]\nreviewers = [\"sigma\", {a = \"b\"}]\n");
8231        assert_eq!(
8232            bad.reviewers,
8233            vec![MALFORMED_REVIEWERS_SENTINEL.to_string()]
8234        );
8235        // A clean all-scalar list still parses normally.
8236        let ok = parse_settings("[review]\nreviewers = [\"sigma\", \"declare\"]\n");
8237        assert_eq!(
8238            ok.reviewers,
8239            vec!["sigma".to_string(), "declare".to_string()]
8240        );
8241    }
8242
8243    #[test]
8244    fn reviewers_all_attested_latest_verdict_wins() {
8245        // events.jsonl is append-ordered: a later attestation supersedes an
8246        // earlier one for the same reviewer at the same head (codex peer P1).
8247        let tmp = tempfile::tempdir().unwrap();
8248        // pass THEN fail -> latest is fail -> unsatisfied.
8249        let pf = write_events(
8250            tmp.path(),
8251            &[
8252                r#"{"ts":"t1","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
8253                r#"{"ts":"t2","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
8254            ],
8255        );
8256        assert!(
8257            !reviewers_all_attested(&pf, &["sigma".to_string()], "h"),
8258            "a fail posted after a pass must revoke it"
8259        );
8260        // fail THEN pass -> latest is pass -> satisfied (re-review cleared it).
8261        let fp = write_events(
8262            tmp.path(),
8263            &[
8264                r#"{"ts":"t1","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
8265                r#"{"ts":"t2","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
8266            ],
8267        );
8268        assert!(
8269            reviewers_all_attested(&fp, &["sigma".to_string()], "h"),
8270            "a pass posted after a fail must restore satisfaction"
8271        );
8272    }
8273
8274    // ── operator review-finding gate (x-f8d4) ────────────────────────────────
8275
8276    #[test]
8277    fn review_finding_open_then_resolved_clears() {
8278        // AC2-HP: an open review_finding gates; an explicit resolve clears it.
8279        let tmp = tempfile::tempdir().unwrap();
8280        let open = write_events(
8281            tmp.path(),
8282            &[
8283                r#"{"ts":"t1","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-1","text":"off-by-one in the loop\nsecond line"}}"#,
8284            ],
8285        );
8286        let (findings, malformed) = open_review_findings(&open, "x-1");
8287        assert_eq!(malformed, 0);
8288        assert_eq!(findings.len(), 1);
8289        assert_eq!(findings[0].id, "f1");
8290        assert_eq!(findings[0].first_line, "off-by-one in the loop"); // first line only
8291
8292        // resolve clears it (node-scoped, only an explicit resolve).
8293        let resolved = write_events(
8294            tmp.path(),
8295            &[
8296                r#"{"ts":"t1","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-1","text":"off-by-one"}}"#,
8297                r#"{"ts":"t2","type":"review_finding_resolved","source":"observer","data":{"finding_id":"f1"}}"#,
8298            ],
8299        );
8300        assert!(open_review_findings(&resolved, "x-1").0.is_empty());
8301    }
8302
8303    #[test]
8304    fn review_finding_is_node_scoped() {
8305        // A finding for a different node must not gate this node.
8306        let tmp = tempfile::tempdir().unwrap();
8307        let p = write_events(
8308            tmp.path(),
8309            &[
8310                r#"{"ts":"t","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-OTHER","text":"not mine"}}"#,
8311            ],
8312        );
8313        assert!(open_review_findings(&p, "x-mine").0.is_empty());
8314        assert_eq!(open_review_findings(&p, "x-OTHER").0.len(), 1);
8315    }
8316
8317    #[test]
8318    fn review_finding_malformed_notices_not_blocks() {
8319        // AC3-FR: a structurally-unparseable review_finding line does NOT block
8320        // (no open finding), but is counted for the audit notice. A review_finding
8321        // missing its id is likewise a malformed notice, never a gating finding.
8322        let tmp = tempfile::tempdir().unwrap();
8323        // A truncated (unparseable) line that still carries the review_finding marker.
8324        let truncated = r#"{"ts":"t","type":"review_finding","data":{"finding_id":"f1"#;
8325        let id_less = r#"{"ts":"t","type":"review_finding","source":"observer","data":{"node":"x-1","text":"no id"}}"#;
8326        let good = r#"{"ts":"t","type":"review_finding","source":"observer","data":{"finding_id":"good","node":"x-1","text":"real one"}}"#;
8327        let p = write_events(tmp.path(), &[truncated, id_less, good]);
8328        let (findings, malformed) = open_review_findings(&p, "x-1");
8329        assert_eq!(findings.len(), 1, "only the well-formed finding gates");
8330        assert_eq!(findings[0].id, "good");
8331        assert_eq!(
8332            malformed, 2,
8333            "the truncated line + the id-less line are noticed"
8334        );
8335    }
8336
8337    #[test]
8338    fn review_finding_block_reason_quotes_first_plus_count() {
8339        let open = vec![
8340            OpenFinding {
8341                id: "aaa".into(),
8342                first_line: "the bug".into(),
8343            },
8344            OpenFinding {
8345                id: "bbb".into(),
8346                first_line: "another".into(),
8347            },
8348        ];
8349        let r = build_findings_block_reason(&open, 1);
8350        assert!(r.contains("aaa"));
8351        assert!(r.contains("the bug"));
8352        assert!(r.contains("fno annotate resolve aaa"));
8353        assert!(r.contains("[+1 more]"));
8354        assert!(r.contains("1 malformed"));
8355    }
8356
8357    #[test]
8358    fn resolved_optional_is_separate_from_required() {
8359        // An optional-only config leaves the REQUIRED set empty (never waited
8360        // on) while the optional set carries the honored-if-present login.
8361        let s = parse_settings(
8362            "[review]\ngithub_apps = []\noptional_apps = [\"chatgpt-codex-connector\"]\n",
8363        );
8364        assert!(
8365            resolved_required_bots(&s).is_empty(),
8366            "optional must not be required"
8367        );
8368        assert_eq!(
8369            resolved_optional_bots(&s),
8370            vec!["chatgpt-codex-connector".to_string()]
8371        );
8372    }
8373
8374    #[test]
8375    fn parse_settings_github_apps_block_list() {
8376        let cfg = "[review]\ngithub_apps = [\n  \"chatgpt-codex-connector\",\n]\n";
8377        let s = parse_settings(cfg);
8378        assert_eq!(
8379            s.github_apps,
8380            Some(vec!["chatgpt-codex-connector".to_string()])
8381        );
8382    }
8383
8384    #[test]
8385    fn parse_settings_github_apps_inline_and_empty() {
8386        let s = parse_settings("[review]\ngithub_apps = [\"a\", \"b\"]\n");
8387        assert_eq!(s.github_apps, Some(vec!["a".to_string(), "b".to_string()]));
8388        let e = parse_settings("[review]\ngithub_apps = []\n");
8389        assert_eq!(e.github_apps, Some(Vec::new()));
8390    }
8391
8392    #[test]
8393    fn resolved_github_apps_wins_over_required_bots_alias() {
8394        // Both set -> github_apps wins (Locked Decision 2).
8395        let s = Settings {
8396            github_apps: Some(vec!["new-bot".to_string()]),
8397            required_bots: Some(vec!["old-bot".to_string()]),
8398            ..Default::default()
8399        };
8400        assert_eq!(resolved_required_bots(&s), vec!["new-bot".to_string()]);
8401        // required_bots-only still gates (legacy alias, AC2-HP).
8402        let legacy = Settings {
8403            required_bots: Some(vec!["old-bot".to_string()]),
8404            ..Default::default()
8405        };
8406        assert_eq!(resolved_required_bots(&legacy), vec!["old-bot".to_string()]);
8407    }
8408
8409    // --- peers -> gate union (x-4baa US4) ---
8410
8411    #[test]
8412    fn parse_settings_peers_inline_scalars() {
8413        let cfg = "[review]\npeers = [\"codex\", \"gemini\"]\npeer_identity = \"fno-peer-bot\"\n";
8414        let s = parse_settings(cfg);
8415        assert_eq!(s.peers.len(), 2);
8416        assert_eq!(s.peers[0].provider, "codex");
8417        assert_eq!(s.peer_identity.as_deref(), Some("fno-peer-bot"));
8418    }
8419
8420    #[test]
8421    fn parse_settings_peers_block_maps_with_identity() {
8422        // A heterogeneous array: an inline-table peer + a bare scalar provider.
8423        let cfg = "[review]\npeers = [{provider = \"codex\", identity = \"fno-codex-bot\"}, \"gemini\"]\n";
8424        let s = parse_settings(cfg);
8425        assert_eq!(s.peers.len(), 2);
8426        assert_eq!(s.peers[0].provider, "codex");
8427        assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8428        assert_eq!(s.peers[1].provider, "gemini");
8429        assert_eq!(s.peers[1].identity, None);
8430    }
8431
8432    #[test]
8433    fn resolved_peers_shared_identity_collapses_to_one_login() {
8434        // Scalar peers share peer_identity -> the gate is that one login on top
8435        // of github_apps (AC1-HP: no App bot, just the peer identity).
8436        let s = Settings {
8437            github_apps: Some(Vec::new()),
8438            peers: vec![
8439                PeerEntry {
8440                    provider: "codex".into(),
8441                    model: None,
8442                    identity: None,
8443                },
8444                PeerEntry {
8445                    provider: "gemini".into(),
8446                    model: None,
8447                    identity: None,
8448                },
8449            ],
8450            peer_identity: Some("fno-peer-bot".into()),
8451            ..Default::default()
8452        };
8453        assert_eq!(resolved_required_bots(&s), vec!["fno-peer-bot".to_string()]);
8454    }
8455
8456    #[test]
8457    fn resolved_peers_per_entry_identities_each_add_a_login() {
8458        let s = Settings {
8459            github_apps: Some(vec!["chatgpt-codex-connector".into()]),
8460            peers: vec![
8461                PeerEntry {
8462                    provider: "codex".into(),
8463                    model: None,
8464                    identity: Some("fno-codex-bot".into()),
8465                },
8466                PeerEntry {
8467                    provider: "gemini".into(),
8468                    model: None,
8469                    identity: Some("fno-gemini-bot".into()),
8470                },
8471            ],
8472            ..Default::default()
8473        };
8474        assert_eq!(
8475            resolved_required_bots(&s),
8476            vec![
8477                "chatgpt-codex-connector".to_string(),
8478                "fno-codex-bot".to_string(),
8479                "fno-gemini-bot".to_string(),
8480            ]
8481        );
8482    }
8483
8484    #[test]
8485    fn parse_settings_github_apps_and_peers_together() {
8486        // github_apps + peers + peer_identity in one [review] table all parse.
8487        let cfg = "[review]\ngithub_apps = [\"chatgpt-codex-connector\"]\npeers = [\"codex\"]\npeer_identity = \"fno-peer-bot\"\n";
8488        let s = parse_settings(cfg);
8489        assert_eq!(
8490            s.github_apps,
8491            Some(vec!["chatgpt-codex-connector".to_string()]),
8492            "github_apps item must be collected"
8493        );
8494        assert_eq!(s.peers.len(), 1, "peers item must be collected");
8495        assert_eq!(s.peers[0].provider, "codex");
8496        assert_eq!(s.peer_identity.as_deref(), Some("fno-peer-bot"));
8497    }
8498
8499    #[test]
8500    fn parse_settings_required_bots_single_item() {
8501        let cfg = "[review]\nrequired_bots = [\"chatgpt-codex-connector\"]\n";
8502        let s = parse_settings(cfg);
8503        assert_eq!(
8504            s.required_bots,
8505            Some(vec!["chatgpt-codex-connector".to_string()])
8506        );
8507    }
8508
8509    #[test]
8510    fn parse_settings_peers_single_mapping_is_one_peer() {
8511        // codex peer review P1: a single top-level table for peers (what
8512        // Python's coerce_peers wraps as [dict]) must parse as ONE peer, not be
8513        // silently dropped - dropping it is a fail-open on a configured peer gate.
8514        let block = parse_settings(
8515            "[review]\npeers = {provider = \"codex\", identity = \"fno-codex-bot\"}\n",
8516        );
8517        assert_eq!(block.peers.len(), 1, "table peers must be one peer");
8518        assert_eq!(block.peers[0].provider, "codex");
8519        assert_eq!(block.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8520        // A dotted-table form parses identically.
8521        let dotted = parse_settings(
8522            "[review.peers]\nprovider = \"gemini\"\nidentity = \"fno-gemini-bot\"\n",
8523        );
8524        assert_eq!(dotted.peers.len(), 1);
8525        assert_eq!(dotted.peers[0].provider, "gemini");
8526        assert_eq!(dotted.peers[0].identity.as_deref(), Some("fno-gemini-bot"));
8527    }
8528
8529    #[test]
8530    fn parse_settings_peers_bare_scalar_is_one_provider() {
8531        // `peers = "codex"` (scalar) matches Python's coerce_peers -> one peer,
8532        // NOT a silent drop (which would fail open + diverge from Python).
8533        let cfg = "[review]\npeers = \"codex\"\npeer_identity = \"fno-peer-bot\"\n";
8534        let s = parse_settings(cfg);
8535        assert_eq!(s.peers.len(), 1);
8536        assert_eq!(s.peers[0].provider, "codex");
8537        // The gate then resolves on the shared identity (fail-closed if unset).
8538        assert_eq!(resolved_required_bots(&s), vec!["fno-peer-bot".to_string()]);
8539    }
8540
8541    #[test]
8542    fn parse_settings_peers_array_of_tables() {
8543        // An array mixing an inline-table peer and a bare scalar provider.
8544        let cfg = "[review]\npeers = [{provider = \"codex\", identity = \"fno-codex-bot\"}, \"gemini\"]\n";
8545        let s = parse_settings(cfg);
8546        assert_eq!(s.peers.len(), 2);
8547        assert_eq!(s.peers[0].provider, "codex");
8548        assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8549        assert_eq!(s.peers[1].provider, "gemini");
8550    }
8551
8552    #[test]
8553    fn parse_settings_peers_map_identity_before_provider() {
8554        // The map parser is order-agnostic (gemini HIGH on #205): `identity`
8555        // before `provider` must still resolve both fields.
8556        let cfg = "[review]\npeers = [{identity = \"fno-codex-bot\", provider = \"codex\"}, {provider = \"gemini\", identity = \"fno-gemini-bot\"}]\n";
8557        let s = parse_settings(cfg);
8558        assert_eq!(s.peers.len(), 2);
8559        assert_eq!(s.peers[0].provider, "codex");
8560        assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8561        assert_eq!(s.peers[1].provider, "gemini");
8562        assert_eq!(s.peers[1].identity.as_deref(), Some("fno-gemini-bot"));
8563    }
8564
8565    #[test]
8566    fn identity_free_peer_uses_local_attestation_not_a_login() {
8567        let s = Settings {
8568            github_apps: Some(Vec::new()),
8569            peers: vec![PeerEntry {
8570                provider: "gemini".into(),
8571                model: None,
8572                identity: None,
8573            }],
8574            peer_identity: None,
8575            ..Default::default()
8576        };
8577        assert!(resolved_required_bots_for_author(&s, Some("codex")).is_empty());
8578        assert_eq!(
8579            resolved_local_peer_reviewers_for_author(&s, Some("codex")),
8580            vec![LOCAL_PEER_REVIEWER.to_string()]
8581        );
8582    }
8583
8584    #[test]
8585    fn identity_free_same_model_peer_is_an_unsatisfiable_local_gate() {
8586        let s = Settings {
8587            peers: vec![PeerEntry {
8588                provider: "codex".into(),
8589                model: None,
8590                identity: None,
8591            }],
8592            ..Default::default()
8593        };
8594        assert_eq!(
8595            resolved_local_peer_reviewers_for_author(&s, Some("codex")),
8596            vec![SAME_MODEL_LOCAL_PEER_SENTINEL.to_string()]
8597        );
8598    }
8599
8600    #[test]
8601    fn identity_free_mixed_peers_form_one_composite_gate() {
8602        let s = Settings {
8603            peers: vec![
8604                PeerEntry {
8605                    provider: "codex".into(),
8606                    model: None,
8607                    identity: None,
8608                },
8609                PeerEntry {
8610                    provider: "claude".into(),
8611                    model: Some("zai,glm-5.2".into()),
8612                    identity: None,
8613                },
8614            ],
8615            ..Default::default()
8616        };
8617        assert_eq!(
8618            resolved_local_peer_reviewers_for_author(&s, Some("codex")),
8619            vec![LOCAL_PEER_REVIEWER.to_string()]
8620        );
8621    }
8622
8623    #[test]
8624    fn explicit_peer_identity_keeps_login_gate_only() {
8625        let s = Settings {
8626            peers: vec![PeerEntry {
8627                provider: "gemini".into(),
8628                model: None,
8629                identity: Some("fno-gemini-bot".into()),
8630            }],
8631            ..Default::default()
8632        };
8633        assert_eq!(
8634            resolved_required_bots_for_author(&s, Some("codex")),
8635            vec!["fno-gemini-bot".to_string()]
8636        );
8637        assert!(resolved_local_peer_reviewers_for_author(&s, Some("codex")).is_empty());
8638    }
8639
8640    #[test]
8641    fn local_peer_attestation_is_head_pinned() {
8642        let td = tempfile::tempdir().unwrap();
8643        let events = td.path().join("events.jsonl");
8644        std::fs::write(
8645            &events,
8646            r#"{"type":"review_attestation","data":{"reviewer":"peer","head_sha":"OLD","verdict":"pass"}}"#,
8647        )
8648        .unwrap();
8649        let peer = vec![LOCAL_PEER_REVIEWER.to_string()];
8650        assert!(!reviewers_all_attested(&events, &peer, "NEW"));
8651        std::fs::write(
8652            &events,
8653            r#"{"type":"review_attestation","data":{"reviewer":"peer","head_sha":"NEW","verdict":"pass"}}"#,
8654        )
8655        .unwrap();
8656        assert!(reviewers_all_attested(&events, &peer, "NEW"));
8657    }
8658
8659    // ---- same-model peer guard (x-c2e7) -----------------------------------
8660
8661    /// US5: effective model family resolution across bare providers, routes,
8662    /// malformed routes (fall back to provider), and unknown providers (None).
8663    #[test]
8664    fn peer_family_mapping_table() {
8665        let bare = |p: &str| PeerEntry {
8666            provider: p.into(),
8667            model: None,
8668            identity: None,
8669        };
8670        let routed = |p: &str, m: &str| PeerEntry {
8671            provider: p.into(),
8672            model: Some(m.into()),
8673            identity: None,
8674        };
8675        // harness_family: names + aliases + case-insensitivity; unknown -> None.
8676        assert_eq!(harness_family("claude"), Some("anthropic"));
8677        assert_eq!(harness_family("ANTHROPIC"), Some("anthropic"));
8678        assert_eq!(harness_family("codex"), Some("openai"));
8679        assert_eq!(harness_family("gemini"), Some("google"));
8680        assert_eq!(harness_family("zai"), None);
8681        // route_provider: exactly two non-empty parts, else None (fall back).
8682        assert_eq!(route_provider("zai,glm-5.2"), Some("zai"));
8683        assert_eq!(route_provider(" openai , gpt-5 "), Some("openai"));
8684        assert_eq!(route_provider("gpt-5"), None); // no comma -> malformed
8685        assert_eq!(route_provider("zai,"), None); // empty model -> malformed
8686        assert_eq!(route_provider(",glm"), None); // empty provider -> malformed
8687        assert_eq!(route_provider("a,b,c"), None); // three parts -> malformed
8688
8689        // peer_family: bare provider, valid route wins, malformed falls back.
8690        assert_eq!(peer_family(&bare("codex")), Some("openai"));
8691        assert_eq!(peer_family(&bare("grok")), None); // unknown -> never matches
8692        assert_eq!(peer_family(&routed("claude", "zai,glm-5.2")), None); // route wins
8693        assert_eq!(
8694            peer_family(&routed("codex", "openai,gpt-5")),
8695            Some("openai")
8696        );
8697        assert_eq!(peer_family(&routed("codex", "gpt-5")), Some("openai")); // malformed -> provider
8698    }
8699
8700    /// AC1-HP: codex author + `peers: [codex]` -> the peer login is replaced by
8701    /// the same-model sentinel so the gate cannot clear.
8702    #[test]
8703    fn same_model_peer_holds_gate() {
8704        let s = Settings {
8705            github_apps: Some(Vec::new()),
8706            peers: vec![PeerEntry {
8707                provider: "codex".into(),
8708                model: None,
8709                identity: None,
8710            }],
8711            peer_identity: Some("fno-peer-bot".into()),
8712            ..Default::default()
8713        };
8714        let logins = resolved_required_bots_for_author(&s, Some("codex"));
8715        assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
8716        assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
8717    }
8718
8719    /// AC2-HP: codex author + `peers: [gemini]` (cross-model) clears exactly as
8720    /// today - the login stays, no sentinel.
8721    #[test]
8722    fn cross_model_peer_login_unchanged() {
8723        let s = Settings {
8724            github_apps: Some(Vec::new()),
8725            peers: vec![PeerEntry {
8726                provider: "gemini".into(),
8727                model: None,
8728                identity: None,
8729            }],
8730            peer_identity: Some("fno-peer-bot".into()),
8731            ..Default::default()
8732        };
8733        let logins = resolved_required_bots_for_author(&s, Some("codex"));
8734        assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
8735    }
8736
8737    /// US1 / step-3b: a claude author with a routed claude peer
8738    /// (`{provider: claude, model: "zai,glm-5.2"}`) is cross-model (GLM via zai)
8739    /// -> the login stays.
8740    #[test]
8741    fn routed_claude_peer_is_cross_model() {
8742        let s = Settings {
8743            github_apps: Some(Vec::new()),
8744            peers: vec![PeerEntry {
8745                provider: "claude".into(),
8746                model: Some("zai,glm-5.2".into()),
8747                identity: None,
8748            }],
8749            peer_identity: Some("fno-peer-bot".into()),
8750            ..Default::default()
8751        };
8752        let logins = resolved_required_bots_for_author(&s, Some("claude"));
8753        assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
8754    }
8755
8756    /// AC3-ERR: a claude peer routed back to the author's own family
8757    /// (`anthropic,...`, hand-edited past the loader) is same-model -> sentinel.
8758    #[test]
8759    fn same_family_route_holds_gate() {
8760        let s = Settings {
8761            github_apps: Some(Vec::new()),
8762            peers: vec![PeerEntry {
8763                provider: "claude".into(),
8764                model: Some("anthropic,claude-opus".into()),
8765                identity: None,
8766            }],
8767            peer_identity: Some("fno-peer-bot".into()),
8768            ..Default::default()
8769        };
8770        let logins = resolved_required_bots_for_author(&s, Some("claude"));
8771        assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
8772        assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
8773    }
8774
8775    /// AC5-EDGE: codex author + `peers: [codex, gemini]` sharing one identity
8776    /// stays satisfiable (gemini backs the login) -> login kept, no sentinel.
8777    #[test]
8778    fn shared_identity_mixed_peers_stays_satisfiable() {
8779        let s = Settings {
8780            github_apps: Some(Vec::new()),
8781            peers: vec![
8782                PeerEntry {
8783                    provider: "codex".into(),
8784                    model: None,
8785                    identity: None,
8786                },
8787                PeerEntry {
8788                    provider: "gemini".into(),
8789                    model: None,
8790                    identity: None,
8791                },
8792            ],
8793            peer_identity: Some("fno-peer-bot".into()),
8794            ..Default::default()
8795        };
8796        let logins = resolved_required_bots_for_author(&s, Some("codex"));
8797        assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
8798    }
8799
8800    /// AC6-FR: unknown harness (None) leaves the login set byte-identical to the
8801    /// no-guard wrapper, even for a would-be same-model config.
8802    #[test]
8803    fn unknown_harness_is_byte_identical() {
8804        let s = Settings {
8805            github_apps: Some(vec!["chatgpt-codex-connector".into()]),
8806            peers: vec![PeerEntry {
8807                provider: "codex".into(),
8808                model: None,
8809                identity: None,
8810            }],
8811            peer_identity: Some("fno-peer-bot".into()),
8812            ..Default::default()
8813        };
8814        // None author => guard inert => equals the no-harness wrapper exactly.
8815        assert_eq!(
8816            resolved_required_bots_for_author(&s, None),
8817            resolved_required_bots(&s)
8818        );
8819        assert!(!resolved_required_bots_for_author(&s, None)
8820            .iter()
8821            .any(|l| l == SAME_MODEL_PEER_SENTINEL));
8822    }
8823
8824    /// A same-model peer whose identity COLLIDES with a required App login is
8825    /// fail-closed, not exempt (codex peer review on PR #375): the App login is
8826    /// kept (its requirement is not loosened) AND the sentinel is added, so a
8827    /// same-model review under the shared login cannot clear the gate.
8828    #[test]
8829    fn base_app_login_collision_is_fail_closed() {
8830        let s = Settings {
8831            github_apps: Some(vec!["fno-peer-bot".into()]),
8832            peers: vec![PeerEntry {
8833                provider: "codex".into(),
8834                model: None,
8835                identity: None,
8836            }],
8837            peer_identity: Some("fno-peer-bot".into()),
8838            ..Default::default()
8839        };
8840        let logins = resolved_required_bots_for_author(&s, Some("codex"));
8841        assert!(logins.iter().any(|l| l == "fno-peer-bot")); // App requirement kept
8842        assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL)); // gate held
8843    }
8844
8845    /// A codex/gemini peer's `model` route is NOT honored (only claude transport
8846    /// executes a route; codex/gemini dispatch runs the bare provider). A codex
8847    /// peer with a zai route stays openai-family -> same-model on a codex author,
8848    /// closing the route-bypass codex flagged on PR #375.
8849    #[test]
8850    fn non_claude_route_is_ignored() {
8851        let routed_codex = PeerEntry {
8852            provider: "codex".into(),
8853            model: Some("zai,glm-5.2".into()),
8854            identity: None,
8855        };
8856        assert_eq!(peer_family(&routed_codex), Some("openai"));
8857        let s = Settings {
8858            github_apps: Some(Vec::new()),
8859            peers: vec![routed_codex],
8860            peer_identity: Some("fno-peer-bot".into()),
8861            ..Default::default()
8862        };
8863        let logins = resolved_required_bots_for_author(&s, Some("codex"));
8864        assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
8865        assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
8866    }
8867
8868    #[test]
8869    fn login_matches_bot_cases() {
8870        // Full login, [bot]-suffixed login, and short config names all match.
8871        assert!(login_matches_bot(
8872            "chatgpt-codex-connector",
8873            "chatgpt-codex-connector"
8874        ));
8875        assert!(login_matches_bot(
8876            "chatgpt-codex-connector[bot]",
8877            "chatgpt-codex-connector"
8878        ));
8879        assert!(login_matches_bot("chatgpt-codex-connector", "codex"));
8880        assert!(login_matches_bot("Gemini-Code-Assist[bot]", "gemini"));
8881        assert!(!login_matches_bot("alice-the-human", "codex"));
8882        // Empty config entry must never match every login.
8883        assert!(!login_matches_bot("anyone", ""));
8884    }
8885
8886    #[test]
8887    fn compute_review_info_per_bot_verdict() {
8888        let required = vec![
8889            "chatgpt-codex-connector".to_string(),
8890            "gemini-code-assist".to_string(),
8891        ];
8892        // Only codex posted a completed pass (COMMENTED counts).
8893        let json = serde_json::json!({
8894            "reviews": [
8895                {"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
8896                 "submittedAt": "2026-06-05T01:00:00Z"}
8897            ],
8898            "comments": []
8899        });
8900        let info = compute_review_info(&json, &required);
8901        assert!(!info.all_required_passed());
8902        assert_eq!(info.missing_bots, vec!["gemini-code-assist".to_string()]);
8903        assert_eq!(info.latest_ts, "2026-06-05T01:00:00Z");
8904    }
8905
8906    // ── x-b167 nudge state ────────────────────────────────────────────────────
8907
8908    fn nudge_cfg() -> NudgeConfig {
8909        NudgeConfig {
8910            login: "chatgpt-codex-connector".into(),
8911            review_handle: "@codex review".into(),
8912            wait_minutes: 15,
8913            ceiling: 3,
8914        }
8915    }
8916    fn nudge_now() -> DateTime<Utc> {
8917        "2026-07-06T02:00:00Z".parse().unwrap()
8918    }
8919    fn mention(body: &str, created: &str) -> Value {
8920        serde_json::json!({"body": body, "createdAt": created})
8921    }
8922
8923    #[test]
8924    fn nudge_needs_nudge_when_never_mentioned() {
8925        let cfg = nudge_cfg();
8926        let b = classify_bot_nudge("chatgpt-codex-connector", &[], Some(&cfg), nudge_now());
8927        assert_eq!(b.class, NudgeClass::NeedsNudge);
8928        assert_eq!(b.nudges, 0);
8929        assert_eq!(b.review_handle, "@codex review");
8930    }
8931
8932    #[test]
8933    fn nudge_awaiting_within_window() {
8934        let cfg = nudge_cfg();
8935        let comments = vec![mention("@codex review", "2026-07-06T01:58:00Z")];
8936        let b = classify_bot_nudge(
8937            "chatgpt-codex-connector",
8938            &comments,
8939            Some(&cfg),
8940            nudge_now(),
8941        );
8942        assert_eq!(b.class, NudgeClass::Awaiting);
8943        assert_eq!(b.nudges, 1);
8944        assert!(b.newest_age_min <= 2);
8945    }
8946
8947    #[test]
8948    fn nudge_unresponsive_after_ceiling() {
8949        // AC3 building block: 3 mentions, newest older than wait_minutes.
8950        let cfg = nudge_cfg();
8951        let comments = vec![
8952            mention("@codex review", "2026-07-06T00:00:00Z"),
8953            mention("hey @codex review please", "2026-07-06T00:30:00Z"),
8954            mention("@codex review", "2026-07-06T01:00:00Z"),
8955        ];
8956        let b = classify_bot_nudge(
8957            "chatgpt-codex-connector",
8958            &comments,
8959            Some(&cfg),
8960            nudge_now(),
8961        );
8962        assert_eq!(b.class, NudgeClass::Unresponsive);
8963        assert_eq!(b.nudges, 3);
8964        assert!(b.span_min >= 120, "span was {}", b.span_min);
8965    }
8966
8967    #[test]
8968    fn nudge_reask_after_timeout_below_ceiling() {
8969        // One mention 60m ago, ceiling 3: the previous nudge timed out, ask again.
8970        let cfg = nudge_cfg();
8971        let comments = vec![mention("@codex review", "2026-07-06T01:00:00Z")];
8972        let b = classify_bot_nudge(
8973            "chatgpt-codex-connector",
8974            &comments,
8975            Some(&cfg),
8976            nudge_now(),
8977        );
8978        assert_eq!(b.class, NudgeClass::NeedsNudge);
8979        assert_eq!(b.nudges, 1);
8980    }
8981
8982    #[test]
8983    fn nudge_none_cfg_is_not_nudgeable() {
8984        // AC7: a peer-login sentinel classifies NotNudgeable.
8985        let b2 = classify_bot_nudge(SAME_MODEL_PEER_SENTINEL, &[], None, nudge_now());
8986        assert_eq!(b2.class, NudgeClass::NotNudgeable);
8987    }
8988
8989    #[test]
8990    fn nudge_malformed_created_at_is_needs_nudge() {
8991        // A mention with an unparseable createdAt must not push toward Unresponsive.
8992        let cfg = nudge_cfg();
8993        let comments = vec![mention("@codex review", "not-a-date")];
8994        let b = classify_bot_nudge(
8995            "chatgpt-codex-connector",
8996            &comments,
8997            Some(&cfg),
8998            nudge_now(),
8999        );
9000        assert_eq!(b.class, NudgeClass::NeedsNudge);
9001        assert_eq!(b.nudges, 1);
9002    }
9003
9004    #[test]
9005    fn resolved_nudge_configs_default_nudges_codex_only() {
9006        let cfgs = resolved_nudge_configs(&Settings::default());
9007        let codex = cfgs
9008            .iter()
9009            .find(|c| c.login == "chatgpt-codex-connector")
9010            .expect("codex nudgeable by default");
9011        assert_eq!(codex.review_handle, "@codex review");
9012        assert_eq!(codex.wait_minutes, 15);
9013        assert_eq!(codex.ceiling, 3);
9014        // gemini ships with an empty review_handle -> not nudgeable.
9015        assert!(cfgs.iter().all(|c| c.login != "gemini-code-assist"));
9016    }
9017
9018    #[test]
9019    fn nudge_override_sets_wait_and_ceiling_inheriting_handle() {
9020        let s = parse_settings(
9021            "[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = 30, ceiling = 5 }\n",
9022        );
9023        let cfgs = resolved_nudge_configs(&s);
9024        let codex = cfgs
9025            .iter()
9026            .find(|c| logins_correspond(&c.login, "chatgpt-codex-connector"))
9027            .unwrap();
9028        assert_eq!(codex.wait_minutes, 30);
9029        assert_eq!(codex.ceiling, 5);
9030        assert_eq!(codex.review_handle, "@codex review");
9031    }
9032
9033    #[test]
9034    fn nudge_override_disabled_removes_login() {
9035        let s =
9036            parse_settings("[review.nudge]\n\"chatgpt-codex-connector\" = { enabled = false }\n");
9037        let cfgs = resolved_nudge_configs(&s);
9038        assert!(cfgs
9039            .iter()
9040            .all(|c| !logins_correspond(&c.login, "chatgpt-codex-connector")));
9041    }
9042
9043    #[test]
9044    fn nudge_override_new_login() {
9045        let s = parse_settings(
9046            "[review.nudge]\n\"some-bot\" = { review_handle = \"@somebot review\", wait_minutes = 10, ceiling = 2 }\n",
9047        );
9048        let cfgs = resolved_nudge_configs(&s);
9049        let b = cfgs.iter().find(|c| c.login == "some-bot").unwrap();
9050        assert_eq!(b.review_handle, "@somebot review");
9051        assert_eq!(b.wait_minutes, 10);
9052        assert_eq!(b.ceiling, 2);
9053    }
9054
9055    #[test]
9056    fn nudge_malformed_override_degrades_to_non_nudgeable() {
9057        // AC8: a scalar, a list, and a non-integer wait_minutes each drop the
9058        // login to non-nudgeable without panicking.
9059        for body in [
9060            "[review.nudge]\n\"chatgpt-codex-connector\" = \"scalar\"\n",
9061            "[review.nudge]\n\"chatgpt-codex-connector\" = [1, 2]\n",
9062            "[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = \"soon\" }\n",
9063            // An absurd wait_minutes would overflow chrono::Duration::minutes and
9064            // panic the stop gate; it must degrade to non-nudgeable, not panic.
9065            "[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = 9999999999999999 }\n",
9066        ] {
9067            let s = parse_settings(body);
9068            let cfgs = resolved_nudge_configs(&s);
9069            assert!(
9070                cfgs.iter()
9071                    .all(|c| !logins_correspond(&c.login, "chatgpt-codex-connector")),
9072                "malformed override must be non-nudgeable: {body}"
9073            );
9074        }
9075    }
9076
9077    #[test]
9078    fn compute_review_info_empty_state_not_a_pass() {
9079        // A review row with an empty state is not a completed pass.
9080        let required = vec!["chatgpt-codex-connector".to_string()];
9081        let json = serde_json::json!({
9082            "reviews": [
9083                {"author": {"login": "chatgpt-codex-connector"}, "state": "",
9084                 "submittedAt": "2026-06-05T01:00:00Z"}
9085            ],
9086            "comments": []
9087        });
9088        let info = compute_review_info(&json, &required);
9089        assert!(!info.all_required_passed());
9090    }
9091
9092    #[test]
9093    fn compute_review_info_usage_limited_bot_dropped() {
9094        // AC1-HP: a required bot that posted only a usage-limit comment (no
9095        // review) leaves missing_bots for usage_limited, so the gate proceeds.
9096        let required = vec!["chatgpt-codex-connector".to_string()];
9097        let json = serde_json::json!({
9098            "reviews": [],
9099            "comments": [
9100                {"author": {"login": "chatgpt-codex-connector"},
9101                 "body": "You have reached your Codex usage limits for code reviews.",
9102                 "createdAt": "2026-07-06T01:00:00Z"}
9103            ]
9104        });
9105        let info = compute_review_info(&json, &required);
9106        assert!(info.missing_bots.is_empty());
9107        assert_eq!(
9108            info.usage_limited,
9109            vec!["chatgpt-codex-connector".to_string()]
9110        );
9111        assert!(info.all_required_passed());
9112    }
9113
9114    #[test]
9115    fn compute_review_info_usage_limit_only_own_comment_counts() {
9116        // AC1-ERR: a usage-limit marker in a HUMAN's comment must not drop the
9117        // bot - detection is scoped to the bot's own author.login.
9118        let required = vec!["chatgpt-codex-connector".to_string()];
9119        let json = serde_json::json!({
9120            "reviews": [],
9121            "comments": [
9122                {"author": {"login": "some-human"},
9123                 "body": "The bot hit its usage limits for code reviews, ugh.",
9124                 "createdAt": "2026-07-06T01:00:00Z"}
9125            ]
9126        });
9127        let info = compute_review_info(&json, &required);
9128        assert_eq!(
9129            info.missing_bots,
9130            vec!["chatgpt-codex-connector".to_string()]
9131        );
9132        assert!(info.usage_limited.is_empty());
9133        assert!(!info.all_required_passed());
9134    }
9135
9136    #[test]
9137    fn compute_review_info_real_review_beats_ratelimit_comment() {
9138        // AC1-EDGE: a bot that posted a usage-limit comment earlier AND a real
9139        // COMMENTED review is counted as passed, never usage-limited (it is
9140        // never in missing_bots to be scanned).
9141        let required = vec!["chatgpt-codex-connector".to_string()];
9142        let json = serde_json::json!({
9143            "reviews": [
9144                {"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
9145                 "submittedAt": "2026-07-06T02:00:00Z"}
9146            ],
9147            "comments": [
9148                {"author": {"login": "chatgpt-codex-connector"},
9149                 "body": "codex usage limits reached",
9150                 "createdAt": "2026-07-06T01:00:00Z"}
9151            ]
9152        });
9153        let info = compute_review_info(&json, &required);
9154        assert!(info.missing_bots.is_empty());
9155        assert!(info.usage_limited.is_empty());
9156        assert!(info.all_required_passed());
9157    }
9158
9159    // ── step 2: inline findings + severity + addressed (US2) ────────────────
9160
9161    #[test]
9162    fn blocking_severity_codex_p1_both_forms() {
9163        // The exact markup codex emits (pinned from PR #447).
9164        assert_eq!(
9165            blocking_severity("![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Bug"),
9166            Some("P1")
9167        );
9168        // Alt-text only and URL only each match.
9169        assert_eq!(blocking_severity("![P1 Badge] something"), Some("P1"));
9170        assert_eq!(
9171            blocking_severity("see https://img.shields.io/badge/P1-orange"),
9172            Some("P1")
9173        );
9174    }
9175
9176    #[test]
9177    fn blocking_severity_codex_p2_p3_advisory() {
9178        assert_eq!(
9179            blocking_severity("![P2 Badge](https://img.shields.io/badge/P2-yellow) nit"),
9180            None
9181        );
9182        assert_eq!(
9183            blocking_severity("![P3 Badge](https://img.shields.io/badge/P3-green) nit"),
9184            None
9185        );
9186    }
9187
9188    #[test]
9189    fn blocking_severity_gemini_critical_high_blocking() {
9190        assert_eq!(
9191            blocking_severity(
9192                "![critical](https://www.gstatic.com/codereviewagent/critical-priority.svg) bad"
9193            ),
9194            Some("critical")
9195        );
9196        assert_eq!(
9197            blocking_severity(
9198                "![high](https://www.gstatic.com/codereviewagent/high-priority.svg) bad"
9199            ),
9200            Some("high")
9201        );
9202    }
9203
9204    #[test]
9205    fn blocking_severity_gemini_medium_low_advisory() {
9206        assert_eq!(
9207            blocking_severity(
9208                "![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) hmm"
9209            ),
9210            None
9211        );
9212        assert_eq!(
9213            blocking_severity(
9214                "![low](https://www.gstatic.com/codereviewagent/low-priority.svg) hmm"
9215            ),
9216            None
9217        );
9218    }
9219
9220    /// Boundaries: unrecognized / absent severity tokens classify advisory,
9221    /// never blocking (locked decision 4).
9222    #[test]
9223    fn blocking_severity_unparseable_is_advisory() {
9224        assert_eq!(blocking_severity("just a comment with no badge"), None);
9225        assert_eq!(blocking_severity(""), None);
9226        assert_eq!(blocking_severity("P1 mentioned in prose only"), None);
9227    }
9228
9229    #[test]
9230    fn max_ts_none_handling() {
9231        assert_eq!(
9232            max_ts("none", "2026-06-05T01:00:00Z"),
9233            "2026-06-05T01:00:00Z"
9234        );
9235        assert_eq!(
9236            max_ts("2026-06-05T01:00:00Z", "none"),
9237            "2026-06-05T01:00:00Z"
9238        );
9239        assert_eq!(max_ts("none", "none"), "none");
9240        assert_eq!(max_ts("", ""), "none");
9241        assert_eq!(
9242            max_ts("2026-06-05T01:00:00Z", "2026-06-05T02:00:00Z"),
9243            "2026-06-05T02:00:00Z"
9244        );
9245    }
9246
9247    fn finding_comment(id: i64, body: &str, created_at: &str) -> Value {
9248        serde_json::json!({
9249            "id": id,
9250            "in_reply_to_id": null,
9251            "user": {"login": "chatgpt-codex-connector[bot]"},
9252            "body": body,
9253            "path": "src/x.rs",
9254            "line": 42,
9255            "created_at": created_at
9256        })
9257    }
9258
9259    fn reply_comment(id: i64, parent: i64, login: &str, body: &str, created_at: &str) -> Value {
9260        serde_json::json!({
9261            "id": id,
9262            "in_reply_to_id": parent,
9263            "user": {"login": login},
9264            "body": body,
9265            "created_at": created_at
9266        })
9267    }
9268
9269    const REQ: &[&str] = &["chatgpt-codex-connector"];
9270
9271    fn req_vec() -> Vec<String> {
9272        REQ.iter().map(|s| s.to_string()).collect()
9273    }
9274
9275    /// AC2-ERR core: a P1 with no reply is unaddressed.
9276    #[test]
9277    fn finding_no_reply_is_unaddressed() {
9278        let comments = vec![finding_comment(
9279            100,
9280            "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9281            "2026-06-05T01:10:00Z",
9282        )];
9283        let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9284        assert_eq!(ts, "2026-06-05T01:10:00Z");
9285        assert_eq!(unaddressed.len(), 1);
9286        assert_eq!(unaddressed[0].path, "src/x.rs");
9287        assert_eq!(unaddressed[0].line, 42);
9288        assert_eq!(unaddressed[0].severity, "P1");
9289    }
9290
9291    /// AC2-HP commit arm: non-bot reply + commit after the finding -> addressed.
9292    #[test]
9293    fn finding_reply_plus_commit_after_is_addressed() {
9294        let comments = vec![
9295            finding_comment(
9296                100,
9297                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9298                "2026-06-05T01:10:00Z",
9299            ),
9300            reply_comment(
9301                101,
9302                100,
9303                "bllshttng",
9304                "fixed in abc123",
9305                "2026-06-05T01:20:00Z",
9306            ),
9307        ];
9308        let commits = vec!["2026-06-05T01:30:00Z".to_string()];
9309        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9310        assert!(unaddressed.is_empty(), "commit-after arm must address");
9311    }
9312
9313    /// AC2-FR wontfix arm: non-bot reply carrying wontfix:, NO commit after.
9314    #[test]
9315    fn finding_wontfix_reply_is_addressed_without_commit() {
9316        let comments = vec![
9317            finding_comment(
9318                100,
9319                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9320                "2026-06-05T01:10:00Z",
9321            ),
9322            reply_comment(
9323                101,
9324                100,
9325                "bllshttng",
9326                "wontfix: intentional - documented tradeoff",
9327                "2026-06-05T01:20:00Z",
9328            ),
9329        ];
9330        // Only commit predates the finding -> commit arm unsatisfied.
9331        let commits = vec!["2026-06-05T01:00:00Z".to_string()];
9332        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9333        assert!(unaddressed.is_empty(), "wontfix arm must address alone");
9334    }
9335
9336    /// Anti-gaming: a commit alone (no reply) does NOT address (locked
9337    /// decision 3 - any unrelated commit would silently clear a P1).
9338    #[test]
9339    fn finding_commit_without_reply_is_unaddressed() {
9340        let comments = vec![finding_comment(
9341            100,
9342            "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9343            "2026-06-05T01:10:00Z",
9344        )];
9345        let commits = vec!["2026-06-05T01:30:00Z".to_string()];
9346        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9347        assert_eq!(unaddressed.len(), 1, "commit alone must not address");
9348    }
9349
9350    /// A bot's own reply in the thread is not an ack.
9351    #[test]
9352    fn finding_bot_reply_only_is_unaddressed() {
9353        let comments = vec![
9354            finding_comment(
9355                100,
9356                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9357                "2026-06-05T01:10:00Z",
9358            ),
9359            reply_comment(
9360                101,
9361                100,
9362                "chatgpt-codex-connector[bot]",
9363                "elaborating on my finding",
9364                "2026-06-05T01:15:00Z",
9365            ),
9366        ];
9367        let commits = vec!["2026-06-05T01:30:00Z".to_string()];
9368        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9369        assert_eq!(unaddressed.len(), 1, "bot self-reply must not count as ack");
9370    }
9371
9372    /// Reply present but neither commit-after nor wontfix -> still unaddressed.
9373    #[test]
9374    fn finding_reply_without_commit_or_wontfix_is_unaddressed() {
9375        let comments = vec![
9376            finding_comment(
9377                100,
9378                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9379                "2026-06-05T01:10:00Z",
9380            ),
9381            reply_comment(
9382                101,
9383                100,
9384                "bllshttng",
9385                "looking into it",
9386                "2026-06-05T01:20:00Z",
9387            ),
9388        ];
9389        let commits = vec!["2026-06-05T01:00:00Z".to_string()]; // predates finding
9390        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9391        assert_eq!(unaddressed.len(), 1);
9392    }
9393
9394    /// A finding from a NON-required bot does not gate.
9395    #[test]
9396    fn finding_from_non_required_bot_ignored() {
9397        let comments = vec![serde_json::json!({
9398            "id": 200,
9399            "in_reply_to_id": null,
9400            "user": {"login": "gemini-code-assist[bot]"},
9401            "body": "![high](https://www.gstatic.com/codereviewagent/high-priority.svg) eh",
9402            "path": "src/y.rs",
9403            "line": 7,
9404            "created_at": "2026-06-05T01:10:00Z"
9405        })];
9406        // required = codex only; gemini finding is not gate-relevant
9407        let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9408        assert!(unaddressed.is_empty());
9409        // ...but its timestamp still feeds the fingerprint.
9410        assert_eq!(ts, "2026-06-05T01:10:00Z");
9411    }
9412
9413    /// Boundaries: empty comments array -> no findings, ts "none".
9414    #[test]
9415    fn empty_comments_no_findings() {
9416        let (ts, unaddressed) = compute_unaddressed_findings(&[], &[], &req_vec(), &[]);
9417        assert_eq!(ts, "none");
9418        assert!(unaddressed.is_empty());
9419    }
9420
9421    /// sigma-review: a blocking finding row with a missing id is SKIPPED
9422    /// (under-block per locked decision 4), never pooled on a default id
9423    /// where one stray reply could clear multiple findings.
9424    #[test]
9425    fn finding_missing_id_skipped_not_pooled() {
9426        let no_id = serde_json::json!({
9427            "in_reply_to_id": null,
9428            "user": {"login": "chatgpt-codex-connector[bot]"},
9429            "body": "![P1 Badge](https://img.shields.io/badge/P1-orange) idless",
9430            "path": "src/z.rs", "line": 3,
9431            "created_at": "2026-06-05T01:05:00Z"
9432        });
9433        let real = finding_comment(
9434            100,
9435            "![P1 Badge](https://img.shields.io/badge/P1-orange) real",
9436            "2026-06-05T01:10:00Z",
9437        );
9438        // A stray reply keyed to id 0 must not ack anything.
9439        let stray = reply_comment(
9440            101,
9441            0,
9442            "bllshttng",
9443            "wontfix: stray",
9444            "2026-06-05T01:20:00Z",
9445        );
9446        let comments = vec![no_id, real, stray];
9447        let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9448        assert_eq!(unaddressed.len(), 1, "only the real finding remains");
9449        assert_eq!(unaddressed[0].id, 100);
9450    }
9451
9452    /// sigma-review: commit-after comparison parses timestamps instead of
9453    /// string-comparing - an offset-suffixed commit date that lexicographically
9454    /// sorts above a Zulu finding date but is EARLIER in UTC must not clear
9455    /// the finding.
9456    #[test]
9457    fn ts_after_parses_offsets_correctly() {
9458        // 23:30+13:00 == 10:30Z, which is BEFORE 11:00Z - but the raw string
9459        // "2026-06-05T23:30:00+13:00" > "2026-06-05T11:00:00Z".
9460        assert!(!ts_after(
9461            "2026-06-05T23:30:00+13:00",
9462            "2026-06-05T11:00:00Z"
9463        ));
9464        // POSITIVE direction proves chrono's FromStr for DateTime<Utc>
9465        // parses offset-suffixed RFC3339 and converts to UTC (gemini's
9466        // #448 critical claimed it errors; empirically it returns
9467        // Ok(2026-06-05T13:30:00Z) here). Without this assertion the
9468        // offset case above could pass vacuously via the Err arm.
9469        assert!(ts_after(
9470            "2026-06-05T23:30:00+10:00", // == 13:30Z
9471            "2026-06-05T11:00:00Z"
9472        ));
9473        assert!(ts_after("2026-06-05T11:00:01Z", "2026-06-05T11:00:00Z"));
9474        assert!(!ts_after("2026-06-05T11:00:00Z", "2026-06-05T11:00:00Z"));
9475        // Unparseable on either side never clears a finding.
9476        assert!(!ts_after("garbage", "2026-06-05T11:00:00Z"));
9477        assert!(!ts_after("2026-06-05T11:00:00Z", "garbage"));
9478        assert!(!ts_after("2026-06-05T11:00:00Z", ""));
9479    }
9480
9481    /// gemini high on #448: max_ts compares chronologically when both sides
9482    /// parse, returning the original string either way (byte-stable
9483    /// fingerprint).
9484    #[test]
9485    fn max_ts_chronological_with_offsets() {
9486        // +13:00 form is EARLIER in UTC despite sorting higher as a string.
9487        assert_eq!(
9488            max_ts("2026-06-05T23:30:00+13:00", "2026-06-05T11:00:00Z"),
9489            "2026-06-05T11:00:00Z"
9490        );
9491        // The winner is returned verbatim.
9492        assert_eq!(
9493            max_ts("2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"),
9494            "2026-06-05T23:30:00+10:00"
9495        );
9496    }
9497
9498    /// Concurrency (Failure Modes): a reply arriving BEFORE its parent
9499    /// finding in the comments array (REST ordering is not guaranteed across
9500    /// pagination) still acks the finding - no order dependence.
9501    #[test]
9502    fn finding_reply_listed_before_finding_still_addressed() {
9503        let comments = vec![
9504            reply_comment(
9505                101,
9506                100,
9507                "bllshttng",
9508                "wontfix: ordering test",
9509                "2026-06-05T01:20:00Z",
9510            ),
9511            finding_comment(
9512                100,
9513                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
9514                "2026-06-05T01:10:00Z",
9515            ),
9516        ];
9517        let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9518        assert!(
9519            unaddressed.is_empty(),
9520            "reply-before-finding ordering must still ack"
9521        );
9522    }
9523
9524    // ── step 2: outage vs no-PR discrimination (US4) ─────────────────────────
9525
9526    #[test]
9527    fn no_pr_stderr_detected() {
9528        assert!(is_no_pr_stderr(
9529            b"no pull requests found for branch \"feat\""
9530        ));
9531        assert!(is_no_pr_stderr(b"No pull requests found for branch \"x\""));
9532        // Outage shapes are NOT no-PR.
9533        assert!(!is_no_pr_stderr(b"connect: network is unreachable"));
9534        assert!(!is_no_pr_stderr(b"API rate limit exceeded"));
9535        assert!(!is_no_pr_stderr(b""));
9536    }
9537}
9538
9539#[cfg(test)]
9540mod done_probe_tests {
9541    use super::*;
9542    use std::time::Duration;
9543
9544    fn fm(body: &str) -> String {
9545        format!("---\ntitle: t\n{body}\n---\n\n# doc\n")
9546    }
9547
9548    fn probes_of(doc: &str) -> Vec<String> {
9549        match parse_done_probes(doc) {
9550            ProbeDecl::Probes(p) => p,
9551            other => panic!("expected probes, got {other:?}"),
9552        }
9553    }
9554
9555    #[test]
9556    fn parses_block_list() {
9557        let doc = fm("done_probes:\n  - \"fno mail list --since 24h | grep -q groom\"\n  - 'echo ok'\nstatus: ready");
9558        assert_eq!(
9559            probes_of(&doc),
9560            vec![
9561                "fno mail list --since 24h | grep -q groom".to_string(),
9562                "echo ok".to_string()
9563            ]
9564        );
9565    }
9566
9567    #[test]
9568    fn parses_inline_list_keeping_commas_inside_commands() {
9569        let doc = fm(r#"done_probes: ["gh api x --jq '.a,.b'", "echo ok"]"#);
9570        assert_eq!(
9571            probes_of(&doc),
9572            vec!["gh api x --jq '.a,.b'".to_string(), "echo ok".to_string()],
9573            "a comma inside a quoted command must not split it into two probes"
9574        );
9575    }
9576
9577    #[test]
9578    fn absent_field_and_explicit_empty_list_are_both_no_gate() {
9579        assert_eq!(parse_done_probes(&fm("done_probes: []")), ProbeDecl::None);
9580        assert_eq!(parse_done_probes(&fm("status: ready")), ProbeDecl::None);
9581        assert_eq!(parse_done_probes("no frontmatter here"), ProbeDecl::None);
9582    }
9583
9584    #[test]
9585    fn a_declaration_this_parser_cannot_read_is_never_no_gate() {
9586        // The vacuous-pass shape: the field is there, so the plan MEANT to gate.
9587        // Reporting None here would silently drop the gate entirely.
9588        let multiline_inline = fm("done_probes: [\n  \"echo a\",\n  \"echo b\"\n]");
9589        assert_eq!(parse_done_probes(&multiline_inline), ProbeDecl::Unparseable);
9590        assert_eq!(
9591            parse_done_probes(&fm("done_probes:\nstatus: ready")),
9592            ProbeDecl::Unparseable,
9593            "a declared-but-empty block must refuse, not pass"
9594        );
9595    }
9596
9597    #[test]
9598    fn inline_list_keeps_escaped_quotes_inside_a_command() {
9599        // A mis-parsed probe is worse than a refused one: it would run a
9600        // DIFFERENT command than the plan declared and gate on its result.
9601        let doc = fm(r#"done_probes: ["sh -c \"echo hi\"", "echo ok"]"#);
9602        assert_eq!(
9603            probes_of(&doc),
9604            vec![r#"sh -c "echo hi""#.to_string(), "echo ok".to_string()]
9605        );
9606    }
9607
9608    #[test]
9609    fn inline_list_preserves_a_trailing_bracket_and_refuses_an_unterminated_one() {
9610        assert_eq!(
9611            probes_of(&fm(r#"done_probes: ["echo [hi]"]"#)),
9612            vec!["echo [hi]".to_string()],
9613            "only the list's own closing bracket may be stripped"
9614        );
9615        assert_eq!(
9616            parse_done_probes(&fm(r#"done_probes: ["echo a""#)),
9617            ProbeDecl::Unparseable,
9618            "an unterminated inline list must refuse, not silently parse"
9619        );
9620    }
9621
9622    #[test]
9623    fn a_comment_inside_the_block_does_not_swallow_the_probes() {
9624        let doc = fm("done_probes:\n  # why this probe exists\n  - echo a\n  - echo b\ntags: []");
9625        assert_eq!(
9626            probes_of(&doc),
9627            vec!["echo a".to_string(), "echo b".to_string()]
9628        );
9629    }
9630
9631    #[test]
9632    fn block_list_stops_at_the_next_key() {
9633        let doc = fm("done_probes:\n  - echo a\ntags: []\nother: x");
9634        assert_eq!(probes_of(&doc), vec!["echo a".to_string()]);
9635    }
9636
9637    #[test]
9638    fn probe_outcomes_render_pass_fail_and_exit_code() {
9639        let tmp = tempfile::tempdir().unwrap();
9640        let t = Duration::from_secs(10);
9641        assert_eq!(run_probe("exit 0", tmp.path(), t).render(), "pass");
9642        assert_eq!(run_probe("exit 3", tmp.path(), t).render(), "fail:3");
9643        assert_eq!(
9644            run_probe("fno-no-such-binary-xyz", tmp.path(), t).render(),
9645            "fail:127",
9646            "a missing binary must fail closed as 127, never pass"
9647        );
9648    }
9649
9650    #[test]
9651    fn hanging_probe_is_killed_within_the_timeout_budget() {
9652        let tmp = tempfile::tempdir().unwrap();
9653        let start = std::time::Instant::now();
9654        let outcome = run_probe("sleep 30", tmp.path(), Duration::from_millis(200));
9655        assert_eq!(outcome.render(), "timeout");
9656        assert!(
9657            start.elapsed() < Duration::from_secs(5),
9658            "run_probe must return on its own timeout, not wait out the child"
9659        );
9660    }
9661
9662    #[test]
9663    fn chatty_probe_does_not_deadlock_on_the_stderr_pipe() {
9664        // A probe writing past the 64KB pipe buffer would hang forever if
9665        // stderr were drained only after exit.
9666        let tmp = tempfile::tempdir().unwrap();
9667        let outcome = run_probe(
9668            "head -c 200000 /dev/zero | tr '\\0' 'x' >&2; exit 1",
9669            tmp.path(),
9670            Duration::from_secs(20),
9671        );
9672        assert_eq!(outcome.render(), "fail:1");
9673        match outcome {
9674            ProbeOutcome::Fail { stderr, .. } => assert!(
9675                stderr.len() <= PROBE_STDERR_CAP,
9676                "stderr must be truncated to {PROBE_STDERR_CAP}"
9677            ),
9678            _ => panic!("expected Fail"),
9679        }
9680    }
9681
9682    #[test]
9683    fn over_cap_declaration_refuses_without_running_anything() {
9684        let tmp = tempfile::tempdir().unwrap();
9685        let plan = tmp.path().join("plan.md");
9686        let sentinel = tmp.path().join("ran");
9687        std::fs::write(
9688            &plan,
9689            fm(&format!(
9690                "done_probes:\n  - touch {0}\n  - echo b\n  - echo c\n  - echo d",
9691                sentinel.display()
9692            )),
9693        )
9694        .unwrap();
9695        let events = tmp.path().join("events.jsonl");
9696        match evaluate_done_probes(
9697            plan.to_str(),
9698            None,
9699            tmp.path(),
9700            &events,
9701            "s1",
9702            Duration::from_secs(10),
9703        ) {
9704            ProbeGate::Fail { reason, .. } => {
9705                assert!(
9706                    reason.contains("cap is 3"),
9707                    "reason names the cap: {reason}"
9708                )
9709            }
9710            _ => panic!("over-cap declaration must refuse"),
9711        }
9712        assert!(!sentinel.exists(), "an over-cap list must not execute");
9713    }
9714
9715    #[test]
9716    fn unreadable_plan_fails_closed_only_when_probes_were_seen_before() {
9717        let tmp = tempfile::tempdir().unwrap();
9718        let events = tmp.path().join("events.jsonl");
9719        let missing = tmp.path().join("gone.md");
9720
9721        // AC2-FR: no probe history -> today's behavior exactly.
9722        assert!(matches!(
9723            evaluate_done_probes(
9724                missing.to_str(),
9725                None,
9726                tmp.path(),
9727                &events,
9728                "s1",
9729                Duration::from_secs(10)
9730            ),
9731            ProbeGate::Absent
9732        ));
9733
9734        // AC1-FR: a prior fire recorded probes -> undeterminable, fail closed.
9735        std::fs::write(
9736            &events,
9737            "{\"type\":\"loop_check\",\"data\":{\"session_id\":\"s1\",\"done_probes\":{\"echo ok\":\"pass\"}}}\n",
9738        )
9739        .unwrap();
9740        match evaluate_done_probes(
9741            missing.to_str(),
9742            None,
9743            tmp.path(),
9744            &events,
9745            "s1",
9746            Duration::from_secs(10),
9747        ) {
9748            ProbeGate::Fail { reason, .. } => assert!(
9749                reason.contains("undeterminable"),
9750                "reason must say undeterminable: {reason}"
9751            ),
9752            _ => panic!("unreadable plan with probe history must fail closed"),
9753        }
9754    }
9755
9756    #[test]
9757    fn a_refusal_where_nothing_ran_still_records_probe_history() {
9758        // Otherwise prior_fires_declared_probes sees no history, and a plan that
9759        // tripped the cap and then went missing degrades to "no gate".
9760        let tmp = tempfile::tempdir().unwrap();
9761        let plan = tmp.path().join("plan.md");
9762        std::fs::write(
9763            &plan,
9764            fm("done_probes:\n  - echo a\n  - echo b\n  - echo c\n  - echo d"),
9765        )
9766        .unwrap();
9767        let events = tmp.path().join("events.jsonl");
9768        let ProbeGate::Fail { results, .. } = evaluate_done_probes(
9769            plan.to_str(),
9770            None,
9771            tmp.path(),
9772            &events,
9773            "s1",
9774            Duration::from_secs(10),
9775        ) else {
9776            panic!("over-cap must refuse");
9777        };
9778        std::fs::write(
9779            &events,
9780            format!(
9781                "{}\n",
9782                serde_json::json!({
9783                    "type": "loop_check",
9784                    "data": {"session_id": "s1", "done_probes": results}
9785                })
9786            ),
9787        )
9788        .unwrap();
9789        assert!(
9790            prior_fires_declared_probes(&events, "s1"),
9791            "a declared-but-never-ran refusal must be visible as probe history"
9792        );
9793    }
9794
9795    #[test]
9796    fn relative_plan_path_resolves_against_the_session_cwd() {
9797        // plan_path is repo-relative in practice; resolving against the process
9798        // cwd would read nothing and silently drop the gate.
9799        let tmp = tempfile::tempdir().unwrap();
9800        std::fs::write(tmp.path().join("plan.md"), fm("done_probes:\n  - exit 0")).unwrap();
9801        let events = tmp.path().join("events.jsonl");
9802        assert!(
9803            matches!(
9804                evaluate_done_probes(
9805                    Some("plan.md"),
9806                    None,
9807                    tmp.path(),
9808                    &events,
9809                    "s1",
9810                    Duration::from_secs(10)
9811                ),
9812                ProbeGate::Pass(_)
9813            ),
9814            "a relative plan_path must resolve against cwd, not the process cwd"
9815        );
9816    }
9817
9818    #[test]
9819    fn timeout_reaches_the_gate_reason() {
9820        let tmp = tempfile::tempdir().unwrap();
9821        let plan = tmp.path().join("plan.md");
9822        std::fs::write(&plan, fm("done_probes:\n  - sleep 30")).unwrap();
9823        let events = tmp.path().join("events.jsonl");
9824        match evaluate_done_probes(
9825            plan.to_str(),
9826            None,
9827            tmp.path(),
9828            &events,
9829            "s1",
9830            Duration::from_millis(200),
9831        ) {
9832            ProbeGate::Fail { reason, results } => {
9833                assert!(
9834                    reason.contains("timed out"),
9835                    "reason names the timeout: {reason}"
9836                );
9837                assert_eq!(results["sleep 30"], "timeout");
9838            }
9839            _ => panic!("a hanging probe must refuse done"),
9840        }
9841    }
9842
9843    #[test]
9844    fn a_pipeline_probe_timeout_does_not_hang_the_gate() {
9845        // `sh -c "a | b"` forks: killing only sh leaves grandchildren holding
9846        // the stderr pipe, so the drain thread never sees EOF. This is the
9847        // documented probe shape, so a regression here wedges every session.
9848        let tmp = tempfile::tempdir().unwrap();
9849        let start = std::time::Instant::now();
9850        let outcome = run_probe("sleep 30 | cat", tmp.path(), Duration::from_millis(200));
9851        assert_eq!(outcome.render(), "timeout");
9852        assert!(
9853            start.elapsed() < Duration::from_secs(10),
9854            "a pipeline probe must not outlive its timeout (took {:?})",
9855            start.elapsed()
9856        );
9857    }
9858
9859    #[test]
9860    fn multibyte_stderr_is_truncated_without_panicking() {
9861        // String::drain panics off a char boundary; probe stderr routinely
9862        // carries arrows and box-drawing characters.
9863        let mut s = "→".repeat(400); // 3 bytes each, straddles the cut
9864        keep_last_on_char_boundary(&mut s, PROBE_STDERR_CAP);
9865        assert!(s.len() <= PROBE_STDERR_CAP);
9866        assert!(s.chars().all(|c| c == '→'), "must not split a character");
9867    }
9868
9869    #[test]
9870    fn stderr_cap_keeps_the_tail_where_the_error_is() {
9871        let mut s = format!("{}\nthe actual error", "noise ".repeat(200));
9872        keep_last_on_char_boundary(&mut s, PROBE_STDERR_CAP);
9873        assert!(
9874            s.ends_with("the actual error"),
9875            "the last line is the diagnostic; keeping the prefix drops it: {s}"
9876        );
9877    }
9878
9879    #[test]
9880    fn block_scalar_escapes_decode_to_the_command_the_plan_meant() {
9881        // Leaving `\"` in would hand sh a DIFFERENT command than declared, and
9882        // would key the event by a string the PyYAML-side grader never matches.
9883        let doc = fm("done_probes:\n  - \"test -n \\\"$(echo hi)\\\"\"");
9884        assert_eq!(probes_of(&doc), vec![r#"test -n "$(echo hi)""#.to_string()]);
9885    }
9886
9887    #[test]
9888    fn single_quoted_scalar_undoubles_its_quote() {
9889        let doc = fm("done_probes:\n  - 'echo it''s fine'");
9890        assert_eq!(probes_of(&doc), vec!["echo it's fine".to_string()]);
9891    }
9892
9893    #[test]
9894    fn plan_path_fragment_is_stripped_before_reading() {
9895        // `plans/p.md#wave-1` must resolve to plans/p.md, not a literal filename
9896        // containing the fragment (which would read nothing -> silent Absent).
9897        let tmp = tempfile::tempdir().unwrap();
9898        std::fs::write(tmp.path().join("plan.md"), fm("done_probes:\n  - exit 0")).unwrap();
9899        let events = tmp.path().join("events.jsonl");
9900        assert!(
9901            matches!(
9902                evaluate_done_probes(
9903                    Some("plan.md#wave-1"),
9904                    None,
9905                    tmp.path(),
9906                    &events,
9907                    "s1",
9908                    Duration::from_secs(10)
9909                ),
9910                ProbeGate::Pass(_)
9911            ),
9912            "a fragment in plan_path must not silently disable the gate"
9913        );
9914    }
9915
9916    #[test]
9917    fn a_backgrounding_probe_does_not_block_the_drain() {
9918        // sh exits immediately while the descendant keeps stderr open, so the
9919        // timeout loop is already over and only the group kill bounds the join.
9920        let tmp = tempfile::tempdir().unwrap();
9921        let start = std::time::Instant::now();
9922        let outcome = run_probe("sleep 300 & exit 0", tmp.path(), Duration::from_secs(30));
9923        assert_eq!(outcome.render(), "pass");
9924        assert!(
9925            start.elapsed() < Duration::from_secs(10),
9926            "a backgrounded descendant must not hold the drain open (took {:?})",
9927            start.elapsed()
9928        );
9929    }
9930
9931    // ── project-level done_probes (x-a534) ────────────────────────────────
9932    //
9933    // A repo-wide guardrail must apply to every plan in the repo, and no plan
9934    // doc may switch it off - a guard on one of two reachable paths is
9935    // decorative.
9936
9937    fn project(cmds: &[&str]) -> Result<Vec<String>, String> {
9938        Ok(cmds.iter().map(|c| c.to_string()).collect())
9939    }
9940
9941    /// A plan doc that declares no probes of its own.
9942    fn bare_plan(dir: &Path) -> std::path::PathBuf {
9943        let plan = dir.join("plan.md");
9944        std::fs::write(&plan, fm("title: p")).unwrap();
9945        plan
9946    }
9947
9948    #[test]
9949    fn a_project_probe_gates_a_plan_that_declares_none() {
9950        // AC1-HP: the repo-wide guardrail runs without being retyped per plan,
9951        // and its result reaches the event payload.
9952        let tmp = tempfile::tempdir().unwrap();
9953        let plan = bare_plan(tmp.path());
9954        let events = tmp.path().join("events.jsonl");
9955        match evaluate_done_probes(
9956            plan.to_str(),
9957            Some(&project(&["true"])),
9958            tmp.path(),
9959            &events,
9960            "s1",
9961            Duration::from_secs(10),
9962        ) {
9963            ProbeGate::Pass(results) => assert_eq!(results["true"], "pass"),
9964            _ => panic!("a passing project probe must let the gate through"),
9965        }
9966    }
9967
9968    #[test]
9969    fn a_failing_project_probe_blocks_and_names_its_source() {
9970        // AC2-ERR: `probe X exited 1` is ambiguous once there are two
9971        // declarations; the operator has to know which file to edit.
9972        let tmp = tempfile::tempdir().unwrap();
9973        let plan = bare_plan(tmp.path());
9974        let events = tmp.path().join("events.jsonl");
9975        match evaluate_done_probes(
9976            plan.to_str(),
9977            Some(&project(&["false"])),
9978            tmp.path(),
9979            &events,
9980            "s1",
9981            Duration::from_secs(10),
9982        ) {
9983            ProbeGate::Fail { reason, .. } => assert!(
9984                reason.contains("project probe `false`"),
9985                "the reason must name the source: {reason}"
9986            ),
9987            _ => panic!("a failing project probe must block"),
9988        }
9989    }
9990
9991    // AC3-INV (a plan declaring `done_probes: []` cannot silence the project's
9992    // gate) is covered end to end by
9993    // done_probes_ac3_inv_a_plan_cannot_silence_the_project_gate in
9994    // tests/loop_check.rs, which drives the real settings merge rather than a
9995    // hand-built probe list. A unit-level twin would assert strictly less.
9996
9997    #[test]
9998    fn an_unparseable_project_declaration_blocks_rather_than_degrading() {
9999        // AC4-ERR: a config key that degrades to no-gate is a guardrail that
10000        // disappears when you typo it.
10001        let tmp = tempfile::tempdir().unwrap();
10002        let plan = bare_plan(tmp.path());
10003        let events = tmp.path().join("events.jsonl");
10004        let junk: Result<Vec<String>, String> = value_as_probe_list(
10005            &"done_probes = { a = 1 }".parse::<toml::Value>().unwrap()["done_probes"],
10006        );
10007        assert!(junk.is_err(), "a mapping is not a probe list");
10008        match evaluate_done_probes(
10009            plan.to_str(),
10010            Some(&junk),
10011            tmp.path(),
10012            &events,
10013            "s1",
10014            Duration::from_secs(10),
10015        ) {
10016            ProbeGate::Fail { reason, results } => {
10017                assert!(
10018                    reason.contains("undeterminable"),
10019                    "must use the plan side's vocabulary: {reason}"
10020                );
10021                assert_eq!(results["_undeterminable"], "unparseable-config-declaration");
10022            }
10023            _ => panic!("an unreadable project declaration must block"),
10024        }
10025    }
10026
10027    #[test]
10028    fn the_cap_is_per_source_not_per_union() {
10029        // AC5-BOUND: 3 + 3 all run. Sharing one budget would make two
10030        // independent authors compete for one number, so a project policy
10031        // would eat a plan's operational probes.
10032        let tmp = tempfile::tempdir().unwrap();
10033        let plan = tmp.path().join("plan.md");
10034        std::fs::write(
10035            &plan,
10036            fm("done_probes:\n  - echo d\n  - echo e\n  - echo f"),
10037        )
10038        .unwrap();
10039        let events = tmp.path().join("events.jsonl");
10040        match evaluate_done_probes(
10041            plan.to_str(),
10042            Some(&project(&["echo a", "echo b", "echo c"])),
10043            tmp.path(),
10044            &events,
10045            "s1",
10046            Duration::from_secs(10),
10047        ) {
10048            ProbeGate::Pass(results) => assert_eq!(
10049                results.as_object().unwrap().len(),
10050                6,
10051                "all six probes must run: {results}"
10052            ),
10053            other => panic!(
10054                "3 + 3 is within the per-source cap: {}",
10055                match other {
10056                    ProbeGate::Fail { reason, .. } => reason,
10057                    _ => "Absent".to_string(),
10058                }
10059            ),
10060        }
10061
10062        // A 4th in the project declaration is still a loud refusal.
10063        match evaluate_done_probes(
10064            plan.to_str(),
10065            Some(&project(&["true", "true", "true", "true"])),
10066            tmp.path(),
10067            &events,
10068            "s1",
10069            Duration::from_secs(10),
10070        ) {
10071            ProbeGate::Fail { reason, .. } => assert!(
10072                reason.contains("config.toml declares 4") && reason.contains("per source"),
10073                "an over-cap project list must refuse loudly: {reason}"
10074            ),
10075            _ => panic!("4 project probes must refuse"),
10076        }
10077    }
10078
10079    #[test]
10080    fn no_declaration_on_either_source_stays_absent() {
10081        // The zero-subprocess path must survive the second source.
10082        let tmp = tempfile::tempdir().unwrap();
10083        let plan = bare_plan(tmp.path());
10084        let events = tmp.path().join("events.jsonl");
10085        assert!(matches!(
10086            evaluate_done_probes(
10087                plan.to_str(),
10088                Some(&project(&[])),
10089                tmp.path(),
10090                &events,
10091                "s1",
10092                Duration::from_secs(10)
10093            ),
10094            ProbeGate::Absent
10095        ));
10096    }
10097
10098    #[test]
10099    fn config_done_probes_parses_off_the_flat_root() {
10100        // The file is flat: `done_probes` at the root, not nested under a
10101        // `config` table.
10102        let s = parse_settings("done_probes = [\"make a11y-check\"]\n");
10103        assert_eq!(s.done_probes, Some(Ok(vec!["make a11y-check".to_string()])),);
10104        assert_eq!(parse_settings("plans_dir = \"x\"\n").done_probes, None);
10105        assert!(parse_settings("done_probes = \"nope\"\n")
10106            .done_probes
10107            .unwrap()
10108            .is_err());
10109        assert!(parse_settings("done_probes = [1]\n")
10110            .done_probes
10111            .unwrap()
10112            .is_err());
10113    }
10114}