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
9//! `crates/fno-agents/src/loop*`.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::process::Command;
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    NoWork,
26    Budget,
27    NoProgress,
28    Interrupted,
29    Aborted,
30}
31
32/// The JSON object written to stdout on every fire.
33#[derive(Debug, Serialize)]
34pub struct LoopCheckOutput {
35    pub decision: String, // "allow" | "block"
36    pub termination_reason: Option<TerminationReason>,
37    pub message: String,
38    pub fires: u64,
39    pub fingerprint: Option<String>,
40}
41
42// ── manifest parsing ──────────────────────────────────────────────────────────
43
44/// Fields parsed from target-state.md YAML frontmatter.
45#[derive(Debug)]
46struct Manifest {
47    session_id: Option<String>,
48    created_at: Option<String>,
49    attended: bool, // default true when absent
50    advisory: bool,
51    no_ship: bool,
52    no_external: bool,
53    legacy_status: Option<String>, // COMPLETE | BLOCKED | ABORTED
54    /// None = absent (unlimited). Some(Ok(v)) = valid cap. Some(Err(s)) = malformed raw value.
55    budget_wall_clock_cap_minutes: Option<Result<u64, String>>,
56    /// None = absent (unlimited). Some(Ok(v)) = valid cap. Some(Err(s)) = malformed raw value.
57    budget_cost_cap_usd: Option<Result<f64, String>>,
58}
59
60impl Default for Manifest {
61    fn default() -> Self {
62        Self {
63            session_id: None,
64            created_at: None,
65            attended: true, // spec: attended defaults to true
66            advisory: false,
67            no_ship: false,
68            no_external: false,
69            legacy_status: None,
70            budget_wall_clock_cap_minutes: None, // None = absent = unlimited
71            budget_cost_cap_usd: None,           // None = absent = unlimited
72        }
73    }
74}
75
76/// Parse frontmatter from a `---\n...\n---\n` block at the top of a file.
77/// Returns None if the file does not start with `---`.
78/// Unknown fields are silently ignored.
79fn parse_manifest(content: &str) -> Option<Manifest> {
80    let content = content.trim_start();
81    if !content.starts_with("---") {
82        return None;
83    }
84    let after_first = &content[3..];
85    // Find closing ---
86    let end = after_first.find("\n---")?;
87    let body = &after_first[..end];
88
89    let mut m = Manifest {
90        attended: true, // default
91        ..Default::default()
92    };
93
94    for line in body.lines() {
95        let line = line.trim();
96        if line.is_empty() || line.starts_with('#') {
97            continue;
98        }
99        if let Some((k, v)) = line.split_once(':') {
100            let k = k.trim();
101            // YAML string values may be quoted; strip surrounding quotes so a
102            // quoted session_id/created_at parses identically (gemini MEDIUM).
103            let v = v.trim().trim_matches(|c| c == '"' || c == '\'');
104            match k {
105                "session_id" => m.session_id = Some(v.to_string()),
106                "created_at" => m.created_at = Some(v.to_string()),
107                "attended" => m.attended = v == "true",
108                "advisory" => m.advisory = v == "true",
109                "no_ship" => m.no_ship = v == "true",
110                "no_external" => m.no_external = v == "true",
111                "status" => {
112                    let upper = v.to_uppercase();
113                    if matches!(upper.as_str(), "COMPLETE" | "BLOCKED" | "ABORTED") {
114                        m.legacy_status = Some(upper);
115                    }
116                }
117                "budget_wall_clock_cap_minutes" => {
118                    // Manifests are machine-written numeric fields; tolerate a '#'-tail
119                    // (e.g. `90# Auto-merge inputs`) by truncating at the first '#'.
120                    let stripped = v
121                        .split_once('#')
122                        .map(|(before, _)| before.trim())
123                        .unwrap_or(v);
124                    m.budget_wall_clock_cap_minutes = Some(stripped.parse::<u64>().map_err(|_| {
125                        eprintln!(
126                            "loop-check: malformed budget cap 'budget_wall_clock_cap_minutes: {v}' - failing closed; fix the config"
127                        );
128                        v.to_string()
129                    }));
130                }
131                "budget_cost_cap_usd" => {
132                    let stripped = v
133                        .split_once('#')
134                        .map(|(before, _)| before.trim())
135                        .unwrap_or(v);
136                    m.budget_cost_cap_usd = Some(stripped.parse::<f64>().map_err(|_| {
137                        eprintln!(
138                            "loop-check: malformed budget cap 'budget_cost_cap_usd: {v}' - failing closed; fix the config"
139                        );
140                        v.to_string()
141                    }));
142                }
143                _ => {}
144            }
145        }
146    }
147    Some(m)
148}
149
150// ── settings parsing ──────────────────────────────────────────────────────────
151
152#[derive(Debug, Default)]
153struct Settings {
154    /// config.budget.attended.wall_clock_cap_minutes
155    /// None = absent. Some(Ok(v)) = valid. Some(Err(s)) = malformed raw value.
156    attended_wall_cap_minutes: Option<Result<u64, String>>,
157    /// config.budget.attended.cost_cap_usd
158    attended_cost_cap_usd: Option<Result<f64, String>>,
159    /// config.budget.unattended.wall_clock_cap_minutes
160    unattended_wall_cap_minutes: Option<Result<u64, String>>,
161    /// config.budget.unattended.cost_cap_usd
162    unattended_cost_cap_usd: Option<Result<f64, String>>,
163    /// flat budget_cap: (folds in ab-41b13d9d) - applies as cost cap for both modes
164    flat_budget_cap: Option<Result<f64, String>>,
165    /// config.ci.declared_none: true
166    ci_declared_none: bool,
167    /// config.external_reviewers list
168    external_reviewers: Vec<String>,
169    /// config.review.required_bots (grilled decision 5 / step 2).
170    /// None = key absent -> code default applies.
171    /// Some([]) = explicitly `[]` -> declared no-review-gate path.
172    /// Some(list) = every listed bot must have a completed review pass.
173    /// A malformed value (scalar, bare key with no items) stays None so the
174    /// gate fails closed to the code default (AC3-ERR).
175    required_bots: Option<Vec<String>>,
176}
177
178/// Strip a trailing YAML inline comment (` # ...`) from a raw scalar value
179/// (codex P2 on #448). YAML requires whitespace before the `#`; a value that
180/// IS a comment strips to empty. Quoted values containing '#' are out of
181/// scope for this minimal parser (no known bot login contains '#').
182fn strip_inline_comment(raw: &str) -> &str {
183    if raw.starts_with('#') {
184        return "";
185    }
186    match raw.find(" #").or_else(|| raw.find("\t#")) {
187        Some(i) => raw[..i].trim_end(),
188        None => raw,
189    }
190}
191
192/// Minimal indentation-aware settings.yaml parser.
193/// Handles nested `config.budget.attended/unattended` blocks plus flat keys.
194fn parse_settings(content: &str) -> Settings {
195    let mut s = Settings::default();
196    let mut in_config = false;
197    let mut in_budget = false;
198    let mut in_attended = false;
199    let mut in_unattended = false;
200    let mut in_ci = false;
201    let mut in_review = false;
202    let mut collecting_reviewers = false;
203    let mut collecting_required_bots = false;
204
205    // Derive the file's indent unit from the first indented line instead of
206    // assuming 2 spaces, so a 4-space-indented settings.yaml parses
207    // identically instead of being silently skipped (gemini HIGH on #447).
208    let unit = content
209        .lines()
210        .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
211        .map(|l| l.len() - l.trim_start().len())
212        .find(|&i| i > 0)
213        .unwrap_or(2);
214
215    for line in content.lines() {
216        if line.trim_start().starts_with('#') || line.trim().is_empty() {
217            continue;
218        }
219        let raw_indent = line.len() - line.trim_start().len();
220        // Normalize to the canonical 2-space levels the state machine below
221        // matches on (0 / 2 / 4 / 6).
222        let indent = (raw_indent / unit) * 2;
223        let trimmed = line.trim();
224
225        // Top-level: indent == 0
226        if indent == 0 {
227            in_config = trimmed.starts_with("config:");
228            collecting_reviewers = false;
229            collecting_required_bots = false;
230            if !in_config {
231                in_budget = false;
232                in_attended = false;
233                in_unattended = false;
234                in_ci = false;
235                in_review = false;
236            }
237            // Flat key: budget_cap: N
238            if let Some(rest) = trimmed.strip_prefix("budget_cap:") {
239                let raw = rest.trim();
240                s.flat_budget_cap = Some(raw.parse::<f64>().map_err(|_| {
241                    eprintln!(
242                        "loop-check: malformed budget cap 'budget_cap: {raw}' - failing closed; fix the config"
243                    );
244                    raw.to_string()
245                }));
246            }
247            continue;
248        }
249
250        // indent == 2: inside config
251        if in_config && indent == 2 {
252            in_budget = trimmed.starts_with("budget:");
253            in_ci = trimmed.starts_with("ci:");
254            in_review = trimmed.starts_with("review:");
255            collecting_reviewers = trimmed.starts_with("external_reviewers:");
256            collecting_required_bots = false;
257            if !in_budget {
258                in_attended = false;
259                in_unattended = false;
260            }
261            continue;
262        }
263
264        // indent == 4: inside config.budget or config.ci
265        if in_config && in_budget && indent == 4 {
266            in_attended = trimmed.starts_with("attended:");
267            in_unattended = trimmed.starts_with("unattended:");
268            continue;
269        }
270
271        if in_config && in_ci && indent == 4 {
272            if let Some(rest) = trimmed.strip_prefix("declared_none:") {
273                s.ci_declared_none = rest.trim() == "true";
274            }
275            continue;
276        }
277
278        // indent == 4: inside config.review
279        if in_config && in_review && indent == 4 {
280            if let Some(rest) = trimmed.strip_prefix("required_bots:") {
281                // Strip a trailing YAML inline comment first (codex P2 on
282                // #448): `required_bots: []  # no review gate` must parse as
283                // the declared-empty form, not fall through to malformed.
284                let raw = strip_inline_comment(rest.trim());
285                if raw == "[]" {
286                    // Explicit empty list: the ONLY way to declare the
287                    // no-review-gate path (US3). Never inferred.
288                    s.required_bots = Some(Vec::new());
289                    collecting_required_bots = false;
290                } else if raw.is_empty() {
291                    // Block-list form: items follow at deeper indent. Until an
292                    // item arrives this stays None - a bare key with nothing
293                    // under it is malformed and fails closed to the code
294                    // default rather than accidentally disabling the gate.
295                    collecting_required_bots = true;
296                } else if raw.starts_with('[') && raw.ends_with(']') {
297                    // Inline list form: required_bots: ["a", "b"]
298                    let inner = &raw[1..raw.len() - 1];
299                    let items: Vec<String> = inner
300                        .split(',')
301                        .map(|p| p.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
302                        .filter(|p| !p.is_empty())
303                        .collect();
304                    s.required_bots = Some(items);
305                    collecting_required_bots = false;
306                } else {
307                    // Scalar / malformed -> fail closed to the code default
308                    eprintln!(
309                        "loop-check: malformed config.review.required_bots '{raw}' (not a list) - using code default"
310                    );
311                    s.required_bots = None;
312                    collecting_required_bots = false;
313                }
314            } else {
315                collecting_required_bots = false;
316            }
317            continue;
318        }
319
320        // Required-bots list items: "- login" under config.review.required_bots
321        if in_config && collecting_required_bots && trimmed.starts_with('-') {
322            let bot = strip_inline_comment(trimmed.trim_start_matches('-').trim())
323                .trim()
324                .trim_matches(|c| c == '"' || c == '\'')
325                .to_string();
326            if !bot.is_empty() {
327                s.required_bots.get_or_insert_with(Vec::new).push(bot);
328            }
329            continue;
330        }
331
332        // indent == 6: inside attended/unattended blocks
333        if in_config && in_budget && (in_attended || in_unattended) && indent == 6 {
334            if let Some(rest) = trimmed.strip_prefix("wall_clock_cap_minutes:") {
335                let raw = rest.trim();
336                let parsed = raw.parse::<u64>().map_err(|_| {
337                    let which = if in_attended { "attended" } else { "unattended" };
338                    eprintln!(
339                        "loop-check: malformed budget cap '{which}.wall_clock_cap_minutes: {raw}' - failing closed; fix the config"
340                    );
341                    raw.to_string()
342                });
343                if in_attended {
344                    s.attended_wall_cap_minutes = Some(parsed);
345                } else {
346                    s.unattended_wall_cap_minutes = Some(parsed);
347                }
348            }
349            if let Some(rest) = trimmed.strip_prefix("cost_cap_usd:") {
350                let raw = rest.trim();
351                let parsed = raw.parse::<f64>().map_err(|_| {
352                    let which = if in_attended { "attended" } else { "unattended" };
353                    eprintln!(
354                        "loop-check: malformed budget cap '{which}.cost_cap_usd: {raw}' - failing closed; fix the config"
355                    );
356                    raw.to_string()
357                });
358                if in_attended {
359                    s.attended_cost_cap_usd = Some(parsed);
360                } else {
361                    s.unattended_cost_cap_usd = Some(parsed);
362                }
363            }
364            continue;
365        }
366
367        // External reviewers list items: "  - login" under config.external_reviewers
368        if in_config && collecting_reviewers && trimmed.starts_with('-') {
369            let reviewer = trimmed.trim_start_matches('-').trim().to_string();
370            if !reviewer.is_empty() {
371                s.external_reviewers.push(reviewer);
372            }
373        }
374    }
375    s
376}
377
378// ── ledger parsing ────────────────────────────────────────────────────────────
379
380/// Sum cost_usd for entries matching session_id. Tolerate missing/malformed as 0.
381fn session_cost_from_ledger(ledger_path: &Path, session_id: &str) -> f64 {
382    let Ok(content) = std::fs::read_to_string(ledger_path) else {
383        return 0.0;
384    };
385    let Ok(arr) = serde_json::from_str::<Value>(&content) else {
386        return 0.0;
387    };
388    let Some(entries) = arr.as_array() else {
389        return 0.0;
390    };
391    let mut total = 0.0_f64;
392    for entry in entries {
393        if entry.get("session_id").and_then(|v| v.as_str()) == Some(session_id) {
394            if let Some(c) = entry.get("cost_usd").and_then(|v| v.as_f64()) {
395                total += c;
396            }
397        }
398    }
399    total
400}
401
402// ── transcript parsing ────────────────────────────────────────────────────────
403
404#[derive(Debug, PartialEq)]
405enum Intent {
406    Promise,
407    Aborted { reason: String },
408    None,
409}
410
411fn extract_assistant_text(val: &Value) -> String {
412    // Try /message/content as string
413    if let Some(s) = val.pointer("/message/content").and_then(|v| v.as_str()) {
414        return s.to_string();
415    }
416    // Try /message/content as array of blocks
417    if let Some(arr) = val.pointer("/message/content").and_then(|v| v.as_array()) {
418        let mut parts = Vec::new();
419        for block in arr {
420            // Only include text blocks (not tool_use, tool_result)
421            if block.get("type").and_then(|t| t.as_str()) == Some("text") {
422                if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
423                    parts.push(t.to_string());
424                }
425            }
426        }
427        return parts.join(" ");
428    }
429    // Fallback: top-level content
430    if let Some(s) = val.get("content").and_then(|v| v.as_str()) {
431        return s.to_string();
432    }
433    String::new()
434}
435
436/// Detect intent with proper attribute extraction for aborted reason.
437fn detect_intent_from_text(text: &str) -> Intent {
438    // Look for <aborted ...> tag
439    if let Some(aborted_start) = text.find("<aborted") {
440        // Find the closing >
441        if let Some(gt) = text[aborted_start..].find('>') {
442            let tag_text = &text[aborted_start..aborted_start + gt + 1];
443            let reason = parse_xml_attr(tag_text, "reason").unwrap_or_default();
444            return Intent::Aborted { reason };
445        }
446    }
447    if text.contains("<promise>") {
448        return Intent::Promise;
449    }
450    Intent::None
451}
452
453fn parse_xml_attr(tag_text: &str, attr: &str) -> Option<String> {
454    let pattern = format!(r#"{attr}=""#);
455    let start = tag_text.find(&pattern)? + pattern.len();
456    let end = tag_text[start..].find('"')?;
457    Some(tag_text[start..start + end].to_string())
458}
459
460/// Extract `last_assistant_message` from the Stop-hook stdin JSON
461/// (ab-223d2dae). The harness emits it as a plain string (the stopping
462/// turn's final assistant text, blocks joined by newline and trimmed),
463/// omitted when empty. Any parse failure -> None so the caller falls back
464/// to the transcript scan.
465fn extract_last_assistant_message(hook_input: &str) -> Option<String> {
466    let val: Value = serde_json::from_str(hook_input).ok()?;
467    let s = val.get("last_assistant_message")?.as_str()?;
468    let trimmed = s.trim();
469    if trimmed.is_empty() {
470        None
471    } else {
472        Some(trimmed.to_string())
473    }
474}
475
476/// A-primary, B-fallback intent read (ab-223d2dae). A present payload is the
477/// stopping turn's final text - recomputed per fire, race-free, overwrite-
478/// proof - and is authoritative, INCLUDING its "no tag" answer. Falling
479/// through to the transcript behind a tag-less payload would resurrect the
480/// stale-promise edge the bounded scan exists to contain. Returns the intent
481/// plus its source for the loop_check event (`payload` | `transcript`).
482fn detect_intent(
483    last_assistant_message: Option<&str>,
484    transcript_path: &Path,
485) -> (Intent, &'static str) {
486    match last_assistant_message {
487        Some(text) => (detect_intent_from_text(text), "payload"),
488        None => (detect_intent_full(transcript_path), "transcript"),
489    }
490}
491
492/// Fallback transcript scan (ab-223d2dae, B): bounded lookback over the
493/// newest INTENT_LOOKBACK_ENTRIES assistant text entries instead of
494/// last-line-only. Newest tag wins; a tag-less entry no longer ends the
495/// scan, which covers the promise-overwritten-by-block-feedback shape when
496/// no payload exists. The bound is load-bearing: a stale promise from
497/// pivoted work must fall out of the window (done()'s head_shipped read is
498/// the real gate against the remainder).
499const INTENT_LOOKBACK_ENTRIES: usize = 5;
500
501fn detect_intent_full(transcript_path: &Path) -> Intent {
502    let Ok(content) = std::fs::read_to_string(transcript_path) else {
503        return Intent::None;
504    };
505
506    let lines: Vec<&str> = content.lines().collect();
507    let mut scanned: usize = 0;
508    for line in lines.iter().rev() {
509        let line = line.trim();
510        if line.is_empty() {
511            continue;
512        }
513        let Ok(val) = serde_json::from_str::<Value>(line) else {
514            continue;
515        };
516        let role = val
517            .pointer("/message/role")
518            .or_else(|| val.get("role"))
519            .and_then(|v| v.as_str())
520            .unwrap_or("");
521        if role != "assistant" {
522            continue;
523        }
524        let text = extract_assistant_text(&val);
525        if text.is_empty() {
526            continue;
527        }
528        match detect_intent_from_text(&text) {
529            Intent::None => {
530                scanned += 1;
531                if scanned >= INTENT_LOOKBACK_ENTRIES {
532                    return Intent::None;
533                }
534            }
535            tagged => return tagged,
536        }
537    }
538    Intent::None
539}
540
541// ── git / gh helpers ──────────────────────────────────────────────────────────
542
543/// PR state vocabulary (fu-4faa3d). Parsed once at the read_pr_info boundary.
544/// `as_str()` reproduces the exact legacy strings so the fingerprint (which
545/// persists across fires in events.jsonl) stays byte-identical.
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547enum PrState {
548    Open,
549    Merged,
550    Closed,
551    /// No PR, or an unrecognized gh state string (fail-closed, AC5-EDGE).
552    None,
553}
554
555impl PrState {
556    fn from_gh_str(s: &str) -> Self {
557        match s {
558            "OPEN" => PrState::Open,
559            "MERGED" => PrState::Merged,
560            "CLOSED" => PrState::Closed,
561            _ => PrState::None,
562        }
563    }
564
565    fn as_str(&self) -> &'static str {
566        match self {
567            PrState::Open => "OPEN",
568            PrState::Merged => "MERGED",
569            PrState::Closed => "CLOSED",
570            PrState::None => "none",
571        }
572    }
573
574    fn is_open_or_merged(&self) -> bool {
575        matches!(self, PrState::Open | PrState::Merged)
576    }
577}
578
579/// CI conclusion vocabulary (fu-4faa3d). `render()` reproduces the exact
580/// legacy strings ("FAILURE:{name}" carries the failing check name).
581#[derive(Debug, Clone, PartialEq, Eq)]
582enum CiConclusion {
583    Success,
584    /// Failing check name when one was identified.
585    Failure(Option<String>),
586    Pending,
587    /// CI read skipped via ci.declared_none.
588    Skipped,
589    /// No checks found (fail-closed unless declared_none).
590    None,
591}
592
593impl CiConclusion {
594    fn render(&self) -> String {
595        match self {
596            CiConclusion::Success => "SUCCESS".to_string(),
597            CiConclusion::Failure(Some(name)) => format!("FAILURE:{name}"),
598            CiConclusion::Failure(None) => "FAILURE".to_string(),
599            CiConclusion::Pending => "PENDING".to_string(),
600            CiConclusion::Skipped => "skipped".to_string(),
601            CiConclusion::None => "none".to_string(),
602        }
603    }
604
605    fn is_ok(&self) -> bool {
606        matches!(self, CiConclusion::Success | CiConclusion::Skipped)
607    }
608}
609
610#[derive(Debug)]
611struct PrInfo {
612    state: PrState,
613    number: i64,
614    /// PR head commit OID; must match local HEAD for DonePRGreen (codex P1
615    /// on #447: a green PR must not complete a session with unpushed work).
616    head_oid: String,
617    ci_conclusion: CiConclusion,
618    /// Newest review/comment/inline-comment activity (ISO8601 or "none");
619    /// folded into the fingerprint's 4th component on done() fires.
620    latest_review_ts: String,
621    reviewed: bool, // every required bot passed AND no unaddressed blocking finding
622    /// Required bots with no completed review pass (names the gap in the
623    /// block message, AC1-UI).
624    missing_bots: Vec<String>,
625    /// Blocking inline findings (codex P1 / gemini critical|high) whose
626    /// thread has no qualifying ack (AC2).
627    unaddressed_findings: Vec<Finding>,
628    /// Reads 3+4 were skipped (per-session no_external OR the repo declared
629    /// `required_bots: []`). Recorded in loop_check events so the skip is
630    /// observable, not silently absent (AC3-UI).
631    review_skipped: bool,
632}
633
634fn git_head_sha(git_bin: &str, cwd: &Path) -> String {
635    let out = Command::new(git_bin)
636        .args(["rev-parse", "HEAD"])
637        .current_dir(cwd)
638        .output();
639    match out {
640        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
641        _ => "unknown".to_string(),
642    }
643}
644
645/// `gh pr view` exits 1 both when no PR exists and when gh itself fails.
646/// "No PR" is real world-state - the fingerprint should record it and the
647/// NoProgress backstop should keep ticking - while an outage must freeze the
648/// streak (US4). Distinguish via gh's deterministic no-PR stderr message. If
649/// gh ever changes the message, no-PR fires degrade to outage semantics
650/// (freeze -> budget ceiling): safe, never a premature termination.
651fn is_no_pr_stderr(stderr: &[u8]) -> bool {
652    String::from_utf8_lossy(stderr)
653        .to_lowercase()
654        .contains("no pull requests found")
655}
656
657/// Capture the last ~200 bytes of stderr as a lossy UTF-8 string.
658fn stderr_tail(bytes: &[u8]) -> String {
659    let s = String::from_utf8_lossy(bytes);
660    let s = s.trim();
661    if s.len() <= 200 {
662        s.to_string()
663    } else {
664        // Byte index must land on a char boundary or the slice panics
665        // (gemini HIGH on PR #447): walk forward to the next boundary.
666        let mut start = s.len() - 200;
667        while start < s.len() && !s.is_char_boundary(start) {
668            start += 1;
669        }
670        s[start..].to_string()
671    }
672}
673
674/// Run done() reads. Returns Ok(PrInfo) or Err((read_name, stderr_tail)) on gh failure.
675fn read_pr_info(
676    gh_bin: &str,
677    cwd: &Path,
678    ci_declared_none: bool,
679    no_external: bool,
680    required_bots: &[String],
681    external_reviewers: &[String],
682) -> Result<PrInfo, (String, String)> {
683    // Read 1: PR state + number + head OID
684    let pr_view_out = Command::new(gh_bin)
685        .args([
686            "pr",
687            "view",
688            "--json",
689            "state,number,headRefName,headRefOid",
690        ])
691        .current_dir(cwd)
692        .output()
693        .map_err(|e| ("pr_view".to_string(), e.to_string()))?;
694
695    if !pr_view_out.status.success() {
696        if is_no_pr_stderr(&pr_view_out.stderr) {
697            // No PR yet: world-state, not an error. done() is simply false
698            // ("no PR for HEAD"), and the backstop can resolve a stuck
699            // no-PR session as NoProgress rather than freezing forever.
700            return Ok(PrInfo {
701                state: PrState::None,
702                number: 0,
703                head_oid: String::new(),
704                ci_conclusion: CiConclusion::None,
705                latest_review_ts: "none".to_string(),
706                reviewed: false,
707                missing_bots: Vec::new(),
708                unaddressed_findings: Vec::new(),
709                review_skipped: false,
710            });
711        }
712        return Err(("pr_view".to_string(), stderr_tail(&pr_view_out.stderr)));
713    }
714
715    let pr_json: Value = serde_json::from_slice(&pr_view_out.stdout)
716        .map_err(|_| ("pr_view_parse".to_string(), String::new()))?;
717
718    let state = PrState::from_gh_str(
719        pr_json
720            .get("state")
721            .and_then(|v| v.as_str())
722            .unwrap_or("none"),
723    );
724    let number = pr_json.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
725    let head_oid = pr_json
726        .get("headRefOid")
727        .and_then(|v| v.as_str())
728        .unwrap_or("")
729        .to_string();
730
731    // Read 2: CI checks
732    let ci_conclusion = if ci_declared_none {
733        CiConclusion::Skipped
734    } else {
735        let checks_out = Command::new(gh_bin)
736            .args(["pr", "checks", "--json", "name,state,bucket"])
737            .current_dir(cwd)
738            .output()
739            .map_err(|e| ("pr_checks".to_string(), e.to_string()))?;
740
741        if !checks_out.status.success() {
742            return Err(("pr_checks".to_string(), stderr_tail(&checks_out.stderr)));
743        }
744
745        let checks: Value = serde_json::from_slice(&checks_out.stdout)
746            .map_err(|_| ("pr_checks_parse".to_string(), String::new()))?;
747
748        compute_ci_conclusion(&checks).map_err(|e| (e, String::new()))?
749    };
750
751    // Reads 3+4: reviews + inline findings. Skipped when the session declares
752    // no_external OR the repo declares `required_bots: []` (the no-review-gate
753    // path, US3 - mirrors ci.declared_none; PR + CI carry the gate). The two
754    // skips are orthogonal: one is per-session, the other repo config.
755    let review_skipped = no_external || required_bots.is_empty();
756    let (latest_review_ts, reviewed, missing_bots, unaddressed_findings) = if review_skipped {
757        ("none".to_string(), true, Vec::new(), Vec::new()) // skip reads, treat as reviewed
758    } else {
759        // Read 3: top-level reviews + issue comments
760        let reviews_out = Command::new(gh_bin)
761            .args(["pr", "view", "--json", "reviews,comments"])
762            .current_dir(cwd)
763            .output()
764            .map_err(|e| ("pr_reviews".to_string(), e.to_string()))?;
765
766        if !reviews_out.status.success() {
767            return Err(("pr_reviews".to_string(), stderr_tail(&reviews_out.stderr)));
768        }
769
770        let reviews_json: Value = serde_json::from_slice(&reviews_out.stdout)
771            .map_err(|_| ("pr_reviews_parse".to_string(), String::new()))?;
772
773        let info = compute_review_info(&reviews_json, required_bots);
774
775        // Read 4: inline review comments (NEW in step 2). Codex's P1s land on
776        // the /pulls/N/comments REST endpoint, which `gh pr view --json
777        // comments` does NOT return (verified on PR #447). --paginate may
778        // emit CONCATENATED JSON arrays (one per page), so parse as a stream.
779        let comments_out = Command::new(gh_bin)
780            .args([
781                "api",
782                &format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments"),
783                "--paginate",
784            ])
785            .current_dir(cwd)
786            .output()
787            .map_err(|e| ("pulls_comments".to_string(), e.to_string()))?;
788
789        if !comments_out.status.success() {
790            return Err((
791                "pulls_comments".to_string(),
792                stderr_tail(&comments_out.stderr),
793            ));
794        }
795
796        let mut inline_comments: Vec<Value> = Vec::new();
797        for page in serde_json::Deserializer::from_slice(&comments_out.stdout).into_iter::<Value>()
798        {
799            let page = page.map_err(|_| ("pulls_comments_parse".to_string(), String::new()))?;
800            match page.as_array() {
801                Some(arr) => inline_comments.extend(arr.iter().cloned()),
802                None => return Err(("pulls_comments_parse".to_string(), String::new())),
803            }
804        }
805
806        // Commit timestamps feed the commit-after arm of "addressed". Only
807        // fetched when a blocking candidate could exist (cheap pre-scan).
808        let has_blocking_candidate = inline_comments.iter().any(|c| {
809            c.get("in_reply_to_id").and_then(|v| v.as_i64()).is_none()
810                && blocking_severity(c.get("body").and_then(|v| v.as_str()).unwrap_or("")).is_some()
811        });
812        let commit_dates: Vec<String> = if has_blocking_candidate {
813            let commits_out = Command::new(gh_bin)
814                .args(["pr", "view", "--json", "commits"])
815                .current_dir(cwd)
816                .output()
817                .map_err(|e| ("pr_commits".to_string(), e.to_string()))?;
818            if !commits_out.status.success() {
819                return Err(("pr_commits".to_string(), stderr_tail(&commits_out.stderr)));
820            }
821            let commits_json: Value = serde_json::from_slice(&commits_out.stdout)
822                .map_err(|_| ("pr_commits_parse".to_string(), String::new()))?;
823            commits_json
824                .get("commits")
825                .and_then(|v| v.as_array())
826                .map(|arr| {
827                    arr.iter()
828                        .filter_map(|c| {
829                            c.get("committedDate")
830                                .and_then(|v| v.as_str())
831                                .map(|s| s.to_string())
832                        })
833                        .collect()
834                })
835                .unwrap_or_default()
836        } else {
837            Vec::new()
838        };
839
840        let (inline_ts, unaddressed) = compute_unaddressed_findings(
841            &inline_comments,
842            &commit_dates,
843            required_bots,
844            external_reviewers,
845        );
846
847        // Read 4's newest comment timestamp joins the activity timestamp so
848        // inline-only review traffic advances the fingerprint (closes the
849        // false-NoProgress hole).
850        let activity_ts = max_ts(&info.latest_ts, &inline_ts);
851        let reviewed = info.all_required_passed() && unaddressed.is_empty();
852        (activity_ts, reviewed, info.missing_bots, unaddressed)
853    };
854
855    Ok(PrInfo {
856        state,
857        number,
858        head_oid,
859        ci_conclusion,
860        latest_review_ts,
861        reviewed,
862        missing_bots,
863        unaddressed_findings,
864        review_skipped,
865    })
866}
867
868fn compute_ci_conclusion(checks: &Value) -> Result<CiConclusion, String> {
869    let arr = match checks.as_array() {
870        Some(a) => a,
871        None => return Err("pr_checks_parse".to_string()),
872    };
873
874    if arr.is_empty() {
875        // No checks configured and no declared_none -> fail closed
876        return Ok(CiConclusion::None);
877    }
878
879    // `gh pr checks --json` classifies each check into a rollup `bucket`:
880    // pass | fail | pending | skipping | cancel. (`conclusion` is NOT an
881    // available field on this subcommand; requesting it errored the read on
882    // every fire - ab-610d2ee3 follow-on, previously masked by the budget
883    // bug terminating sessions before this read ran.) Unknown or missing
884    // buckets fail closed as Pending - never green.
885    let bucket_of = |check: &Value| -> String {
886        check
887            .get("bucket")
888            .and_then(|v| v.as_str())
889            .unwrap_or("")
890            .to_lowercase()
891    };
892
893    if let Some(failing) = arr
894        .iter()
895        .find(|c| matches!(bucket_of(c).as_str(), "fail" | "cancel"))
896    {
897        let name = failing
898            .get("name")
899            .and_then(|v| v.as_str())
900            .unwrap_or("unknown");
901        return Ok(CiConclusion::Failure(Some(name.to_string())));
902    }
903    if arr
904        .iter()
905        .any(|c| !matches!(bucket_of(c).as_str(), "pass" | "skipping"))
906    {
907        return Ok(CiConclusion::Pending);
908    }
909    Ok(CiConclusion::Success)
910}
911
912/// Known bot logins that count as reviewers when external_reviewers is not configured.
913const KNOWN_BOTS: &[&str] = &["chatgpt-codex-connector", "gemini-code-assist"];
914
915/// Default must-have-reviewed list when config.review.required_bots is absent.
916/// EMPTY for fresh installs: a clone with no review configuration completes on
917/// PR + CI green without hanging on a review bot it has never set up (a fresh
918/// `/target` otherwise runs to the budget cap waiting for a codex review that
919/// never arrives). Maintainers who want an external-review gate pin it
920/// explicitly via config.review.required_bots (e.g. ["chatgpt-codex-connector"]).
921const DEFAULT_REQUIRED_BOTS: &[&str] = &[];
922
923fn resolved_required_bots(settings: &Settings) -> Vec<String> {
924    match &settings.required_bots {
925        Some(list) => list.clone(),
926        None => DEFAULT_REQUIRED_BOTS
927            .iter()
928            .map(|s| s.to_string())
929            .collect(),
930    }
931}
932
933/// Case-insensitive substring match so a configured short name ("codex") or a
934/// full login both match the review author, including gh's `[bot]`-suffixed
935/// form (reference_gh_bot_login_suffix_polling_trap).
936fn login_matches_bot(login: &str, bot: &str) -> bool {
937    !bot.is_empty() && login.to_lowercase().contains(&bot.to_lowercase())
938}
939
940fn is_bot_reviewer(login: &str, external_reviewers: &[String]) -> bool {
941    if !external_reviewers.is_empty() {
942        let login_lower = login.to_lowercase();
943        // Case-insensitive substring match: "gemini" matches "gemini-code-assist[bot]"
944        if external_reviewers
945            .iter()
946            .any(|r| login_lower.contains(&r.to_lowercase()))
947        {
948            return true;
949        }
950        // Configured list present but no entry matched: fall back to bot heuristic
951        // so a configured-but-partial list doesn't make reviewed unreachable.
952    }
953    // Default: endswith [bot] or known list
954    login.ends_with("[bot]") || KNOWN_BOTS.iter().any(|&b| login.contains(b))
955}
956
957/// Per-required-bot review verdict (grilled decision 5 / step 2).
958#[derive(Debug)]
959struct ReviewInfo {
960    /// Latest review/comment activity timestamp, or "none".
961    latest_ts: String,
962    /// Required bots with no completed review pass. A pass is a top-level
963    /// review with any non-empty state on ANY commit - in practice COMMENTED
964    /// (verified on PR #447; codex reviews once per PR and never re-reviews,
965    /// so requiring a pass on HEAD would make the gate unsatisfiable).
966    missing_bots: Vec<String>,
967}
968
969impl ReviewInfo {
970    /// Every required bot has at least one completed pass.
971    fn all_required_passed(&self) -> bool {
972        self.missing_bots.is_empty()
973    }
974}
975
976fn compute_review_info(reviews_json: &Value, required_bots: &[String]) -> ReviewInfo {
977    let reviews = reviews_json
978        .get("reviews")
979        .and_then(|v| v.as_array())
980        .map(|v| v.as_slice())
981        .unwrap_or(&[]);
982    let comments = reviews_json
983        .get("comments")
984        .and_then(|v| v.as_array())
985        .map(|v| v.as_slice())
986        .unwrap_or(&[]);
987
988    let mut latest_ts = String::new(); // empty; "none" returned if no activity found
989    let mut passed: Vec<bool> = vec![false; required_bots.len()];
990
991    for r in reviews {
992        let login = r
993            .pointer("/author/login")
994            .and_then(|v| v.as_str())
995            .unwrap_or("");
996        let submitted_at = r.get("submittedAt").and_then(|v| v.as_str()).unwrap_or("");
997        let state = r.get("state").and_then(|v| v.as_str()).unwrap_or("");
998
999        if !submitted_at.is_empty() && submitted_at > latest_ts.as_str() {
1000            latest_ts = submitted_at.to_string();
1001        }
1002
1003        if !state.is_empty() {
1004            for (i, bot) in required_bots.iter().enumerate() {
1005                if login_matches_bot(login, bot) {
1006                    passed[i] = true;
1007                }
1008            }
1009        }
1010    }
1011
1012    for c in comments {
1013        let created_at = c.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
1014        if !created_at.is_empty() && created_at > latest_ts.as_str() {
1015            latest_ts = created_at.to_string();
1016        }
1017    }
1018
1019    let final_ts = if latest_ts.is_empty() {
1020        "none".to_string()
1021    } else {
1022        latest_ts
1023    };
1024
1025    let missing_bots: Vec<String> = required_bots
1026        .iter()
1027        .zip(passed.iter())
1028        .filter(|(_, ok)| !**ok)
1029        .map(|(bot, _)| bot.clone())
1030        .collect();
1031
1032    ReviewInfo {
1033        latest_ts: final_ts,
1034        missing_bots,
1035    }
1036}
1037
1038// ── inline findings (Read 4, step 2 / US2) ────────────────────────────────────
1039
1040/// A blocking inline finding: a root review comment (in_reply_to_id == null)
1041/// authored by a required bot whose body carries a blocking severity badge.
1042#[derive(Debug, Clone)]
1043struct Finding {
1044    id: i64,
1045    /// Bot login that posted the finding (REST `user.login`).
1046    author: String,
1047    path: String,
1048    line: i64,
1049    created_at: String,
1050    /// Parsed severity label (P1 / critical / high).
1051    severity: &'static str,
1052}
1053
1054/// Parse a blocking severity from the bot's own badge markup. The exact
1055/// strings are pinned from PR #447 ground truth; both the alt-text and the
1056/// badge-URL forms are matched so a partial render still classifies:
1057///   codex:  `![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)`
1058///   gemini: `![high](https://www.gstatic.com/codereviewagent/high-priority.svg)`
1059/// Anything unparseable is advisory, never blocking (locked decision 4:
1060/// under-blocking is the only safe failure - the agent cannot edit a bot's
1061/// comment, and PR history is the post-hoc backstop).
1062fn blocking_severity(body: &str) -> Option<&'static str> {
1063    if body.contains("![P1 Badge]") || body.contains("badge/P1-") {
1064        return Some("P1");
1065    }
1066    if body.contains("![critical]") || body.contains("critical-priority.svg") {
1067        return Some("critical");
1068    }
1069    if body.contains("![high]") || body.contains("high-priority.svg") {
1070        return Some("high");
1071    }
1072    None
1073}
1074
1075/// Max of two timestamp strings, treating "none"/"" as the lowest value.
1076/// Both sides are compared chronologically when they parse (gemini HIGH on
1077/// #448: an offset-suffixed timestamp can sort above a Zulu one
1078/// lexicographically while being earlier in UTC); the returned value is
1079/// always one of the ORIGINAL strings so the fingerprint stays byte-stable.
1080/// Unparseable-but-real strings fall back to lexicographic comparison.
1081fn max_ts(a: &str, b: &str) -> String {
1082    if let (Ok(da), Ok(db)) = (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
1083        return if da >= db {
1084            a.to_string()
1085        } else {
1086            b.to_string()
1087        };
1088    }
1089    let a_real = !a.is_empty() && a != "none";
1090    let b_real = !b.is_empty() && b != "none";
1091    match (a_real, b_real) {
1092        (true, true) => {
1093            if a >= b {
1094                a.to_string()
1095            } else {
1096                b.to_string()
1097            }
1098        }
1099        (true, false) => a.to_string(),
1100        (false, true) => b.to_string(),
1101        (false, false) => "none".to_string(),
1102    }
1103}
1104
1105/// The `wontfix:` decline marker (documented in skills/check-pr). Matched
1106/// case-insensitively in a non-bot reply body.
1107const WONTFIX_MARKER: &str = "wontfix:";
1108
1109/// True iff `a` is strictly after `b`. Both sides parse as RFC3339; an
1110/// unparseable timestamp returns false, so a blocking finding is never
1111/// cleared on garbage data. Raw string comparison is NOT used here because
1112/// offset-suffixed and Z-suffixed forms mis-order lexicographically
1113/// (e.g. "...T23:30:00+13:00" sorts above "...T11:00:00Z" as a string but
1114/// is 30 minutes EARLIER in UTC).
1115fn ts_after(a: &str, b: &str) -> bool {
1116    match (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
1117        (Ok(da), Ok(db)) => da > db,
1118        _ => false,
1119    }
1120}
1121
1122/// Walk the `/pulls/N/comments` array (REST shape: `user.login`,
1123/// `in_reply_to_id`, `created_at`). Returns the newest comment timestamp
1124/// (fingerprint contribution) and the UNADDRESSED blocking findings.
1125///
1126/// A blocking finding is addressed iff its thread has a non-bot reply AND
1127/// (a commit landed after the finding's created_at OR a non-bot reply body
1128/// carries `wontfix:`). The reply is mandatory: a commit alone must not
1129/// silently clear a P1 (anti-gaming, locked decision 3).
1130fn compute_unaddressed_findings(
1131    comments: &[Value],
1132    commit_dates: &[String],
1133    required_bots: &[String],
1134    external_reviewers: &[String],
1135) -> (String, Vec<Finding>) {
1136    let mut latest_ts = String::new();
1137    let mut candidates: Vec<Finding> = Vec::new();
1138    // finding id -> non-bot replies' bodies
1139    let mut replies: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
1140
1141    for c in comments {
1142        let created_at = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
1143        if !created_at.is_empty() && created_at > latest_ts.as_str() {
1144            latest_ts = created_at.to_string();
1145        }
1146
1147        let login = c
1148            .pointer("/user/login")
1149            .and_then(|v| v.as_str())
1150            .unwrap_or("");
1151        let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
1152        let in_reply_to = c.get("in_reply_to_id").and_then(|v| v.as_i64());
1153
1154        match in_reply_to {
1155            Some(parent_id) => {
1156                // A reply. Only non-bot replies count as the agent's ack.
1157                if !is_bot_reviewer(login, external_reviewers) {
1158                    replies.entry(parent_id).or_default().push(body.to_string());
1159                }
1160            }
1161            None => {
1162                // A root comment: a finding when a required bot posted it
1163                // with a blocking badge.
1164                let by_required_bot = required_bots
1165                    .iter()
1166                    .any(|bot| login_matches_bot(login, bot));
1167                if by_required_bot {
1168                    if let Some(severity) = blocking_severity(body) {
1169                        // A REST comment always carries an integer id; a row
1170                        // without one is schema drift. Skip it rather than
1171                        // pooling id-less findings on a shared default bucket
1172                        // where a single stray reply could mark them all
1173                        // addressed (under-blocking is the safe direction per
1174                        // locked decision 4; PR history is the backstop).
1175                        let Some(id) = c.get("id").and_then(|v| v.as_i64()) else {
1176                            eprintln!(
1177                                "loop-check: skipping blocking finding with missing id (author={login})"
1178                            );
1179                            continue;
1180                        };
1181                        candidates.push(Finding {
1182                            id,
1183                            author: login.to_string(),
1184                            path: c
1185                                .get("path")
1186                                .and_then(|v| v.as_str())
1187                                .unwrap_or("unknown")
1188                                .to_string(),
1189                            line: c
1190                                .get("line")
1191                                .and_then(|v| v.as_i64())
1192                                .or_else(|| c.get("original_line").and_then(|v| v.as_i64()))
1193                                .unwrap_or(0),
1194                            created_at: created_at.to_string(),
1195                            severity,
1196                        });
1197                    }
1198                }
1199            }
1200        }
1201    }
1202
1203    let unaddressed: Vec<Finding> = candidates
1204        .into_iter()
1205        .filter(|f| {
1206            let non_bot_replies = replies.get(&f.id);
1207            let has_reply = non_bot_replies.map(|r| !r.is_empty()).unwrap_or(false);
1208            if !has_reply {
1209                return true; // no ack -> unaddressed
1210            }
1211            let commit_after = commit_dates.iter().any(|d| ts_after(d, &f.created_at));
1212            let wontfix = non_bot_replies
1213                .map(|rs| rs.iter().any(|b| b.to_lowercase().contains(WONTFIX_MARKER)))
1214                .unwrap_or(false);
1215            !(commit_after || wontfix)
1216        })
1217        .collect();
1218
1219    let final_ts = if latest_ts.is_empty() {
1220        "none".to_string()
1221    } else {
1222        latest_ts
1223    };
1224    (final_ts, unaddressed)
1225}
1226
1227// ── fingerprint + fire history ────────────────────────────────────────────────
1228
1229fn make_fingerprint(
1230    head_sha: &str,
1231    pr_state: &str,
1232    ci_conclusion: &str,
1233    latest_ts: &str,
1234) -> String {
1235    format!("{head_sha}|{pr_state}|{ci_conclusion}|{latest_ts}")
1236}
1237
1238/// Count prior loop_check events for this session_id in the project events file.
1239/// Returns (total_fires, consecutive_unchanged_count, last_fingerprint_in_log).
1240///
1241/// `current_fp` is the fingerprint computed this fire (used for streak matching).
1242/// `last_fp` is the most recent fingerprint recorded in the events log for this
1243/// session -- used for carry-forward when the gh pre-read fails this fire.
1244fn read_prior_fires(
1245    events_path: &Path,
1246    session_id: &str,
1247    current_fp: &str,
1248) -> (u64, u64, Option<String>) {
1249    let Ok(content) = std::fs::read_to_string(events_path) else {
1250        return (0, 0, None);
1251    };
1252
1253    let mut total: u64 = 0;
1254
1255    for line in content.lines() {
1256        let Ok(val) = serde_json::from_str::<Value>(line) else {
1257            continue;
1258        };
1259        if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
1260            continue;
1261        }
1262        if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
1263            continue;
1264        }
1265        total += 1;
1266    }
1267
1268    // Calculate consecutive streak from the end (how many recent fires share current_fp)
1269    // and capture the most recent fp recorded.
1270    let mut consecutive: u64 = 0;
1271    let mut last_fp: Option<String> = None;
1272    for line in content.lines().rev() {
1273        let Ok(val) = serde_json::from_str::<Value>(line) else {
1274            continue;
1275        };
1276        if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
1277            continue;
1278        }
1279        if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
1280            continue;
1281        }
1282        // US4: gh-errored fires are TRANSPARENT to the streak - they neither
1283        // advance nor reset the consecutive count (their recorded fp is just
1284        // a carry-forward, not an observation). After an outage clears, the
1285        // streak resumes from its pre-outage value (AC4-FR).
1286        if val
1287            .pointer("/data/fp_read_failed")
1288            .and_then(|v| v.as_bool())
1289            == Some(true)
1290        {
1291            continue;
1292        }
1293        let fp = val
1294            .pointer("/data/fingerprint")
1295            .and_then(|v| v.as_str())
1296            .unwrap_or("");
1297        // Capture the most recent fp (first match in reverse order)
1298        if last_fp.is_none() && !fp.is_empty() {
1299            last_fp = Some(fp.to_string());
1300        }
1301        if fp == current_fp {
1302            consecutive += 1;
1303        } else {
1304            break;
1305        }
1306    }
1307
1308    (total, consecutive, last_fp)
1309}
1310
1311// ── event emission ────────────────────────────────────────────────────────────
1312
1313/// Envelope struct for target-stream events. Field order ts,type,source,data is
1314/// preserved because serde_json serializes struct fields in declaration order.
1315/// Method is named `append_loop_event` (NOT .emit / .emit_fields) so the
1316/// production-emit scanner test in lib.rs does not capture it and force
1317/// registration in KNOWN_EVENT_KINDS (which is the Branch B / fno-agents
1318/// daemon stream, not the target stream that these events belong to).
1319#[derive(Debug, Serialize)]
1320struct LoopEventEnvelope<'a> {
1321    ts: String,
1322    #[serde(rename = "type")]
1323    event_type: &'a str,
1324    source: &'static str,
1325    data: serde_json::Value,
1326}
1327
1328// pub(crate): the `finalize` verb (step 6, ab-f8e5f214) reuses this so its
1329// `session_finalized` events carry the identical RFC3339 timestamp shape.
1330pub(crate) fn now_rfc3339_utc() -> String {
1331    // Seconds precision, Z suffix, as required by the envelope spec.
1332    let now = chrono::Utc::now();
1333    now.format("%Y-%m-%dT%H:%M:%SZ").to_string()
1334}
1335
1336/// Append a target-stream event to a file (O_APPEND, create if missing).
1337/// Failure is loud on stderr but never fatal to the decision.
1338fn append_loop_event(path: &Path, event_type: &str, data: serde_json::Value) {
1339    let env = LoopEventEnvelope {
1340        ts: now_rfc3339_utc(),
1341        event_type,
1342        source: "hook",
1343        data,
1344    };
1345    let Ok(mut line) = serde_json::to_string(&env) else {
1346        eprintln!("loop-check: failed to serialize event {event_type}");
1347        return;
1348    };
1349    line.push('\n');
1350
1351    // Create parent dirs
1352    if let Some(parent) = path.parent() {
1353        let _ = std::fs::create_dir_all(parent);
1354    }
1355
1356    match std::fs::OpenOptions::new()
1357        .create(true)
1358        .append(true)
1359        .open(path)
1360    {
1361        Ok(mut f) => {
1362            if let Err(e) = f.write_all(line.as_bytes()) {
1363                eprintln!(
1364                    "loop-check: failed to write event {event_type} to {}: {e}",
1365                    path.display()
1366                );
1367            }
1368        }
1369        Err(e) => {
1370            eprintln!(
1371                "loop-check: failed to open events file {}: {e}",
1372                path.display()
1373            );
1374        }
1375    }
1376}
1377
1378/// Append to both project and global event logs.
1379///
1380/// pub(crate): the `finalize` verb (step 6, ab-f8e5f214) emits its
1381/// `session_finalized` / `session_finalize_failed` events through the same
1382/// writer so they land in both logs with the identical `{ts,type,source,data}`
1383/// envelope loop-check uses.
1384pub(crate) fn emit_to_both(
1385    project_events: &Path,
1386    global_events: &Path,
1387    event_type: &str,
1388    data: serde_json::Value,
1389) {
1390    append_loop_event(project_events, event_type, data.clone());
1391    if project_events != global_events {
1392        append_loop_event(global_events, event_type, data);
1393    }
1394}
1395
1396// ── cancel sentinel ───────────────────────────────────────────────────────────
1397
1398fn check_cancel_sentinel(cwd: &Path, created_at: &Option<String>) -> bool {
1399    let sentinel = cwd.join(".fno/.target-cancelled");
1400    let tombstone = cwd.join(".fno/.target-cancelled-final");
1401
1402    for path in &[&tombstone, &sentinel] {
1403        if !path.exists() {
1404            continue;
1405        }
1406        // Check mtime >= created_at
1407        if let Some(ca) = created_at {
1408            if let Ok(parsed_ca) = ca.parse::<DateTime<Utc>>() {
1409                if let Ok(meta) = std::fs::metadata(path) {
1410                    if let Ok(modified) = meta.modified() {
1411                        let sentinel_time: DateTime<Utc> = modified.into();
1412                        if sentinel_time >= parsed_ca {
1413                            return true;
1414                        }
1415                        // Stale sentinel (older than created_at) -> ignore
1416                        continue;
1417                    }
1418                }
1419            }
1420            // Can't read mtime -> treat as present (fail-closed)
1421            return true;
1422        }
1423        return true;
1424    }
1425    false
1426}
1427
1428// ── budget check ──────────────────────────────────────────────────────────────
1429
1430#[derive(Debug, PartialEq)]
1431enum BudgetTrip {
1432    WallClock,
1433    Cost,
1434}
1435
1436/// Resolve an `Option<Result<T, String>>` budget cap for use in check_budget.
1437/// - None => absent (no cap)
1438/// - Some(Ok(v)) => valid cap value
1439/// - Some(Err(raw)) => malformed: fail-closed, treat as cap exceeded immediately
1440enum ResolvedCap<T> {
1441    Absent,
1442    Valid(T),
1443    Malformed(String),
1444}
1445
1446fn resolve_cap<T: Copy>(cap: &Option<Result<T, String>>) -> ResolvedCap<T> {
1447    match cap {
1448        None => ResolvedCap::Absent,
1449        Some(Ok(v)) => ResolvedCap::Valid(*v),
1450        Some(Err(raw)) => ResolvedCap::Malformed(raw.clone()),
1451    }
1452}
1453
1454fn check_budget(
1455    manifest: &Manifest,
1456    settings: &Settings,
1457    now: &DateTime<Utc>,
1458    ledger_path: &Path,
1459) -> Option<BudgetTrip> {
1460    let attended = manifest.attended;
1461
1462    // Wall-clock cap: prefer manifest value, then settings
1463    let wall_cap = match resolve_cap(&manifest.budget_wall_clock_cap_minutes) {
1464        ResolvedCap::Absent => {
1465            if attended {
1466                resolve_cap(&settings.attended_wall_cap_minutes)
1467            } else {
1468                resolve_cap(&settings.unattended_wall_cap_minutes)
1469            }
1470        }
1471        other => other,
1472    };
1473
1474    match wall_cap {
1475        ResolvedCap::Malformed(raw) => {
1476            eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
1477            return Some(BudgetTrip::WallClock);
1478        }
1479        ResolvedCap::Valid(cap) => {
1480            if let Some(ca_str) = &manifest.created_at {
1481                if let Ok(created) = ca_str.parse::<DateTime<Utc>>() {
1482                    // Guard against negative elapsed (clock skew / future created_at)
1483                    let duration = now.signed_duration_since(created);
1484                    let elapsed_min = if duration.num_minutes() < 0 {
1485                        0u64
1486                    } else {
1487                        duration.num_minutes() as u64
1488                    };
1489                    if elapsed_min >= cap {
1490                        return Some(BudgetTrip::WallClock);
1491                    }
1492                }
1493            }
1494        }
1495        ResolvedCap::Absent => {}
1496    }
1497
1498    // Cost cap: prefer manifest value, then nested settings, then flat budget_cap
1499    let cost_cap = match resolve_cap(&manifest.budget_cost_cap_usd) {
1500        ResolvedCap::Absent => {
1501            let nested = if attended {
1502                resolve_cap(&settings.attended_cost_cap_usd)
1503            } else {
1504                resolve_cap(&settings.unattended_cost_cap_usd)
1505            };
1506            match nested {
1507                ResolvedCap::Absent => resolve_cap(&settings.flat_budget_cap),
1508                other => other,
1509            }
1510        }
1511        other => other,
1512    };
1513
1514    match cost_cap {
1515        ResolvedCap::Malformed(raw) => {
1516            eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
1517            Some(BudgetTrip::Cost)
1518        }
1519        ResolvedCap::Valid(cap) => {
1520            if let Some(session_id) = &manifest.session_id {
1521                let cost = session_cost_from_ledger(ledger_path, session_id);
1522                if cost >= cap {
1523                    return Some(BudgetTrip::Cost);
1524                }
1525            }
1526            None
1527        }
1528        ResolvedCap::Absent => None,
1529    }
1530}
1531
1532// ── main decision function ────────────────────────────────────────────────────
1533
1534/// CLI flags parsed for `loop-check`. The three required paths are
1535/// non-optional by construction (fu-4faa3d): `parse_args` validates them and
1536/// returns `Err` on absence, so downstream code cannot forget to check.
1537#[derive(Debug)]
1538struct LoopCheckArgs {
1539    state_path: PathBuf,
1540    transcript_path: PathBuf,
1541    cwd: PathBuf,
1542    /// Override for the GLOBAL settings file (default $HOME/.fno/
1543    /// settings.yaml). Tests point it at a nonexistent path for hermeticity.
1544    global_settings_path: Option<PathBuf>,
1545    events_path: Option<PathBuf>,
1546    global_events_path: Option<PathBuf>,
1547    settings_path: Option<PathBuf>,
1548    ledger_path: Option<PathBuf>,
1549    now_override: Option<String>,
1550    gh_bin: String,
1551    git_bin: String,
1552    /// When set, the full Stop-hook JSON payload is read from stdin so
1553    /// `last_assistant_message` becomes the primary intent channel
1554    /// (ab-223d2dae). Flag-gated so manual terminal invocations never hang
1555    /// on a stdin read.
1556    hook_input_stdin: bool,
1557}
1558
1559fn parse_args(args: &[String]) -> Result<LoopCheckArgs, String> {
1560    let mut state_path: Option<PathBuf> = None;
1561    let mut transcript_path: Option<PathBuf> = None;
1562    let mut cwd: Option<PathBuf> = None;
1563    let mut global_settings_path: Option<PathBuf> = None;
1564    let mut events_path: Option<PathBuf> = None;
1565    let mut global_events_path: Option<PathBuf> = None;
1566    let mut settings_path: Option<PathBuf> = None;
1567    let mut ledger_path: Option<PathBuf> = None;
1568    let mut now_override: Option<String> = None;
1569    let mut gh_bin = std::env::var("FNO_LOOPCHECK_GH_BIN").unwrap_or_else(|_| "gh".to_string());
1570    let mut git_bin = std::env::var("FNO_LOOPCHECK_GIT_BIN").unwrap_or_else(|_| "git".to_string());
1571    let mut hook_input_stdin = false;
1572
1573    // Skip the "loop-check" verb itself if present
1574    let args = if args.first().map(|s| s.as_str()) == Some("loop-check") {
1575        &args[1..]
1576    } else {
1577        args
1578    };
1579
1580    let mut i = 0;
1581    while i < args.len() {
1582        let arg = &args[i];
1583        // Support both --flag value and --flag=value forms. Unknown flags are
1584        // tolerated (AC5-FR: forward-compat for the shim).
1585        if let Some(val) = try_flag_value(arg, "--state", args, &mut i) {
1586            state_path = Some(PathBuf::from(val));
1587        } else if let Some(val) = try_flag_value(arg, "--transcript", args, &mut i) {
1588            transcript_path = Some(PathBuf::from(val));
1589        } else if let Some(val) = try_flag_value(arg, "--cwd", args, &mut i) {
1590            cwd = Some(PathBuf::from(val));
1591        } else if let Some(val) = try_flag_value(arg, "--events", args, &mut i) {
1592            events_path = Some(PathBuf::from(val));
1593        } else if let Some(val) = try_flag_value(arg, "--global-events", args, &mut i) {
1594            global_events_path = Some(PathBuf::from(val));
1595        } else if let Some(val) = try_flag_value(arg, "--settings", args, &mut i) {
1596            settings_path = Some(PathBuf::from(val));
1597        } else if let Some(val) = try_flag_value(arg, "--global-settings", args, &mut i) {
1598            global_settings_path = Some(PathBuf::from(val));
1599        } else if let Some(val) = try_flag_value(arg, "--ledger", args, &mut i) {
1600            ledger_path = Some(PathBuf::from(val));
1601        } else if let Some(val) = try_flag_value(arg, "--now", args, &mut i) {
1602            now_override = Some(val);
1603        } else if let Some(val) = try_flag_value(arg, "--gh-bin", args, &mut i) {
1604            gh_bin = val;
1605        } else if let Some(val) = try_flag_value(arg, "--git-bin", args, &mut i) {
1606            git_bin = val;
1607        } else if arg == "--hook-input-stdin" {
1608            // Bare boolean flag (no value): try_flag_value would consume the
1609            // next token as a value, so it is matched directly (ab-223d2dae).
1610            hook_input_stdin = true;
1611        }
1612        i += 1;
1613    }
1614
1615    // Required-flag validation lives here (AC5-ERR), not downstream in decide().
1616    let state_path = state_path.ok_or_else(|| "--state is required".to_string())?;
1617    let transcript_path = transcript_path.ok_or_else(|| "--transcript is required".to_string())?;
1618    let cwd = cwd.ok_or_else(|| "--cwd is required".to_string())?;
1619
1620    Ok(LoopCheckArgs {
1621        state_path,
1622        transcript_path,
1623        cwd,
1624        global_settings_path,
1625        events_path,
1626        global_events_path,
1627        settings_path,
1628        ledger_path,
1629        now_override,
1630        gh_bin,
1631        git_bin,
1632        hook_input_stdin,
1633    })
1634}
1635
1636fn try_flag_value(arg: &str, flag: &str, args: &[String], i: &mut usize) -> Option<String> {
1637    if arg == flag {
1638        *i += 1;
1639        args.get(*i).cloned()
1640    } else if let Some(val) = arg.strip_prefix(&format!("{flag}=")) {
1641        Some(val.to_string())
1642    } else {
1643        None
1644    }
1645}
1646
1647/// Core decision logic. Returns (exit_code, json_output).
1648/// Exit 0 always for allow/block; non-zero only for internal/CLI errors.
1649pub fn decide(args: &[String]) -> (i32, String) {
1650    // Missing required flags are CLI misuse: exit 2 with the same JSON error
1651    // shape the pre-refactor inline checks emitted (AC5-ERR).
1652    let parsed = match parse_args(args) {
1653        Ok(p) => p,
1654        Err(e) => {
1655            let out = serde_json::json!({ "error": e });
1656            return (2, out.to_string());
1657        }
1658    };
1659
1660    let state_path = parsed.state_path.clone();
1661    let transcript_path = parsed.transcript_path.clone();
1662    let cwd = parsed.cwd.clone();
1663
1664    // ab-223d2dae (A): the shim feeds the full Stop-hook JSON via stdin so
1665    // the stopping turn's final text (`last_assistant_message`, recomputed
1666    // per fire) is readable without racing the transcript flush. Read or
1667    // parse failures degrade to None (transcript fallback), never an error -
1668    // but a genuine I/O error is named on stderr (-> the shim's
1669    // loop-check.stderr.log) so a sustained stdin failure is separable from
1670    // an ordinary transcript-channel fire in the forensic trail.
1671    let last_assistant_message: Option<String> = if parsed.hook_input_stdin {
1672        match std::io::read_to_string(std::io::stdin()) {
1673            Ok(s) => extract_last_assistant_message(&s),
1674            Err(e) => {
1675                eprintln!(
1676                    "loop-check: failed to read hook input from stdin: {e}; falling back to transcript scan"
1677                );
1678                None
1679            }
1680        }
1681    } else {
1682        None
1683    };
1684
1685    // Parse manifest
1686    let manifest_content = match std::fs::read_to_string(&state_path) {
1687        Ok(c) => c,
1688        Err(e) => {
1689            eprintln!(
1690                "loop-check: cannot read state file {}: {e}",
1691                state_path.display()
1692            );
1693            let out = allow_output(
1694                "allow",
1695                None,
1696                "corrupt/missing manifest; allowing exit",
1697                0,
1698                None,
1699            );
1700            return (0, out);
1701        }
1702    };
1703
1704    let manifest = match parse_manifest(&manifest_content) {
1705        Some(m) => m,
1706        None => {
1707            eprintln!("loop-check: corrupt manifest (no frontmatter)");
1708            let out = allow_output(
1709                "allow",
1710                None,
1711                "corrupt manifest (no frontmatter); allowing exit",
1712                0,
1713                None,
1714            );
1715            return (0, out);
1716        }
1717    };
1718
1719    // Resolve paths
1720    let project_events = parsed
1721        .events_path
1722        .clone()
1723        .unwrap_or_else(|| cwd.join(".fno/events.jsonl"));
1724
1725    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
1726    let global_events = parsed
1727        .global_events_path
1728        .clone()
1729        .unwrap_or_else(|| PathBuf::from(&home).join(".fno/events.jsonl"));
1730
1731    let ledger_path = parsed
1732        .ledger_path
1733        .clone()
1734        .unwrap_or_else(|| cwd.join(".fno/ledger.json"));
1735
1736    // Parse settings: GLOBAL first, then overlay the project-local file's
1737    // populated fields (codex P1 on #447: budgets normally live in the
1738    // global file; a project-local settings.yaml with unrelated content
1739    // must not silently uncap the session). An explicit --settings path
1740    // replaces the merge entirely (tests rely on full isolation).
1741    let settings = if let Some(ref explicit) = parsed.settings_path {
1742        if let Ok(sc) = std::fs::read_to_string(explicit) {
1743            parse_settings(&sc)
1744        } else {
1745            Settings::default()
1746        }
1747    } else {
1748        let global_path = parsed
1749            .global_settings_path
1750            .clone()
1751            .unwrap_or_else(|| PathBuf::from(&home).join(".fno/settings.yaml"));
1752        let mut merged = std::fs::read_to_string(&global_path)
1753            .map(|sc| parse_settings(&sc))
1754            .unwrap_or_default();
1755        if let Ok(sc) = std::fs::read_to_string(cwd.join(".fno/settings.yaml")) {
1756            let local = parse_settings(&sc);
1757            if local.attended_wall_cap_minutes.is_some() {
1758                merged.attended_wall_cap_minutes = local.attended_wall_cap_minutes;
1759            }
1760            if local.attended_cost_cap_usd.is_some() {
1761                merged.attended_cost_cap_usd = local.attended_cost_cap_usd;
1762            }
1763            if local.unattended_wall_cap_minutes.is_some() {
1764                merged.unattended_wall_cap_minutes = local.unattended_wall_cap_minutes;
1765            }
1766            if local.unattended_cost_cap_usd.is_some() {
1767                merged.unattended_cost_cap_usd = local.unattended_cost_cap_usd;
1768            }
1769            if local.flat_budget_cap.is_some() {
1770                merged.flat_budget_cap = local.flat_budget_cap;
1771            }
1772            if local.ci_declared_none {
1773                merged.ci_declared_none = true;
1774            }
1775            if !local.external_reviewers.is_empty() {
1776                merged.external_reviewers = local.external_reviewers;
1777            }
1778            if local.required_bots.is_some() {
1779                // Some([]) is a meaningful project-local override (declared
1780                // no-review-gate), so presence - not non-emptiness - wins.
1781                merged.required_bots = local.required_bots;
1782            }
1783        }
1784        merged
1785    };
1786
1787    // Resolve the must-have-reviewed list once (code default when unset).
1788    let required_bots = resolved_required_bots(&settings);
1789
1790    // Now timestamp
1791    let now: DateTime<Utc> = if let Some(ref s) = parsed.now_override {
1792        s.parse().unwrap_or_else(|_| Utc::now())
1793    } else {
1794        Utc::now()
1795    };
1796
1797    let session_id = manifest
1798        .session_id
1799        .clone()
1800        .unwrap_or_else(|| "unknown".to_string());
1801    let emit = |event_type: &str, data: serde_json::Value| {
1802        emit_to_both(&project_events, &global_events, event_type, data);
1803    };
1804
1805    // ── Step 1: cancel sentinel ───────────────────────────────────────────────
1806    if check_cancel_sentinel(&cwd, &manifest.created_at) {
1807        emit(
1808            "termination",
1809            serde_json::json!({
1810                "session_id": session_id,
1811                "reason": "Interrupted",
1812                "message": "cancel sentinel present"
1813            }),
1814        );
1815        return (
1816            0,
1817            allow_output(
1818                "allow",
1819                Some(TerminationReason::Interrupted),
1820                "cancel sentinel present; exiting",
1821                0,
1822                None,
1823            ),
1824        );
1825    }
1826
1827    // ── Step 2: legacy terminal status ───────────────────────────────────────
1828    if let Some(ref status) = manifest.legacy_status {
1829        emit(
1830            "loop_check_legacy_manifest",
1831            serde_json::json!({
1832                "session_id": session_id,
1833                "status": status
1834            }),
1835        );
1836        return (
1837            0,
1838            allow_output(
1839                "allow",
1840                None,
1841                &format!("legacy manifest status={status}; allowing exit"),
1842                0,
1843                None,
1844            ),
1845        );
1846    }
1847
1848    // ── Step 3: budget check ──────────────────────────────────────────────────
1849    if let Some(trip) = check_budget(&manifest, &settings, &now, &ledger_path) {
1850        let axis = match &trip {
1851            BudgetTrip::WallClock => "wall_clock",
1852            BudgetTrip::Cost => "cost",
1853        };
1854        emit(
1855            "termination",
1856            serde_json::json!({
1857                "session_id": session_id,
1858                "reason": "Budget",
1859                "axis": axis,
1860                "message": format!("budget exceeded (axis={axis})")
1861            }),
1862        );
1863        return (
1864            0,
1865            allow_output(
1866                "allow",
1867                Some(TerminationReason::Budget),
1868                &format!("budget exceeded (axis={axis})"),
1869                0,
1870                None,
1871            ),
1872        );
1873    }
1874
1875    // ── Check gh binary availability ──────────────────────────────────────────
1876    // Probe by attempting to spawn; if the binary doesn't exist at all (NotFound
1877    // error kind), treat as absent. Exit-code failures from valid gh commands
1878    // are handled per-read below as transient failures, not absence.
1879    let gh_bin = &parsed.gh_bin;
1880    let gh_available = {
1881        // Use a harmless read-only probe: `gh auth status` exits non-zero when
1882        // not logged in, but the binary IS present. We only care about
1883        // NotFound (binary missing from path entirely).
1884        match Command::new(gh_bin).arg("--version").output() {
1885            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1886            Err(_) => false,
1887            Ok(_) => true, // any exit code: binary exists
1888        }
1889    };
1890
1891    if !gh_available {
1892        if !manifest.attended && !manifest.advisory {
1893            // Unattended + no advisory + no gh -> Interrupted
1894            emit(
1895                "termination",
1896                serde_json::json!({
1897                    "session_id": session_id,
1898                    "reason": "Interrupted",
1899                    "message": "gh binary not found; unattended sessions require gh"
1900                }),
1901            );
1902            return (
1903                0,
1904                allow_output(
1905                    "allow",
1906                    Some(TerminationReason::Interrupted),
1907                    "gh binary not found; unattended sessions require gh",
1908                    0,
1909                    None,
1910                ),
1911            );
1912        }
1913        // Attended or declared advisory -> advisory mode (promise + budget only).
1914        // Budget was already checked above; honor intent here so a promise can
1915        // terminate an advisory session (AC5-ERR) - gh reads are impossible, so
1916        // the promise alone is the completion signal.
1917        emit(
1918            "loop_advisory_mode",
1919            serde_json::json!({
1920                "session_id": session_id,
1921                "attended": manifest.attended
1922            }),
1923        );
1924        let (advisory_intent, _advisory_intent_source) =
1925            detect_intent(last_assistant_message.as_deref(), &transcript_path);
1926        if let Intent::Aborted { ref reason } = advisory_intent {
1927            emit(
1928                "termination",
1929                serde_json::json!({
1930                    "session_id": session_id,
1931                    "reason": "Aborted",
1932                    "message": reason
1933                }),
1934            );
1935            return (
1936                0,
1937                allow_output(
1938                    "allow",
1939                    Some(TerminationReason::Aborted),
1940                    "aborted tag detected (advisory mode)",
1941                    0,
1942                    None,
1943                ),
1944            );
1945        }
1946        if advisory_intent == Intent::Promise {
1947            emit(
1948                "termination",
1949                serde_json::json!({
1950                    "session_id": session_id,
1951                    "reason": "DoneAdvisory",
1952                    "message": "promise accepted in advisory mode (gh unavailable)"
1953                }),
1954            );
1955            return (
1956                0,
1957                allow_output(
1958                    "allow",
1959                    Some(TerminationReason::DoneAdvisory),
1960                    "promise accepted in advisory mode (gh unavailable)",
1961                    0,
1962                    None,
1963                ),
1964            );
1965        }
1966        return (
1967            0,
1968            allow_output(
1969                "block",
1970                None,
1971                "gh binary not found; running in advisory mode (promise + budget only)",
1972                0,
1973                None,
1974            ),
1975        );
1976    }
1977
1978    // ── Step 4: intent + backstop ─────────────────────────────────────────────
1979    let (intent, intent_source) =
1980        detect_intent(last_assistant_message.as_deref(), &transcript_path);
1981    let git_bin = &parsed.git_bin;
1982    let head_sha = git_head_sha(git_bin, &cwd);
1983
1984    // Compute fingerprint from a quick PR state read (or "none" if no PR)
1985    // We do a lightweight fingerprint computation even when intent is None,
1986    // to check backstop.
1987    let backstop_n: u64 = if manifest.attended { 5 } else { 3 };
1988
1989    // Read PR info for fingerprint.
1990    // On a hard gh failure (spawn error, non-zero exit, unparseable JSON), carry
1991    // forward the most recent prior fingerprint so the consecutive-unchanged streak
1992    // continues instead of resetting to "none|none|none" which would mask NoProgress.
1993    // fp_read_failed is recorded in the event payload for observability.
1994    let fp_read_result = Command::new(gh_bin)
1995        .args(["pr", "view", "--json", "state,number,headRefName"])
1996        .current_dir(&cwd)
1997        .output();
1998    let (fp_pr_state, fp_ci, fp_review_ts, fp_read_failed) = match fp_read_result {
1999        Ok(o) if o.status.success() => {
2000            let pv: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
2001            let state =
2002                PrState::from_gh_str(pv.get("state").and_then(|v| v.as_str()).unwrap_or("none"));
2003
2004            // Get CI
2005            let ci = match Command::new(gh_bin)
2006                .args(["pr", "checks", "--json", "name,state,bucket"])
2007                .current_dir(&cwd)
2008                .output()
2009            {
2010                Ok(co) if co.status.success() => {
2011                    let cv: Value = serde_json::from_slice(&co.stdout).unwrap_or(Value::Null);
2012                    compute_ci_conclusion(&cv).unwrap_or(CiConclusion::None)
2013                }
2014                _ => CiConclusion::None,
2015            };
2016
2017            // Get review ts (skipped for no_external sessions and declared
2018            // no-review repos, matching the done() Read 3/4 skip)
2019            let rv_ts = if !manifest.no_external && !required_bots.is_empty() {
2020                match Command::new(gh_bin)
2021                    .args(["pr", "view", "--json", "reviews,comments"])
2022                    .current_dir(&cwd)
2023                    .output()
2024                {
2025                    Ok(ro) if ro.status.success() => {
2026                        let rv: Value = serde_json::from_slice(&ro.stdout).unwrap_or(Value::Null);
2027                        compute_review_info(&rv, &required_bots).latest_ts
2028                    }
2029                    _ => "none".to_string(),
2030                }
2031            } else {
2032                "none".to_string()
2033            };
2034
2035            (state, ci, rv_ts, false)
2036        }
2037        // No PR yet: a healthy fire with a "none" fingerprint (world-state,
2038        // not an outage) - the backstop keeps ticking for a session that
2039        // never ships a PR.
2040        Ok(o) if is_no_pr_stderr(&o.stderr) => {
2041            (PrState::None, CiConclusion::None, "none".to_string(), false)
2042        }
2043        // Hard gh failure (spawn error OR non-zero exit): mark as failed; we will
2044        // carry forward the prior fingerprint after reading the events log.
2045        _ => (PrState::None, CiConclusion::None, "none".to_string(), true),
2046    };
2047
2048    // Build a tentative fingerprint from this fire's gh reads.
2049    let tentative_fp = make_fingerprint(
2050        &head_sha,
2051        fp_pr_state.as_str(),
2052        &fp_ci.render(),
2053        &fp_review_ts,
2054    );
2055
2056    // Read prior fires. We pass the tentative_fp for streak counting; if the gh
2057    // read failed we'll override the fingerprint with the carried-forward value below.
2058    let (prior_fires, consecutive_unchanged, last_recorded_fp) =
2059        read_prior_fires(&project_events, &session_id, &tentative_fp);
2060
2061    // If the pre-read gh call hard-failed, carry forward the prior fingerprint
2062    // (so the streak continues) rather than resetting to "none|none|none".
2063    let fingerprint = if fp_read_failed {
2064        last_recorded_fp.unwrap_or(tentative_fp)
2065    } else {
2066        tentative_fp
2067    };
2068
2069    // Recount consecutive streak with the (possibly carried-forward) fingerprint.
2070    // We already counted against the tentative_fp; if different, recount from the log.
2071    let consecutive_unchanged = if fp_read_failed {
2072        // Re-read the streak against the carried-forward fingerprint.
2073        let (_, streak, _) = read_prior_fires(&project_events, &session_id, &fingerprint);
2074        streak
2075    } else {
2076        consecutive_unchanged
2077    };
2078
2079    let this_fire = prior_fires + 1;
2080    // consecutive_unchanged counts prior identical fires; adding this fire.
2081    // US4: a gh-errored fire is itself transparent - the count holds at its
2082    // prior value instead of advancing (AC4-HP).
2083    let consecutive_after = if fp_read_failed {
2084        consecutive_unchanged
2085    } else {
2086        consecutive_unchanged + 1
2087    };
2088
2089    let backstop_tripped = consecutive_after >= backstop_n;
2090
2091    // D (ab-223d2dae): probe done() after MUTE_PROBE_N unchanged mute fires
2092    // instead of waiting out the full backstop streak. A done-but-mute
2093    // session (all reads pass, no promise as final text) now resolves as a
2094    // late DonePRGreen in ~2 fires instead of 5/3 - the post-wedge events
2095    // audit counted 337 backstop fires, i.e. ~1000 no-op confirmation laps.
2096    // NoProgress still requires the full backstop_n streak (unchanged below),
2097    // so the grilled-9 backstop semantics are intact; a probed fire whose
2098    // done() fails simply blocks with the named reason.
2099    const MUTE_PROBE_N: u64 = 2;
2100
2101    // Run done() on intent OR backstop OR mute-probe
2102    if intent != Intent::None || backstop_tripped || consecutive_after >= MUTE_PROBE_N {
2103        // Handle aborted first
2104        if let Intent::Aborted { ref reason } = intent {
2105            emit(
2106                "termination",
2107                serde_json::json!({
2108                    "session_id": session_id,
2109                    "reason": "Aborted",
2110                    "message": reason
2111                }),
2112            );
2113            emit(
2114                "loop_check",
2115                serde_json::json!({
2116                    "session_id": session_id,
2117                    "fingerprint": fingerprint,
2118                    "fires": this_fire,
2119                    "consecutive_unchanged": consecutive_after,
2120                    "decision": "allow",
2121                    "intent": "aborted",
2122                    "intent_source": intent_source,
2123                    "pr_state": fp_pr_state.as_str(),
2124                    "ci": fp_ci.render(),
2125                    "reviewed": false,
2126                    "fp_read_failed": fp_read_failed
2127                }),
2128            );
2129            return (
2130                0,
2131                allow_output(
2132                    "allow",
2133                    Some(TerminationReason::Aborted),
2134                    "aborted tag detected",
2135                    this_fire,
2136                    Some(fingerprint),
2137                ),
2138            );
2139        }
2140
2141        // Advisory unit (no_ship or manifest advisory)
2142        if (manifest.no_ship || manifest.advisory) && intent == Intent::Promise {
2143            emit(
2144                "termination",
2145                serde_json::json!({
2146                    "session_id": session_id,
2147                    "reason": "DoneAdvisory",
2148                    "message": "promise in advisory/no_ship unit"
2149                }),
2150            );
2151            emit(
2152                "loop_check",
2153                serde_json::json!({
2154                    "session_id": session_id,
2155                    "fingerprint": fingerprint,
2156                    "fires": this_fire,
2157                    "consecutive_unchanged": consecutive_after,
2158                    "decision": "allow",
2159                    "intent": "promise",
2160                    "intent_source": intent_source,
2161                    "pr_state": fp_pr_state.as_str(),
2162                    "ci": fp_ci.render(),
2163                    "reviewed": true,
2164                    "fp_read_failed": fp_read_failed
2165                }),
2166            );
2167            return (
2168                0,
2169                allow_output(
2170                    "allow",
2171                    Some(TerminationReason::DoneAdvisory),
2172                    "promise + advisory unit; done",
2173                    this_fire,
2174                    Some(fingerprint),
2175                ),
2176            );
2177        }
2178
2179        // Run done() for code units
2180        let done_result = run_done(
2181            gh_bin,
2182            &cwd,
2183            settings.ci_declared_none,
2184            manifest.no_external,
2185            &required_bots,
2186            &settings.external_reviewers,
2187        );
2188
2189        match done_result {
2190            Ok(pr_info) => {
2191                // Read 4's newest activity timestamp folds into the
2192                // fingerprint's 4th component: a late inline finding advances
2193                // the fingerprint (re-block, not NoProgress - the codex
2194                // findings-minutes-after-summary shape). State/CI components
2195                // stay on the pre-read basis so quiet fires stay comparable.
2196                // Skipped entirely when the pre-read failed: its stale
2197                // none|none components would leak into done_fp and manufacture
2198                // a fingerprint change on a fire US4 declares transparent
2199                // (sigma-review finding on this branch).
2200                let (fingerprint, consecutive_after) = if !fp_read_failed {
2201                    let done_fp = make_fingerprint(
2202                        &head_sha,
2203                        fp_pr_state.as_str(),
2204                        &fp_ci.render(),
2205                        &max_ts(&fp_review_ts, &pr_info.latest_review_ts),
2206                    );
2207                    if done_fp != fingerprint {
2208                        let (_, streak, _) =
2209                            read_prior_fires(&project_events, &session_id, &done_fp);
2210                        (done_fp, streak + 1)
2211                    } else {
2212                        (fingerprint, consecutive_after)
2213                    }
2214                } else {
2215                    (fingerprint, consecutive_after)
2216                };
2217                let backstop_tripped = consecutive_after >= backstop_n;
2218
2219                let ci_ok = pr_info.ci_conclusion.is_ok();
2220                let pr_open = pr_info.state.is_open_or_merged();
2221                // codex P1 on #447: a green PR must also contain the local
2222                // HEAD - otherwise unpushed work terminates as DonePRGreen
2223                // without ever shipping. MERGED PRs are exempt only when the
2224                // local HEAD matches too; an unpushed commit on top of a
2225                // merged PR is still unshipped work.
2226                let head_shipped = !pr_info.head_oid.is_empty() && pr_info.head_oid == head_sha;
2227
2228                if pr_open && ci_ok && pr_info.reviewed && head_shipped {
2229                    emit(
2230                        "termination",
2231                        serde_json::json!({
2232                            "session_id": session_id,
2233                            "reason": "DonePRGreen",
2234                            "message": format!("PR #{} green and reviewed", pr_info.number)
2235                        }),
2236                    );
2237                    emit(
2238                        "loop_check",
2239                        serde_json::json!({
2240                            "session_id": session_id,
2241                            "fingerprint": fingerprint,
2242                            "fires": this_fire,
2243                            "consecutive_unchanged": consecutive_after,
2244                            "decision": "allow",
2245                            "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
2246                            "intent_source": intent_source,
2247                            "pr_state": pr_info.state.as_str(),
2248                            "ci": pr_info.ci_conclusion.render(),
2249                            "reviewed": pr_info.reviewed,
2250                            "review_skipped": pr_info.review_skipped,
2251                            "unaddressed_blocking": pr_info.unaddressed_findings.len(),
2252                            "fp_read_failed": fp_read_failed
2253                        }),
2254                    );
2255                    return (
2256                        0,
2257                        allow_output(
2258                            "allow",
2259                            Some(TerminationReason::DonePRGreen),
2260                            &format!("PR #{} is green and reviewed", pr_info.number),
2261                            this_fire,
2262                            Some(fingerprint),
2263                        ),
2264                    );
2265                }
2266
2267                if backstop_tripped && (!pr_open || !ci_ok || !pr_info.reviewed) {
2268                    // Backstop tripped + done() false -> NoProgress
2269                    emit(
2270                        "termination",
2271                        serde_json::json!({
2272                            "session_id": session_id,
2273                            "reason": "NoProgress",
2274                            "message": format!("fingerprint unchanged for {} consecutive fires; PR not done", consecutive_after)
2275                        }),
2276                    );
2277                    emit(
2278                        "loop_check",
2279                        serde_json::json!({
2280                            "session_id": session_id,
2281                            "fingerprint": fingerprint,
2282                            "fires": this_fire,
2283                            "consecutive_unchanged": consecutive_after,
2284                            "decision": "allow",
2285                            "intent": "backstop",
2286                            "intent_source": intent_source,
2287                            "pr_state": pr_info.state.as_str(),
2288                            "ci": pr_info.ci_conclusion.render(),
2289                            "reviewed": pr_info.reviewed,
2290                            "review_skipped": pr_info.review_skipped,
2291                            "unaddressed_blocking": pr_info.unaddressed_findings.len(),
2292                            "fp_read_failed": fp_read_failed
2293                        }),
2294                    );
2295                    return (0, allow_output(
2296                        "allow",
2297                        Some(TerminationReason::NoProgress),
2298                        &format!(
2299                            "fingerprint unchanged for {} fires; HEAD={}, PR={}, CI={}, reviewed={}",
2300                            consecutive_after, &head_sha[..8.min(head_sha.len())],
2301                            pr_info.state.as_str(), pr_info.ci_conclusion.render(), pr_info.reviewed
2302                        ),
2303                        this_fire,
2304                        Some(fingerprint),
2305                    ));
2306                }
2307
2308                // done() false on promise -> block with named reason. P2
2309                // (ab-098967b4): enrich with a loop-boundary inbox nudge.
2310                let reason = crate::nudge::append_inbox_nudge(
2311                    &build_block_reason(&pr_info, &head_sha),
2312                    &cwd,
2313                    &session_id,
2314                );
2315                emit(
2316                    "loop_check",
2317                    serde_json::json!({
2318                        "session_id": session_id,
2319                        "fingerprint": fingerprint,
2320                        "fires": this_fire,
2321                        "consecutive_unchanged": consecutive_after,
2322                        "decision": "block",
2323                        "intent": if intent == Intent::Promise { "promise" } else { "none" },
2324                        "intent_source": intent_source,
2325                        "pr_state": pr_info.state.as_str(),
2326                        "ci": pr_info.ci_conclusion.render(),
2327                        "reviewed": pr_info.reviewed,
2328                        "review_skipped": pr_info.review_skipped,
2329                        "unaddressed_blocking": pr_info.unaddressed_findings.len(),
2330                        "fp_read_failed": fp_read_failed
2331                    }),
2332                );
2333                return (
2334                    0,
2335                    allow_output("block", None, &reason, this_fire, Some(fingerprint)),
2336                );
2337            }
2338            Err((failed_read, failed_stderr)) => {
2339                // US4 (locked decision 6, REVERSES the wedge's behavior): a
2340                // gh-errored done() read NEVER terminates NoProgress, even
2341                // with the backstop tripped - a healthy session must not be
2342                // killed because GitHub blipped. The fire blocks-and-retries
2343                // and is recorded fp_read_failed=true, keeping it transparent
2344                // to the streak. Budget remains the sole ceiling during a
2345                // sustained outage (AC4-EDGE; budget is checked before any
2346                // gh read, so the outage never makes a session immortal).
2347                emit(
2348                    "loop_check_gh_error",
2349                    serde_json::json!({
2350                        "session_id": session_id,
2351                        "read": failed_read,
2352                        "stderr_tail": failed_stderr
2353                    }),
2354                );
2355                emit(
2356                    "loop_check",
2357                    serde_json::json!({
2358                        "session_id": session_id,
2359                        "fingerprint": fingerprint,
2360                        "fires": this_fire,
2361                        "consecutive_unchanged": consecutive_after,
2362                        "decision": "block",
2363                        "intent": if intent == Intent::Promise { "promise" } else { "none" },
2364                        "intent_source": intent_source,
2365                        "pr_state": "unknown",
2366                        "ci": "unknown",
2367                        "reviewed": false,
2368                        "fp_read_failed": true
2369                    }),
2370                );
2371                return (
2372                    0,
2373                    allow_output(
2374                        "block",
2375                        None,
2376                        &format!("gh read '{failed_read}' failed; retrying next fire"),
2377                        this_fire,
2378                        Some(fingerprint),
2379                    ),
2380                );
2381            }
2382        }
2383    }
2384
2385    // ── Step 5: no intent, no backstop -> block, record fingerprint ───────────
2386    emit(
2387        "loop_check",
2388        serde_json::json!({
2389            "session_id": session_id,
2390            "fingerprint": fingerprint,
2391            "fires": this_fire,
2392            "consecutive_unchanged": consecutive_after,
2393            "decision": "block",
2394            "intent": "none",
2395            "intent_source": intent_source,
2396            "pr_state": fp_pr_state.as_str(),
2397            "ci": fp_ci.render(),
2398            "reviewed": false,
2399            "fp_read_failed": fp_read_failed
2400        }),
2401    );
2402
2403    // P2 (ab-098967b4): the dominant loop-yield boundary. Enrich the continue
2404    // message with a one-line inbox nudge so an autonomous loop surfaces mail.
2405    let continue_msg = crate::nudge::append_inbox_nudge(
2406        "continue working; no completion signal",
2407        &cwd,
2408        &session_id,
2409    );
2410    (
2411        0,
2412        allow_output("block", None, &continue_msg, this_fire, Some(fingerprint)),
2413    )
2414}
2415
2416fn run_done(
2417    gh_bin: &str,
2418    cwd: &Path,
2419    ci_declared_none: bool,
2420    no_external: bool,
2421    required_bots: &[String],
2422    external_reviewers: &[String],
2423) -> Result<PrInfo, (String, String)> {
2424    read_pr_info(
2425        gh_bin,
2426        cwd,
2427        ci_declared_none,
2428        no_external,
2429        required_bots,
2430        external_reviewers,
2431    )
2432}
2433
2434fn build_block_reason(pr: &PrInfo, local_head: &str) -> String {
2435    if !pr.state.is_open_or_merged() {
2436        return format!(
2437            "no PR for HEAD (pr_state={}); keep working",
2438            pr.state.as_str()
2439        );
2440    }
2441
2442    if !pr.head_oid.is_empty() && pr.head_oid != local_head {
2443        return format!(
2444            "PR #{} head {} != local HEAD {}: push the latest commits before completing",
2445            pr.number,
2446            &pr.head_oid[..8.min(pr.head_oid.len())],
2447            &local_head[..8.min(local_head.len())]
2448        );
2449    }
2450
2451    if !pr.ci_conclusion.is_ok() {
2452        if pr.ci_conclusion == CiConclusion::None {
2453            return format!(
2454                "no CI checks found on PR #{}; declare ci.declared_none: true in settings if intentional",
2455                pr.number
2456            );
2457        }
2458        // Pending is "not green YET", not red. The MUTE_PROBE_N probe
2459        // (ab-223d2dae) runs done() while CI is commonly still in flight,
2460        // so a "CI failed" message here would mislead the blocked agent
2461        // into debugging a nonexistent failure on every quiet fire.
2462        if pr.ci_conclusion == CiConclusion::Pending {
2463            return format!(
2464                "CI still running on PR #{}; wait for it to finish",
2465                pr.number
2466            );
2467        }
2468        let check_name = match &pr.ci_conclusion {
2469            CiConclusion::Failure(Some(name)) => name.as_str(),
2470            _ => "CI",
2471        };
2472        return format!("CI red on PR #{}: {} failed", pr.number, check_name);
2473    }
2474
2475    if !pr.reviewed {
2476        if !pr.missing_bots.is_empty() {
2477            // AC1-UI: name the specific missing bot(s), not a generic
2478            // "not reviewed".
2479            return format!(
2480                "PR #{}: {} has not reviewed",
2481                pr.number,
2482                pr.missing_bots.join(", ")
2483            );
2484        }
2485        if !pr.unaddressed_findings.is_empty() {
2486            // AC2-UI: name the specific finding (path:line) and the remedy.
2487            let f = &pr.unaddressed_findings[0];
2488            let more = if pr.unaddressed_findings.len() > 1 {
2489                format!(" [+{} more]", pr.unaddressed_findings.len() - 1)
2490            } else {
2491                String::new()
2492            };
2493            return format!(
2494                "PR #{}: {} {} at {}:{} unaddressed (reply in-thread or wontfix:){}",
2495                pr.number, f.author, f.severity, f.path, f.line, more
2496            );
2497        }
2498        return format!("PR #{} not yet reviewed by a bot reviewer", pr.number);
2499    }
2500
2501    format!("PR #{} done() returned false (unknown reason)", pr.number)
2502}
2503
2504fn allow_output(
2505    decision: &str,
2506    termination_reason: Option<TerminationReason>,
2507    message: &str,
2508    fires: u64,
2509    fingerprint: Option<String>,
2510) -> String {
2511    let out = LoopCheckOutput {
2512        decision: decision.to_string(),
2513        termination_reason,
2514        message: message.to_string(),
2515        fires,
2516        fingerprint,
2517    };
2518    serde_json::to_string(&out).unwrap_or_else(|_| r#"{"decision":"allow","termination_reason":null,"message":"serialization error","fires":0,"fingerprint":null}"#.to_string())
2519}
2520
2521// ── public entry points ───────────────────────────────────────────────────────
2522
2523/// Entry point called from `bin/client.rs` direct dispatch.
2524/// Prints JSON to stdout, returns exit code.
2525pub fn run_loop_check(args: &[String]) -> i32 {
2526    let (code, json) = decide(args);
2527    println!("{json}");
2528    code
2529}
2530
2531/// Test-friendly variant that returns (exit_code, json_string) without printing.
2532/// Used by integration tests in tests/loop_check.rs.
2533pub fn run_loop_check_capture(args: &[String]) -> (i32, String) {
2534    decide(args)
2535}
2536
2537// ── unit tests ────────────────────────────────────────────────────────────────
2538
2539#[cfg(test)]
2540mod tests {
2541    use super::*;
2542
2543    #[test]
2544    fn parse_manifest_minimal() {
2545        let content =
2546            "---\nsession_id: abc\ncreated_at: 2026-06-05T00:00:00Z\nattended: true\n---\n";
2547        let m = parse_manifest(content).unwrap();
2548        assert_eq!(m.session_id.as_deref(), Some("abc"));
2549        assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
2550        assert!(m.attended);
2551        assert!(m.legacy_status.is_none());
2552    }
2553
2554    #[test]
2555    fn parse_manifest_legacy_complete() {
2556        let content =
2557            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: COMPLETE\n---\n";
2558        let m = parse_manifest(content).unwrap();
2559        assert_eq!(m.legacy_status.as_deref(), Some("COMPLETE"));
2560    }
2561
2562    #[test]
2563    fn parse_manifest_legacy_blocked() {
2564        let content =
2565            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: BLOCKED\n---\n";
2566        let m = parse_manifest(content).unwrap();
2567        assert_eq!(m.legacy_status.as_deref(), Some("BLOCKED"));
2568    }
2569
2570    #[test]
2571    fn parse_manifest_no_ship() {
2572        let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nno_ship: true\n---\n";
2573        let m = parse_manifest(content).unwrap();
2574        assert!(m.no_ship);
2575        assert!(!m.no_external);
2576    }
2577
2578    #[test]
2579    fn parse_manifest_strips_quotes() {
2580        // gemini MEDIUM on #447: quoted YAML values must parse identically.
2581        let content = "---\nsession_id: \"s-quoted\"\ncreated_at: '2026-06-05T00:00:00Z'\n---\n";
2582        let m = parse_manifest(content).unwrap();
2583        assert_eq!(m.session_id.as_deref(), Some("s-quoted"));
2584        assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
2585    }
2586
2587    #[test]
2588    fn parse_settings_four_space_indent() {
2589        // gemini HIGH on #447: indent unit is derived, not assumed 2-space.
2590        let yaml = "config:\n    budget:\n        unattended:\n            cost_cap_usd: 7.5\n    ci:\n        declared_none: true\n";
2591        let s = parse_settings(yaml);
2592        assert_eq!(s.unattended_cost_cap_usd, Some(Ok(7.5)));
2593        assert!(s.ci_declared_none);
2594    }
2595
2596    #[test]
2597    fn stderr_tail_multibyte_boundary_no_panic() {
2598        // gemini HIGH on #447: tail slice must land on a char boundary.
2599        let mut payload = String::new();
2600        while payload.len() < 300 {
2601            payload.push('\u{00e9}'); // 2-byte char so len-200 can split one
2602        }
2603        let tail = stderr_tail(payload.as_bytes());
2604        assert!(tail.len() <= 200);
2605        assert!(!tail.is_empty());
2606    }
2607
2608    #[test]
2609    fn parse_manifest_attended_default_true() {
2610        let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\n---\n";
2611        let m = parse_manifest(content).unwrap();
2612        assert!(m.attended, "attended should default to true when absent");
2613    }
2614
2615    #[test]
2616    fn parse_manifest_budget_caps() {
2617        let content =
2618            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: 120\nbudget_cost_cap_usd: 5.0\n---\n";
2619        let m = parse_manifest(content).unwrap();
2620        assert_eq!(m.budget_wall_clock_cap_minutes, Some(Ok(120)));
2621        assert_eq!(m.budget_cost_cap_usd, Some(Ok(5.0)));
2622    }
2623
2624    #[test]
2625    fn parse_manifest_no_frontmatter_returns_none() {
2626        let content = "no frontmatter here";
2627        assert!(parse_manifest(content).is_none());
2628    }
2629
2630    #[test]
2631    fn parse_settings_flat_budget_cap() {
2632        let yaml = "budget_cap: 2.5\n";
2633        let s = parse_settings(yaml);
2634        assert_eq!(s.flat_budget_cap, Some(Ok(2.5)));
2635    }
2636
2637    #[test]
2638    fn parse_settings_nested_budget() {
2639        let yaml = "config:\n  budget:\n    attended:\n      wall_clock_cap_minutes: 90\n      cost_cap_usd: 10.0\n    unattended:\n      wall_clock_cap_minutes: 60\n      cost_cap_usd: 5.0\n";
2640        let s = parse_settings(yaml);
2641        assert_eq!(s.attended_wall_cap_minutes, Some(Ok(90)));
2642        assert_eq!(s.attended_cost_cap_usd, Some(Ok(10.0)));
2643        assert_eq!(s.unattended_wall_cap_minutes, Some(Ok(60)));
2644        assert_eq!(s.unattended_cost_cap_usd, Some(Ok(5.0)));
2645    }
2646
2647    #[test]
2648    fn parse_settings_ci_declared_none() {
2649        let yaml = "config:\n  ci:\n    declared_none: true\n";
2650        let s = parse_settings(yaml);
2651        assert!(s.ci_declared_none);
2652    }
2653
2654    #[test]
2655    fn parse_settings_comments_ignored() {
2656        let yaml = "# top comment\nbudget_cap: 1.0\n# another\nconfig:\n  # inner\n  ci:\n    declared_none: true\n";
2657        let s = parse_settings(yaml);
2658        assert_eq!(s.flat_budget_cap, Some(Ok(1.0)));
2659        assert!(s.ci_declared_none);
2660    }
2661
2662    #[test]
2663    fn detect_intent_promise() {
2664        let tmp = tempfile::tempdir().unwrap();
2665        let path = tmp.path().join("t.jsonl");
2666        let line = serde_json::json!({
2667            "message": {"role": "assistant", "content": "done <promise>COMPLETE</promise>"}
2668        });
2669        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2670        assert_eq!(detect_intent_full(&path), Intent::Promise);
2671    }
2672
2673    #[test]
2674    fn detect_intent_aborted_beats_promise() {
2675        let tmp = tempfile::tempdir().unwrap();
2676        let path = tmp.path().join("t.jsonl");
2677        // Last line has aborted (even if earlier had promise, aborted in same msg wins)
2678        let line = serde_json::json!({
2679            "message": {"role": "assistant", "content": "<aborted reason=\"user\">done</aborted>"}
2680        });
2681        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2682        assert!(matches!(detect_intent_full(&path), Intent::Aborted { .. }));
2683    }
2684
2685    #[test]
2686    fn detect_intent_tool_result_ignored() {
2687        // Tool result content with promise-like text should not trigger
2688        let tmp = tempfile::tempdir().unwrap();
2689        let path = tmp.path().join("t.jsonl");
2690        let user_line = serde_json::json!({
2691            "message": {"role": "user", "content": "<promise>fake</promise>"}
2692        });
2693        std::fs::write(&path, serde_json::to_string(&user_line).unwrap() + "\n").unwrap();
2694        assert_eq!(detect_intent_full(&path), Intent::None);
2695    }
2696
2697    #[test]
2698    fn detect_intent_none_when_no_assistant() {
2699        let tmp = tempfile::tempdir().unwrap();
2700        let path = tmp.path().join("t.jsonl");
2701        let line = serde_json::json!({"message": {"role": "user", "content": "go"}});
2702        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2703        assert_eq!(detect_intent_full(&path), Intent::None);
2704    }
2705
2706    #[test]
2707    fn detect_intent_array_content_blocks() {
2708        let tmp = tempfile::tempdir().unwrap();
2709        let path = tmp.path().join("t.jsonl");
2710        let line = serde_json::json!({
2711            "message": {
2712                "role": "assistant",
2713                "content": [
2714                    {"type": "text", "text": "<promise>done</promise>"},
2715                    {"type": "tool_use", "name": "Bash"}
2716                ]
2717            }
2718        });
2719        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2720        assert_eq!(detect_intent_full(&path), Intent::Promise);
2721    }
2722
2723    #[test]
2724    fn extract_last_assistant_message_plain_string() {
2725        let payload = r#"{"transcript_path":"/t.jsonl","last_assistant_message":"  done <promise>MISSION COMPLETE: x</promise>  "}"#;
2726        assert_eq!(
2727            extract_last_assistant_message(payload).as_deref(),
2728            Some("done <promise>MISSION COMPLETE: x</promise>")
2729        );
2730    }
2731
2732    #[test]
2733    fn extract_last_assistant_message_degrades_to_none() {
2734        // Missing field, malformed JSON, non-string value, and empty/blank
2735        // strings all degrade to None (transcript fallback), never an error.
2736        assert_eq!(
2737            extract_last_assistant_message(r#"{"transcript_path":"/t.jsonl"}"#),
2738            None
2739        );
2740        assert_eq!(extract_last_assistant_message("not json {"), None);
2741        assert_eq!(
2742            extract_last_assistant_message(r#"{"last_assistant_message":{"text":"obj"}}"#),
2743            None
2744        );
2745        assert_eq!(
2746            extract_last_assistant_message(r#"{"last_assistant_message":"   "}"#),
2747            None
2748        );
2749    }
2750
2751    #[test]
2752    fn detect_intent_payload_promise_wins_over_stale_transcript() {
2753        // AC2-HP: at the promise turn's own fire the transcript does NOT yet
2754        // contain the final message; the payload alone must carry the intent.
2755        let tmp = tempfile::tempdir().unwrap();
2756        let path = tmp.path().join("t.jsonl");
2757        let line = serde_json::json!({
2758            "message": {"role": "assistant", "content": "still working on it"}
2759        });
2760        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2761        let (intent, source) =
2762            detect_intent(Some("<promise>MISSION COMPLETE: done</promise>"), &path);
2763        assert_eq!(intent, Intent::Promise);
2764        assert_eq!(source, "payload");
2765    }
2766
2767    #[test]
2768    fn detect_intent_payload_no_tag_is_authoritative() {
2769        // A tag-less payload is the stopping turn's final text; it must NOT
2770        // fall through to the transcript (stale-promise containment).
2771        let tmp = tempfile::tempdir().unwrap();
2772        let path = tmp.path().join("t.jsonl");
2773        let line = serde_json::json!({
2774            "message": {"role": "assistant", "content": "<promise>old stale promise</promise>"}
2775        });
2776        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2777        let (intent, source) = detect_intent(Some("moving on to other work"), &path);
2778        assert_eq!(intent, Intent::None);
2779        assert_eq!(source, "payload");
2780    }
2781
2782    #[test]
2783    fn detect_intent_payload_aborted_beats_promise() {
2784        let (intent, source) = detect_intent(
2785            Some("<promise>done</promise> <aborted reason=\"kill\">stop</aborted>"),
2786            Path::new("/nonexistent"),
2787        );
2788        assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
2789        assert_eq!(source, "payload");
2790    }
2791
2792    #[test]
2793    fn detect_intent_absent_payload_falls_back_to_transcript() {
2794        let tmp = tempfile::tempdir().unwrap();
2795        let path = tmp.path().join("t.jsonl");
2796        let line = serde_json::json!({
2797            "message": {"role": "assistant", "content": "<promise>COMPLETE</promise>"}
2798        });
2799        std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2800        let (intent, source) = detect_intent(None, &path);
2801        assert_eq!(intent, Intent::Promise);
2802        assert_eq!(source, "transcript");
2803    }
2804
2805    #[test]
2806    fn detect_intent_lookback_finds_promise_behind_block_feedback() {
2807        // AC2-EDGE ("the block destroys the evidence"): promise 3 assistant
2808        // text entries back - block feedback reply + a follow-up on top -
2809        // must still be detected by the bounded fallback scan.
2810        let tmp = tempfile::tempdir().unwrap();
2811        let path = tmp.path().join("t.jsonl");
2812        let mut content = String::new();
2813        for text in [
2814            "<promise>MISSION COMPLETE: shipped</promise>",
2815            "acknowledged the block; checking CI",
2816            "CI is still pending, waiting",
2817        ] {
2818            let line = serde_json::json!({
2819                "message": {"role": "assistant", "content": text}
2820            });
2821            content.push_str(&serde_json::to_string(&line).unwrap());
2822            content.push('\n');
2823        }
2824        std::fs::write(&path, content).unwrap();
2825        assert_eq!(detect_intent_full(&path), Intent::Promise);
2826    }
2827
2828    #[test]
2829    fn detect_intent_lookback_bound_holds() {
2830        // AC2-EDGE ("grill the stale-promise edge"): a promise older than
2831        // INTENT_LOOKBACK_ENTRIES assistant text entries must NOT ride the
2832        // window.
2833        let tmp = tempfile::tempdir().unwrap();
2834        let path = tmp.path().join("t.jsonl");
2835        let mut content = String::new();
2836        let line = serde_json::json!({
2837            "message": {"role": "assistant", "content": "<promise>stale</promise>"}
2838        });
2839        content.push_str(&serde_json::to_string(&line).unwrap());
2840        content.push('\n');
2841        for i in 0..INTENT_LOOKBACK_ENTRIES {
2842            let line = serde_json::json!({
2843                "message": {"role": "assistant", "content": format!("pivoted work step {i}")}
2844            });
2845            content.push_str(&serde_json::to_string(&line).unwrap());
2846            content.push('\n');
2847        }
2848        std::fs::write(&path, content).unwrap();
2849        assert_eq!(detect_intent_full(&path), Intent::None);
2850    }
2851
2852    #[test]
2853    fn parse_args_hook_input_stdin_flag() {
2854        let args: Vec<String> = [
2855            "loop-check",
2856            "--state",
2857            "/s.md",
2858            "--transcript",
2859            "/t.jsonl",
2860            "--cwd",
2861            "/w",
2862            "--hook-input-stdin",
2863        ]
2864        .iter()
2865        .map(|s| s.to_string())
2866        .collect();
2867        let parsed = parse_args(&args).unwrap();
2868        assert!(parsed.hook_input_stdin);
2869        // Bare flag must not swallow a following flag as its value.
2870        assert_eq!(parsed.cwd, PathBuf::from("/w"));
2871    }
2872
2873    #[test]
2874    fn block_reason_pending_ci_is_not_red() {
2875        // The MUTE_PROBE_N probe runs done() while CI is often still in
2876        // flight; a Pending conclusion must read as "still running", never
2877        // as the misleading "CI red ... failed" (observed live on PR #455).
2878        let pr = PrInfo {
2879            state: PrState::Open,
2880            number: 455,
2881            head_oid: "abc".to_string(),
2882            ci_conclusion: CiConclusion::Pending,
2883            latest_review_ts: "none".to_string(),
2884            reviewed: false,
2885            missing_bots: vec![],
2886            unaddressed_findings: vec![],
2887            review_skipped: false,
2888        };
2889        let reason = build_block_reason(&pr, "abc");
2890        assert!(
2891            reason.contains("still running"),
2892            "pending CI must not read as red; got: {reason}"
2893        );
2894        assert!(!reason.contains("failed"), "got: {reason}");
2895    }
2896
2897    #[test]
2898    fn fingerprint_format() {
2899        let fp = make_fingerprint("sha123", "OPEN", "SUCCESS", "2026-06-05T01:00:00Z");
2900        assert_eq!(fp, "sha123|OPEN|SUCCESS|2026-06-05T01:00:00Z");
2901    }
2902
2903    #[test]
2904    fn ci_conclusion_failure_extracts_name() {
2905        let checks = serde_json::json!([
2906            {"name": "unit-tests", "state": "FAILURE", "bucket": "fail"}
2907        ]);
2908        let result = compute_ci_conclusion(&checks).unwrap();
2909        assert_eq!(
2910            result,
2911            CiConclusion::Failure(Some("unit-tests".to_string()))
2912        );
2913        let rendered = result.render();
2914        assert!(rendered.starts_with("FAILURE:"), "got: {rendered}");
2915        assert!(rendered.contains("unit-tests"), "got: {rendered}");
2916    }
2917
2918    /// A cancelled check is a failure, and a skipping sibling never masks it.
2919    #[test]
2920    fn ci_conclusion_cancel_is_failure() {
2921        let checks = serde_json::json!([
2922            {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
2923            {"name": "deploy", "state": "CANCELLED", "bucket": "cancel"}
2924        ]);
2925        assert_eq!(
2926            compute_ci_conclusion(&checks).unwrap(),
2927            CiConclusion::Failure(Some("deploy".to_string()))
2928        );
2929    }
2930
2931    /// pass + skipping rolls up green; a pending bucket blocks it.
2932    #[test]
2933    fn ci_conclusion_bucket_vocabulary() {
2934        let green = serde_json::json!([
2935            {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
2936            {"name": "publish", "state": "SKIPPED", "bucket": "skipping"}
2937        ]);
2938        assert_eq!(
2939            compute_ci_conclusion(&green).unwrap(),
2940            CiConclusion::Success
2941        );
2942
2943        let pending = serde_json::json!([
2944            {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
2945            {"name": "smoke", "state": "IN_PROGRESS", "bucket": "pending"}
2946        ]);
2947        assert_eq!(
2948            compute_ci_conclusion(&pending).unwrap(),
2949            CiConclusion::Pending
2950        );
2951    }
2952
2953    /// An unknown or missing bucket fails closed as Pending, never green.
2954    #[test]
2955    fn ci_conclusion_unknown_bucket_fails_closed() {
2956        let unknown = serde_json::json!([
2957            {"name": "ci", "state": "SUCCESS", "bucket": "mystery"}
2958        ]);
2959        assert_eq!(
2960            compute_ci_conclusion(&unknown).unwrap(),
2961            CiConclusion::Pending
2962        );
2963
2964        let missing = serde_json::json!([{"name": "ci", "state": "SUCCESS"}]);
2965        assert_eq!(
2966            compute_ci_conclusion(&missing).unwrap(),
2967            CiConclusion::Pending
2968        );
2969    }
2970
2971    #[test]
2972    fn ci_conclusion_empty_returns_none() {
2973        let checks = serde_json::json!([]);
2974        let result = compute_ci_conclusion(&checks).unwrap();
2975        assert_eq!(result, CiConclusion::None);
2976        assert_eq!(result.render(), "none");
2977    }
2978
2979    #[test]
2980    fn ci_conclusion_all_success() {
2981        let checks = serde_json::json!([
2982            {"name": "ci", "state": "SUCCESS", "bucket": "pass"}
2983        ]);
2984        let result = compute_ci_conclusion(&checks).unwrap();
2985        assert_eq!(result, CiConclusion::Success);
2986        assert_eq!(result.render(), "SUCCESS");
2987    }
2988
2989    /// AC5-HP: enums parse known gh strings.
2990    #[test]
2991    fn pr_state_parses_known_gh_strings() {
2992        assert_eq!(PrState::from_gh_str("OPEN"), PrState::Open);
2993        assert_eq!(PrState::from_gh_str("MERGED"), PrState::Merged);
2994        assert_eq!(PrState::from_gh_str("CLOSED"), PrState::Closed);
2995        assert_eq!(PrState::from_gh_str("none"), PrState::None);
2996    }
2997
2998    /// AC5-EDGE: an unexpected gh state string maps to PrState::None
2999    /// (fail-closed), never panics.
3000    #[test]
3001    fn pr_state_unknown_string_fails_closed() {
3002        assert_eq!(PrState::from_gh_str("DRAFT"), PrState::None);
3003        assert_eq!(PrState::from_gh_str(""), PrState::None);
3004        assert_eq!(PrState::from_gh_str("open"), PrState::None);
3005    }
3006
3007    /// AC5-UI: as_str/render reproduce the exact legacy fingerprint vocabulary.
3008    #[test]
3009    fn enum_rendering_byte_identical_to_legacy_strings() {
3010        assert_eq!(PrState::Open.as_str(), "OPEN");
3011        assert_eq!(PrState::Merged.as_str(), "MERGED");
3012        assert_eq!(PrState::Closed.as_str(), "CLOSED");
3013        assert_eq!(PrState::None.as_str(), "none");
3014        assert_eq!(CiConclusion::Success.render(), "SUCCESS");
3015        assert_eq!(
3016            CiConclusion::Failure(Some("lint".into())).render(),
3017            "FAILURE:lint"
3018        );
3019        assert_eq!(CiConclusion::Failure(None).render(), "FAILURE");
3020        assert_eq!(CiConclusion::Pending.render(), "PENDING");
3021        assert_eq!(CiConclusion::Skipped.render(), "skipped");
3022        assert_eq!(CiConclusion::None.render(), "none");
3023    }
3024
3025    /// AC5-ERR: required flags validated in parse_args, which returns Err.
3026    #[test]
3027    fn parse_args_missing_required_flags_err() {
3028        let no_state: Vec<String> = vec![
3029            "loop-check".into(),
3030            "--transcript".into(),
3031            "/t".into(),
3032            "--cwd".into(),
3033            "/c".into(),
3034        ];
3035        assert_eq!(
3036            parse_args(&no_state).unwrap_err(),
3037            "--state is required".to_string()
3038        );
3039
3040        let no_transcript: Vec<String> = vec!["loop-check".into(), "--state".into(), "/s".into()];
3041        assert_eq!(
3042            parse_args(&no_transcript).unwrap_err(),
3043            "--transcript is required".to_string()
3044        );
3045
3046        let no_cwd: Vec<String> = vec![
3047            "loop-check".into(),
3048            "--state".into(),
3049            "/s".into(),
3050            "--transcript".into(),
3051            "/t".into(),
3052        ];
3053        assert_eq!(
3054            parse_args(&no_cwd).unwrap_err(),
3055            "--cwd is required".to_string()
3056        );
3057    }
3058
3059    /// AC5-FR: an unknown flag is tolerated (forward-compat for the shim).
3060    #[test]
3061    fn parse_args_unknown_flag_tolerated() {
3062        let args: Vec<String> = vec![
3063            "loop-check".into(),
3064            "--state".into(),
3065            "/s".into(),
3066            "--transcript".into(),
3067            "/t".into(),
3068            "--cwd".into(),
3069            "/c".into(),
3070            "--future-flag=whatever".into(),
3071            "--another-unknown".into(),
3072            "value".into(),
3073        ];
3074        let parsed = parse_args(&args).expect("unknown flags must be ignored");
3075        assert_eq!(parsed.state_path, PathBuf::from("/s"));
3076        assert_eq!(parsed.transcript_path, PathBuf::from("/t"));
3077        assert_eq!(parsed.cwd, PathBuf::from("/c"));
3078    }
3079
3080    #[test]
3081    fn budget_flat_key_enforces_cost_cap_ab41b13d9d() {
3082        // Prove the flat budget_cap key enforces as cost cap for BOTH attended and
3083        // unattended - this is the ab-41b13d9d fold-in test.
3084        let settings_yaml = "budget_cap: 0.10\n";
3085        let settings = parse_settings(settings_yaml);
3086        assert_eq!(settings.flat_budget_cap, Some(Ok(0.10)));
3087        // No nested blocks configured
3088        assert!(settings.attended_cost_cap_usd.is_none());
3089        assert!(settings.unattended_cost_cap_usd.is_none());
3090        // The budget resolver picks flat_budget_cap as cost cap fallback
3091        // for both attended=true and attended=false (tested in check_budget)
3092
3093        let manifest_att = Manifest {
3094            session_id: Some("s1".into()),
3095            created_at: Some("2026-06-05T00:00:00Z".into()),
3096            attended: true,
3097            ..Default::default()
3098        };
3099        let manifest_unatt = Manifest {
3100            session_id: Some("s1".into()),
3101            created_at: Some("2026-06-05T00:00:00Z".into()),
3102            attended: false,
3103            ..Default::default()
3104        };
3105
3106        // Ledger with cost > 0.10
3107        let tmp = tempfile::tempdir().unwrap();
3108        let ledger = tmp.path().join("ledger.json");
3109        std::fs::write(&ledger, r#"[{"session_id":"s1","cost_usd":0.50}]"#).unwrap();
3110
3111        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3112
3113        assert_eq!(
3114            check_budget(&manifest_att, &settings, &now, &ledger),
3115            Some(BudgetTrip::Cost),
3116            "flat budget_cap must enforce for attended"
3117        );
3118        assert_eq!(
3119            check_budget(&manifest_unatt, &settings, &now, &ledger),
3120            Some(BudgetTrip::Cost),
3121            "flat budget_cap must enforce for unattended"
3122        );
3123    }
3124
3125    #[test]
3126    fn is_bot_reviewer_known_patterns() {
3127        assert!(is_bot_reviewer("gemini-code-assist[bot]", &[]));
3128        assert!(is_bot_reviewer("chatgpt-codex-connector", &[]));
3129        assert!(is_bot_reviewer("some-bot[bot]", &[]));
3130        assert!(!is_bot_reviewer("human-reviewer", &[]));
3131    }
3132
3133    #[test]
3134    fn is_bot_reviewer_with_external_list() {
3135        let external = vec!["my-bot".to_string()];
3136        // "my-bot" is a substring of "my-bot" -> match via configured list
3137        assert!(is_bot_reviewer("my-bot", &external));
3138        // "other-bot[bot]" doesn't match "my-bot" substring, but falls back to
3139        // the [bot] suffix heuristic (configured list must not make reviewed unreachable)
3140        assert!(is_bot_reviewer("other-bot[bot]", &external));
3141    }
3142
3143    #[test]
3144    fn session_cost_from_ledger_sums_session_only() {
3145        let tmp = tempfile::tempdir().unwrap();
3146        let ledger = tmp.path().join("l.json");
3147        std::fs::write(
3148            &ledger,
3149            r#"[{"session_id":"a","cost_usd":1.0},{"session_id":"b","cost_usd":0.5},{"session_id":"a","cost_usd":0.25}]"#,
3150        )
3151        .unwrap();
3152        let cost = session_cost_from_ledger(&ledger, "a");
3153        assert!((cost - 1.25).abs() < 0.001, "expected 1.25, got {cost}");
3154    }
3155
3156    #[test]
3157    fn session_cost_missing_ledger_returns_zero() {
3158        let cost = session_cost_from_ledger(Path::new("/nonexistent/l.json"), "s");
3159        assert_eq!(cost, 0.0);
3160    }
3161
3162    #[test]
3163    fn allow_output_serializes_correctly() {
3164        let json = allow_output(
3165            "allow",
3166            Some(TerminationReason::DonePRGreen),
3167            "done",
3168            3,
3169            Some("fp".into()),
3170        );
3171        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
3172        assert_eq!(v["decision"], "allow");
3173        // Verify variant names serialize byte-identically to the spec strings.
3174        assert_eq!(v["termination_reason"], "DonePRGreen");
3175        assert_eq!(v["fires"], 3);
3176        assert_eq!(v["fingerprint"], "fp");
3177    }
3178
3179    #[test]
3180    fn allow_output_null_termination_reason() {
3181        let json = allow_output("block", None, "continue", 1, None);
3182        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
3183        assert!(v["termination_reason"].is_null());
3184        assert!(v["fingerprint"].is_null());
3185    }
3186
3187    #[test]
3188    fn termination_reason_variant_names_byte_identical() {
3189        // Fix 6: all TerminationReason variants must serialize to the exact strings
3190        // the spec names - no rename attributes applied.
3191        let cases = [
3192            (TerminationReason::DonePRGreen, "DonePRGreen"),
3193            (TerminationReason::DoneAdvisory, "DoneAdvisory"),
3194            (TerminationReason::NoWork, "NoWork"),
3195            (TerminationReason::Budget, "Budget"),
3196            (TerminationReason::NoProgress, "NoProgress"),
3197            (TerminationReason::Interrupted, "Interrupted"),
3198            (TerminationReason::Aborted, "Aborted"),
3199        ];
3200        for (variant, expected) in cases {
3201            let json = serde_json::to_string(&variant).unwrap();
3202            // serde serializes enum unit variants as "\"VariantName\""
3203            assert_eq!(
3204                json,
3205                format!("\"{expected}\""),
3206                "variant {expected} serialized incorrectly"
3207            );
3208        }
3209    }
3210
3211    #[test]
3212    fn manifest_default_attended_is_true() {
3213        // Fix 7: manual Default impl must set attended=true (derive would give false)
3214        let m = Manifest::default();
3215        assert!(m.attended, "Manifest::default() must have attended=true");
3216        assert!(!m.advisory);
3217        assert!(!m.no_ship);
3218        assert!(!m.no_external);
3219        assert!(m.session_id.is_none());
3220        assert!(m.budget_cost_cap_usd.is_none());
3221        assert!(m.budget_wall_clock_cap_minutes.is_none());
3222    }
3223
3224    #[test]
3225    fn parse_manifest_malformed_cost_cap_fail_closed() {
3226        // Fix 2: a present but unparseable cost cap must be Err (fail-closed)
3227        let content =
3228            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_cost_cap_usd: 5.OO\n---\n";
3229        let m = parse_manifest(content).unwrap();
3230        assert!(
3231            matches!(m.budget_cost_cap_usd, Some(Err(_))),
3232            "malformed cost cap must be Some(Err(...))"
3233        );
3234    }
3235
3236    #[test]
3237    fn parse_manifest_malformed_wall_cap_fail_closed() {
3238        let content =
3239            "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: abc\n---\n";
3240        let m = parse_manifest(content).unwrap();
3241        assert!(
3242            matches!(m.budget_wall_clock_cap_minutes, Some(Err(_))),
3243            "malformed wall cap must be Some(Err(...))"
3244        );
3245    }
3246
3247    #[test]
3248    fn parse_settings_malformed_flat_cap_fail_closed() {
3249        let yaml = "budget_cap: not_a_number\n";
3250        let s = parse_settings(yaml);
3251        assert!(
3252            matches!(s.flat_budget_cap, Some(Err(_))),
3253            "malformed flat_budget_cap must be Some(Err(...))"
3254        );
3255    }
3256
3257    #[test]
3258    fn check_budget_malformed_cost_cap_trips_budget() {
3259        // Fix 2: malformed cap in manifest -> Budget termination (fail-closed)
3260        let m = Manifest {
3261            session_id: Some("s".into()),
3262            created_at: Some("2026-06-05T00:00:00Z".into()),
3263            budget_cost_cap_usd: Some(Err("5.OO".into())),
3264            ..Default::default()
3265        };
3266        let s = Settings::default();
3267        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3268        let tmp = tempfile::tempdir().unwrap();
3269        let ledger = tmp.path().join("ledger.json");
3270        std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":0.0}]"#).unwrap();
3271        assert_eq!(
3272            check_budget(&m, &s, &now, &ledger),
3273            Some(BudgetTrip::Cost),
3274            "malformed cost cap must fail closed"
3275        );
3276    }
3277
3278    #[test]
3279    fn check_budget_absent_cap_is_unlimited() {
3280        // ABSENT caps stay unlimited - must not trip
3281        let m = Manifest {
3282            session_id: Some("s".into()),
3283            created_at: Some("2026-06-05T00:00:00Z".into()),
3284            ..Default::default()
3285        };
3286        let s = Settings::default();
3287        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3288        let tmp = tempfile::tempdir().unwrap();
3289        let ledger = tmp.path().join("ledger.json");
3290        std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":9999.0}]"#).unwrap();
3291        assert_eq!(
3292            check_budget(&m, &s, &now, &ledger),
3293            None,
3294            "absent cap must be unlimited"
3295        );
3296    }
3297
3298    #[test]
3299    fn check_budget_negative_elapsed_no_trip() {
3300        // Fix 3: created_at in the future (clock skew) -> elapsed=0 -> no wall-clock trip
3301        let m = Manifest {
3302            session_id: Some("s".into()),
3303            // created_at is 1 hour in the future
3304            created_at: Some("2026-06-05T02:00:00Z".into()),
3305            budget_wall_clock_cap_minutes: Some(Ok(30)),
3306            ..Default::default()
3307        };
3308        let s = Settings::default();
3309        // now is earlier than created_at
3310        let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3311        let tmp = tempfile::tempdir().unwrap();
3312        let ledger = tmp.path().join("ledger.json");
3313        std::fs::write(&ledger, "[]").unwrap();
3314        assert_eq!(
3315            check_budget(&m, &s, &now, &ledger),
3316            None,
3317            "negative elapsed (future created_at) must not trip wall clock cap"
3318        );
3319    }
3320
3321    #[test]
3322    fn is_bot_reviewer_configured_short_names_match_real_logins() {
3323        // Fix 1: configured entries use substring matching.
3324        // "gemini" (short config name) must match "gemini-code-assist[bot]"
3325        // "codex" must match "chatgpt-codex-connector"
3326        let external = vec!["gemini".to_string(), "codex".to_string()];
3327        assert!(
3328            is_bot_reviewer("gemini-code-assist[bot]", &external),
3329            "gemini short name must substring-match gemini-code-assist[bot]"
3330        );
3331        assert!(
3332            is_bot_reviewer("chatgpt-codex-connector", &external),
3333            "codex short name must substring-match chatgpt-codex-connector"
3334        );
3335    }
3336
3337    #[test]
3338    fn is_bot_reviewer_configured_list_falls_back_to_bot_heuristic() {
3339        // Fix 1: when configured list has [some-human] but a bot review arrives,
3340        // fallback to endswith-[bot] heuristic so reviewed remains reachable.
3341        let external = vec!["some-human".to_string()];
3342        assert!(
3343            is_bot_reviewer("gemini-code-assist[bot]", &external),
3344            "configured list with no match must still fall back to [bot] heuristic"
3345        );
3346    }
3347
3348    #[test]
3349    fn is_bot_reviewer_empty_config_human_only_returns_false() {
3350        // Fix 1: empty config + human-only review -> false
3351        assert!(
3352            !is_bot_reviewer("alice-the-human", &[]),
3353            "human reviewer with empty config must return false"
3354        );
3355    }
3356
3357    // ── step 2: required_bots parsing + resolution (US1/US3) ────────────────
3358
3359    #[test]
3360    fn parse_settings_required_bots_block_list() {
3361        let yaml = "config:\n  review:\n    required_bots:\n      - chatgpt-codex-connector\n      - gemini-code-assist\n";
3362        let s = parse_settings(yaml);
3363        assert_eq!(
3364            s.required_bots,
3365            Some(vec![
3366                "chatgpt-codex-connector".to_string(),
3367                "gemini-code-assist".to_string()
3368            ])
3369        );
3370    }
3371
3372    #[test]
3373    fn parse_settings_required_bots_inline_empty_is_declared_empty() {
3374        // The explicit [] form is the ONLY way to declare the no-review-gate
3375        // path (US3, locked decision 2).
3376        let yaml = "config:\n  review:\n    required_bots: []\n";
3377        let s = parse_settings(yaml);
3378        assert_eq!(s.required_bots, Some(Vec::new()));
3379    }
3380
3381    #[test]
3382    fn parse_settings_required_bots_inline_list() {
3383        let yaml = "config:\n  review:\n    required_bots: [\"codex\", 'gemini']\n";
3384        let s = parse_settings(yaml);
3385        assert_eq!(
3386            s.required_bots,
3387            Some(vec!["codex".to_string(), "gemini".to_string()])
3388        );
3389    }
3390
3391    /// AC3-ERR: a non-list value fails closed to the code default (None).
3392    #[test]
3393    fn parse_settings_required_bots_scalar_malformed_defaults() {
3394        let yaml = "config:\n  review:\n    required_bots: gemini\n";
3395        let s = parse_settings(yaml);
3396        assert_eq!(s.required_bots, None, "scalar must fail closed to default");
3397    }
3398
3399    /// A bare `required_bots:` key with no items is malformed (YAML null, not
3400    /// []), so it must NOT accidentally disable the review gate.
3401    #[test]
3402    fn parse_settings_required_bots_bare_key_no_items_defaults() {
3403        let yaml = "config:\n  review:\n    required_bots:\n  ci:\n    declared_none: true\n";
3404        let s = parse_settings(yaml);
3405        assert_eq!(
3406            s.required_bots, None,
3407            "bare key must fail closed to default"
3408        );
3409        assert!(s.ci_declared_none, "following keys still parse");
3410    }
3411
3412    /// codex P2 on #448: YAML inline comments must not change the parsed
3413    /// value - `required_bots: []  # no review gate` is still the declared
3414    /// empty form, and commented list forms still parse.
3415    #[test]
3416    fn parse_settings_required_bots_inline_comments_stripped() {
3417        let empty = parse_settings("config:\n  review:\n    required_bots: []  # no review gate\n");
3418        assert_eq!(empty.required_bots, Some(Vec::new()));
3419
3420        let inline = parse_settings(
3421            "config:\n  review:\n    required_bots: [chatgpt-codex-connector] # required\n",
3422        );
3423        assert_eq!(
3424            inline.required_bots,
3425            Some(vec!["chatgpt-codex-connector".to_string()])
3426        );
3427
3428        let block = parse_settings(
3429            "config:\n  review:\n    required_bots: # the gate\n      - chatgpt-codex-connector # codex\n",
3430        );
3431        assert_eq!(
3432            block.required_bots,
3433            Some(vec!["chatgpt-codex-connector".to_string()])
3434        );
3435
3436        // A scalar with a comment is still malformed -> default.
3437        let scalar = parse_settings("config:\n  review:\n    required_bots: gemini # oops\n");
3438        assert_eq!(scalar.required_bots, None);
3439    }
3440
3441    #[test]
3442    fn parse_settings_required_bots_four_space_indent() {
3443        let yaml =
3444            "config:\n    review:\n        required_bots:\n            - chatgpt-codex-connector\n";
3445        let s = parse_settings(yaml);
3446        assert_eq!(
3447            s.required_bots,
3448            Some(vec!["chatgpt-codex-connector".to_string()])
3449        );
3450    }
3451
3452    #[test]
3453    fn resolved_required_bots_default_is_empty() {
3454        // Fresh-install default: no required review bot, so a clone with no
3455        // review configuration is not blocked waiting for a bot it never set up.
3456        let s = Settings::default();
3457        assert!(
3458            resolved_required_bots(&s).is_empty(),
3459            "absent required_bots config must resolve to no review gate"
3460        );
3461    }
3462
3463    #[test]
3464    fn resolved_required_bots_explicit_list_wins() {
3465        let s = Settings {
3466            required_bots: Some(vec!["my-bot".to_string()]),
3467            ..Default::default()
3468        };
3469        assert_eq!(resolved_required_bots(&s), vec!["my-bot".to_string()]);
3470        let empty = Settings {
3471            required_bots: Some(Vec::new()),
3472            ..Default::default()
3473        };
3474        assert!(resolved_required_bots(&empty).is_empty());
3475    }
3476
3477    #[test]
3478    fn login_matches_bot_cases() {
3479        // Full login, [bot]-suffixed login, and short config names all match.
3480        assert!(login_matches_bot(
3481            "chatgpt-codex-connector",
3482            "chatgpt-codex-connector"
3483        ));
3484        assert!(login_matches_bot(
3485            "chatgpt-codex-connector[bot]",
3486            "chatgpt-codex-connector"
3487        ));
3488        assert!(login_matches_bot("chatgpt-codex-connector", "codex"));
3489        assert!(login_matches_bot("Gemini-Code-Assist[bot]", "gemini"));
3490        assert!(!login_matches_bot("alice-the-human", "codex"));
3491        // Empty config entry must never match every login.
3492        assert!(!login_matches_bot("anyone", ""));
3493    }
3494
3495    #[test]
3496    fn compute_review_info_per_bot_verdict() {
3497        let required = vec![
3498            "chatgpt-codex-connector".to_string(),
3499            "gemini-code-assist".to_string(),
3500        ];
3501        // Only codex posted a completed pass (COMMENTED counts).
3502        let json = serde_json::json!({
3503            "reviews": [
3504                {"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
3505                 "submittedAt": "2026-06-05T01:00:00Z"}
3506            ],
3507            "comments": []
3508        });
3509        let info = compute_review_info(&json, &required);
3510        assert!(!info.all_required_passed());
3511        assert_eq!(info.missing_bots, vec!["gemini-code-assist".to_string()]);
3512        assert_eq!(info.latest_ts, "2026-06-05T01:00:00Z");
3513    }
3514
3515    #[test]
3516    fn compute_review_info_empty_state_not_a_pass() {
3517        // A review row with an empty state is not a completed pass.
3518        let required = vec!["chatgpt-codex-connector".to_string()];
3519        let json = serde_json::json!({
3520            "reviews": [
3521                {"author": {"login": "chatgpt-codex-connector"}, "state": "",
3522                 "submittedAt": "2026-06-05T01:00:00Z"}
3523            ],
3524            "comments": []
3525        });
3526        let info = compute_review_info(&json, &required);
3527        assert!(!info.all_required_passed());
3528    }
3529
3530    // ── step 2: inline findings + severity + addressed (US2) ────────────────
3531
3532    #[test]
3533    fn blocking_severity_codex_p1_both_forms() {
3534        // The exact markup codex emits (pinned from PR #447).
3535        assert_eq!(
3536            blocking_severity("![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat) Bug"),
3537            Some("P1")
3538        );
3539        // Alt-text only and URL only each match.
3540        assert_eq!(blocking_severity("![P1 Badge] something"), Some("P1"));
3541        assert_eq!(
3542            blocking_severity("see https://img.shields.io/badge/P1-orange"),
3543            Some("P1")
3544        );
3545    }
3546
3547    #[test]
3548    fn blocking_severity_codex_p2_p3_advisory() {
3549        assert_eq!(
3550            blocking_severity("![P2 Badge](https://img.shields.io/badge/P2-yellow) nit"),
3551            None
3552        );
3553        assert_eq!(
3554            blocking_severity("![P3 Badge](https://img.shields.io/badge/P3-green) nit"),
3555            None
3556        );
3557    }
3558
3559    #[test]
3560    fn blocking_severity_gemini_critical_high_blocking() {
3561        assert_eq!(
3562            blocking_severity(
3563                "![critical](https://www.gstatic.com/codereviewagent/critical-priority.svg) bad"
3564            ),
3565            Some("critical")
3566        );
3567        assert_eq!(
3568            blocking_severity(
3569                "![high](https://www.gstatic.com/codereviewagent/high-priority.svg) bad"
3570            ),
3571            Some("high")
3572        );
3573    }
3574
3575    #[test]
3576    fn blocking_severity_gemini_medium_low_advisory() {
3577        assert_eq!(
3578            blocking_severity(
3579                "![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) hmm"
3580            ),
3581            None
3582        );
3583        assert_eq!(
3584            blocking_severity(
3585                "![low](https://www.gstatic.com/codereviewagent/low-priority.svg) hmm"
3586            ),
3587            None
3588        );
3589    }
3590
3591    /// Boundaries: unrecognized / absent severity tokens classify advisory,
3592    /// never blocking (locked decision 4).
3593    #[test]
3594    fn blocking_severity_unparseable_is_advisory() {
3595        assert_eq!(blocking_severity("just a comment with no badge"), None);
3596        assert_eq!(blocking_severity(""), None);
3597        assert_eq!(blocking_severity("P1 mentioned in prose only"), None);
3598    }
3599
3600    #[test]
3601    fn max_ts_none_handling() {
3602        assert_eq!(
3603            max_ts("none", "2026-06-05T01:00:00Z"),
3604            "2026-06-05T01:00:00Z"
3605        );
3606        assert_eq!(
3607            max_ts("2026-06-05T01:00:00Z", "none"),
3608            "2026-06-05T01:00:00Z"
3609        );
3610        assert_eq!(max_ts("none", "none"), "none");
3611        assert_eq!(max_ts("", ""), "none");
3612        assert_eq!(
3613            max_ts("2026-06-05T01:00:00Z", "2026-06-05T02:00:00Z"),
3614            "2026-06-05T02:00:00Z"
3615        );
3616    }
3617
3618    fn finding_comment(id: i64, body: &str, created_at: &str) -> Value {
3619        serde_json::json!({
3620            "id": id,
3621            "in_reply_to_id": null,
3622            "user": {"login": "chatgpt-codex-connector[bot]"},
3623            "body": body,
3624            "path": "src/x.rs",
3625            "line": 42,
3626            "created_at": created_at
3627        })
3628    }
3629
3630    fn reply_comment(id: i64, parent: i64, login: &str, body: &str, created_at: &str) -> Value {
3631        serde_json::json!({
3632            "id": id,
3633            "in_reply_to_id": parent,
3634            "user": {"login": login},
3635            "body": body,
3636            "created_at": created_at
3637        })
3638    }
3639
3640    const REQ: &[&str] = &["chatgpt-codex-connector"];
3641
3642    fn req_vec() -> Vec<String> {
3643        REQ.iter().map(|s| s.to_string()).collect()
3644    }
3645
3646    /// AC2-ERR core: a P1 with no reply is unaddressed.
3647    #[test]
3648    fn finding_no_reply_is_unaddressed() {
3649        let comments = vec![finding_comment(
3650            100,
3651            "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3652            "2026-06-05T01:10:00Z",
3653        )];
3654        let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3655        assert_eq!(ts, "2026-06-05T01:10:00Z");
3656        assert_eq!(unaddressed.len(), 1);
3657        assert_eq!(unaddressed[0].path, "src/x.rs");
3658        assert_eq!(unaddressed[0].line, 42);
3659        assert_eq!(unaddressed[0].severity, "P1");
3660    }
3661
3662    /// AC2-HP commit arm: non-bot reply + commit after the finding -> addressed.
3663    #[test]
3664    fn finding_reply_plus_commit_after_is_addressed() {
3665        let comments = vec![
3666            finding_comment(
3667                100,
3668                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3669                "2026-06-05T01:10:00Z",
3670            ),
3671            reply_comment(
3672                101,
3673                100,
3674                "bllshttng",
3675                "fixed in abc123",
3676                "2026-06-05T01:20:00Z",
3677            ),
3678        ];
3679        let commits = vec!["2026-06-05T01:30:00Z".to_string()];
3680        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3681        assert!(unaddressed.is_empty(), "commit-after arm must address");
3682    }
3683
3684    /// AC2-FR wontfix arm: non-bot reply carrying wontfix:, NO commit after.
3685    #[test]
3686    fn finding_wontfix_reply_is_addressed_without_commit() {
3687        let comments = vec![
3688            finding_comment(
3689                100,
3690                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3691                "2026-06-05T01:10:00Z",
3692            ),
3693            reply_comment(
3694                101,
3695                100,
3696                "bllshttng",
3697                "wontfix: intentional - documented tradeoff",
3698                "2026-06-05T01:20:00Z",
3699            ),
3700        ];
3701        // Only commit predates the finding -> commit arm unsatisfied.
3702        let commits = vec!["2026-06-05T01:00:00Z".to_string()];
3703        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3704        assert!(unaddressed.is_empty(), "wontfix arm must address alone");
3705    }
3706
3707    /// Anti-gaming: a commit alone (no reply) does NOT address (locked
3708    /// decision 3 - any unrelated commit would silently clear a P1).
3709    #[test]
3710    fn finding_commit_without_reply_is_unaddressed() {
3711        let comments = vec![finding_comment(
3712            100,
3713            "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3714            "2026-06-05T01:10:00Z",
3715        )];
3716        let commits = vec!["2026-06-05T01:30:00Z".to_string()];
3717        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3718        assert_eq!(unaddressed.len(), 1, "commit alone must not address");
3719    }
3720
3721    /// A bot's own reply in the thread is not an ack.
3722    #[test]
3723    fn finding_bot_reply_only_is_unaddressed() {
3724        let comments = vec![
3725            finding_comment(
3726                100,
3727                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3728                "2026-06-05T01:10:00Z",
3729            ),
3730            reply_comment(
3731                101,
3732                100,
3733                "chatgpt-codex-connector[bot]",
3734                "elaborating on my finding",
3735                "2026-06-05T01:15:00Z",
3736            ),
3737        ];
3738        let commits = vec!["2026-06-05T01:30:00Z".to_string()];
3739        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3740        assert_eq!(unaddressed.len(), 1, "bot self-reply must not count as ack");
3741    }
3742
3743    /// Reply present but neither commit-after nor wontfix -> still unaddressed.
3744    #[test]
3745    fn finding_reply_without_commit_or_wontfix_is_unaddressed() {
3746        let comments = vec![
3747            finding_comment(
3748                100,
3749                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3750                "2026-06-05T01:10:00Z",
3751            ),
3752            reply_comment(
3753                101,
3754                100,
3755                "bllshttng",
3756                "looking into it",
3757                "2026-06-05T01:20:00Z",
3758            ),
3759        ];
3760        let commits = vec!["2026-06-05T01:00:00Z".to_string()]; // predates finding
3761        let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3762        assert_eq!(unaddressed.len(), 1);
3763    }
3764
3765    /// A finding from a NON-required bot does not gate.
3766    #[test]
3767    fn finding_from_non_required_bot_ignored() {
3768        let comments = vec![serde_json::json!({
3769            "id": 200,
3770            "in_reply_to_id": null,
3771            "user": {"login": "gemini-code-assist[bot]"},
3772            "body": "![high](https://www.gstatic.com/codereviewagent/high-priority.svg) eh",
3773            "path": "src/y.rs",
3774            "line": 7,
3775            "created_at": "2026-06-05T01:10:00Z"
3776        })];
3777        // required = codex only; gemini finding is not gate-relevant
3778        let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3779        assert!(unaddressed.is_empty());
3780        // ...but its timestamp still feeds the fingerprint.
3781        assert_eq!(ts, "2026-06-05T01:10:00Z");
3782    }
3783
3784    /// Boundaries: empty comments array -> no findings, ts "none".
3785    #[test]
3786    fn empty_comments_no_findings() {
3787        let (ts, unaddressed) = compute_unaddressed_findings(&[], &[], &req_vec(), &[]);
3788        assert_eq!(ts, "none");
3789        assert!(unaddressed.is_empty());
3790    }
3791
3792    /// sigma-review: a blocking finding row with a missing id is SKIPPED
3793    /// (under-block per locked decision 4), never pooled on a default id
3794    /// where one stray reply could clear multiple findings.
3795    #[test]
3796    fn finding_missing_id_skipped_not_pooled() {
3797        let no_id = serde_json::json!({
3798            "in_reply_to_id": null,
3799            "user": {"login": "chatgpt-codex-connector[bot]"},
3800            "body": "![P1 Badge](https://img.shields.io/badge/P1-orange) idless",
3801            "path": "src/z.rs", "line": 3,
3802            "created_at": "2026-06-05T01:05:00Z"
3803        });
3804        let real = finding_comment(
3805            100,
3806            "![P1 Badge](https://img.shields.io/badge/P1-orange) real",
3807            "2026-06-05T01:10:00Z",
3808        );
3809        // A stray reply keyed to id 0 must not ack anything.
3810        let stray = reply_comment(
3811            101,
3812            0,
3813            "bllshttng",
3814            "wontfix: stray",
3815            "2026-06-05T01:20:00Z",
3816        );
3817        let comments = vec![no_id, real, stray];
3818        let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3819        assert_eq!(unaddressed.len(), 1, "only the real finding remains");
3820        assert_eq!(unaddressed[0].id, 100);
3821    }
3822
3823    /// sigma-review: commit-after comparison parses timestamps instead of
3824    /// string-comparing - an offset-suffixed commit date that lexicographically
3825    /// sorts above a Zulu finding date but is EARLIER in UTC must not clear
3826    /// the finding.
3827    #[test]
3828    fn ts_after_parses_offsets_correctly() {
3829        // 23:30+13:00 == 10:30Z, which is BEFORE 11:00Z - but the raw string
3830        // "2026-06-05T23:30:00+13:00" > "2026-06-05T11:00:00Z".
3831        assert!(!ts_after(
3832            "2026-06-05T23:30:00+13:00",
3833            "2026-06-05T11:00:00Z"
3834        ));
3835        // POSITIVE direction proves chrono's FromStr for DateTime<Utc>
3836        // parses offset-suffixed RFC3339 and converts to UTC (gemini's
3837        // #448 critical claimed it errors; empirically it returns
3838        // Ok(2026-06-05T13:30:00Z) here). Without this assertion the
3839        // offset case above could pass vacuously via the Err arm.
3840        assert!(ts_after(
3841            "2026-06-05T23:30:00+10:00", // == 13:30Z
3842            "2026-06-05T11:00:00Z"
3843        ));
3844        assert!(ts_after("2026-06-05T11:00:01Z", "2026-06-05T11:00:00Z"));
3845        assert!(!ts_after("2026-06-05T11:00:00Z", "2026-06-05T11:00:00Z"));
3846        // Unparseable on either side never clears a finding.
3847        assert!(!ts_after("garbage", "2026-06-05T11:00:00Z"));
3848        assert!(!ts_after("2026-06-05T11:00:00Z", "garbage"));
3849        assert!(!ts_after("2026-06-05T11:00:00Z", ""));
3850    }
3851
3852    /// gemini high on #448: max_ts compares chronologically when both sides
3853    /// parse, returning the original string either way (byte-stable
3854    /// fingerprint).
3855    #[test]
3856    fn max_ts_chronological_with_offsets() {
3857        // +13:00 form is EARLIER in UTC despite sorting higher as a string.
3858        assert_eq!(
3859            max_ts("2026-06-05T23:30:00+13:00", "2026-06-05T11:00:00Z"),
3860            "2026-06-05T11:00:00Z"
3861        );
3862        // The winner is returned verbatim.
3863        assert_eq!(
3864            max_ts("2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"),
3865            "2026-06-05T23:30:00+10:00"
3866        );
3867    }
3868
3869    /// Concurrency (Failure Modes): a reply arriving BEFORE its parent
3870    /// finding in the comments array (REST ordering is not guaranteed across
3871    /// pagination) still acks the finding - no order dependence.
3872    #[test]
3873    fn finding_reply_listed_before_finding_still_addressed() {
3874        let comments = vec![
3875            reply_comment(
3876                101,
3877                100,
3878                "bllshttng",
3879                "wontfix: ordering test",
3880                "2026-06-05T01:20:00Z",
3881            ),
3882            finding_comment(
3883                100,
3884                "![P1 Badge](https://img.shields.io/badge/P1-orange) bug",
3885                "2026-06-05T01:10:00Z",
3886            ),
3887        ];
3888        let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3889        assert!(
3890            unaddressed.is_empty(),
3891            "reply-before-finding ordering must still ack"
3892        );
3893    }
3894
3895    // ── step 2: outage vs no-PR discrimination (US4) ─────────────────────────
3896
3897    #[test]
3898    fn no_pr_stderr_detected() {
3899        assert!(is_no_pr_stderr(
3900            b"no pull requests found for branch \"feat\""
3901        ));
3902        assert!(is_no_pr_stderr(b"No pull requests found for branch \"x\""));
3903        // Outage shapes are NOT no-PR.
3904        assert!(!is_no_pr_stderr(b"connect: network is unreachable"));
3905        assert!(!is_no_pr_stderr(b"API rate limit exceeded"));
3906        assert!(!is_no_pr_stderr(b""));
3907    }
3908}