Skip to main content

devflow_core/
agent_result.rs

1//! Agent completion detection — parses DEVFLOW_RESULT markers and evaluates
2//! exit codes to determine whether a coding agent succeeded or failed.
3//!
4//! Four-layer decision engine:
5//! 0. Run operator-authored external post-condition probes (authoritative failure)
6//! 1. Parse DEVFLOW_RESULT from agent stdout (authoritative for ordinary plans)
7//! 2. Exit code + commit count gate (reliable fallback)
8//! 3. Process gone + commits exist (last resort warning)
9
10use crate::config::GitFlowConfig;
11use crate::stage::Stage;
12use crate::state::State;
13use std::path::{Path, PathBuf};
14
15/// Parsed agent completion result.
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct AgentResult {
18    pub status: AgentStatus,
19    pub exit_code: Option<i32>,
20    pub reason: Option<String>,
21    pub commits: Option<u32>,
22    pub summary: Option<String>,
23    /// The Validate stage's self-reported verdict — distinct from `status`.
24    /// `status` reports whether the stage's task (running `/gsd-validate-phase`)
25    /// completed; `verdict` reports whether validation ITSELF passed. Only
26    /// `Some(Verdict::Pass)` should advance Validate to Ship; `Some(Verdict::Gaps)`
27    /// and `None` both gate/loop back to Code (see `advance()`'s Validate arm).
28    /// Ignored entirely for non-Validate stages.
29    ///
30    /// Deserialized leniently via [`deserialize_verdict_lenient`]: an absent,
31    /// unknown, or mis-cased value becomes `None` rather than failing the
32    /// whole `AgentResult` parse (T-13-14) — a malformed verdict must never
33    /// silently drop a valid `status` to Layer 2.
34    #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
35    pub verdict: Option<Verdict>,
36}
37
38/// Agent completion status determined by DevFlow.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum AgentStatus {
42    /// Agent self-reported success via DEVFLOW_RESULT.
43    Success,
44    /// Agent self-reported failure, or exit code + commit gate indicated failure.
45    Failed,
46    /// Agent stopped because an upstream API or usage quota rate-limited it.
47    RateLimited,
48    /// No signal received — fallback to exit code / commit heuristic.
49    Unknown,
50}
51
52/// The Validate stage's self-reported verdict (13b verdict-vs-ran split).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Verdict {
56    /// Validation found no gaps — ready to advance to Ship.
57    Pass,
58    /// Validation found gaps that still need fixing — must loop back to Code
59    /// (or gate, depending on the consecutive-failure threshold).
60    Gaps,
61}
62
63/// Deserialize `verdict` leniently: an absent, unknown, or mis-cased value
64/// (e.g. `"wat"`, `"Pass"`) becomes `Ok(None)` rather than an error, so a
65/// malformed verdict never fails the whole `from_str::<AgentResult>` parse
66/// and silently drops a valid `status` to Layer 2 (T-13-14, consensus #5).
67///
68/// Matching is intentionally exact-case (only the wire-format lowercase
69/// strings `"pass"`/`"gaps"` are accepted) — a mis-cased value like `"Pass"`
70/// is NOT case-folded into a match; it is treated the same as an unknown
71/// value and maps to `None`, so a subtly wrong-case verdict fails safe
72/// (gate/loop) instead of silently passing.
73///
74/// WR-09 (13-REVIEW.md): decodes as `serde_json::Value` first, then only
75/// pattern-matches the string case — a non-string JSON type (`true`, `123`,
76/// an object) is a wrong *type*, not a malformed string value, and must
77/// still fall through to `None` rather than erroring out the entire
78/// `AgentResult` parse (the same guarantee this deserializer already gives
79/// mis-cased/unknown string values).
80fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
81where
82    D: serde::Deserializer<'de>,
83{
84    let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
85    Ok(raw.and_then(|v| {
86        v.as_str().and_then(|s| match s {
87            "pass" => Some(Verdict::Pass),
88            "gaps" => Some(Verdict::Gaps),
89            _ => None,
90        })
91    }))
92}
93
94/// Errors produced by agent result evaluation.
95#[derive(Debug, thiserror::Error)]
96pub enum ResultError {
97    #[error("I/O error reading agent output: {0}")]
98    Io(#[from] std::io::Error),
99    #[error("phase directory not found")]
100    NoPhaseDir,
101}
102
103/// Search stdout for a DEVFLOW_RESULT marker.
104///
105/// The marker is a single line starting with `DEVFLOW_RESULT:` followed by
106/// a JSON object with at minimum a `status` field. Matching is case-insensitive.
107///
108/// When an agent is run with `--output-format json` (e.g. Claude), its final
109/// message is wrapped in a JSON result envelope with the text — and its
110/// embedded newlines — escaped inside a `result` field. In that case the
111/// marker never appears at the start of a line, so we first unwrap the
112/// envelope and search the inner text.
113pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
114    if let Some(inner) = extract_json_result_text(stdout)
115        && let Some(result) = parse_marker_lines(&inner)
116    {
117        return Some(result);
118    }
119    parse_marker_lines(stdout)
120}
121
122/// Detect agent-specific rate-limit output and return the retry description.
123///
124/// Claude can emit a JSON result envelope when run with `--output-format json`;
125/// Codex commonly emits plain text such as "Try again at ...". This function is
126/// intentionally conservative so ordinary progress text does not become a
127/// false positive.
128pub fn detect_rate_limit(stdout: &str) -> Option<String> {
129    detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
130}
131
132fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
133    let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
134    let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
135        || json_has_i64(&value, "api_error_status", 429)
136        || json_has_i64(&value, "status", 429)
137        || json_has_i64(&value, "status_code", 429);
138    if !rate_limited {
139        return None;
140    }
141    json_find_key(&value, "retry_after")
142        .and_then(json_scalar_to_string)
143        .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
144        .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
145        .or_else(|| Some("usage limit".to_string()))
146}
147
148fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
149    // This heuristic exists for Codex's PLAIN-TEXT output. JSONL event lines
150    // are authoritative and handled by parse_codex_event_result — scanning
151    // them here false-positives on document content echoed into events
152    // (13-06 dogfood finding: GSD reference tables mentioning "rate limiting"
153    // were read by the agent, echoed into an `item.completed` payload, and
154    // this scan returned that entire multi-KB line as the "retry time").
155    let stdout: String = stdout
156        .lines()
157        .filter(|line| {
158            serde_json::from_str::<serde_json::Value>(line)
159                .map(|v| !v.is_object())
160                .unwrap_or(true)
161        })
162        .collect::<Vec<_>>()
163        .join("\n");
164    let stdout = stdout.as_str();
165    let lower = stdout.to_ascii_lowercase();
166    if let Some(idx) = lower.find("try again at ") {
167        let start = idx + "try again at ".len();
168        let retry = stdout[start..]
169            .lines()
170            .next()
171            .unwrap_or_default()
172            .trim()
173            .trim_end_matches(['.', ',', ';'])
174            .trim();
175        if !retry.is_empty() {
176            return Some(retry.to_string());
177        }
178    }
179
180    if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
181        stdout
182            .lines()
183            .find(|line| {
184                let line = line.to_ascii_lowercase();
185                line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
186            })
187            .map(str::trim)
188            .filter(|line| !line.is_empty())
189            .map(str::to_string)
190            .or_else(|| Some("usage limit".to_string()))
191    } else {
192        None
193    }
194}
195
196/// If `stdout` is a JSON result envelope, return the decoded `result` text
197/// field (with escapes such as `\n` resolved). Returns `None` for plain text.
198fn extract_json_result_text(stdout: &str) -> Option<String> {
199    let trimmed = stdout.trim();
200    if !trimmed.starts_with('{') {
201        return None;
202    }
203    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
204    value.get("result")?.as_str().map(str::to_string)
205}
206
207// WR-12 (13-REVIEW.md), revised: these traversal helpers run on the coding
208// agent's raw stdout (via detect_claude_rate_limit, which every `devflow
209// advance` invocation runs through evaluate_layer1), so deeply nested JSON —
210// accidental or adversarial — must not stack-overflow the process. The
211// traversal is iterative (an explicit worklist), so nesting depth never
212// consumes call stack and no depth cap is needed. The first WR-12 fix capped
213// recursion at 64, which silently missed keys at depths 64–128 — nesting
214// serde_json's default 128-level parse recursion limit (the only producer of
215// these `Value`s) accepts just fine.
216
217/// Depth-first pre-order scan over every JSON object in `value`, returning
218/// the first `Some` produced by `visit` on an object's map.
219fn json_scan<'a, T>(
220    value: &'a serde_json::Value,
221    visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
222) -> Option<T> {
223    let mut stack = vec![value];
224    while let Some(current) = stack.pop() {
225        match current {
226            serde_json::Value::Object(map) => {
227                if let Some(found) = visit(map) {
228                    return Some(found);
229                }
230                // Push in reverse so pop order preserves document order.
231                for child in map.values().rev() {
232                    stack.push(child);
233                }
234            }
235            serde_json::Value::Array(values) => {
236                for child in values.iter().rev() {
237                    stack.push(child);
238                }
239            }
240            _ => {}
241        }
242    }
243    None
244}
245
246fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
247    json_scan(value, |map| {
248        (map.get(key)?.as_str()? == expected).then_some(())
249    })
250    .is_some()
251}
252
253fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
254    json_scan(value, |map| {
255        (map.get(key)?.as_i64()? == expected).then_some(())
256    })
257    .is_some()
258}
259
260fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
261    json_scan(value, |map| map.get(key))
262}
263
264fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
265    match value {
266        serde_json::Value::String(s) => Some(s.clone()),
267        serde_json::Value::Number(n) => Some(n.to_string()),
268        _ => None,
269    }
270}
271
272/// Read the top-level `is_error` boolean (and, if present, `num_turns`) from
273/// a Claude JSON result envelope (`--output-format json`) and treat
274/// `is_error: true` as an authoritative Layer-1 failure.
275///
276/// This is checked BEFORE the `DEVFLOW_RESULT` marker path in
277/// [`evaluate_layer1`], so `is_error: true` OVERRIDES a stale/echoed success
278/// marker embedded in the same envelope's `result` text — the envelope is
279/// authoritative for errors. `is_error` absent or `false` returns `None`,
280/// deferring to the marker path and, ultimately, Layer 2. It runs AFTER
281/// `detect_claude_rate_limit`, though: rate-limit envelopes also carry
282/// `is_error: true`, and the specific `RateLimited` classification (which
283/// drives sequentagent's handoff and the resume cron) must win over this
284/// generic `Failed`.
285///
286/// Per RESEARCH Pitfall 5, `is_error` (not specific `subtype` strings) is
287/// the documented, stable signal — this does not special-case non-success
288/// subtype values beyond what already exists in `detect_claude_rate_limit`.
289fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
290    let trimmed = stdout.trim();
291    if !trimmed.starts_with('{') {
292        return None;
293    }
294    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
295    let is_error = value.get("is_error")?.as_bool()?;
296    if !is_error {
297        return None;
298    }
299
300    let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
301    let base_reason = value
302        .get("result")
303        .and_then(serde_json::Value::as_str)
304        .map(str::to_string)
305        .or_else(|| {
306            value
307                .get("subtype")
308                .and_then(serde_json::Value::as_str)
309                .map(str::to_string)
310        })
311        .unwrap_or_else(|| "agent reported is_error".to_string());
312    let reason = match num_turns {
313        Some(n) => format!("{base_reason} (num_turns: {n})"),
314        None => base_reason,
315    };
316
317    Some(AgentResult {
318        status: AgentStatus::Failed,
319        exit_code: None,
320        reason: Some(reason),
321        commits: None,
322        summary: None,
323        verdict: None,
324    })
325}
326
327/// Determine whether a set of parsed JSONL lines look like a Codex `--json`
328/// event stream (as opposed to a single-document Claude envelope or plain
329/// text) — i.e. at least one line is a `thread.started` or `turn.*` event.
330fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
331    events.iter().any(|v| {
332        v.get("type")
333            .and_then(serde_json::Value::as_str)
334            .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
335    })
336}
337
338/// Parse a Codex `--json` JSONL event stream (one JSON object per line) and
339/// look at the LAST terminal event (`turn.completed` / `turn.failed`).
340///
341/// Only decisive when the captured stdout is actually a Codex event stream
342/// (per [`is_codex_event_stream`]) — a single-document Claude envelope
343/// (`type: "result"`, no `turn.*` lines) is not consumed here and returns
344/// `None`, so the Claude envelope/marker paths handle it instead.
345///
346/// `turn.failed` is decisive: returns `AgentStatus::Failed` with `reason`
347/// from `error.message`. A final `turn.completed` with no `DEVFLOW_RESULT`
348/// marker returns `None` (defers to Layer 2) rather than an unconditional
349/// Success — a marker-less turn must not silently advance a stage (this is
350/// the composition fix that keeps a marker-less Validate run from
351/// false-passing to Ship).
352///
353/// NOTE: written against the documented `--json` event schema (thread.started
354/// / turn.started / item.* / turn.completed with usage / turn.failed with
355/// error.message) but not yet verified against the installed Codex CLI
356/// version — the 13-06 dogfood run captures real output and reconciles any
357/// delta, the same empirical practice 12-12-SUMMARY.md used for Claude.
358fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
359    let events: Vec<serde_json::Value> = stdout
360        .lines()
361        .filter(|line| !line.trim().is_empty())
362        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
363        .collect();
364
365    if !is_codex_event_stream(&events) {
366        return None;
367    }
368
369    // Codex delivers the agent's DEVFLOW_RESULT self-report inside an
370    // `agent_message` item's `text` — never as a raw stdout line — so the
371    // top-level marker scan cannot see it (13-06 dogfood finding: a Codex
372    // `DEVFLOW_RESULT: failed` was invisible and the run fell through to
373    // heuristics). The decoded `text` is a plain marker line; reuse the
374    // marker parser on it. Last marker wins, matching parse_marker_lines.
375    let marker = events.iter().rev().find_map(|v| {
376        if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
377            return None;
378        }
379        let item = v.get("item")?;
380        if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
381            return None;
382        }
383        let text = item.get("text").and_then(serde_json::Value::as_str)?;
384        parse_marker_lines(text)
385    });
386    if marker.is_some() {
387        return marker;
388    }
389
390    let terminal = events.iter().rev().find(|v| {
391        matches!(
392            v.get("type").and_then(serde_json::Value::as_str),
393            Some("turn.completed") | Some("turn.failed")
394        )
395    })?;
396
397    if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
398        // turn.completed (or any other terminal we don't recognize) defers
399        // to Layer 2 rather than an unconditional Success.
400        return None;
401    }
402
403    let reason = terminal
404        .get("error")
405        .and_then(|e| e.get("message"))
406        .and_then(serde_json::Value::as_str)
407        .map(str::to_string)
408        .unwrap_or_else(|| "codex turn failed".to_string());
409
410    Some(AgentResult {
411        status: AgentStatus::Failed,
412        exit_code: None,
413        reason: Some(reason),
414        commits: None,
415        summary: None,
416        verdict: None,
417    })
418}
419
420/// Scan the last ~4000 characters of `stdout` in reverse line order.
421///
422/// `DEVFLOW_RESULT` markers are ASCII. Searching the bounded tail and returning
423/// the last valid marker ensures the agent's final status wins over an earlier
424/// prompt echo without requiring the surrounding output to be ASCII.
425fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
426    // Only search the tail — agents may echo the marker in their prompt
427    // and we want the LAST occurrence (which is their actual final status).
428    let tail: String = stdout
429        .chars()
430        .rev()
431        .take(4000)
432        .collect::<Vec<_>>()
433        .into_iter()
434        .rev()
435        .collect();
436
437    for line in tail.lines().rev() {
438        let Some(json_str) = line
439            .strip_prefix("DEVFLOW_RESULT: ")
440            .or_else(|| line.strip_prefix("devflow_result: "))
441            .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
442            .or_else(|| line.strip_prefix("devflow_result:"))
443        else {
444            continue;
445        };
446
447        let json_str = json_str.trim();
448        if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
449            return Some(result);
450        }
451    }
452    None
453}
454
455/// Layer 1: Try to detect agent result from the native per-adapter envelope
456/// or the DEVFLOW_RESULT marker in stdout.
457///
458/// Precedence: Claude rate-limit envelope (a SPECIFIC failure that must
459/// outrank the generic `is_error` check — rate-limit envelopes carry
460/// `is_error: true`, and classifying them `Failed` would kill sequentagent's
461/// handoff/cron path) → Claude envelope `is_error: true` (authoritative,
462/// overrides a success marker) → DEVFLOW_RESULT marker (portable; works for
463/// plain text and a Claude envelope's unwrapped `result` text) → Codex JSONL
464/// event stream (`turn.failed` decisive; `turn.completed` defers) → Codex
465/// plain-text rate-limit heuristic (least authoritative, stays last).
466pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
467    let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
468    // Read lossily: in monitor mode the agent's stdout reaches this file via
469    // raw sh redirection, so one invalid UTF-8 byte in a strict
470    // read_to_string would silently disable ALL Layer-1 detection (marker,
471    // envelope, rate limit) — the same failure class CR-01 (13-REVIEW.md)
472    // fixed in the blocking-mode capture.
473    let bytes = std::fs::read(&stdout_path).ok()?;
474    let stdout = String::from_utf8_lossy(&bytes);
475    detect_claude_rate_limit(&stdout)
476        .map(rate_limited_result)
477        .or_else(|| detect_claude_envelope_failure(&stdout))
478        .or_else(|| parse_devflow_result(&stdout))
479        .or_else(|| parse_codex_event_result(&stdout))
480        .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
481}
482
483/// Build the `RateLimited` result Layer 1 reports for a detected retry hint.
484fn rate_limited_result(retry: String) -> AgentResult {
485    AgentResult {
486        status: AgentStatus::RateLimited,
487        exit_code: None,
488        reason: Some(format!("rate limited until {retry}")),
489        commits: None,
490        summary: None,
491        verdict: None,
492    }
493}
494
495/// Layer 2: Use exit code + commit count to determine result.
496///
497/// Reads exit code from `.devflow/phase-NN-exit` file.
498/// Counts commits in `feature/phase-NN` branch (if it exists).
499///
500/// The commit-count gate ("no commits → failed") is scoped to `stage` — it
501/// only applies to `Stage::Plan`/`Stage::Code` (checked via an explicit
502/// `matches!`, NOT `Stage::is_agent_stage()`, since that also includes
503/// `Define`, which legitimately produces zero commits). `exit≠0` is ALWAYS
504/// `Failed`, for every stage — only the `exit=0`/zero-commits branch is
505/// stage-scoped.
506///
507/// Decision matrix:
508///   exit≠0                                              → Failed (ALL stages)
509///   exit=0, stage in {Plan, Code}, commits=0             → Failed ("no work done")
510///   exit=0, stage in {Plan, Code}, commits>0             → Success
511///   exit=0, stage NOT in {Plan, Code} (Define/Validate/Ship), commits=0 → Success
512///           (not commit-gated; Validate's real pass signal is its verdict,
513///           not a bare zero-commit — see Task 2's turn.completed deferral)
514///   exit unknown                                         → fall to Layer 3 (return None)
515///
516/// WR-06 (13-REVIEW.md): takes only the explicit `project_root` parameter
517/// for both the `.devflow/` file paths and the git subprocess `current_dir`
518/// — previously it also accepted `state: &State` and used `state.project_root`
519/// for the git calls, which every caller happened to pass consistently with
520/// `project_root` but which the function itself had no way to enforce.
521pub fn evaluate_layer2(
522    project_root: &Path,
523    phase: u32,
524    git_flow: &GitFlowConfig,
525    stage: Stage,
526) -> Result<Option<AgentResult>, ResultError> {
527    let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
528    let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
529        Ok(s) => s.trim().parse().unwrap_or(-1),
530        Err(_) => return Ok(None), // fall to Layer 3
531    };
532
533    let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
534
535    // Verify branch exists before counting commits.
536    let branch_exists = std::process::Command::new("git")
537        .args(["rev-parse", "--verify", &branch])
538        .current_dir(project_root)
539        .output()
540        .map(|o| o.status.success())
541        .unwrap_or(false);
542
543    let commits: u32 = if branch_exists {
544        let range = format!("{}..{branch}", git_flow.develop);
545        std::process::Command::new("git")
546            .args(["rev-list", "--count", &range])
547            .current_dir(project_root)
548            .output()
549            .ok()
550            .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
551            .unwrap_or(0)
552    } else {
553        0
554    };
555
556    let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
557    let no_work_done = commit_gated && commits == 0;
558
559    Ok(Some(AgentResult {
560        status: if exit_code != 0 || no_work_done {
561            AgentStatus::Failed
562        } else {
563            AgentStatus::Success
564        },
565        exit_code: Some(exit_code),
566        reason: if exit_code != 0 {
567            Some(format!(
568                "agent exited with code {} ({} commits on {})",
569                exit_code, commits, branch
570            ))
571        } else if no_work_done {
572            Some(format!(
573                "no commits found on {} (agent exit code was {})",
574                branch, exit_code
575            ))
576        } else {
577            Some(format!(
578                "{} commits on {} (agent exit code was {})",
579                commits, branch, exit_code
580            ))
581        },
582        commits: Some(commits),
583        summary: None,
584        verdict: None,
585    }))
586}
587
588/// Layer 3: Last resort — agent process is gone, commits exist.
589///
590/// Returns Unknown status with a warning. This only fires when
591/// neither Layer 1 nor Layer 2 produced a definitive result.
592pub fn evaluate_layer3(
593    project_root: &Path,
594    phase: u32,
595    git_flow: &GitFlowConfig,
596) -> Result<AgentResult, ResultError> {
597    let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
598    let commits = std::process::Command::new("git")
599        .args([
600            "rev-list",
601            "--count",
602            &format!("{}..{branch}", git_flow.develop),
603        ])
604        .current_dir(project_root)
605        .output()
606        .ok()
607        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
608        .unwrap_or(0);
609
610    Ok(AgentResult {
611        status: AgentStatus::Unknown,
612        exit_code: None,
613        reason: if commits > 0 {
614            Some(format!(
615                "unverified — agent process is gone but {} commits exist on {}",
616                commits, branch
617            ))
618        } else {
619            Some("no work detected — agent process is gone with no commits".into())
620        },
621        commits: Some(commits),
622        summary: None,
623        verdict: None,
624    })
625}
626
627/// Layer 0: run explicitly operator-approved external post-condition probes.
628///
629/// A failed probe outranks every agent-controlled signal. Successful probes
630/// defer to the existing Layer 1/2/3 cascade so ordinary completion evidence
631/// is still required. With no declarations (or when disabled), behavior is
632/// byte-for-byte the pre-Phase-16 cascade.
633fn evaluate_layer0(
634    project_root: &Path,
635    state: &State,
636    approved_commands: Option<&[String]>,
637) -> Option<AgentResult> {
638    if state.stage != Stage::Code || !crate::config::external_verify_enabled(project_root) {
639        return None;
640    }
641
642    let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
643    let commands = crate::verify::external_verify_commands(execution_root, state.phase);
644    if commands.is_empty() {
645        return approved_commands.map(|_| AgentResult {
646            status: AgentStatus::Failed,
647            exit_code: None,
648            reason: Some(
649                "external verification approval mismatch; PLAN declaration was removed".into(),
650            ),
651            commits: None,
652            summary: None,
653            verdict: None,
654        });
655    }
656    let Some(approved_commands) = approved_commands else {
657        return Some(AgentResult {
658            status: AgentStatus::Failed,
659            exit_code: None,
660            reason: Some(format!(
661                "external verification is not approved; set {} to the reviewed JSON command array",
662                crate::verify::TRUST_EXTERNAL_VERIFY_ENV
663            )),
664            commits: None,
665            summary: None,
666            verdict: None,
667        });
668    };
669    if commands != approved_commands {
670        return Some(AgentResult {
671            status: AgentStatus::Failed,
672            exit_code: None,
673            reason: Some("external verification approval mismatch; PLAN commands changed".into()),
674            commits: None,
675            summary: None,
676            verdict: None,
677        });
678    }
679    commands
680        .into_iter()
681        .find(|command| !crate::verify::run_external_verification(command, execution_root))
682        .map(|command| AgentResult {
683            status: AgentStatus::Failed,
684            exit_code: None,
685            reason: Some(format!("external verification failed: {command}")),
686            commits: None,
687            summary: None,
688            verdict: None,
689        })
690}
691
692/// Full four-layer evaluation: returns the best available AgentResult.
693pub fn evaluate_agent_result(
694    project_root: &Path,
695    state: &State,
696    git_flow: &GitFlowConfig,
697) -> Result<AgentResult, ResultError> {
698    let approval = crate::verify::external_verification_approval();
699    evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
700}
701
702fn evaluate_agent_result_inner(
703    project_root: &Path,
704    state: &State,
705    git_flow: &GitFlowConfig,
706    approved_commands: Option<&[String]>,
707) -> Result<AgentResult, ResultError> {
708    // Layer 0: operator-authored external post-condition (authoritative failure)
709    if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
710        return Ok(result);
711    }
712
713    // Layer 1: DEVFLOW_RESULT marker (authoritative)
714    if let Some(result) = evaluate_layer1(project_root, state.phase) {
715        return Ok(result);
716    }
717
718    // Layer 2: Exit code + commit gate
719    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
720        return Ok(result);
721    }
722
723    // Layer 3: Process existence + commits
724    evaluate_layer3(project_root, state.phase, git_flow)
725}
726
727/// Path to the .devflow directory for a project root.
728fn devflow_dir(project_root: &Path) -> PathBuf {
729    project_root.join(".devflow")
730}
731
732/// Path to the stdout file for a given phase.
733pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
734    devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
735}
736
737/// Path where the agent's stderr is captured for a given phase.
738/// Lives alongside `stdout_path` under `.devflow/`.
739pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
740    devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
741}
742
743/// Path to the exit code file for a given phase.
744pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
745    devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
746}
747
748/// Path to the file where the monitor records the launched agent's PID.
749pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
750    devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
751}
752
753/// Path to the archived-capture-history directory for a phase (16b).
754///
755/// `.devflow/history/phase-NN/` holds retained per-stage capture generations
756/// so a false-positive self-report can be diagnosed after the fact. Exposed
757/// as a constructor (rather than inlined at each call site) so downstream
758/// tooling (16h in 16-07's correlation, 16i in 16-05's enumeration) always
759/// derives the path from here instead of hardcoding it.
760pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
761    devflow_dir(project_root)
762        .join("history")
763        .join(format!("phase-{:02}", phase))
764}
765
766/// Monotonically increasing tie-breaker appended to the nanosecond timestamp
767/// used to stamp archived generations, so two archives issued within the
768/// same nanosecond (possible in a tight test loop) never collide.
769static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
770
771/// A stamp unique within this process, used to name an archived generation.
772/// The outgoing stage's name is not available at the `archive_phase_files`
773/// call site (see `launch_stage` in main.rs), so a monotonic timestamp is
774/// used instead — sufficient to order and identify generations.
775fn archive_stamp() -> String {
776    let nanos = std::time::SystemTime::now()
777        .duration_since(std::time::UNIX_EPOCH)
778        .map(|d| d.as_nanos())
779        .unwrap_or(0);
780    let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
781    format!("{nanos}-{seq}")
782}
783
784/// Archive the prior stage's stdout/exit captures into bounded per-phase
785/// history instead of wiping them outright, so a false-positive self-report
786/// can be diagnosed after the fact (16b). Replaces the old
787/// `cleanup_phase_files`, which deleted these files unconditionally.
788///
789/// At most `retain` capture generations are kept per phase; older ones are
790/// pruned (see [`prune_history`]). The agent-pid file is still removed
791/// outright — it is process bookkeeping, not diagnostic output. When there
792/// is nothing to archive (first launch), this is a no-op success.
793pub fn archive_phase_files(
794    project_root: &Path,
795    evidence_root: &Path,
796    phase: u32,
797    retain: usize,
798) -> Result<Option<String>, std::io::Error> {
799    archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
800}
801
802fn archive_phase_files_with_stamp(
803    project_root: &Path,
804    evidence_root: &Path,
805    phase: u32,
806    retain: usize,
807    stamp: &str,
808) -> Result<Option<String>, std::io::Error> {
809    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
810
811    let stdout_src = stdout_path(project_root, phase);
812    let exit_src = exit_code_path(project_root, phase);
813    let stdout_exists = stdout_src.exists();
814    let exit_exists = exit_src.exists();
815    if !stdout_exists && !exit_exists {
816        return Ok(None); // Nothing to archive — first launch.
817    }
818
819    let history_dir = history_dir(project_root, phase);
820    std::fs::create_dir_all(&history_dir)?;
821
822    let staging_dir = history_dir.join(format!(".pending-{stamp}"));
823    std::fs::create_dir(&staging_dir)?;
824    let stdout_stage = staging_dir.join("stdout");
825    let exit_stage = staging_dir.join("exit");
826    let review_stage = staging_dir.join("REVIEW.md");
827    let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
828    let exit_dest = history_dir.join(format!("{stamp}-exit"));
829    let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
830    let review_src = phase_review_path(evidence_root, phase);
831
832    let mut stdout_staged = false;
833    let mut exit_staged = false;
834    let mut stdout_published = false;
835    let mut exit_published = false;
836    let mut review_published = false;
837
838    let archive_result = (|| -> Result<(), std::io::Error> {
839        if stdout_exists {
840            std::fs::rename(&stdout_src, &stdout_stage)?;
841            stdout_staged = true;
842        }
843        if exit_exists {
844            std::fs::rename(&exit_src, &exit_stage)?;
845            exit_staged = true;
846        }
847        if let Some(review) = &review_src {
848            std::fs::copy(review, &review_stage)?;
849        }
850
851        if stdout_exists {
852            std::fs::rename(&stdout_stage, &stdout_dest)?;
853            stdout_staged = false;
854            stdout_published = true;
855        }
856        if exit_exists {
857            std::fs::rename(&exit_stage, &exit_dest)?;
858            exit_staged = false;
859            exit_published = true;
860        }
861        if review_src.is_some() {
862            std::fs::rename(&review_stage, &review_dest)?;
863            review_published = true;
864        }
865        Ok(())
866    })();
867
868    if let Err(error) = archive_result {
869        let mut rollback_error = None;
870        let mut restore = |from: &Path, to: &Path| {
871            if let Err(error) = std::fs::rename(from, to)
872                && rollback_error.is_none()
873            {
874                rollback_error = Some(error);
875            }
876        };
877        if stdout_published {
878            restore(&stdout_dest, &stdout_src);
879        } else if stdout_staged {
880            restore(&stdout_stage, &stdout_src);
881        }
882        if exit_published {
883            restore(&exit_dest, &exit_src);
884        } else if exit_staged {
885            restore(&exit_stage, &exit_src);
886        }
887        if review_published {
888            let _ = std::fs::remove_file(&review_dest);
889        }
890        let _ = std::fs::remove_dir_all(&staging_dir);
891
892        if let Some(rollback_error) = rollback_error {
893            return Err(std::io::Error::new(
894                error.kind(),
895                format!("{error}; archive rollback failed: {rollback_error}"),
896            ));
897        }
898        return Err(error);
899    }
900
901    let _ = std::fs::remove_dir(&staging_dir);
902
903    prune_history(&history_dir, retain);
904    Ok(Some(stamp.to_string()))
905}
906
907fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
908    let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
909    let prefix = format!("{phase:02}-");
910    for entry in phases.flatten() {
911        if entry
912            .file_name()
913            .to_str()
914            .is_some_and(|name| name.starts_with(&prefix))
915        {
916            let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
917            if review.exists() {
918                return Some(review);
919            }
920        }
921    }
922    None
923}
924
925/// Keep only the newest `retain` capture generations under `history_dir`,
926/// deleting older ones. Generations are grouped by their stamp (the shared
927/// prefix of a `{stamp}-stdout`/`{stamp}-exit` pair, split off the trailing
928/// `-stdout`/`-exit` suffix via `rsplit_once`) and ordered lexicographically,
929/// which matches numeric/chronological order for the fixed-width nanosecond
930/// stamps `archive_stamp` produces. Ordering parses both numeric components;
931/// the process-local sequence is intentionally not fixed-width.
932fn prune_history(history_dir: &Path, retain: usize) {
933    let Ok(entries) = std::fs::read_dir(history_dir) else {
934        return;
935    };
936
937    let mut stamps: Vec<String> = entries
938        .flatten()
939        .filter_map(|entry| {
940            let name = entry.file_name().to_str()?.to_string();
941            name.rsplit_once('-')
942                .map(|(stamp, _suffix)| stamp.to_string())
943        })
944        .collect();
945    stamps.sort_by_key(|stamp| {
946        let mut parts = stamp.split('-');
947        let nanos = parts
948            .next()
949            .and_then(|part| part.parse::<u128>().ok())
950            .unwrap_or(0);
951        let sequence = parts
952            .next()
953            .and_then(|part| part.parse::<u64>().ok())
954            .unwrap_or(0);
955        (nanos, sequence)
956    });
957    stamps.dedup();
958
959    if stamps.len() <= retain {
960        return;
961    }
962
963    let to_remove = stamps.len() - retain;
964    for stamp in &stamps[..to_remove] {
965        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
966        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
967        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
968    }
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::config::GitFlowConfig;
975    use crate::mode::Mode;
976    use crate::stage::Stage;
977    use crate::state::{AgentKind, State};
978    use std::process::Command;
979
980    fn state_in(root: &Path, phase: u32) -> State {
981        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
982        state.stage = Stage::Code;
983        state
984    }
985
986    fn git(root: &Path, args: &[&str]) {
987        let output = Command::new("git")
988            .args(args)
989            .current_dir(root)
990            .output()
991            .unwrap();
992        assert!(
993            output.status.success(),
994            "git {:?} failed\nstdout: {}\nstderr: {}",
995            args,
996            String::from_utf8_lossy(&output.stdout),
997            String::from_utf8_lossy(&output.stderr)
998        );
999    }
1000
1001    fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1002        git(root, &["init"]);
1003        git(root, &["config", "user.email", "devflow@example.com"]);
1004        git(root, &["config", "user.name", "DevFlow Tests"]);
1005        git(root, &["config", "commit.gpgsign", "false"]);
1006        git(root, &["config", "tag.gpgsign", "false"]);
1007        git(root, &["checkout", "-b", "develop"]);
1008        std::fs::write(root.join("README.md"), "base\n").unwrap();
1009        git(root, &["add", "README.md"]);
1010        git(root, &["commit", "-m", "base"]);
1011
1012        let branch = format!("feature/phase-{phase:02}");
1013        git(root, &["checkout", "-b", &branch]);
1014        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1015        git(root, &["add", "phase.txt"]);
1016        git(root, &["commit", "-m", "feature work"]);
1017    }
1018
1019    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
1020    /// develop's tip with **no** extra commit (0 commits ahead).
1021    fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1022        git(root, &["init"]);
1023        git(root, &["config", "user.email", "devflow@example.com"]);
1024        git(root, &["config", "user.name", "DevFlow Tests"]);
1025        git(root, &["config", "commit.gpgsign", "false"]);
1026        git(root, &["config", "tag.gpgsign", "false"]);
1027        git(root, &["checkout", "-b", "develop"]);
1028        std::fs::write(root.join("README.md"), "base\n").unwrap();
1029        git(root, &["add", "README.md"]);
1030        git(root, &["commit", "-m", "base"]);
1031
1032        let branch = format!("feature/phase-{phase:02}");
1033        git(root, &["checkout", "-b", &branch]);
1034    }
1035
1036    #[test]
1037    fn parse_success_marker() {
1038        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1039        let result = parse_devflow_result(stdout).unwrap();
1040        assert_eq!(result.status, AgentStatus::Success);
1041    }
1042
1043    #[test]
1044    fn parse_failed_marker_with_reason() {
1045        let stdout =
1046            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1047        let result = parse_devflow_result(stdout).unwrap();
1048        assert_eq!(result.status, AgentStatus::Failed);
1049        assert_eq!(result.reason.unwrap(), "clippy errors");
1050    }
1051
1052    #[test]
1053    fn parse_missing_marker_returns_none() {
1054        let stdout = "just some output\nno marker here\n";
1055        assert!(parse_devflow_result(stdout).is_none());
1056    }
1057
1058    #[test]
1059    fn parse_malformed_json_returns_none() {
1060        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1061        assert!(parse_devflow_result(stdout).is_none());
1062    }
1063
1064    #[test]
1065    fn parse_lowercase_marker() {
1066        let stdout = "devflow_result: {\"status\":\"success\"}\n";
1067        let result = parse_devflow_result(stdout).unwrap();
1068        assert_eq!(result.status, AgentStatus::Success);
1069    }
1070
1071    #[test]
1072    fn parse_marker_without_space_after_colon() {
1073        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1074        let result = parse_devflow_result(stdout).unwrap();
1075        assert_eq!(result.status, AgentStatus::Success);
1076    }
1077
1078    #[test]
1079    fn parse_lowercase_no_space_marker() {
1080        // Lowercase prefix AND no space after the colon — the combination that
1081        // the Phase 6 review flagged as uncovered.
1082        let stdout = "devflow_result:{\"status\":\"success\"}\n";
1083        let result = parse_devflow_result(stdout).unwrap();
1084        assert_eq!(result.status, AgentStatus::Success);
1085    }
1086
1087    #[test]
1088    fn parse_finds_last_marker_in_tail() {
1089        // Multiple markers — should find the last one.
1090        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1091        let result = parse_devflow_result(stdout).unwrap();
1092        assert_eq!(result.status, AgentStatus::Success);
1093    }
1094
1095    #[test]
1096    fn parse_marker_lines_returns_last_marker_in_long_output() {
1097        let stdout = format!(
1098            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1099             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1100            "prefix".repeat(900),
1101            "tail output".repeat(100)
1102        );
1103
1104        let result = parse_marker_lines(&stdout).unwrap();
1105
1106        assert_eq!(result.status, AgentStatus::Success);
1107    }
1108
1109    #[test]
1110    fn parse_marker_only_in_last_4000_chars() {
1111        // Marker beyond 4000 chars from end should not be found.
1112        let prefix = "a".repeat(5000);
1113        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1114        assert!(parse_devflow_result(&stdout).is_none());
1115    }
1116
1117    #[test]
1118    fn parse_marker_with_commits_and_summary() {
1119        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1120        let result = parse_devflow_result(stdout).unwrap();
1121        assert_eq!(result.status, AgentStatus::Success);
1122        assert_eq!(result.commits, Some(3));
1123        assert_eq!(result.summary.unwrap(), "added tests");
1124    }
1125
1126    #[test]
1127    fn parse_marker_inside_json_result_envelope() {
1128        // Claude --output-format json wraps the final text in a `result` field
1129        // with embedded newlines escaped.
1130        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1131        let result = parse_devflow_result(stdout).unwrap();
1132        assert_eq!(result.status, AgentStatus::Success);
1133        assert_eq!(result.commits, Some(2));
1134    }
1135
1136    #[test]
1137    fn parse_failed_marker_inside_json_envelope() {
1138        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1139        let result = parse_devflow_result(stdout).unwrap();
1140        assert_eq!(result.status, AgentStatus::Failed);
1141        assert_eq!(result.reason.unwrap(), "tests failed");
1142    }
1143
1144    #[test]
1145    fn parse_json_envelope_without_marker_returns_none() {
1146        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1147        assert!(parse_devflow_result(stdout).is_none());
1148    }
1149
1150    #[test]
1151    fn detect_claude_json_rate_limit_by_subtype() {
1152        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1153        assert_eq!(
1154            detect_rate_limit(stdout).as_deref(),
1155            Some("2026-06-18T15:45:30Z")
1156        );
1157    }
1158
1159    #[test]
1160    fn detect_claude_json_rate_limit_by_429() {
1161        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1162        assert_eq!(
1163            detect_rate_limit(stdout).as_deref(),
1164            Some("Too many requests. Try later.")
1165        );
1166    }
1167
1168    #[test]
1169    fn detect_codex_try_again_rate_limit() {
1170        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1171        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1172    }
1173
1174    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
1175    /// `json_find_key` run on the coding agent's raw stdout via
1176    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
1177    /// goes through. Deeply nested JSON — accidental or adversarial — must
1178    /// not stack-overflow the process, and a real marker at any depth
1179    /// serde_json will parse (its default recursion limit is exactly 128)
1180    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
1181    /// silently misclassified rate-limit markers at depths 64–128.
1182    #[test]
1183    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1184        // 100 levels: parseable by serde_json (limit 128), deeper than the
1185        // removed 64-level traversal cap that used to hide the marker.
1186        const DEPTH: usize = 100;
1187        let mut stdout = String::new();
1188        for _ in 0..DEPTH {
1189            stdout.push_str(r#"{"nested":"#);
1190        }
1191        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1192        for _ in 0..DEPTH {
1193            stdout.push('}');
1194        }
1195
1196        // Must return promptly without crashing AND find the buried marker —
1197        // the iterative worklist traversal has no silent-miss window.
1198        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1199    }
1200
1201    #[test]
1202    fn detect_rate_limit_ignores_normal_stdout() {
1203        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1204        assert!(detect_rate_limit(stdout).is_none());
1205    }
1206
1207    #[test]
1208    fn claude_envelope_is_error_detected() {
1209        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1210        let result = detect_claude_envelope_failure(stdout).unwrap();
1211        assert_eq!(result.status, AgentStatus::Failed);
1212    }
1213
1214    #[test]
1215    fn claude_is_error_overrides_success_marker() {
1216        let dir = tempfile::tempdir().unwrap();
1217        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1218        std::fs::write(
1219            stdout_path(dir.path(), 9),
1220            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1221        )
1222        .unwrap();
1223
1224        let result = evaluate_layer1(dir.path(), 9).unwrap();
1225
1226        assert_eq!(result.status, AgentStatus::Failed);
1227    }
1228
1229    #[test]
1230    fn claude_envelope_is_error_false_defers() {
1231        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1232        assert!(detect_claude_envelope_failure(stdout).is_none());
1233    }
1234
1235    #[test]
1236    fn claude_envelope_marker_still_wins() {
1237        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1238        assert!(detect_claude_envelope_failure(stdout).is_none());
1239        let result = parse_devflow_result(stdout).unwrap();
1240        assert_eq!(result.status, AgentStatus::Success);
1241        assert_eq!(result.commits, Some(2));
1242    }
1243
1244    #[test]
1245    fn codex_event_stream_parses_turn_failed() {
1246        let stdout = concat!(
1247            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1248            "{\"type\":\"turn.started\"}\n",
1249            "{\"type\":\"item.started\",\"item\":{}}\n",
1250            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1251        );
1252        let result = parse_codex_event_result(stdout).unwrap();
1253        assert_eq!(result.status, AgentStatus::Failed);
1254        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1255    }
1256
1257    #[test]
1258    fn codex_turn_completed_no_marker_defers() {
1259        let stdout = concat!(
1260            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1261            "{\"type\":\"turn.started\"}\n",
1262            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1263        );
1264        assert!(parse_codex_event_result(stdout).is_none());
1265    }
1266
1267    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
1268    /// inside an `agent_message` item's text, never as a raw stdout line. A
1269    /// self-reported failure followed by a bare `turn.completed` must parse
1270    /// as Failed with the agent's reason — not defer to Layer 2 (which would
1271    /// see exit 0 and call it a success).
1272    #[test]
1273    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1274        let stdout = concat!(
1275            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1276            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1277            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1278        );
1279        let result = parse_codex_event_result(stdout).unwrap();
1280        assert_eq!(result.status, AgentStatus::Failed);
1281        assert_eq!(
1282            result.reason.as_deref(),
1283            Some("interactive input unavailable")
1284        );
1285    }
1286
1287    #[test]
1288    fn codex_agent_message_marker_success_short_circuits() {
1289        let stdout = concat!(
1290            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1291            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1292            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1293        );
1294        let result = parse_codex_event_result(stdout).unwrap();
1295        assert_eq!(result.status, AgentStatus::Success);
1296    }
1297
1298    /// 13-06 dogfood regression: document content echoed into a JSONL event
1299    /// (GSD reference tables mentioning "rate limiting") must not trip the
1300    /// plain-text rate-limit heuristic — it returned the entire multi-KB
1301    /// event line as the "retry time" and that reached the desktop
1302    /// notification verbatim.
1303    #[test]
1304    fn detect_rate_limit_ignores_json_event_lines() {
1305        let stdout = concat!(
1306            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1307            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1308            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1309        );
1310        assert_eq!(detect_rate_limit(stdout), None);
1311    }
1312
1313    #[test]
1314    fn detect_rate_limit_still_reads_codex_plain_text() {
1315        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1316        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1317    }
1318
1319    #[test]
1320    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1321        let stdout = concat!(
1322            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1323            "not json at all\n",
1324            "{\"type\":\"item.started\",\"item\":{}}\n",
1325            "{\"type\":\"item.updated\",\"item\":{}}\n",
1326            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1327        );
1328        let result = parse_codex_event_result(stdout).unwrap();
1329        assert_eq!(result.status, AgentStatus::Failed);
1330        assert_eq!(result.reason.as_deref(), Some("boom"));
1331    }
1332
1333    #[test]
1334    fn claude_envelope_not_consumed_by_codex_parser() {
1335        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1336        assert!(parse_codex_event_result(stdout).is_none());
1337    }
1338
1339    #[test]
1340    fn evaluate_layer1_reports_rate_limited_without_marker() {
1341        let dir = tempfile::tempdir().unwrap();
1342        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1343        std::fs::write(
1344            stdout_path(dir.path(), 7),
1345            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1346        )
1347        .unwrap();
1348
1349        let result = evaluate_layer1(dir.path(), 7).unwrap();
1350
1351        assert_eq!(result.status, AgentStatus::RateLimited);
1352        assert_eq!(
1353            result.reason.as_deref(),
1354            Some("rate limited until 2026-06-18T15:45:30Z")
1355        );
1356    }
1357
1358    /// A real Claude rate-limit envelope carries `is_error: true` alongside
1359    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
1360    /// must outrank the generic is_error → Failed path, or sequentagent's
1361    /// handoff/cron machinery never triggers for the exact case it exists for.
1362    #[test]
1363    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1364        let dir = tempfile::tempdir().unwrap();
1365        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1366        std::fs::write(
1367            stdout_path(dir.path(), 7),
1368            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1369        )
1370        .unwrap();
1371
1372        let result = evaluate_layer1(dir.path(), 7).unwrap();
1373
1374        assert_eq!(result.status, AgentStatus::RateLimited);
1375        assert_eq!(
1376            result.reason.as_deref(),
1377            Some("rate limited until 2026-06-18T15:45:30Z")
1378        );
1379    }
1380
1381    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
1382    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
1383    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
1384    /// detection (the blocking-mode capture was fixed; the file read here is
1385    /// the other half of the same bug).
1386    #[test]
1387    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1388        let dir = tempfile::tempdir().unwrap();
1389        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1390        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1391        bytes.extend_from_slice(
1392            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1393        );
1394        std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1395
1396        let result = evaluate_layer1(dir.path(), 5).unwrap();
1397
1398        assert_eq!(result.status, AgentStatus::Failed);
1399        assert_eq!(result.reason.as_deref(), Some("review: bad"));
1400    }
1401
1402    #[test]
1403    fn failing_external_probe_outranks_success_marker() {
1404        let dir = tempfile::tempdir().unwrap();
1405        let phase_dir = dir
1406            .path()
1407            .join(".planning/phases/16-pipeline-reliability-hardening");
1408        std::fs::create_dir_all(&phase_dir).unwrap();
1409        std::fs::write(
1410            phase_dir.join("16-03-PLAN.md"),
1411            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1412        )
1413        .unwrap();
1414        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1415        std::fs::write(
1416            stdout_path(dir.path(), 16),
1417            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1418        )
1419        .unwrap();
1420        let state = state_in(dir.path(), 16);
1421
1422        let approval = vec!["test -f externally-shipped".to_string()];
1423        let result = evaluate_agent_result_inner(
1424            dir.path(),
1425            &state,
1426            &GitFlowConfig::default(),
1427            Some(&approval),
1428        )
1429        .unwrap();
1430
1431        assert_eq!(result.status, AgentStatus::Failed);
1432        assert!(
1433            result
1434                .reason
1435                .as_deref()
1436                .is_some_and(|reason| reason.contains("external verification failed"))
1437        );
1438    }
1439
1440    #[test]
1441    fn external_probe_runs_only_after_code_and_reads_execution_worktree() {
1442        let dir = tempfile::tempdir().unwrap();
1443        let worktree = dir.path().join("phase-worktree");
1444        let phase_dir = worktree.join(".planning/phases/16-reliability");
1445        std::fs::create_dir_all(&phase_dir).unwrap();
1446        std::fs::write(
1447            phase_dir.join("16-01-PLAN.md"),
1448            "---\nexternal_verify: \"test -f implemented\"\n---\n",
1449        )
1450        .unwrap();
1451        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1452        std::fs::write(
1453            stdout_path(dir.path(), 16),
1454            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1455        )
1456        .unwrap();
1457        let mut state = state_in(dir.path(), 16);
1458        state.worktree_path = Some(worktree.clone());
1459        state.stage = Stage::Plan;
1460
1461        let approval = vec!["test -f implemented".to_string()];
1462        let plan_result = evaluate_agent_result_inner(
1463            dir.path(),
1464            &state,
1465            &GitFlowConfig::default(),
1466            Some(&approval),
1467        )
1468        .unwrap();
1469        assert_eq!(plan_result.status, AgentStatus::Success);
1470
1471        state.stage = Stage::Code;
1472        let code_result = evaluate_agent_result_inner(
1473            dir.path(),
1474            &state,
1475            &GitFlowConfig::default(),
1476            Some(&approval),
1477        )
1478        .unwrap();
1479        assert_eq!(code_result.status, AgentStatus::Failed);
1480
1481        std::fs::write(worktree.join("implemented"), "done").unwrap();
1482        let passing = evaluate_agent_result_inner(
1483            dir.path(),
1484            &state,
1485            &GitFlowConfig::default(),
1486            Some(&approval),
1487        )
1488        .unwrap();
1489        assert_eq!(passing.status, AgentStatus::Success);
1490    }
1491
1492    #[test]
1493    fn changed_external_probe_never_inherits_prior_approval() {
1494        let dir = tempfile::tempdir().unwrap();
1495        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1496        std::fs::create_dir_all(&phase_dir).unwrap();
1497        std::fs::write(
1498            phase_dir.join("16-01-PLAN.md"),
1499            "---\nexternal_verify: \"touch escaped\"\n---\n",
1500        )
1501        .unwrap();
1502        let state = state_in(dir.path(), 16);
1503        let approved = vec!["test -f reviewed-artifact".to_string()];
1504
1505        let result = evaluate_agent_result_inner(
1506            dir.path(),
1507            &state,
1508            &GitFlowConfig::default(),
1509            Some(&approved),
1510        )
1511        .unwrap();
1512
1513        assert_eq!(result.status, AgentStatus::Failed);
1514        assert!(result.reason.unwrap().contains("approval mismatch"));
1515        assert!(!dir.path().join("escaped").exists());
1516    }
1517
1518    #[test]
1519    fn removed_external_probe_fails_closed_against_prior_approval() {
1520        let dir = tempfile::tempdir().unwrap();
1521        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1522        std::fs::write(
1523            stdout_path(dir.path(), 16),
1524            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1525        )
1526        .unwrap();
1527        let state = state_in(dir.path(), 16);
1528        let approved = vec!["test -f shipped".to_string()];
1529
1530        let result = evaluate_agent_result_inner(
1531            dir.path(),
1532            &state,
1533            &GitFlowConfig::default(),
1534            Some(&approved),
1535        )
1536        .unwrap();
1537
1538        assert_eq!(result.status, AgentStatus::Failed);
1539        assert!(result.reason.unwrap().contains("declaration was removed"));
1540    }
1541
1542    #[test]
1543    fn no_external_declaration_preserves_layer1_result() {
1544        let dir = tempfile::tempdir().unwrap();
1545        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1546        std::fs::write(
1547            stdout_path(dir.path(), 16),
1548            "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1549        )
1550        .unwrap();
1551        let state = state_in(dir.path(), 16);
1552        let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1553
1554        let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1555
1556        assert_eq!(
1557            serde_json::to_value(full).unwrap(),
1558            serde_json::to_value(layer1).unwrap()
1559        );
1560    }
1561
1562    #[test]
1563    fn archive_moves_captures_into_history_and_removes_pid_file() {
1564        // 16b: prior-stage captures must survive a simulated next-launch by
1565        // appearing under .devflow/history/phase-NN/, not be wiped outright.
1566        let dir = tempfile::tempdir().unwrap();
1567        let root = dir.path();
1568        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1569        std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
1570        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1571        std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
1572
1573        archive_phase_files(root, root, 1, 5).unwrap();
1574
1575        // The live capture paths are gone (moved, not merely deleted).
1576        assert!(!root.join(".devflow/phase-01-stdout").exists());
1577        assert!(!root.join(".devflow/phase-01-exit").exists());
1578        // Agent-pid is bookkeeping, not diagnostic — still removed outright.
1579        assert!(!root.join(".devflow/phase-01-agent-pid").exists());
1580
1581        let history = history_dir(root, 1);
1582        let archived: Vec<_> = std::fs::read_dir(&history)
1583            .unwrap()
1584            .flatten()
1585            .map(|e| e.file_name().to_string_lossy().into_owned())
1586            .collect();
1587        let archived_stdout = archived
1588            .iter()
1589            .find(|name| name.ends_with("-stdout"))
1590            .expect("stdout capture should be archived into history");
1591        assert!(archived.iter().any(|name| name.ends_with("-exit")));
1592        let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
1593        assert_eq!(contents, "prior stdout");
1594    }
1595
1596    #[test]
1597    fn archive_is_noop_when_nothing_to_archive() {
1598        let dir = tempfile::tempdir().unwrap();
1599        let root = dir.path();
1600        // Should not panic when there is nothing to archive (first launch).
1601        archive_phase_files(root, root, 1, 5).unwrap();
1602        assert!(!history_dir(root, 1).exists());
1603    }
1604
1605    #[test]
1606    fn archive_handles_missing_devflow_dir() {
1607        let dir = tempfile::tempdir().unwrap();
1608        let root = dir.path();
1609        // No .devflow dir at all — should not panic.
1610        archive_phase_files(root, root, 1, 5).unwrap();
1611    }
1612
1613    #[test]
1614    fn archive_failure_preserves_live_capture_for_retry() {
1615        let dir = tempfile::tempdir().unwrap();
1616        let root = dir.path();
1617        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1618        std::fs::write(stdout_path(root, 1), "evidence").unwrap();
1619        // A file where the history directory must be forces create_dir_all
1620        // to fail before the live capture is moved or a monitor can truncate it.
1621        std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
1622
1623        assert!(archive_phase_files(root, root, 1, 5).is_err());
1624        assert_eq!(
1625            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1626            "evidence"
1627        );
1628    }
1629
1630    #[test]
1631    fn archive_second_publish_failure_rolls_back_complete_live_pair() {
1632        let dir = tempfile::tempdir().unwrap();
1633        let root = dir.path();
1634        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1635        std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
1636        std::fs::write(exit_code_path(root, 1), "17").unwrap();
1637        let history = history_dir(root, 1);
1638        std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
1639
1640        assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
1641
1642        assert_eq!(
1643            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1644            "stdout evidence"
1645        );
1646        assert_eq!(
1647            std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
1648            "17"
1649        );
1650        assert!(!history.join("fixed-stdout").exists());
1651        assert!(!history.join(".pending-fixed").exists());
1652    }
1653
1654    #[test]
1655    fn archive_review_copy_failure_rolls_back_complete_live_pair() {
1656        let dir = tempfile::tempdir().unwrap();
1657        let root = dir.path();
1658        let evidence_root = root.join("phase-worktree");
1659        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1660        std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
1661        std::fs::write(exit_code_path(root, 1), "23").unwrap();
1662        let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
1663        std::fs::create_dir_all(&review).unwrap();
1664
1665        assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
1666
1667        assert_eq!(
1668            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
1669            "stdout evidence"
1670        );
1671        assert_eq!(
1672            std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
1673            "23"
1674        );
1675        let history = history_dir(root, 1);
1676        assert!(!history.join("review-copy-stdout").exists());
1677        assert!(!history.join("review-copy-exit").exists());
1678        assert!(!history.join(".pending-review-copy").exists());
1679    }
1680
1681    #[test]
1682    fn archive_snapshots_current_review_into_same_generation() {
1683        let dir = tempfile::tempdir().unwrap();
1684        let root = dir.path();
1685        let evidence_root = root.join("phase-worktree");
1686        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1687        std::fs::write(stdout_path(root, 1), "attempt").unwrap();
1688        let phase_dir = evidence_root.join(".planning/phases/01-example");
1689        std::fs::create_dir_all(&phase_dir).unwrap();
1690        std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
1691
1692        let stamp = archive_phase_files(root, &evidence_root, 1, 5)
1693            .unwrap()
1694            .unwrap();
1695
1696        assert_eq!(
1697            std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
1698                .unwrap(),
1699            "review one"
1700        );
1701    }
1702
1703    #[test]
1704    fn archive_prunes_history_to_retain_count() {
1705        let dir = tempfile::tempdir().unwrap();
1706        let root = dir.path();
1707        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1708
1709        for i in 0..7 {
1710            std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
1711            std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1712            archive_phase_files(root, root, 1, 3).unwrap();
1713        }
1714
1715        let history = history_dir(root, 1);
1716        let stdout_count = std::fs::read_dir(&history)
1717            .unwrap()
1718            .flatten()
1719            .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
1720            .count();
1721        let exit_count = std::fs::read_dir(&history)
1722            .unwrap()
1723            .flatten()
1724            .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
1725            .count();
1726        assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
1727        assert_eq!(exit_count, 3, "expected at most 3 retained generations");
1728    }
1729
1730    #[test]
1731    fn evaluate_agent_result_reads_files_end_to_end() {
1732        let dir = tempfile::tempdir().unwrap();
1733        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1734        std::fs::write(
1735            stdout_path(dir.path(), 6),
1736            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
1737        )
1738        .unwrap();
1739        std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
1740        let state = state_in(dir.path(), 6);
1741
1742        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1743
1744        assert_eq!(result.status, AgentStatus::Success);
1745        assert_eq!(result.commits, Some(2));
1746        assert_eq!(result.summary.as_deref(), Some("ok"));
1747    }
1748
1749    #[test]
1750    fn evaluate_layer1_finds_devflow_result_in_file() {
1751        let dir = tempfile::tempdir().unwrap();
1752        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1753        std::fs::write(
1754            stdout_path(dir.path(), 3),
1755            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
1756        )
1757        .unwrap();
1758
1759        let result = evaluate_layer1(dir.path(), 3).unwrap();
1760
1761        assert_eq!(result.status, AgentStatus::Failed);
1762        assert_eq!(result.reason.as_deref(), Some("bad output"));
1763    }
1764
1765    #[test]
1766    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
1767        let dir = tempfile::tempdir().unwrap();
1768        init_repo_with_feature_commit(dir.path(), 4);
1769        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1770        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1771        let state = state_in(dir.path(), 4);
1772
1773        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1774            .unwrap()
1775            .unwrap();
1776
1777        assert_eq!(result.status, AgentStatus::Success);
1778        assert_eq!(result.exit_code, Some(0));
1779        assert_eq!(result.commits, Some(1));
1780        assert!(result.reason.unwrap().contains("1 commits"));
1781    }
1782
1783    #[test]
1784    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
1785        // exit=0 but the feature branch has 0 commits ahead of develop →
1786        // "no work done" failure (the Layer 2 middle branch).
1787        let dir = tempfile::tempdir().unwrap();
1788        init_repo_with_feature_no_commit(dir.path(), 4);
1789        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1790        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1791        let state = state_in(dir.path(), 4);
1792
1793        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1794            .unwrap()
1795            .unwrap();
1796
1797        assert_eq!(result.status, AgentStatus::Failed);
1798        assert_eq!(result.exit_code, Some(0));
1799        assert_eq!(result.commits, Some(0));
1800        assert!(result.reason.unwrap().contains("no commits"));
1801    }
1802
1803    #[test]
1804    fn evaluate_layer2_nonzero_exit_is_failed() {
1805        // Non-zero exit code → failure regardless of commit count.
1806        let dir = tempfile::tempdir().unwrap();
1807        init_repo_with_feature_commit(dir.path(), 4);
1808        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1809        std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
1810        let state = state_in(dir.path(), 4);
1811
1812        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1813            .unwrap()
1814            .unwrap();
1815
1816        assert_eq!(result.status, AgentStatus::Failed);
1817        assert_eq!(result.exit_code, Some(1));
1818        assert!(result.reason.unwrap().contains("exited with code 1"));
1819    }
1820
1821    #[test]
1822    fn layer2_nonzero_exit_is_failed_all_stages() {
1823        // Non-zero exit is Failed regardless of stage — including Define and
1824        // Validate, which are exempt from the zero-commit gate but NOT from
1825        // the exit-code check.
1826        let dir = tempfile::tempdir().unwrap();
1827        init_repo_with_feature_no_commit(dir.path(), 10);
1828        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1829        std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
1830
1831        for stage in [
1832            Stage::Define,
1833            Stage::Plan,
1834            Stage::Code,
1835            Stage::Validate,
1836            Stage::Ship,
1837        ] {
1838            let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
1839                .unwrap()
1840                .unwrap();
1841            assert_eq!(
1842                result.status,
1843                AgentStatus::Failed,
1844                "stage {stage:?} should be Failed on nonzero exit"
1845            );
1846        }
1847    }
1848
1849    #[test]
1850    fn layer2_skips_commit_gate_for_define_and_validate() {
1851        let dir = tempfile::tempdir().unwrap();
1852        init_repo_with_feature_no_commit(dir.path(), 11);
1853        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1854        std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
1855
1856        for stage in [Stage::Define, Stage::Validate] {
1857            let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
1858                .unwrap()
1859                .unwrap();
1860            assert_ne!(
1861                result.status,
1862                AgentStatus::Failed,
1863                "stage {stage:?} should not be Failed for zero commits"
1864            );
1865        }
1866
1867        // Code stage with the same zero-commit inputs is still Failed
1868        // (existing behavior preserved).
1869        let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
1870            .unwrap()
1871            .unwrap();
1872        assert_eq!(result.status, AgentStatus::Failed);
1873    }
1874
1875    #[test]
1876    fn evaluate_layer3_falls_back_to_commit_count() {
1877        let dir = tempfile::tempdir().unwrap();
1878        init_repo_with_feature_commit(dir.path(), 5);
1879
1880        let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
1881
1882        assert_eq!(result.status, AgentStatus::Unknown);
1883        assert_eq!(result.exit_code, None);
1884        assert_eq!(result.commits, Some(1));
1885        assert!(result.reason.unwrap().contains("1 commits"));
1886    }
1887
1888    #[test]
1889    fn parse_devflow_result_reads_verdict() {
1890        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
1891        let result = parse_devflow_result(stdout).unwrap();
1892        assert_eq!(result.status, AgentStatus::Success);
1893        assert_eq!(result.verdict, Some(Verdict::Gaps));
1894    }
1895
1896    #[test]
1897    fn parse_devflow_result_reads_verdict_pass() {
1898        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
1899        let result = parse_devflow_result(stdout).unwrap();
1900        assert_eq!(result.status, AgentStatus::Success);
1901        assert_eq!(result.verdict, Some(Verdict::Pass));
1902    }
1903
1904    #[test]
1905    fn parse_devflow_result_verdict_absent_is_none() {
1906        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
1907        let result = parse_devflow_result(stdout).unwrap();
1908        assert_eq!(result.status, AgentStatus::Success);
1909        assert_eq!(result.verdict, None);
1910    }
1911
1912    #[test]
1913    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
1914        // An unknown verdict string must not fail the whole marker parse —
1915        // status must still come through as Success with verdict None (T-13-14).
1916        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
1917        let result = parse_devflow_result(unknown).unwrap();
1918        assert_eq!(result.status, AgentStatus::Success);
1919        assert_eq!(result.verdict, None);
1920
1921        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
1922        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
1923        let result = parse_devflow_result(miscased).unwrap();
1924        assert_eq!(result.status, AgentStatus::Success);
1925        assert_eq!(result.verdict, None);
1926    }
1927
1928    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
1929    /// JSON *type* (bool, number, object) must be just as lenient as a
1930    /// malformed string value — before the fix, deserializing straight to
1931    /// `Option<String>` errored out the entire `AgentResult` parse for a
1932    /// type mismatch, defeating the doc comment's "a malformed verdict must
1933    /// never silently drop a valid status" guarantee for this specific case.
1934    #[test]
1935    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
1936        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
1937        let result = parse_devflow_result(bool_verdict).unwrap();
1938        assert_eq!(result.status, AgentStatus::Success);
1939        assert_eq!(result.verdict, None);
1940
1941        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
1942        let result = parse_devflow_result(numeric_verdict).unwrap();
1943        assert_eq!(result.status, AgentStatus::Success);
1944        assert_eq!(result.verdict, None);
1945
1946        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
1947        let result = parse_devflow_result(object_verdict).unwrap();
1948        assert_eq!(result.status, AgentStatus::Success);
1949        assert_eq!(result.verdict, None);
1950    }
1951}