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