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//! Three-layer decision engine:
5//! 1. Parse DEVFLOW_RESULT from agent stdout (authoritative)
6//! 2. Exit code + commit count gate (reliable fallback)
7//! 3. Process gone + commits exist (last resort warning)
8
9use crate::config::GitFlowConfig;
10use crate::stage::Stage;
11use crate::state::State;
12use std::path::{Path, PathBuf};
13
14/// Parsed agent completion result.
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub struct AgentResult {
17    pub status: AgentStatus,
18    pub exit_code: Option<i32>,
19    pub reason: Option<String>,
20    pub commits: Option<u32>,
21    pub summary: Option<String>,
22    /// The Validate stage's self-reported verdict — distinct from `status`.
23    /// `status` reports whether the stage's task (running `/gsd-validate-phase`)
24    /// completed; `verdict` reports whether validation ITSELF passed. Only
25    /// `Some(Verdict::Pass)` should advance Validate to Ship; `Some(Verdict::Gaps)`
26    /// and `None` both gate/loop back to Code (see `advance()`'s Validate arm).
27    /// Ignored entirely for non-Validate stages.
28    ///
29    /// Deserialized leniently via [`deserialize_verdict_lenient`]: an absent,
30    /// unknown, or mis-cased value becomes `None` rather than failing the
31    /// whole `AgentResult` parse (T-13-14) — a malformed verdict must never
32    /// silently drop a valid `status` to Layer 2.
33    #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
34    pub verdict: Option<Verdict>,
35}
36
37/// Agent completion status determined by DevFlow.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum AgentStatus {
41    /// Agent self-reported success via DEVFLOW_RESULT.
42    Success,
43    /// Agent self-reported failure, or exit code + commit gate indicated failure.
44    Failed,
45    /// Agent stopped because an upstream API or usage quota rate-limited it.
46    RateLimited,
47    /// No signal received — fallback to exit code / commit heuristic.
48    Unknown,
49}
50
51/// The Validate stage's self-reported verdict (13b verdict-vs-ran split).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Verdict {
55    /// Validation found no gaps — ready to advance to Ship.
56    Pass,
57    /// Validation found gaps that still need fixing — must loop back to Code
58    /// (or gate, depending on the consecutive-failure threshold).
59    Gaps,
60}
61
62/// Deserialize `verdict` leniently: an absent, unknown, or mis-cased value
63/// (e.g. `"wat"`, `"Pass"`) becomes `Ok(None)` rather than an error, so a
64/// malformed verdict never fails the whole `from_str::<AgentResult>` parse
65/// and silently drops a valid `status` to Layer 2 (T-13-14, consensus #5).
66///
67/// Matching is intentionally exact-case (only the wire-format lowercase
68/// strings `"pass"`/`"gaps"` are accepted) — a mis-cased value like `"Pass"`
69/// is NOT case-folded into a match; it is treated the same as an unknown
70/// value and maps to `None`, so a subtly wrong-case verdict fails safe
71/// (gate/loop) instead of silently passing.
72///
73/// WR-09 (13-REVIEW.md): decodes as `serde_json::Value` first, then only
74/// pattern-matches the string case — a non-string JSON type (`true`, `123`,
75/// an object) is a wrong *type*, not a malformed string value, and must
76/// still fall through to `None` rather than erroring out the entire
77/// `AgentResult` parse (the same guarantee this deserializer already gives
78/// mis-cased/unknown string values).
79fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
80where
81    D: serde::Deserializer<'de>,
82{
83    let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
84    Ok(raw.and_then(|v| {
85        v.as_str().and_then(|s| match s {
86            "pass" => Some(Verdict::Pass),
87            "gaps" => Some(Verdict::Gaps),
88            _ => None,
89        })
90    }))
91}
92
93/// Errors produced by agent result evaluation.
94#[derive(Debug, thiserror::Error)]
95pub enum ResultError {
96    #[error("I/O error reading agent output: {0}")]
97    Io(#[from] std::io::Error),
98    #[error("phase directory not found")]
99    NoPhaseDir,
100}
101
102/// Search stdout for a DEVFLOW_RESULT marker.
103///
104/// The marker is a single line starting with `DEVFLOW_RESULT:` followed by
105/// a JSON object with at minimum a `status` field. Matching is case-insensitive.
106///
107/// When an agent is run with `--output-format json` (e.g. Claude), its final
108/// message is wrapped in a JSON result envelope with the text — and its
109/// embedded newlines — escaped inside a `result` field. In that case the
110/// marker never appears at the start of a line, so we first unwrap the
111/// envelope and search the inner text.
112pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
113    if let Some(inner) = extract_json_result_text(stdout)
114        && let Some(result) = parse_marker_lines(&inner)
115    {
116        return Some(result);
117    }
118    parse_marker_lines(stdout)
119}
120
121/// Detect agent-specific rate-limit output and return the retry description.
122///
123/// Claude can emit a JSON result envelope when run with `--output-format json`;
124/// Codex commonly emits plain text such as "Try again at ...". This function is
125/// intentionally conservative so ordinary progress text does not become a
126/// false positive.
127pub fn detect_rate_limit(stdout: &str) -> Option<String> {
128    detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
129}
130
131fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
132    let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
133    let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
134        || json_has_i64(&value, "api_error_status", 429)
135        || json_has_i64(&value, "status", 429)
136        || json_has_i64(&value, "status_code", 429);
137    if !rate_limited {
138        return None;
139    }
140    json_find_key(&value, "retry_after")
141        .and_then(json_scalar_to_string)
142        .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
143        .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
144        .or_else(|| Some("usage limit".to_string()))
145}
146
147fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
148    // This heuristic exists for Codex's PLAIN-TEXT output. JSONL event lines
149    // are authoritative and handled by parse_codex_event_result — scanning
150    // them here false-positives on document content echoed into events
151    // (13-06 dogfood finding: GSD reference tables mentioning "rate limiting"
152    // were read by the agent, echoed into an `item.completed` payload, and
153    // this scan returned that entire multi-KB line as the "retry time").
154    let stdout: String = stdout
155        .lines()
156        .filter(|line| {
157            serde_json::from_str::<serde_json::Value>(line)
158                .map(|v| !v.is_object())
159                .unwrap_or(true)
160        })
161        .collect::<Vec<_>>()
162        .join("\n");
163    let stdout = stdout.as_str();
164    let lower = stdout.to_ascii_lowercase();
165    if let Some(idx) = lower.find("try again at ") {
166        let start = idx + "try again at ".len();
167        let retry = stdout[start..]
168            .lines()
169            .next()
170            .unwrap_or_default()
171            .trim()
172            .trim_end_matches(['.', ',', ';'])
173            .trim();
174        if !retry.is_empty() {
175            return Some(retry.to_string());
176        }
177    }
178
179    if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
180        stdout
181            .lines()
182            .find(|line| {
183                let line = line.to_ascii_lowercase();
184                line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
185            })
186            .map(str::trim)
187            .filter(|line| !line.is_empty())
188            .map(str::to_string)
189            .or_else(|| Some("usage limit".to_string()))
190    } else {
191        None
192    }
193}
194
195/// If `stdout` is a JSON result envelope, return the decoded `result` text
196/// field (with escapes such as `\n` resolved). Returns `None` for plain text.
197fn extract_json_result_text(stdout: &str) -> Option<String> {
198    let trimmed = stdout.trim();
199    if !trimmed.starts_with('{') {
200        return None;
201    }
202    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
203    value.get("result")?.as_str().map(str::to_string)
204}
205
206// WR-12 (13-REVIEW.md), revised: these traversal helpers run on the coding
207// agent's raw stdout (via detect_claude_rate_limit, which every `devflow
208// advance` invocation runs through evaluate_layer1), so deeply nested JSON —
209// accidental or adversarial — must not stack-overflow the process. The
210// traversal is iterative (an explicit worklist), so nesting depth never
211// consumes call stack and no depth cap is needed. The first WR-12 fix capped
212// recursion at 64, which silently missed keys at depths 64–128 — nesting
213// serde_json's default 128-level parse recursion limit (the only producer of
214// these `Value`s) accepts just fine.
215
216/// Depth-first pre-order scan over every JSON object in `value`, returning
217/// the first `Some` produced by `visit` on an object's map.
218fn json_scan<'a, T>(
219    value: &'a serde_json::Value,
220    visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
221) -> Option<T> {
222    let mut stack = vec![value];
223    while let Some(current) = stack.pop() {
224        match current {
225            serde_json::Value::Object(map) => {
226                if let Some(found) = visit(map) {
227                    return Some(found);
228                }
229                // Push in reverse so pop order preserves document order.
230                for child in map.values().rev() {
231                    stack.push(child);
232                }
233            }
234            serde_json::Value::Array(values) => {
235                for child in values.iter().rev() {
236                    stack.push(child);
237                }
238            }
239            _ => {}
240        }
241    }
242    None
243}
244
245fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
246    json_scan(value, |map| {
247        (map.get(key)?.as_str()? == expected).then_some(())
248    })
249    .is_some()
250}
251
252fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
253    json_scan(value, |map| {
254        (map.get(key)?.as_i64()? == expected).then_some(())
255    })
256    .is_some()
257}
258
259fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
260    json_scan(value, |map| map.get(key))
261}
262
263fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
264    match value {
265        serde_json::Value::String(s) => Some(s.clone()),
266        serde_json::Value::Number(n) => Some(n.to_string()),
267        _ => None,
268    }
269}
270
271/// Read the top-level `is_error` boolean (and, if present, `num_turns`) from
272/// a Claude JSON result envelope (`--output-format json`) and treat
273/// `is_error: true` as an authoritative Layer-1 failure.
274///
275/// This is checked BEFORE the `DEVFLOW_RESULT` marker path in
276/// [`evaluate_layer1`], so `is_error: true` OVERRIDES a stale/echoed success
277/// marker embedded in the same envelope's `result` text — the envelope is
278/// authoritative for errors. `is_error` absent or `false` returns `None`,
279/// deferring to the marker path and, ultimately, Layer 2. It runs AFTER
280/// `detect_claude_rate_limit`, though: rate-limit envelopes also carry
281/// `is_error: true`, and the specific `RateLimited` classification (which
282/// drives sequentagent's handoff and the resume cron) must win over this
283/// generic `Failed`.
284///
285/// Per RESEARCH Pitfall 5, `is_error` (not specific `subtype` strings) is
286/// the documented, stable signal — this does not special-case non-success
287/// subtype values beyond what already exists in `detect_claude_rate_limit`.
288fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
289    let trimmed = stdout.trim();
290    if !trimmed.starts_with('{') {
291        return None;
292    }
293    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
294    let is_error = value.get("is_error")?.as_bool()?;
295    if !is_error {
296        return None;
297    }
298
299    let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
300    let base_reason = value
301        .get("result")
302        .and_then(serde_json::Value::as_str)
303        .map(str::to_string)
304        .or_else(|| {
305            value
306                .get("subtype")
307                .and_then(serde_json::Value::as_str)
308                .map(str::to_string)
309        })
310        .unwrap_or_else(|| "agent reported is_error".to_string());
311    let reason = match num_turns {
312        Some(n) => format!("{base_reason} (num_turns: {n})"),
313        None => base_reason,
314    };
315
316    Some(AgentResult {
317        status: AgentStatus::Failed,
318        exit_code: None,
319        reason: Some(reason),
320        commits: None,
321        summary: None,
322        verdict: None,
323    })
324}
325
326/// Determine whether a set of parsed JSONL lines look like a Codex `--json`
327/// event stream (as opposed to a single-document Claude envelope or plain
328/// text) — i.e. at least one line is a `thread.started` or `turn.*` event.
329fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
330    events.iter().any(|v| {
331        v.get("type")
332            .and_then(serde_json::Value::as_str)
333            .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
334    })
335}
336
337/// Parse a Codex `--json` JSONL event stream (one JSON object per line) and
338/// look at the LAST terminal event (`turn.completed` / `turn.failed`).
339///
340/// Only decisive when the captured stdout is actually a Codex event stream
341/// (per [`is_codex_event_stream`]) — a single-document Claude envelope
342/// (`type: "result"`, no `turn.*` lines) is not consumed here and returns
343/// `None`, so the Claude envelope/marker paths handle it instead.
344///
345/// `turn.failed` is decisive: returns `AgentStatus::Failed` with `reason`
346/// from `error.message`. A final `turn.completed` with no `DEVFLOW_RESULT`
347/// marker returns `None` (defers to Layer 2) rather than an unconditional
348/// Success — a marker-less turn must not silently advance a stage (this is
349/// the composition fix that keeps a marker-less Validate run from
350/// false-passing to Ship).
351///
352/// NOTE: written against the documented `--json` event schema (thread.started
353/// / turn.started / item.* / turn.completed with usage / turn.failed with
354/// error.message) but not yet verified against the installed Codex CLI
355/// version — the 13-06 dogfood run captures real output and reconciles any
356/// delta, the same empirical practice 12-12-SUMMARY.md used for Claude.
357fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
358    let events: Vec<serde_json::Value> = stdout
359        .lines()
360        .filter(|line| !line.trim().is_empty())
361        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
362        .collect();
363
364    if !is_codex_event_stream(&events) {
365        return None;
366    }
367
368    // Codex delivers the agent's DEVFLOW_RESULT self-report inside an
369    // `agent_message` item's `text` — never as a raw stdout line — so the
370    // top-level marker scan cannot see it (13-06 dogfood finding: a Codex
371    // `DEVFLOW_RESULT: failed` was invisible and the run fell through to
372    // heuristics). The decoded `text` is a plain marker line; reuse the
373    // marker parser on it. Last marker wins, matching parse_marker_lines.
374    let marker = events.iter().rev().find_map(|v| {
375        if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
376            return None;
377        }
378        let item = v.get("item")?;
379        if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
380            return None;
381        }
382        let text = item.get("text").and_then(serde_json::Value::as_str)?;
383        parse_marker_lines(text)
384    });
385    if marker.is_some() {
386        return marker;
387    }
388
389    let terminal = events.iter().rev().find(|v| {
390        matches!(
391            v.get("type").and_then(serde_json::Value::as_str),
392            Some("turn.completed") | Some("turn.failed")
393        )
394    })?;
395
396    if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
397        // turn.completed (or any other terminal we don't recognize) defers
398        // to Layer 2 rather than an unconditional Success.
399        return None;
400    }
401
402    let reason = terminal
403        .get("error")
404        .and_then(|e| e.get("message"))
405        .and_then(serde_json::Value::as_str)
406        .map(str::to_string)
407        .unwrap_or_else(|| "codex turn failed".to_string());
408
409    Some(AgentResult {
410        status: AgentStatus::Failed,
411        exit_code: None,
412        reason: Some(reason),
413        commits: None,
414        summary: None,
415        verdict: None,
416    })
417}
418
419/// Scan the last ~4000 characters of `stdout` in reverse line order.
420///
421/// `DEVFLOW_RESULT` markers are ASCII. Searching the bounded tail and returning
422/// the last valid marker ensures the agent's final status wins over an earlier
423/// prompt echo without requiring the surrounding output to be ASCII.
424fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
425    // Only search the tail — agents may echo the marker in their prompt
426    // and we want the LAST occurrence (which is their actual final status).
427    let tail: String = stdout
428        .chars()
429        .rev()
430        .take(4000)
431        .collect::<Vec<_>>()
432        .into_iter()
433        .rev()
434        .collect();
435
436    for line in tail.lines().rev() {
437        let Some(json_str) = line
438            .strip_prefix("DEVFLOW_RESULT: ")
439            .or_else(|| line.strip_prefix("devflow_result: "))
440            .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
441            .or_else(|| line.strip_prefix("devflow_result:"))
442        else {
443            continue;
444        };
445
446        let json_str = json_str.trim();
447        if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
448            return Some(result);
449        }
450    }
451    None
452}
453
454/// Layer 1: Try to detect agent result from the native per-adapter envelope
455/// or the DEVFLOW_RESULT marker in stdout.
456///
457/// Precedence: Claude rate-limit envelope (a SPECIFIC failure that must
458/// outrank the generic `is_error` check — rate-limit envelopes carry
459/// `is_error: true`, and classifying them `Failed` would kill sequentagent's
460/// handoff/cron path) → Claude envelope `is_error: true` (authoritative,
461/// overrides a success marker) → DEVFLOW_RESULT marker (portable; works for
462/// plain text and a Claude envelope's unwrapped `result` text) → Codex JSONL
463/// event stream (`turn.failed` decisive; `turn.completed` defers) → Codex
464/// plain-text rate-limit heuristic (least authoritative, stays last).
465pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
466    let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
467    // Read lossily: in monitor mode the agent's stdout reaches this file via
468    // raw sh redirection, so one invalid UTF-8 byte in a strict
469    // read_to_string would silently disable ALL Layer-1 detection (marker,
470    // envelope, rate limit) — the same failure class CR-01 (13-REVIEW.md)
471    // fixed in the blocking-mode capture.
472    let bytes = std::fs::read(&stdout_path).ok()?;
473    let stdout = String::from_utf8_lossy(&bytes);
474    detect_claude_rate_limit(&stdout)
475        .map(rate_limited_result)
476        .or_else(|| detect_claude_envelope_failure(&stdout))
477        .or_else(|| parse_devflow_result(&stdout))
478        .or_else(|| parse_codex_event_result(&stdout))
479        .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
480}
481
482/// Build the `RateLimited` result Layer 1 reports for a detected retry hint.
483fn rate_limited_result(retry: String) -> AgentResult {
484    AgentResult {
485        status: AgentStatus::RateLimited,
486        exit_code: None,
487        reason: Some(format!("rate limited until {retry}")),
488        commits: None,
489        summary: None,
490        verdict: None,
491    }
492}
493
494/// Layer 2: Use exit code + commit count to determine result.
495///
496/// Reads exit code from `.devflow/phase-NN-exit` file.
497/// Counts commits in `feature/phase-NN` branch (if it exists).
498///
499/// The commit-count gate ("no commits → failed") is scoped to `stage` — it
500/// only applies to `Stage::Plan`/`Stage::Code` (checked via an explicit
501/// `matches!`, NOT `Stage::is_agent_stage()`, since that also includes
502/// `Define`, which legitimately produces zero commits). `exit≠0` is ALWAYS
503/// `Failed`, for every stage — only the `exit=0`/zero-commits branch is
504/// stage-scoped.
505///
506/// Decision matrix:
507///   exit≠0                                              → Failed (ALL stages)
508///   exit=0, stage in {Plan, Code}, commits=0             → Failed ("no work done")
509///   exit=0, stage in {Plan, Code}, commits>0             → Success
510///   exit=0, stage NOT in {Plan, Code} (Define/Validate/Ship), commits=0 → Success
511///           (not commit-gated; Validate's real pass signal is its verdict,
512///           not a bare zero-commit — see Task 2's turn.completed deferral)
513///   exit unknown                                         → fall to Layer 3 (return None)
514///
515/// WR-06 (13-REVIEW.md): takes only the explicit `project_root` parameter
516/// for both the `.devflow/` file paths and the git subprocess `current_dir`
517/// — previously it also accepted `state: &State` and used `state.project_root`
518/// for the git calls, which every caller happened to pass consistently with
519/// `project_root` but which the function itself had no way to enforce.
520pub fn evaluate_layer2(
521    project_root: &Path,
522    phase: u32,
523    git_flow: &GitFlowConfig,
524    stage: Stage,
525) -> Result<Option<AgentResult>, ResultError> {
526    let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
527    let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
528        Ok(s) => s.trim().parse().unwrap_or(-1),
529        Err(_) => return Ok(None), // fall to Layer 3
530    };
531
532    let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
533
534    // Verify branch exists before counting commits.
535    let branch_exists = std::process::Command::new("git")
536        .args(["rev-parse", "--verify", &branch])
537        .current_dir(project_root)
538        .output()
539        .map(|o| o.status.success())
540        .unwrap_or(false);
541
542    let commits: u32 = if branch_exists {
543        let range = format!("{}..{branch}", git_flow.develop);
544        std::process::Command::new("git")
545            .args(["rev-list", "--count", &range])
546            .current_dir(project_root)
547            .output()
548            .ok()
549            .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
550            .unwrap_or(0)
551    } else {
552        0
553    };
554
555    let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
556    let no_work_done = commit_gated && commits == 0;
557
558    Ok(Some(AgentResult {
559        status: if exit_code != 0 || no_work_done {
560            AgentStatus::Failed
561        } else {
562            AgentStatus::Success
563        },
564        exit_code: Some(exit_code),
565        reason: if exit_code != 0 {
566            Some(format!(
567                "agent exited with code {} ({} commits on {})",
568                exit_code, commits, branch
569            ))
570        } else if no_work_done {
571            Some(format!(
572                "no commits found on {} (agent exit code was {})",
573                branch, exit_code
574            ))
575        } else {
576            Some(format!(
577                "{} commits on {} (agent exit code was {})",
578                commits, branch, exit_code
579            ))
580        },
581        commits: Some(commits),
582        summary: None,
583        verdict: None,
584    }))
585}
586
587/// Layer 3: Last resort — agent process is gone, commits exist.
588///
589/// Returns Unknown status with a warning. This only fires when
590/// neither Layer 1 nor Layer 2 produced a definitive result.
591pub fn evaluate_layer3(
592    project_root: &Path,
593    phase: u32,
594    git_flow: &GitFlowConfig,
595) -> Result<AgentResult, ResultError> {
596    let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
597    let commits = std::process::Command::new("git")
598        .args([
599            "rev-list",
600            "--count",
601            &format!("{}..{branch}", git_flow.develop),
602        ])
603        .current_dir(project_root)
604        .output()
605        .ok()
606        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
607        .unwrap_or(0);
608
609    Ok(AgentResult {
610        status: AgentStatus::Unknown,
611        exit_code: None,
612        reason: if commits > 0 {
613            Some(format!(
614                "unverified — agent process is gone but {} commits exist on {}",
615                commits, branch
616            ))
617        } else {
618            Some("no work detected — agent process is gone with no commits".into())
619        },
620        commits: Some(commits),
621        summary: None,
622        verdict: None,
623    })
624}
625
626/// Full three-layer evaluation: returns the best available AgentResult.
627pub fn evaluate_agent_result(
628    project_root: &Path,
629    state: &State,
630    git_flow: &GitFlowConfig,
631) -> Result<AgentResult, ResultError> {
632    // Layer 1: DEVFLOW_RESULT marker (authoritative)
633    if let Some(result) = evaluate_layer1(project_root, state.phase) {
634        return Ok(result);
635    }
636
637    // Layer 2: Exit code + commit gate
638    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
639        return Ok(result);
640    }
641
642    // Layer 3: Process existence + commits
643    evaluate_layer3(project_root, state.phase, git_flow)
644}
645
646/// Path to the .devflow directory for a project root.
647fn devflow_dir(project_root: &Path) -> PathBuf {
648    project_root.join(".devflow")
649}
650
651/// Path to the stdout file for a given phase.
652pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
653    devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
654}
655
656/// Path where the agent's stderr is captured for a given phase.
657/// Lives alongside `stdout_path` under `.devflow/`.
658pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
659    devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
660}
661
662/// Path to the exit code file for a given phase.
663pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
664    devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
665}
666
667/// Path to the file where the monitor records the launched agent's PID.
668pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
669    devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
670}
671
672/// Clean up old stdout, exit code, and agent-pid files for a phase before starting.
673pub fn cleanup_phase_files(project_root: &Path, phase: u32) {
674    let _ = std::fs::remove_file(stdout_path(project_root, phase));
675    let _ = std::fs::remove_file(exit_code_path(project_root, phase));
676    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::config::GitFlowConfig;
683    use crate::mode::Mode;
684    use crate::stage::Stage;
685    use crate::state::{AgentKind, State};
686    use std::process::Command;
687
688    fn state_in(root: &Path, phase: u32) -> State {
689        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
690        state.stage = Stage::Code;
691        state
692    }
693
694    fn git(root: &Path, args: &[&str]) {
695        let output = Command::new("git")
696            .args(args)
697            .current_dir(root)
698            .output()
699            .unwrap();
700        assert!(
701            output.status.success(),
702            "git {:?} failed\nstdout: {}\nstderr: {}",
703            args,
704            String::from_utf8_lossy(&output.stdout),
705            String::from_utf8_lossy(&output.stderr)
706        );
707    }
708
709    fn init_repo_with_feature_commit(root: &Path, phase: u32) {
710        git(root, &["init"]);
711        git(root, &["config", "user.email", "devflow@example.com"]);
712        git(root, &["config", "user.name", "DevFlow Tests"]);
713        git(root, &["config", "commit.gpgsign", "false"]);
714        git(root, &["config", "tag.gpgsign", "false"]);
715        git(root, &["checkout", "-b", "develop"]);
716        std::fs::write(root.join("README.md"), "base\n").unwrap();
717        git(root, &["add", "README.md"]);
718        git(root, &["commit", "-m", "base"]);
719
720        let branch = format!("feature/phase-{phase:02}");
721        git(root, &["checkout", "-b", &branch]);
722        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
723        git(root, &["add", "phase.txt"]);
724        git(root, &["commit", "-m", "feature work"]);
725    }
726
727    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
728    /// develop's tip with **no** extra commit (0 commits ahead).
729    fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
730        git(root, &["init"]);
731        git(root, &["config", "user.email", "devflow@example.com"]);
732        git(root, &["config", "user.name", "DevFlow Tests"]);
733        git(root, &["config", "commit.gpgsign", "false"]);
734        git(root, &["config", "tag.gpgsign", "false"]);
735        git(root, &["checkout", "-b", "develop"]);
736        std::fs::write(root.join("README.md"), "base\n").unwrap();
737        git(root, &["add", "README.md"]);
738        git(root, &["commit", "-m", "base"]);
739
740        let branch = format!("feature/phase-{phase:02}");
741        git(root, &["checkout", "-b", &branch]);
742    }
743
744    #[test]
745    fn parse_success_marker() {
746        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
747        let result = parse_devflow_result(stdout).unwrap();
748        assert_eq!(result.status, AgentStatus::Success);
749    }
750
751    #[test]
752    fn parse_failed_marker_with_reason() {
753        let stdout =
754            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
755        let result = parse_devflow_result(stdout).unwrap();
756        assert_eq!(result.status, AgentStatus::Failed);
757        assert_eq!(result.reason.unwrap(), "clippy errors");
758    }
759
760    #[test]
761    fn parse_missing_marker_returns_none() {
762        let stdout = "just some output\nno marker here\n";
763        assert!(parse_devflow_result(stdout).is_none());
764    }
765
766    #[test]
767    fn parse_malformed_json_returns_none() {
768        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
769        assert!(parse_devflow_result(stdout).is_none());
770    }
771
772    #[test]
773    fn parse_lowercase_marker() {
774        let stdout = "devflow_result: {\"status\":\"success\"}\n";
775        let result = parse_devflow_result(stdout).unwrap();
776        assert_eq!(result.status, AgentStatus::Success);
777    }
778
779    #[test]
780    fn parse_marker_without_space_after_colon() {
781        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
782        let result = parse_devflow_result(stdout).unwrap();
783        assert_eq!(result.status, AgentStatus::Success);
784    }
785
786    #[test]
787    fn parse_lowercase_no_space_marker() {
788        // Lowercase prefix AND no space after the colon — the combination that
789        // the Phase 6 review flagged as uncovered.
790        let stdout = "devflow_result:{\"status\":\"success\"}\n";
791        let result = parse_devflow_result(stdout).unwrap();
792        assert_eq!(result.status, AgentStatus::Success);
793    }
794
795    #[test]
796    fn parse_finds_last_marker_in_tail() {
797        // Multiple markers — should find the last one.
798        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
799        let result = parse_devflow_result(stdout).unwrap();
800        assert_eq!(result.status, AgentStatus::Success);
801    }
802
803    #[test]
804    fn parse_marker_lines_returns_last_marker_in_long_output() {
805        let stdout = format!(
806            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
807             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
808            "prefix".repeat(900),
809            "tail output".repeat(100)
810        );
811
812        let result = parse_marker_lines(&stdout).unwrap();
813
814        assert_eq!(result.status, AgentStatus::Success);
815    }
816
817    #[test]
818    fn parse_marker_only_in_last_4000_chars() {
819        // Marker beyond 4000 chars from end should not be found.
820        let prefix = "a".repeat(5000);
821        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
822        assert!(parse_devflow_result(&stdout).is_none());
823    }
824
825    #[test]
826    fn parse_marker_with_commits_and_summary() {
827        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
828        let result = parse_devflow_result(stdout).unwrap();
829        assert_eq!(result.status, AgentStatus::Success);
830        assert_eq!(result.commits, Some(3));
831        assert_eq!(result.summary.unwrap(), "added tests");
832    }
833
834    #[test]
835    fn parse_marker_inside_json_result_envelope() {
836        // Claude --output-format json wraps the final text in a `result` field
837        // with embedded newlines escaped.
838        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
839        let result = parse_devflow_result(stdout).unwrap();
840        assert_eq!(result.status, AgentStatus::Success);
841        assert_eq!(result.commits, Some(2));
842    }
843
844    #[test]
845    fn parse_failed_marker_inside_json_envelope() {
846        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
847        let result = parse_devflow_result(stdout).unwrap();
848        assert_eq!(result.status, AgentStatus::Failed);
849        assert_eq!(result.reason.unwrap(), "tests failed");
850    }
851
852    #[test]
853    fn parse_json_envelope_without_marker_returns_none() {
854        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
855        assert!(parse_devflow_result(stdout).is_none());
856    }
857
858    #[test]
859    fn detect_claude_json_rate_limit_by_subtype() {
860        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
861        assert_eq!(
862            detect_rate_limit(stdout).as_deref(),
863            Some("2026-06-18T15:45:30Z")
864        );
865    }
866
867    #[test]
868    fn detect_claude_json_rate_limit_by_429() {
869        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
870        assert_eq!(
871            detect_rate_limit(stdout).as_deref(),
872            Some("Too many requests. Try later.")
873        );
874    }
875
876    #[test]
877    fn detect_codex_try_again_rate_limit() {
878        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
879        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
880    }
881
882    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
883    /// `json_find_key` run on the coding agent's raw stdout via
884    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
885    /// goes through. Deeply nested JSON — accidental or adversarial — must
886    /// not stack-overflow the process, and a real marker at any depth
887    /// serde_json will parse (its default recursion limit is exactly 128)
888    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
889    /// silently misclassified rate-limit markers at depths 64–128.
890    #[test]
891    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
892        // 100 levels: parseable by serde_json (limit 128), deeper than the
893        // removed 64-level traversal cap that used to hide the marker.
894        const DEPTH: usize = 100;
895        let mut stdout = String::new();
896        for _ in 0..DEPTH {
897            stdout.push_str(r#"{"nested":"#);
898        }
899        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
900        for _ in 0..DEPTH {
901            stdout.push('}');
902        }
903
904        // Must return promptly without crashing AND find the buried marker —
905        // the iterative worklist traversal has no silent-miss window.
906        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
907    }
908
909    #[test]
910    fn detect_rate_limit_ignores_normal_stdout() {
911        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
912        assert!(detect_rate_limit(stdout).is_none());
913    }
914
915    #[test]
916    fn claude_envelope_is_error_detected() {
917        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
918        let result = detect_claude_envelope_failure(stdout).unwrap();
919        assert_eq!(result.status, AgentStatus::Failed);
920    }
921
922    #[test]
923    fn claude_is_error_overrides_success_marker() {
924        let dir = tempfile::tempdir().unwrap();
925        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
926        std::fs::write(
927            stdout_path(dir.path(), 9),
928            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
929        )
930        .unwrap();
931
932        let result = evaluate_layer1(dir.path(), 9).unwrap();
933
934        assert_eq!(result.status, AgentStatus::Failed);
935    }
936
937    #[test]
938    fn claude_envelope_is_error_false_defers() {
939        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
940        assert!(detect_claude_envelope_failure(stdout).is_none());
941    }
942
943    #[test]
944    fn claude_envelope_marker_still_wins() {
945        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
946        assert!(detect_claude_envelope_failure(stdout).is_none());
947        let result = parse_devflow_result(stdout).unwrap();
948        assert_eq!(result.status, AgentStatus::Success);
949        assert_eq!(result.commits, Some(2));
950    }
951
952    #[test]
953    fn codex_event_stream_parses_turn_failed() {
954        let stdout = concat!(
955            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
956            "{\"type\":\"turn.started\"}\n",
957            "{\"type\":\"item.started\",\"item\":{}}\n",
958            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
959        );
960        let result = parse_codex_event_result(stdout).unwrap();
961        assert_eq!(result.status, AgentStatus::Failed);
962        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
963    }
964
965    #[test]
966    fn codex_turn_completed_no_marker_defers() {
967        let stdout = concat!(
968            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
969            "{\"type\":\"turn.started\"}\n",
970            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
971        );
972        assert!(parse_codex_event_result(stdout).is_none());
973    }
974
975    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
976    /// inside an `agent_message` item's text, never as a raw stdout line. A
977    /// self-reported failure followed by a bare `turn.completed` must parse
978    /// as Failed with the agent's reason — not defer to Layer 2 (which would
979    /// see exit 0 and call it a success).
980    #[test]
981    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
982        let stdout = concat!(
983            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
984            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
985            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
986        );
987        let result = parse_codex_event_result(stdout).unwrap();
988        assert_eq!(result.status, AgentStatus::Failed);
989        assert_eq!(
990            result.reason.as_deref(),
991            Some("interactive input unavailable")
992        );
993    }
994
995    #[test]
996    fn codex_agent_message_marker_success_short_circuits() {
997        let stdout = concat!(
998            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
999            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1000            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1001        );
1002        let result = parse_codex_event_result(stdout).unwrap();
1003        assert_eq!(result.status, AgentStatus::Success);
1004    }
1005
1006    /// 13-06 dogfood regression: document content echoed into a JSONL event
1007    /// (GSD reference tables mentioning "rate limiting") must not trip the
1008    /// plain-text rate-limit heuristic — it returned the entire multi-KB
1009    /// event line as the "retry time" and that reached the desktop
1010    /// notification verbatim.
1011    #[test]
1012    fn detect_rate_limit_ignores_json_event_lines() {
1013        let stdout = concat!(
1014            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1015            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1016            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1017        );
1018        assert_eq!(detect_rate_limit(stdout), None);
1019    }
1020
1021    #[test]
1022    fn detect_rate_limit_still_reads_codex_plain_text() {
1023        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1024        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1025    }
1026
1027    #[test]
1028    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1029        let stdout = concat!(
1030            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1031            "not json at all\n",
1032            "{\"type\":\"item.started\",\"item\":{}}\n",
1033            "{\"type\":\"item.updated\",\"item\":{}}\n",
1034            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1035        );
1036        let result = parse_codex_event_result(stdout).unwrap();
1037        assert_eq!(result.status, AgentStatus::Failed);
1038        assert_eq!(result.reason.as_deref(), Some("boom"));
1039    }
1040
1041    #[test]
1042    fn claude_envelope_not_consumed_by_codex_parser() {
1043        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1044        assert!(parse_codex_event_result(stdout).is_none());
1045    }
1046
1047    #[test]
1048    fn evaluate_layer1_reports_rate_limited_without_marker() {
1049        let dir = tempfile::tempdir().unwrap();
1050        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1051        std::fs::write(
1052            stdout_path(dir.path(), 7),
1053            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1054        )
1055        .unwrap();
1056
1057        let result = evaluate_layer1(dir.path(), 7).unwrap();
1058
1059        assert_eq!(result.status, AgentStatus::RateLimited);
1060        assert_eq!(
1061            result.reason.as_deref(),
1062            Some("rate limited until 2026-06-18T15:45:30Z")
1063        );
1064    }
1065
1066    /// A real Claude rate-limit envelope carries `is_error: true` alongside
1067    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
1068    /// must outrank the generic is_error → Failed path, or sequentagent's
1069    /// handoff/cron machinery never triggers for the exact case it exists for.
1070    #[test]
1071    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1072        let dir = tempfile::tempdir().unwrap();
1073        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1074        std::fs::write(
1075            stdout_path(dir.path(), 7),
1076            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1077        )
1078        .unwrap();
1079
1080        let result = evaluate_layer1(dir.path(), 7).unwrap();
1081
1082        assert_eq!(result.status, AgentStatus::RateLimited);
1083        assert_eq!(
1084            result.reason.as_deref(),
1085            Some("rate limited until 2026-06-18T15:45:30Z")
1086        );
1087    }
1088
1089    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
1090    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
1091    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
1092    /// detection (the blocking-mode capture was fixed; the file read here is
1093    /// the other half of the same bug).
1094    #[test]
1095    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1096        let dir = tempfile::tempdir().unwrap();
1097        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1098        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1099        bytes.extend_from_slice(
1100            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1101        );
1102        std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1103
1104        let result = evaluate_layer1(dir.path(), 5).unwrap();
1105
1106        assert_eq!(result.status, AgentStatus::Failed);
1107        assert_eq!(result.reason.as_deref(), Some("review: bad"));
1108    }
1109
1110    #[test]
1111    fn cleanup_removes_phase_files() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let root = dir.path();
1114        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1115        std::fs::write(root.join(".devflow/phase-01-stdout"), "test").unwrap();
1116        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1117
1118        cleanup_phase_files(root, 1);
1119        assert!(!root.join(".devflow/phase-01-stdout").exists());
1120        assert!(!root.join(".devflow/phase-01-exit").exists());
1121    }
1122
1123    #[test]
1124    fn cleanup_handles_missing_files() {
1125        let dir = tempfile::tempdir().unwrap();
1126        let root = dir.path();
1127        // Should not panic when files don't exist.
1128        cleanup_phase_files(root, 1);
1129    }
1130
1131    #[test]
1132    fn evaluate_agent_result_reads_files_end_to_end() {
1133        let dir = tempfile::tempdir().unwrap();
1134        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1135        std::fs::write(
1136            stdout_path(dir.path(), 6),
1137            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
1138        )
1139        .unwrap();
1140        std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
1141        let state = state_in(dir.path(), 6);
1142
1143        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1144
1145        assert_eq!(result.status, AgentStatus::Success);
1146        assert_eq!(result.commits, Some(2));
1147        assert_eq!(result.summary.as_deref(), Some("ok"));
1148    }
1149
1150    #[test]
1151    fn evaluate_layer1_finds_devflow_result_in_file() {
1152        let dir = tempfile::tempdir().unwrap();
1153        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1154        std::fs::write(
1155            stdout_path(dir.path(), 3),
1156            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
1157        )
1158        .unwrap();
1159
1160        let result = evaluate_layer1(dir.path(), 3).unwrap();
1161
1162        assert_eq!(result.status, AgentStatus::Failed);
1163        assert_eq!(result.reason.as_deref(), Some("bad output"));
1164    }
1165
1166    #[test]
1167    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
1168        let dir = tempfile::tempdir().unwrap();
1169        init_repo_with_feature_commit(dir.path(), 4);
1170        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1171        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1172        let state = state_in(dir.path(), 4);
1173
1174        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1175            .unwrap()
1176            .unwrap();
1177
1178        assert_eq!(result.status, AgentStatus::Success);
1179        assert_eq!(result.exit_code, Some(0));
1180        assert_eq!(result.commits, Some(1));
1181        assert!(result.reason.unwrap().contains("1 commits"));
1182    }
1183
1184    #[test]
1185    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
1186        // exit=0 but the feature branch has 0 commits ahead of develop →
1187        // "no work done" failure (the Layer 2 middle branch).
1188        let dir = tempfile::tempdir().unwrap();
1189        init_repo_with_feature_no_commit(dir.path(), 4);
1190        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1191        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
1192        let state = state_in(dir.path(), 4);
1193
1194        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1195            .unwrap()
1196            .unwrap();
1197
1198        assert_eq!(result.status, AgentStatus::Failed);
1199        assert_eq!(result.exit_code, Some(0));
1200        assert_eq!(result.commits, Some(0));
1201        assert!(result.reason.unwrap().contains("no commits"));
1202    }
1203
1204    #[test]
1205    fn evaluate_layer2_nonzero_exit_is_failed() {
1206        // Non-zero exit code → failure regardless of commit count.
1207        let dir = tempfile::tempdir().unwrap();
1208        init_repo_with_feature_commit(dir.path(), 4);
1209        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1210        std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
1211        let state = state_in(dir.path(), 4);
1212
1213        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
1214            .unwrap()
1215            .unwrap();
1216
1217        assert_eq!(result.status, AgentStatus::Failed);
1218        assert_eq!(result.exit_code, Some(1));
1219        assert!(result.reason.unwrap().contains("exited with code 1"));
1220    }
1221
1222    #[test]
1223    fn layer2_nonzero_exit_is_failed_all_stages() {
1224        // Non-zero exit is Failed regardless of stage — including Define and
1225        // Validate, which are exempt from the zero-commit gate but NOT from
1226        // the exit-code check.
1227        let dir = tempfile::tempdir().unwrap();
1228        init_repo_with_feature_no_commit(dir.path(), 10);
1229        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1230        std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
1231
1232        for stage in [
1233            Stage::Define,
1234            Stage::Plan,
1235            Stage::Code,
1236            Stage::Validate,
1237            Stage::Ship,
1238        ] {
1239            let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
1240                .unwrap()
1241                .unwrap();
1242            assert_eq!(
1243                result.status,
1244                AgentStatus::Failed,
1245                "stage {stage:?} should be Failed on nonzero exit"
1246            );
1247        }
1248    }
1249
1250    #[test]
1251    fn layer2_skips_commit_gate_for_define_and_validate() {
1252        let dir = tempfile::tempdir().unwrap();
1253        init_repo_with_feature_no_commit(dir.path(), 11);
1254        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1255        std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
1256
1257        for stage in [Stage::Define, Stage::Validate] {
1258            let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
1259                .unwrap()
1260                .unwrap();
1261            assert_ne!(
1262                result.status,
1263                AgentStatus::Failed,
1264                "stage {stage:?} should not be Failed for zero commits"
1265            );
1266        }
1267
1268        // Code stage with the same zero-commit inputs is still Failed
1269        // (existing behavior preserved).
1270        let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
1271            .unwrap()
1272            .unwrap();
1273        assert_eq!(result.status, AgentStatus::Failed);
1274    }
1275
1276    #[test]
1277    fn evaluate_layer3_falls_back_to_commit_count() {
1278        let dir = tempfile::tempdir().unwrap();
1279        init_repo_with_feature_commit(dir.path(), 5);
1280
1281        let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
1282
1283        assert_eq!(result.status, AgentStatus::Unknown);
1284        assert_eq!(result.exit_code, None);
1285        assert_eq!(result.commits, Some(1));
1286        assert!(result.reason.unwrap().contains("1 commits"));
1287    }
1288
1289    #[test]
1290    fn parse_devflow_result_reads_verdict() {
1291        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
1292        let result = parse_devflow_result(stdout).unwrap();
1293        assert_eq!(result.status, AgentStatus::Success);
1294        assert_eq!(result.verdict, Some(Verdict::Gaps));
1295    }
1296
1297    #[test]
1298    fn parse_devflow_result_reads_verdict_pass() {
1299        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
1300        let result = parse_devflow_result(stdout).unwrap();
1301        assert_eq!(result.status, AgentStatus::Success);
1302        assert_eq!(result.verdict, Some(Verdict::Pass));
1303    }
1304
1305    #[test]
1306    fn parse_devflow_result_verdict_absent_is_none() {
1307        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
1308        let result = parse_devflow_result(stdout).unwrap();
1309        assert_eq!(result.status, AgentStatus::Success);
1310        assert_eq!(result.verdict, None);
1311    }
1312
1313    #[test]
1314    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
1315        // An unknown verdict string must not fail the whole marker parse —
1316        // status must still come through as Success with verdict None (T-13-14).
1317        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
1318        let result = parse_devflow_result(unknown).unwrap();
1319        assert_eq!(result.status, AgentStatus::Success);
1320        assert_eq!(result.verdict, None);
1321
1322        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
1323        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
1324        let result = parse_devflow_result(miscased).unwrap();
1325        assert_eq!(result.status, AgentStatus::Success);
1326        assert_eq!(result.verdict, None);
1327    }
1328
1329    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
1330    /// JSON *type* (bool, number, object) must be just as lenient as a
1331    /// malformed string value — before the fix, deserializing straight to
1332    /// `Option<String>` errored out the entire `AgentResult` parse for a
1333    /// type mismatch, defeating the doc comment's "a malformed verdict must
1334    /// never silently drop a valid status" guarantee for this specific case.
1335    #[test]
1336    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
1337        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
1338        let result = parse_devflow_result(bool_verdict).unwrap();
1339        assert_eq!(result.status, AgentStatus::Success);
1340        assert_eq!(result.verdict, None);
1341
1342        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
1343        let result = parse_devflow_result(numeric_verdict).unwrap();
1344        assert_eq!(result.status, AgentStatus::Success);
1345        assert_eq!(result.verdict, None);
1346
1347        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
1348        let result = parse_devflow_result(object_verdict).unwrap();
1349        assert_eq!(result.status, AgentStatus::Success);
1350        assert_eq!(result.verdict, None);
1351    }
1352}