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/// Reconciles Layer 0's affirmative-success result with Layer 1's
799/// self-reported verdict at `Stage::Validate` (18e).
800///
801/// Layer 0's affirmative-success arm above short-circuits the cascade before
802/// Layer 1 ever runs (`evaluate_agent_result_inner` returns immediately on
803/// any `Some(..)` from Layer 0), but Layer 1 is the ONLY carrier of a
804/// `verdict` — `status` reports whether the stage's task ran; `verdict`
805/// reports whether validation itself passed (see `AgentResult::verdict`'s
806/// doc comment). At `Stage::Validate` that meant an agent's explicit
807/// `verdict: pass` was silently discarded and `advance()` computed a failure
808/// from it — a regression introduced by this project's own 17-03, fixed
809/// here.
810///
811/// `decided_by_layer` deliberately stays `Some(0)` — Layer 0 still DECIDED
812/// the `status`; Layer 1 only supplies the `verdict`. The CLI relies on that
813/// value to tell an `external_verify` Validate apart from an ordinary one
814/// (`classify_validate_outcome`, 18e).
815///
816/// Scoped to `Stage::Validate` only (flagged assumption in 18-05-PLAN.md): at
817/// every other stage an affirmative Layer 0 success keeps `verdict: None`,
818/// unchanged from current behavior. A Layer 0 FAILURE is never passed here —
819/// only its affirmative-success arm is, so a failed probe still outranks
820/// every agent-controlled signal.
821fn reconcile_layer0_verdict(
822    project_root: &Path,
823    state: &State,
824    result: AgentResult,
825) -> AgentResult {
826    if state.stage != Stage::Validate
827        || result.status != AgentStatus::Success
828        || result.decided_by_layer != Some(0)
829    {
830        return result;
831    }
832    let verdict = evaluate_layer1(project_root, state.phase).and_then(|layer1| layer1.verdict);
833    AgentResult { verdict, ..result }
834}
835
836/// Full four-layer evaluation: returns the best available AgentResult.
837pub fn evaluate_agent_result(
838    project_root: &Path,
839    state: &State,
840    git_flow: &GitFlowConfig,
841) -> Result<AgentResult, ResultError> {
842    let approval = crate::verify::external_verification_approval();
843    evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
844}
845
846fn evaluate_agent_result_inner(
847    project_root: &Path,
848    state: &State,
849    git_flow: &GitFlowConfig,
850    approved_commands: Option<&[String]>,
851) -> Result<AgentResult, ResultError> {
852    // Layer 0: operator-authored external post-condition (authoritative failure)
853    if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
854        return Ok(reconcile_layer0_verdict(project_root, state, result));
855    }
856
857    // Layer 1: DEVFLOW_RESULT marker (authoritative)
858    if let Some(result) = evaluate_layer1(project_root, state.phase) {
859        return Ok(result);
860    }
861
862    // Layer 2: Exit code + commit gate
863    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
864        return Ok(result);
865    }
866
867    // Layer 3: Process existence + commits
868    evaluate_layer3(project_root, state.phase, git_flow)
869}
870
871/// Path to the .devflow directory for a project root.
872fn devflow_dir(project_root: &Path) -> PathBuf {
873    project_root.join(".devflow")
874}
875
876/// Path to the stdout file for a given phase.
877pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
878    devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
879}
880
881/// Path where the agent's stderr is captured for a given phase.
882/// Lives alongside `stdout_path` under `.devflow/`.
883pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
884    devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
885}
886
887/// Path to the exit code file for a given phase.
888pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
889    devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
890}
891
892/// Path to the file where the monitor records the launched agent's PID.
893pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
894    devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
895}
896
897/// Path to the archived-capture-history directory for a phase (16b).
898///
899/// `.devflow/history/phase-NN/` holds retained per-stage capture generations
900/// so a false-positive self-report can be diagnosed after the fact. Exposed
901/// as a constructor (rather than inlined at each call site) so downstream
902/// tooling (16h in 16-07's correlation, 16i in 16-05's enumeration) always
903/// derives the path from here instead of hardcoding it.
904pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
905    devflow_dir(project_root)
906        .join("history")
907        .join(format!("phase-{:02}", phase))
908}
909
910/// Monotonically increasing tie-breaker appended to the nanosecond timestamp
911/// used to stamp archived generations, so two archives issued within the
912/// same nanosecond (possible in a tight test loop) never collide.
913static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
914
915/// A stamp unique within this process, used to name an archived generation.
916/// The outgoing stage's name is not available at the `archive_phase_files`
917/// call site (see `launch_stage` in main.rs), so a monotonic timestamp is
918/// used instead — sufficient to order and identify generations.
919fn archive_stamp() -> String {
920    let nanos = std::time::SystemTime::now()
921        .duration_since(std::time::UNIX_EPOCH)
922        .map(|d| d.as_nanos())
923        .unwrap_or(0);
924    let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
925    format!("{nanos}-{seq}")
926}
927
928/// Archive the prior stage's stdout/exit captures into bounded per-phase
929/// history instead of wiping them outright, so a false-positive self-report
930/// can be diagnosed after the fact (16b). Replaces the old
931/// `cleanup_phase_files`, which deleted these files unconditionally.
932///
933/// At most `retain` capture generations are kept per phase; older ones are
934/// pruned (see [`prune_history`]). The agent-pid file is still removed
935/// outright — it is process bookkeeping, not diagnostic output. When there
936/// is nothing to archive (first launch), this is a no-op success.
937pub fn archive_phase_files(
938    project_root: &Path,
939    evidence_root: &Path,
940    phase: u32,
941    retain: usize,
942) -> Result<Option<String>, std::io::Error> {
943    archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
944}
945
946fn archive_phase_files_with_stamp(
947    project_root: &Path,
948    evidence_root: &Path,
949    phase: u32,
950    retain: usize,
951    stamp: &str,
952) -> Result<Option<String>, std::io::Error> {
953    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
954
955    let stdout_src = stdout_path(project_root, phase);
956    let exit_src = exit_code_path(project_root, phase);
957    let stdout_exists = stdout_src.exists();
958    let exit_exists = exit_src.exists();
959    if !stdout_exists && !exit_exists {
960        return Ok(None); // Nothing to archive — first launch.
961    }
962
963    let history_dir = history_dir(project_root, phase);
964    std::fs::create_dir_all(&history_dir)?;
965
966    let staging_dir = history_dir.join(format!(".pending-{stamp}"));
967    std::fs::create_dir(&staging_dir)?;
968    let stdout_stage = staging_dir.join("stdout");
969    let exit_stage = staging_dir.join("exit");
970    let review_stage = staging_dir.join("REVIEW.md");
971    let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
972    let exit_dest = history_dir.join(format!("{stamp}-exit"));
973    let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
974    let review_src = phase_review_path(evidence_root, phase);
975
976    let mut stdout_staged = false;
977    let mut exit_staged = false;
978    let mut stdout_published = false;
979    let mut exit_published = false;
980    let mut review_published = false;
981
982    let archive_result = (|| -> Result<(), std::io::Error> {
983        if stdout_exists {
984            std::fs::rename(&stdout_src, &stdout_stage)?;
985            stdout_staged = true;
986        }
987        if exit_exists {
988            std::fs::rename(&exit_src, &exit_stage)?;
989            exit_staged = true;
990        }
991        if let Some(review) = &review_src {
992            std::fs::copy(review, &review_stage)?;
993        }
994
995        if stdout_exists {
996            std::fs::rename(&stdout_stage, &stdout_dest)?;
997            stdout_staged = false;
998            stdout_published = true;
999        }
1000        if exit_exists {
1001            std::fs::rename(&exit_stage, &exit_dest)?;
1002            exit_staged = false;
1003            exit_published = true;
1004        }
1005        if review_src.is_some() {
1006            std::fs::rename(&review_stage, &review_dest)?;
1007            review_published = true;
1008        }
1009        Ok(())
1010    })();
1011
1012    if let Err(error) = archive_result {
1013        let mut rollback_error = None;
1014        let mut restore = |from: &Path, to: &Path| {
1015            if let Err(error) = std::fs::rename(from, to)
1016                && rollback_error.is_none()
1017            {
1018                rollback_error = Some(error);
1019            }
1020        };
1021        if stdout_published {
1022            restore(&stdout_dest, &stdout_src);
1023        } else if stdout_staged {
1024            restore(&stdout_stage, &stdout_src);
1025        }
1026        if exit_published {
1027            restore(&exit_dest, &exit_src);
1028        } else if exit_staged {
1029            restore(&exit_stage, &exit_src);
1030        }
1031        if review_published {
1032            let _ = std::fs::remove_file(&review_dest);
1033        }
1034        let _ = std::fs::remove_dir_all(&staging_dir);
1035
1036        if let Some(rollback_error) = rollback_error {
1037            return Err(std::io::Error::new(
1038                error.kind(),
1039                format!("{error}; archive rollback failed: {rollback_error}"),
1040            ));
1041        }
1042        return Err(error);
1043    }
1044
1045    let _ = std::fs::remove_dir(&staging_dir);
1046
1047    prune_history(&history_dir, retain);
1048    Ok(Some(stamp.to_string()))
1049}
1050
1051fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
1052    let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
1053    let prefix = format!("{phase:02}-");
1054    for entry in phases.flatten() {
1055        if entry
1056            .file_name()
1057            .to_str()
1058            .is_some_and(|name| name.starts_with(&prefix))
1059        {
1060            let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
1061            if review.exists() {
1062                return Some(review);
1063            }
1064        }
1065    }
1066    None
1067}
1068
1069/// Keep only the newest `retain` capture generations under `history_dir`,
1070/// deleting older ones. Generations are grouped by their stamp (the shared
1071/// prefix of a `{stamp}-stdout`/`{stamp}-exit` pair, split off the trailing
1072/// `-stdout`/`-exit` suffix via `rsplit_once`) and ordered lexicographically,
1073/// which matches numeric/chronological order for the fixed-width nanosecond
1074/// stamps `archive_stamp` produces. Ordering parses both numeric components;
1075/// the process-local sequence is intentionally not fixed-width.
1076fn prune_history(history_dir: &Path, retain: usize) {
1077    let Ok(entries) = std::fs::read_dir(history_dir) else {
1078        return;
1079    };
1080
1081    let mut stamps: Vec<String> = entries
1082        .flatten()
1083        .filter_map(|entry| {
1084            let name = entry.file_name().to_str()?.to_string();
1085            name.rsplit_once('-')
1086                .map(|(stamp, _suffix)| stamp.to_string())
1087        })
1088        .collect();
1089    stamps.sort_by_key(|stamp| {
1090        let mut parts = stamp.split('-');
1091        let nanos = parts
1092            .next()
1093            .and_then(|part| part.parse::<u128>().ok())
1094            .unwrap_or(0);
1095        let sequence = parts
1096            .next()
1097            .and_then(|part| part.parse::<u64>().ok())
1098            .unwrap_or(0);
1099        (nanos, sequence)
1100    });
1101    stamps.dedup();
1102
1103    if stamps.len() <= retain {
1104        return;
1105    }
1106
1107    let to_remove = stamps.len() - retain;
1108    for stamp in &stamps[..to_remove] {
1109        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
1110        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
1111        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
1112    }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use crate::config::GitFlowConfig;
1119    use crate::mode::Mode;
1120    use crate::stage::Stage;
1121    use crate::state::{AgentKind, State};
1122    use std::process::Command;
1123
1124    fn state_in(root: &Path, phase: u32) -> State {
1125        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1126        state.stage = Stage::Code;
1127        state
1128    }
1129
1130    fn git(root: &Path, args: &[&str]) {
1131        let output = Command::new("git")
1132            .args(args)
1133            .current_dir(root)
1134            .output()
1135            .unwrap();
1136        assert!(
1137            output.status.success(),
1138            "git {:?} failed\nstdout: {}\nstderr: {}",
1139            args,
1140            String::from_utf8_lossy(&output.stdout),
1141            String::from_utf8_lossy(&output.stderr)
1142        );
1143    }
1144
1145    fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1146        git(root, &["init"]);
1147        git(root, &["config", "user.email", "devflow@example.com"]);
1148        git(root, &["config", "user.name", "DevFlow Tests"]);
1149        git(root, &["config", "commit.gpgsign", "false"]);
1150        git(root, &["config", "tag.gpgsign", "false"]);
1151        git(root, &["checkout", "-b", "develop"]);
1152        std::fs::write(root.join("README.md"), "base\n").unwrap();
1153        git(root, &["add", "README.md"]);
1154        git(root, &["commit", "-m", "base"]);
1155
1156        let branch = format!("feature/phase-{phase:02}");
1157        git(root, &["checkout", "-b", &branch]);
1158        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1159        git(root, &["add", "phase.txt"]);
1160        git(root, &["commit", "-m", "feature work"]);
1161    }
1162
1163    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
1164    /// develop's tip with **no** extra commit (0 commits ahead).
1165    fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1166        git(root, &["init"]);
1167        git(root, &["config", "user.email", "devflow@example.com"]);
1168        git(root, &["config", "user.name", "DevFlow Tests"]);
1169        git(root, &["config", "commit.gpgsign", "false"]);
1170        git(root, &["config", "tag.gpgsign", "false"]);
1171        git(root, &["checkout", "-b", "develop"]);
1172        std::fs::write(root.join("README.md"), "base\n").unwrap();
1173        git(root, &["add", "README.md"]);
1174        git(root, &["commit", "-m", "base"]);
1175
1176        let branch = format!("feature/phase-{phase:02}");
1177        git(root, &["checkout", "-b", &branch]);
1178    }
1179
1180    #[test]
1181    fn parse_success_marker() {
1182        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1183        let result = parse_devflow_result(stdout).unwrap();
1184        assert_eq!(result.status, AgentStatus::Success);
1185    }
1186
1187    #[test]
1188    fn parse_failed_marker_with_reason() {
1189        let stdout =
1190            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1191        let result = parse_devflow_result(stdout).unwrap();
1192        assert_eq!(result.status, AgentStatus::Failed);
1193        assert_eq!(result.reason.unwrap(), "clippy errors");
1194    }
1195
1196    #[test]
1197    fn parse_missing_marker_returns_none() {
1198        let stdout = "just some output\nno marker here\n";
1199        assert!(parse_devflow_result(stdout).is_none());
1200    }
1201
1202    #[test]
1203    fn parse_malformed_json_returns_none() {
1204        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1205        assert!(parse_devflow_result(stdout).is_none());
1206    }
1207
1208    #[test]
1209    fn parse_lowercase_marker() {
1210        let stdout = "devflow_result: {\"status\":\"success\"}\n";
1211        let result = parse_devflow_result(stdout).unwrap();
1212        assert_eq!(result.status, AgentStatus::Success);
1213    }
1214
1215    #[test]
1216    fn parse_marker_without_space_after_colon() {
1217        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1218        let result = parse_devflow_result(stdout).unwrap();
1219        assert_eq!(result.status, AgentStatus::Success);
1220    }
1221
1222    #[test]
1223    fn parse_lowercase_no_space_marker() {
1224        // Lowercase prefix AND no space after the colon — the combination that
1225        // the Phase 6 review flagged as uncovered.
1226        let stdout = "devflow_result:{\"status\":\"success\"}\n";
1227        let result = parse_devflow_result(stdout).unwrap();
1228        assert_eq!(result.status, AgentStatus::Success);
1229    }
1230
1231    #[test]
1232    fn parse_finds_last_marker_in_tail() {
1233        // Multiple markers — should find the last one.
1234        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1235        let result = parse_devflow_result(stdout).unwrap();
1236        assert_eq!(result.status, AgentStatus::Success);
1237    }
1238
1239    #[test]
1240    fn parse_marker_lines_returns_last_marker_in_long_output() {
1241        let stdout = format!(
1242            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1243             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1244            "prefix".repeat(900),
1245            "tail output".repeat(100)
1246        );
1247
1248        let result = parse_marker_lines(&stdout).unwrap();
1249
1250        assert_eq!(result.status, AgentStatus::Success);
1251    }
1252
1253    #[test]
1254    fn parse_marker_only_in_last_4000_chars() {
1255        // Marker beyond 4000 chars from end should not be found.
1256        let prefix = "a".repeat(5000);
1257        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1258        assert!(parse_devflow_result(&stdout).is_none());
1259    }
1260
1261    #[test]
1262    fn parse_marker_with_commits_and_summary() {
1263        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1264        let result = parse_devflow_result(stdout).unwrap();
1265        assert_eq!(result.status, AgentStatus::Success);
1266        assert_eq!(result.commits, Some(3));
1267        assert_eq!(result.summary.unwrap(), "added tests");
1268    }
1269
1270    #[test]
1271    fn parse_marker_inside_json_result_envelope() {
1272        // Claude --output-format json wraps the final text in a `result` field
1273        // with embedded newlines escaped.
1274        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1275        let result = parse_devflow_result(stdout).unwrap();
1276        assert_eq!(result.status, AgentStatus::Success);
1277        assert_eq!(result.commits, Some(2));
1278    }
1279
1280    #[test]
1281    fn parse_failed_marker_inside_json_envelope() {
1282        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1283        let result = parse_devflow_result(stdout).unwrap();
1284        assert_eq!(result.status, AgentStatus::Failed);
1285        assert_eq!(result.reason.unwrap(), "tests failed");
1286    }
1287
1288    #[test]
1289    fn parse_json_envelope_without_marker_returns_none() {
1290        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1291        assert!(parse_devflow_result(stdout).is_none());
1292    }
1293
1294    #[test]
1295    fn detect_claude_json_rate_limit_by_subtype() {
1296        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1297        assert_eq!(
1298            detect_rate_limit(stdout).as_deref(),
1299            Some("2026-06-18T15:45:30Z")
1300        );
1301    }
1302
1303    #[test]
1304    fn detect_claude_json_rate_limit_by_429() {
1305        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1306        assert_eq!(
1307            detect_rate_limit(stdout).as_deref(),
1308            Some("Too many requests. Try later.")
1309        );
1310    }
1311
1312    #[test]
1313    fn detect_codex_try_again_rate_limit() {
1314        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1315        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1316    }
1317
1318    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
1319    /// `json_find_key` run on the coding agent's raw stdout via
1320    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
1321    /// goes through. Deeply nested JSON — accidental or adversarial — must
1322    /// not stack-overflow the process, and a real marker at any depth
1323    /// serde_json will parse (its default recursion limit is exactly 128)
1324    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
1325    /// silently misclassified rate-limit markers at depths 64–128.
1326    #[test]
1327    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1328        // 100 levels: parseable by serde_json (limit 128), deeper than the
1329        // removed 64-level traversal cap that used to hide the marker.
1330        const DEPTH: usize = 100;
1331        let mut stdout = String::new();
1332        for _ in 0..DEPTH {
1333            stdout.push_str(r#"{"nested":"#);
1334        }
1335        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1336        for _ in 0..DEPTH {
1337            stdout.push('}');
1338        }
1339
1340        // Must return promptly without crashing AND find the buried marker —
1341        // the iterative worklist traversal has no silent-miss window.
1342        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1343    }
1344
1345    #[test]
1346    fn detect_rate_limit_ignores_normal_stdout() {
1347        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1348        assert!(detect_rate_limit(stdout).is_none());
1349    }
1350
1351    #[test]
1352    fn claude_envelope_is_error_detected() {
1353        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1354        let result = detect_claude_envelope_failure(stdout).unwrap();
1355        assert_eq!(result.status, AgentStatus::Failed);
1356    }
1357
1358    #[test]
1359    fn claude_is_error_overrides_success_marker() {
1360        let dir = tempfile::tempdir().unwrap();
1361        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1362        std::fs::write(
1363            stdout_path(dir.path(), 9),
1364            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1365        )
1366        .unwrap();
1367
1368        let result = evaluate_layer1(dir.path(), 9).unwrap();
1369
1370        assert_eq!(result.status, AgentStatus::Failed);
1371    }
1372
1373    #[test]
1374    fn claude_envelope_is_error_false_defers() {
1375        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1376        assert!(detect_claude_envelope_failure(stdout).is_none());
1377    }
1378
1379    #[test]
1380    fn claude_envelope_marker_still_wins() {
1381        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1382        assert!(detect_claude_envelope_failure(stdout).is_none());
1383        let result = parse_devflow_result(stdout).unwrap();
1384        assert_eq!(result.status, AgentStatus::Success);
1385        assert_eq!(result.commits, Some(2));
1386    }
1387
1388    #[test]
1389    fn codex_event_stream_parses_turn_failed() {
1390        let stdout = concat!(
1391            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1392            "{\"type\":\"turn.started\"}\n",
1393            "{\"type\":\"item.started\",\"item\":{}}\n",
1394            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1395        );
1396        let result = parse_codex_event_result(stdout).unwrap();
1397        assert_eq!(result.status, AgentStatus::Failed);
1398        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1399    }
1400
1401    #[test]
1402    fn codex_turn_completed_no_marker_defers() {
1403        let stdout = concat!(
1404            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1405            "{\"type\":\"turn.started\"}\n",
1406            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1407        );
1408        assert!(parse_codex_event_result(stdout).is_none());
1409    }
1410
1411    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
1412    /// inside an `agent_message` item's text, never as a raw stdout line. A
1413    /// self-reported failure followed by a bare `turn.completed` must parse
1414    /// as Failed with the agent's reason — not defer to Layer 2 (which would
1415    /// see exit 0 and call it a success).
1416    #[test]
1417    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1418        let stdout = concat!(
1419            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1420            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1421            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1422        );
1423        let result = parse_codex_event_result(stdout).unwrap();
1424        assert_eq!(result.status, AgentStatus::Failed);
1425        assert_eq!(
1426            result.reason.as_deref(),
1427            Some("interactive input unavailable")
1428        );
1429    }
1430
1431    #[test]
1432    fn codex_agent_message_marker_success_short_circuits() {
1433        let stdout = concat!(
1434            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1435            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1436            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1437        );
1438        let result = parse_codex_event_result(stdout).unwrap();
1439        assert_eq!(result.status, AgentStatus::Success);
1440    }
1441
1442    /// 13-06 dogfood regression: document content echoed into a JSONL event
1443    /// (GSD reference tables mentioning "rate limiting") must not trip the
1444    /// plain-text rate-limit heuristic — it returned the entire multi-KB
1445    /// event line as the "retry time" and that reached the desktop
1446    /// notification verbatim.
1447    #[test]
1448    fn detect_rate_limit_ignores_json_event_lines() {
1449        let stdout = concat!(
1450            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1451            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1452            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1453        );
1454        assert_eq!(detect_rate_limit(stdout), None);
1455    }
1456
1457    #[test]
1458    fn detect_rate_limit_still_reads_codex_plain_text() {
1459        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1460        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1461    }
1462
1463    #[test]
1464    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1465        let stdout = concat!(
1466            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1467            "not json at all\n",
1468            "{\"type\":\"item.started\",\"item\":{}}\n",
1469            "{\"type\":\"item.updated\",\"item\":{}}\n",
1470            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1471        );
1472        let result = parse_codex_event_result(stdout).unwrap();
1473        assert_eq!(result.status, AgentStatus::Failed);
1474        assert_eq!(result.reason.as_deref(), Some("boom"));
1475    }
1476
1477    #[test]
1478    fn claude_envelope_not_consumed_by_codex_parser() {
1479        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1480        assert!(parse_codex_event_result(stdout).is_none());
1481    }
1482
1483    #[test]
1484    fn evaluate_layer1_reports_rate_limited_without_marker() {
1485        let dir = tempfile::tempdir().unwrap();
1486        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1487        std::fs::write(
1488            stdout_path(dir.path(), 7),
1489            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1490        )
1491        .unwrap();
1492
1493        let result = evaluate_layer1(dir.path(), 7).unwrap();
1494
1495        assert_eq!(result.status, AgentStatus::RateLimited);
1496        assert_eq!(
1497            result.reason.as_deref(),
1498            Some("rate limited until 2026-06-18T15:45:30Z")
1499        );
1500    }
1501
1502    /// A real Claude rate-limit envelope carries `is_error: true` alongside
1503    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
1504    /// must outrank the generic is_error → Failed path, or sequentagent's
1505    /// handoff/cron machinery never triggers for the exact case it exists for.
1506    #[test]
1507    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1508        let dir = tempfile::tempdir().unwrap();
1509        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1510        std::fs::write(
1511            stdout_path(dir.path(), 7),
1512            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1513        )
1514        .unwrap();
1515
1516        let result = evaluate_layer1(dir.path(), 7).unwrap();
1517
1518        assert_eq!(result.status, AgentStatus::RateLimited);
1519        assert_eq!(
1520            result.reason.as_deref(),
1521            Some("rate limited until 2026-06-18T15:45:30Z")
1522        );
1523    }
1524
1525    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
1526    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
1527    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
1528    /// detection (the blocking-mode capture was fixed; the file read here is
1529    /// the other half of the same bug).
1530    #[test]
1531    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1532        let dir = tempfile::tempdir().unwrap();
1533        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1534        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1535        bytes.extend_from_slice(
1536            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1537        );
1538        std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1539
1540        let result = evaluate_layer1(dir.path(), 5).unwrap();
1541
1542        assert_eq!(result.status, AgentStatus::Failed);
1543        assert_eq!(result.reason.as_deref(), Some("review: bad"));
1544    }
1545
1546    #[test]
1547    fn failing_external_probe_outranks_success_marker() {
1548        let dir = tempfile::tempdir().unwrap();
1549        let phase_dir = dir
1550            .path()
1551            .join(".planning/phases/16-pipeline-reliability-hardening");
1552        std::fs::create_dir_all(&phase_dir).unwrap();
1553        std::fs::write(
1554            phase_dir.join("16-03-PLAN.md"),
1555            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1556        )
1557        .unwrap();
1558        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1559        std::fs::write(
1560            stdout_path(dir.path(), 16),
1561            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1562        )
1563        .unwrap();
1564        let state = state_in(dir.path(), 16);
1565
1566        let approval = vec!["test -f externally-shipped".to_string()];
1567        let result = evaluate_agent_result_inner(
1568            dir.path(),
1569            &state,
1570            &GitFlowConfig::default(),
1571            Some(&approval),
1572        )
1573        .unwrap();
1574
1575        assert_eq!(result.status, AgentStatus::Failed);
1576        assert!(
1577            result
1578                .reason
1579                .as_deref()
1580                .is_some_and(|reason| reason.contains("external verification failed"))
1581        );
1582    }
1583
1584    /// D-05 gap 1 / D-06 (17-03): Layer 0 now evaluates on every stage, not
1585    /// only Code. Also covers the review-flagged worktree bug (Plan 03
1586    /// MEDIUM, OpenCode): PLAN discovery must read `project_root` (where
1587    /// `.planning/phases/` actually lives), while probe execution still
1588    /// reads `execution_root` (the worktree) — using the worktree for
1589    /// discovery would find zero commands and mis-fire the "PLAN removed"
1590    /// veto.
1591    #[test]
1592    fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1593        let dir = tempfile::tempdir().unwrap();
1594        let worktree = dir.path().join("phase-worktree");
1595        std::fs::create_dir_all(&worktree).unwrap();
1596        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1597        std::fs::create_dir_all(&phase_dir).unwrap();
1598        std::fs::write(
1599            phase_dir.join("16-01-PLAN.md"),
1600            "---\nexternal_verify: \"test -f implemented\"\n---\n",
1601        )
1602        .unwrap();
1603        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1604        std::fs::write(
1605            stdout_path(dir.path(), 16),
1606            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1607        )
1608        .unwrap();
1609        let mut state = state_in(dir.path(), 16);
1610        state.worktree_path = Some(worktree.clone());
1611        state.stage = Stage::Plan;
1612
1613        let approval = vec!["test -f implemented".to_string()];
1614
1615        // Layer 0 now fires on Plan too — the probe file does not yet exist
1616        // in the worktree, so this must fail on the probe itself (NOT a
1617        // false PLAN-removed veto, which would mean discovery silently
1618        // returned zero commands).
1619        let plan_result = evaluate_agent_result_inner(
1620            dir.path(),
1621            &state,
1622            &GitFlowConfig::default(),
1623            Some(&approval),
1624        )
1625        .unwrap();
1626        assert_eq!(plan_result.status, AgentStatus::Failed);
1627        assert!(
1628            plan_result
1629                .reason
1630                .as_deref()
1631                .is_some_and(|reason| reason.contains("external verification failed")),
1632            "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1633            plan_result.reason
1634        );
1635
1636        state.stage = Stage::Code;
1637        let code_result = evaluate_agent_result_inner(
1638            dir.path(),
1639            &state,
1640            &GitFlowConfig::default(),
1641            Some(&approval),
1642        )
1643        .unwrap();
1644        assert_eq!(code_result.status, AgentStatus::Failed);
1645
1646        // The probe still executes against execution_root (the worktree) —
1647        // only PLAN discovery moved to project_root.
1648        std::fs::write(worktree.join("implemented"), "done").unwrap();
1649        let passing = evaluate_agent_result_inner(
1650            dir.path(),
1651            &state,
1652            &GitFlowConfig::default(),
1653            Some(&approval),
1654        )
1655        .unwrap();
1656        assert_eq!(passing.status, AgentStatus::Success);
1657        assert_eq!(passing.decided_by_layer, Some(0));
1658    }
1659
1660    #[test]
1661    fn changed_external_probe_never_inherits_prior_approval() {
1662        let dir = tempfile::tempdir().unwrap();
1663        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1664        std::fs::create_dir_all(&phase_dir).unwrap();
1665        std::fs::write(
1666            phase_dir.join("16-01-PLAN.md"),
1667            "---\nexternal_verify: \"touch escaped\"\n---\n",
1668        )
1669        .unwrap();
1670        let state = state_in(dir.path(), 16);
1671        let approved = vec!["test -f reviewed-artifact".to_string()];
1672
1673        let result = evaluate_agent_result_inner(
1674            dir.path(),
1675            &state,
1676            &GitFlowConfig::default(),
1677            Some(&approved),
1678        )
1679        .unwrap();
1680
1681        assert_eq!(result.status, AgentStatus::Failed);
1682        assert!(result.reason.unwrap().contains("approval mismatch"));
1683        assert!(!dir.path().join("escaped").exists());
1684    }
1685
1686    #[test]
1687    fn removed_external_probe_fails_closed_against_prior_approval() {
1688        let dir = tempfile::tempdir().unwrap();
1689        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1690        std::fs::write(
1691            stdout_path(dir.path(), 16),
1692            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1693        )
1694        .unwrap();
1695        let state = state_in(dir.path(), 16);
1696        let approved = vec!["test -f shipped".to_string()];
1697
1698        let result = evaluate_agent_result_inner(
1699            dir.path(),
1700            &state,
1701            &GitFlowConfig::default(),
1702            Some(&approved),
1703        )
1704        .unwrap();
1705
1706        assert_eq!(result.status, AgentStatus::Failed);
1707        assert!(result.reason.unwrap().contains("declaration was removed"));
1708    }
1709
1710    #[test]
1711    fn no_external_declaration_preserves_layer1_result() {
1712        let dir = tempfile::tempdir().unwrap();
1713        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1714        std::fs::write(
1715            stdout_path(dir.path(), 16),
1716            "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
1717        )
1718        .unwrap();
1719        let state = state_in(dir.path(), 16);
1720        let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
1721
1722        let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
1723
1724        assert_eq!(
1725            serde_json::to_value(full).unwrap(),
1726            serde_json::to_value(layer1).unwrap()
1727        );
1728    }
1729
1730    /// D-05 gap 2 (17-03): a declared, operator-approved external
1731    /// post-condition whose probe passes is affirmative Success evidence on
1732    /// its own — even with zero commits and on a non-Code stage (Define
1733    /// here). No agent stdout is written at all, so if Layer 0 did not
1734    /// short-circuit, there would be nothing for Layer 1 to find and Layer 2
1735    /// would fall through for lack of an exit-code file.
1736    #[test]
1737    fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
1738        let dir = tempfile::tempdir().unwrap();
1739        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1740        std::fs::create_dir_all(&phase_dir).unwrap();
1741        std::fs::write(
1742            phase_dir.join("16-01-PLAN.md"),
1743            "---\nexternal_verify: \"test -f shipped\"\n---\n",
1744        )
1745        .unwrap();
1746        std::fs::write(dir.path().join("shipped"), "done").unwrap();
1747        let mut state = state_in(dir.path(), 16);
1748        state.stage = Stage::Define;
1749
1750        let approval = vec!["test -f shipped".to_string()];
1751        let result = evaluate_agent_result_inner(
1752            dir.path(),
1753            &state,
1754            &GitFlowConfig::default(),
1755            Some(&approval),
1756        )
1757        .unwrap();
1758
1759        assert_eq!(result.status, AgentStatus::Success);
1760        assert_eq!(result.decided_by_layer, Some(0));
1761        assert_eq!(result.commits, None);
1762        // Off-Validate stage: verdict reconciliation does not apply (18e).
1763        assert_eq!(result.verdict, None);
1764    }
1765
1766    /// Review Plan 03 LOW (Codex+OpenCode), 16a: an approved all-passing
1767    /// Layer 0 probe intentionally outranks a Layer 1 self-reported failure
1768    /// marker — proven here at the cascade level (`evaluate_agent_result_inner`),
1769    /// not merely in isolation on `evaluate_layer0`.
1770    #[test]
1771    fn layer0_affirmative_success_outranks_layer1_failure_marker() {
1772        let dir = tempfile::tempdir().unwrap();
1773        let phase_dir = dir
1774            .path()
1775            .join(".planning/phases/16-pipeline-reliability-hardening");
1776        std::fs::create_dir_all(&phase_dir).unwrap();
1777        std::fs::write(
1778            phase_dir.join("16-03-PLAN.md"),
1779            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1780        )
1781        .unwrap();
1782        std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
1783        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1784        std::fs::write(
1785            stdout_path(dir.path(), 16),
1786            "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
1787        )
1788        .unwrap();
1789        let state = state_in(dir.path(), 16);
1790
1791        let approval = vec!["test -f externally-shipped".to_string()];
1792        let result = evaluate_agent_result_inner(
1793            dir.path(),
1794            &state,
1795            &GitFlowConfig::default(),
1796            Some(&approval),
1797        )
1798        .unwrap();
1799
1800        assert_eq!(result.status, AgentStatus::Success);
1801        assert_eq!(result.decided_by_layer, Some(0));
1802        // Off-Validate stage (Code): verdict reconciliation does not apply,
1803        // even though Layer 1's marker here reports a (failure) status (18e).
1804        assert_eq!(result.verdict, None);
1805    }
1806
1807    /// D-05/18e: Layer 0's affirmative-success arm at `Stage::Validate` must
1808    /// consult Layer 1's verdict rather than discard it — the two-signal
1809    /// reconciliation `reconcile_layer0_verdict` adds. Covers all three
1810    /// verdict states Layer 1 can produce: pass, gaps, and no marker at all.
1811    #[test]
1812    fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
1813        let dir = tempfile::tempdir().unwrap();
1814        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1815        std::fs::create_dir_all(&phase_dir).unwrap();
1816        std::fs::write(
1817            phase_dir.join("16-01-PLAN.md"),
1818            "---\nexternal_verify: \"test -f shipped\"\n---\n",
1819        )
1820        .unwrap();
1821        std::fs::write(dir.path().join("shipped"), "done").unwrap();
1822        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1823        let mut state = state_in(dir.path(), 16);
1824        state.stage = Stage::Validate;
1825        let approval = vec!["test -f shipped".to_string()];
1826
1827        std::fs::write(
1828            stdout_path(dir.path(), 16),
1829            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1830        )
1831        .unwrap();
1832        let result = evaluate_agent_result_inner(
1833            dir.path(),
1834            &state,
1835            &GitFlowConfig::default(),
1836            Some(&approval),
1837        )
1838        .unwrap();
1839        assert_eq!(result.status, AgentStatus::Success);
1840        assert_eq!(result.decided_by_layer, Some(0));
1841        assert_eq!(result.verdict, Some(Verdict::Pass));
1842
1843        std::fs::write(
1844            stdout_path(dir.path(), 16),
1845            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
1846        )
1847        .unwrap();
1848        let result = evaluate_agent_result_inner(
1849            dir.path(),
1850            &state,
1851            &GitFlowConfig::default(),
1852            Some(&approval),
1853        )
1854        .unwrap();
1855        assert_eq!(result.verdict, Some(Verdict::Gaps));
1856
1857        std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
1858        let result = evaluate_agent_result_inner(
1859            dir.path(),
1860            &state,
1861            &GitFlowConfig::default(),
1862            Some(&approval),
1863        )
1864        .unwrap();
1865        assert_eq!(result.verdict, None);
1866    }
1867
1868    /// 18e's reconciliation is scoped to `Stage::Validate` only (flagged
1869    /// assumption in 18-05-PLAN.md): at every other stage an affirmative
1870    /// Layer 0 success must keep `verdict: None`, even when Layer 1's marker
1871    /// carries an explicit verdict.
1872    #[test]
1873    fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
1874        let dir = tempfile::tempdir().unwrap();
1875        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1876        std::fs::create_dir_all(&phase_dir).unwrap();
1877        std::fs::write(
1878            phase_dir.join("16-01-PLAN.md"),
1879            "---\nexternal_verify: \"test -f shipped\"\n---\n",
1880        )
1881        .unwrap();
1882        std::fs::write(dir.path().join("shipped"), "done").unwrap();
1883        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1884        std::fs::write(
1885            stdout_path(dir.path(), 16),
1886            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
1887        )
1888        .unwrap();
1889        let state = state_in(dir.path(), 16); // Stage::Code by default
1890        let approval = vec!["test -f shipped".to_string()];
1891
1892        let result = evaluate_agent_result_inner(
1893            dir.path(),
1894            &state,
1895            &GitFlowConfig::default(),
1896            Some(&approval),
1897        )
1898        .unwrap();
1899
1900        assert_eq!(result.status, AgentStatus::Success);
1901        assert_eq!(result.decided_by_layer, Some(0));
1902        assert_eq!(result.verdict, None);
1903    }
1904
1905    /// Ordering edge (17a): with multiple declared probes, ALL must pass for
1906    /// affirmative Success — the first failing probe vetoes the outcome
1907    /// regardless of which position it occupies among the declarations.
1908    #[test]
1909    fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
1910        let dir = tempfile::tempdir().unwrap();
1911        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1912        std::fs::create_dir_all(&phase_dir).unwrap();
1913        // 16-01 comes first alphabetically and passes; 16-02 comes second and fails.
1914        std::fs::write(
1915            phase_dir.join("16-01-PLAN.md"),
1916            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1917        )
1918        .unwrap();
1919        std::fs::write(
1920            phase_dir.join("16-02-PLAN.md"),
1921            "---\nexternal_verify: \"test -f never-created\"\n---\n",
1922        )
1923        .unwrap();
1924        std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
1925        let mut state = state_in(dir.path(), 16);
1926        state.stage = Stage::Define;
1927
1928        let approval = vec![
1929            "test -f passing-artifact".to_string(),
1930            "test -f never-created".to_string(),
1931        ];
1932        let result_a = evaluate_agent_result_inner(
1933            dir.path(),
1934            &state,
1935            &GitFlowConfig::default(),
1936            Some(&approval),
1937        )
1938        .unwrap();
1939        assert_eq!(result_a.status, AgentStatus::Failed);
1940        assert!(
1941            result_a
1942                .reason
1943                .as_deref()
1944                .is_some_and(|reason| reason.contains("never-created")),
1945            "unexpected reason: {:?}",
1946            result_a.reason
1947        );
1948
1949        // Swap which position fails: 16-01 now fails, 16-02 passes. The
1950        // overall outcome must still veto — order of declaration must not
1951        // matter.
1952        std::fs::write(
1953            phase_dir.join("16-01-PLAN.md"),
1954            "---\nexternal_verify: \"test -f still-missing\"\n---\n",
1955        )
1956        .unwrap();
1957        std::fs::write(
1958            phase_dir.join("16-02-PLAN.md"),
1959            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
1960        )
1961        .unwrap();
1962        let approval_swapped = vec![
1963            "test -f still-missing".to_string(),
1964            "test -f passing-artifact".to_string(),
1965        ];
1966        let result_b = evaluate_agent_result_inner(
1967            dir.path(),
1968            &state,
1969            &GitFlowConfig::default(),
1970            Some(&approval_swapped),
1971        )
1972        .unwrap();
1973        assert_eq!(result_b.status, AgentStatus::Failed);
1974
1975        // Now make BOTH pass: only then is the outcome Success.
1976        std::fs::write(dir.path().join("still-missing"), "done").unwrap();
1977        let result_c = evaluate_agent_result_inner(
1978            dir.path(),
1979            &state,
1980            &GitFlowConfig::default(),
1981            Some(&approval_swapped),
1982        )
1983        .unwrap();
1984        assert_eq!(result_c.status, AgentStatus::Success);
1985        assert_eq!(result_c.decided_by_layer, Some(0));
1986    }
1987
1988    #[test]
1989    fn archive_moves_captures_into_history_and_removes_pid_file() {
1990        // 16b: prior-stage captures must survive a simulated next-launch by
1991        // appearing under .devflow/history/phase-NN/, not be wiped outright.
1992        let dir = tempfile::tempdir().unwrap();
1993        let root = dir.path();
1994        std::fs::create_dir_all(root.join(".devflow")).unwrap();
1995        std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
1996        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
1997        std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
1998
1999        archive_phase_files(root, root, 1, 5).unwrap();
2000
2001        // The live capture paths are gone (moved, not merely deleted).
2002        assert!(!root.join(".devflow/phase-01-stdout").exists());
2003        assert!(!root.join(".devflow/phase-01-exit").exists());
2004        // Agent-pid is bookkeeping, not diagnostic — still removed outright.
2005        assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2006
2007        let history = history_dir(root, 1);
2008        let archived: Vec<_> = std::fs::read_dir(&history)
2009            .unwrap()
2010            .flatten()
2011            .map(|e| e.file_name().to_string_lossy().into_owned())
2012            .collect();
2013        let archived_stdout = archived
2014            .iter()
2015            .find(|name| name.ends_with("-stdout"))
2016            .expect("stdout capture should be archived into history");
2017        assert!(archived.iter().any(|name| name.ends_with("-exit")));
2018        let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2019        assert_eq!(contents, "prior stdout");
2020    }
2021
2022    #[test]
2023    fn archive_is_noop_when_nothing_to_archive() {
2024        let dir = tempfile::tempdir().unwrap();
2025        let root = dir.path();
2026        // Should not panic when there is nothing to archive (first launch).
2027        archive_phase_files(root, root, 1, 5).unwrap();
2028        assert!(!history_dir(root, 1).exists());
2029    }
2030
2031    #[test]
2032    fn archive_handles_missing_devflow_dir() {
2033        let dir = tempfile::tempdir().unwrap();
2034        let root = dir.path();
2035        // No .devflow dir at all — should not panic.
2036        archive_phase_files(root, root, 1, 5).unwrap();
2037    }
2038
2039    #[test]
2040    fn archive_failure_preserves_live_capture_for_retry() {
2041        let dir = tempfile::tempdir().unwrap();
2042        let root = dir.path();
2043        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2044        std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2045        // A file where the history directory must be forces create_dir_all
2046        // to fail before the live capture is moved or a monitor can truncate it.
2047        std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2048
2049        assert!(archive_phase_files(root, root, 1, 5).is_err());
2050        assert_eq!(
2051            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2052            "evidence"
2053        );
2054    }
2055
2056    #[test]
2057    fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2058        let dir = tempfile::tempdir().unwrap();
2059        let root = dir.path();
2060        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2061        std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2062        std::fs::write(exit_code_path(root, 1), "17").unwrap();
2063        let history = history_dir(root, 1);
2064        std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2065
2066        assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2067
2068        assert_eq!(
2069            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2070            "stdout evidence"
2071        );
2072        assert_eq!(
2073            std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2074            "17"
2075        );
2076        assert!(!history.join("fixed-stdout").exists());
2077        assert!(!history.join(".pending-fixed").exists());
2078    }
2079
2080    #[test]
2081    fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2082        let dir = tempfile::tempdir().unwrap();
2083        let root = dir.path();
2084        let evidence_root = root.join("phase-worktree");
2085        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2086        std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2087        std::fs::write(exit_code_path(root, 1), "23").unwrap();
2088        let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2089        std::fs::create_dir_all(&review).unwrap();
2090
2091        assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2092
2093        assert_eq!(
2094            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2095            "stdout evidence"
2096        );
2097        assert_eq!(
2098            std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2099            "23"
2100        );
2101        let history = history_dir(root, 1);
2102        assert!(!history.join("review-copy-stdout").exists());
2103        assert!(!history.join("review-copy-exit").exists());
2104        assert!(!history.join(".pending-review-copy").exists());
2105    }
2106
2107    #[test]
2108    fn archive_snapshots_current_review_into_same_generation() {
2109        let dir = tempfile::tempdir().unwrap();
2110        let root = dir.path();
2111        let evidence_root = root.join("phase-worktree");
2112        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2113        std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2114        let phase_dir = evidence_root.join(".planning/phases/01-example");
2115        std::fs::create_dir_all(&phase_dir).unwrap();
2116        std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2117
2118        let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2119            .unwrap()
2120            .unwrap();
2121
2122        assert_eq!(
2123            std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2124                .unwrap(),
2125            "review one"
2126        );
2127    }
2128
2129    #[test]
2130    fn archive_prunes_history_to_retain_count() {
2131        let dir = tempfile::tempdir().unwrap();
2132        let root = dir.path();
2133        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2134
2135        for i in 0..7 {
2136            std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2137            std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2138            archive_phase_files(root, root, 1, 3).unwrap();
2139        }
2140
2141        let history = history_dir(root, 1);
2142        let stdout_count = std::fs::read_dir(&history)
2143            .unwrap()
2144            .flatten()
2145            .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2146            .count();
2147        let exit_count = std::fs::read_dir(&history)
2148            .unwrap()
2149            .flatten()
2150            .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2151            .count();
2152        assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2153        assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2154    }
2155
2156    #[test]
2157    fn evaluate_agent_result_reads_files_end_to_end() {
2158        let dir = tempfile::tempdir().unwrap();
2159        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2160        std::fs::write(
2161            stdout_path(dir.path(), 6),
2162            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2163        )
2164        .unwrap();
2165        std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2166        let state = state_in(dir.path(), 6);
2167
2168        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2169
2170        assert_eq!(result.status, AgentStatus::Success);
2171        assert_eq!(result.commits, Some(2));
2172        assert_eq!(result.summary.as_deref(), Some("ok"));
2173    }
2174
2175    #[test]
2176    fn evaluate_layer1_finds_devflow_result_in_file() {
2177        let dir = tempfile::tempdir().unwrap();
2178        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2179        std::fs::write(
2180            stdout_path(dir.path(), 3),
2181            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2182        )
2183        .unwrap();
2184
2185        let result = evaluate_layer1(dir.path(), 3).unwrap();
2186
2187        assert_eq!(result.status, AgentStatus::Failed);
2188        assert_eq!(result.reason.as_deref(), Some("bad output"));
2189    }
2190
2191    #[test]
2192    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2193        let dir = tempfile::tempdir().unwrap();
2194        init_repo_with_feature_commit(dir.path(), 4);
2195        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2196        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2197        let state = state_in(dir.path(), 4);
2198
2199        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2200            .unwrap()
2201            .unwrap();
2202
2203        assert_eq!(result.status, AgentStatus::Success);
2204        assert_eq!(result.exit_code, Some(0));
2205        assert_eq!(result.commits, Some(1));
2206        assert!(result.reason.unwrap().contains("1 commits"));
2207    }
2208
2209    #[test]
2210    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2211        // exit=0 but the feature branch has 0 commits ahead of develop →
2212        // "no work done" failure (the Layer 2 middle branch).
2213        let dir = tempfile::tempdir().unwrap();
2214        init_repo_with_feature_no_commit(dir.path(), 4);
2215        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2216        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2217        let state = state_in(dir.path(), 4);
2218
2219        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2220            .unwrap()
2221            .unwrap();
2222
2223        assert_eq!(result.status, AgentStatus::Failed);
2224        assert_eq!(result.exit_code, Some(0));
2225        assert_eq!(result.commits, Some(0));
2226        assert!(result.reason.unwrap().contains("no commits"));
2227    }
2228
2229    #[test]
2230    fn evaluate_layer2_nonzero_exit_is_failed() {
2231        // Non-zero exit code → failure regardless of commit count.
2232        let dir = tempfile::tempdir().unwrap();
2233        init_repo_with_feature_commit(dir.path(), 4);
2234        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2235        std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2236        let state = state_in(dir.path(), 4);
2237
2238        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2239            .unwrap()
2240            .unwrap();
2241
2242        assert_eq!(result.status, AgentStatus::Failed);
2243        assert_eq!(result.exit_code, Some(1));
2244        assert!(result.reason.unwrap().contains("exited with code 1"));
2245    }
2246
2247    #[test]
2248    fn layer2_nonzero_exit_is_failed_all_stages() {
2249        // Non-zero exit is Failed regardless of stage — including Define and
2250        // Validate, which are exempt from the zero-commit gate but NOT from
2251        // the exit-code check.
2252        let dir = tempfile::tempdir().unwrap();
2253        init_repo_with_feature_no_commit(dir.path(), 10);
2254        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2255        std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2256
2257        for stage in [
2258            Stage::Define,
2259            Stage::Plan,
2260            Stage::Code,
2261            Stage::Validate,
2262            Stage::Ship,
2263        ] {
2264            let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2265                .unwrap()
2266                .unwrap();
2267            assert_eq!(
2268                result.status,
2269                AgentStatus::Failed,
2270                "stage {stage:?} should be Failed on nonzero exit"
2271            );
2272        }
2273    }
2274
2275    #[test]
2276    fn layer2_skips_commit_gate_for_define_and_validate() {
2277        let dir = tempfile::tempdir().unwrap();
2278        init_repo_with_feature_no_commit(dir.path(), 11);
2279        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2280        std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2281
2282        for stage in [Stage::Define, Stage::Validate] {
2283            let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2284                .unwrap()
2285                .unwrap();
2286            assert_ne!(
2287                result.status,
2288                AgentStatus::Failed,
2289                "stage {stage:?} should not be Failed for zero commits"
2290            );
2291        }
2292
2293        // Code stage with the same zero-commit inputs is still Failed
2294        // (existing behavior preserved).
2295        let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2296            .unwrap()
2297            .unwrap();
2298        assert_eq!(result.status, AgentStatus::Failed);
2299    }
2300
2301    #[test]
2302    fn evaluate_layer3_falls_back_to_commit_count() {
2303        let dir = tempfile::tempdir().unwrap();
2304        init_repo_with_feature_commit(dir.path(), 5);
2305
2306        let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2307
2308        assert_eq!(result.status, AgentStatus::Unknown);
2309        assert_eq!(result.exit_code, None);
2310        assert_eq!(result.commits, Some(1));
2311        assert!(result.reason.unwrap().contains("1 commits"));
2312        assert_eq!(result.decided_by_layer, Some(3));
2313    }
2314
2315    /// D-02/D-03 case 3 (17-03): "process gone, nothing accounted for" — zero
2316    /// commits and no declared external post-condition — is a fail-closed
2317    /// `Failed` outcome that flags human review, not a blanket advanceable
2318    /// `Unknown`. The commits-present case above stays `Unknown` (gated
2319    /// downstream by Plan 04's never-advance dispatch, D-04) — only the
2320    /// zero-commit sub-case is reclassified here.
2321    #[test]
2322    fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2323        let dir = tempfile::tempdir().unwrap();
2324        init_repo_with_feature_no_commit(dir.path(), 5);
2325
2326        let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2327
2328        assert_eq!(result.status, AgentStatus::Failed);
2329        assert_eq!(result.exit_code, None);
2330        assert_eq!(result.commits, Some(0));
2331        assert_eq!(result.decided_by_layer, Some(3));
2332        let reason = result.reason.unwrap();
2333        assert!(reason.contains("no work"), "reason was: {reason}");
2334        assert!(
2335            reason.to_ascii_lowercase().contains("human review"),
2336            "reason was: {reason}"
2337        );
2338    }
2339
2340    #[test]
2341    fn parse_devflow_result_reads_verdict() {
2342        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2343        let result = parse_devflow_result(stdout).unwrap();
2344        assert_eq!(result.status, AgentStatus::Success);
2345        assert_eq!(result.verdict, Some(Verdict::Gaps));
2346    }
2347
2348    #[test]
2349    fn parse_devflow_result_reads_verdict_pass() {
2350        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2351        let result = parse_devflow_result(stdout).unwrap();
2352        assert_eq!(result.status, AgentStatus::Success);
2353        assert_eq!(result.verdict, Some(Verdict::Pass));
2354    }
2355
2356    #[test]
2357    fn parse_devflow_result_verdict_absent_is_none() {
2358        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2359        let result = parse_devflow_result(stdout).unwrap();
2360        assert_eq!(result.status, AgentStatus::Success);
2361        assert_eq!(result.verdict, None);
2362    }
2363
2364    #[test]
2365    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2366        // An unknown verdict string must not fail the whole marker parse —
2367        // status must still come through as Success with verdict None (T-13-14).
2368        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2369        let result = parse_devflow_result(unknown).unwrap();
2370        assert_eq!(result.status, AgentStatus::Success);
2371        assert_eq!(result.verdict, None);
2372
2373        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
2374        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2375        let result = parse_devflow_result(miscased).unwrap();
2376        assert_eq!(result.status, AgentStatus::Success);
2377        assert_eq!(result.verdict, None);
2378    }
2379
2380    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
2381    /// JSON *type* (bool, number, object) must be just as lenient as a
2382    /// malformed string value — before the fix, deserializing straight to
2383    /// `Option<String>` errored out the entire `AgentResult` parse for a
2384    /// type mismatch, defeating the doc comment's "a malformed verdict must
2385    /// never silently drop a valid status" guarantee for this specific case.
2386    #[test]
2387    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2388        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2389        let result = parse_devflow_result(bool_verdict).unwrap();
2390        assert_eq!(result.status, AgentStatus::Success);
2391        assert_eq!(result.verdict, None);
2392
2393        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2394        let result = parse_devflow_result(numeric_verdict).unwrap();
2395        assert_eq!(result.status, AgentStatus::Success);
2396        assert_eq!(result.verdict, None);
2397
2398        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2399        let result = parse_devflow_result(object_verdict).unwrap();
2400        assert_eq!(result.status, AgentStatus::Success);
2401        assert_eq!(result.verdict, None);
2402    }
2403
2404    /// D-07 (17-01): the two new multi-word variants must serialize with
2405    /// their word boundary preserved — `#[serde(rename_all = "lowercase")]`
2406    /// alone would collapse `ResourceKilled` to `"resourcekilled"` (Pitfall 1).
2407    #[test]
2408    fn multi_word_variants_serialize_with_word_boundary() {
2409        assert_eq!(
2410            serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2411            "\"resource_killed\""
2412        );
2413        assert_eq!(
2414            serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2415            "\"agent_unavailable\""
2416        );
2417        assert_eq!(
2418            serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2419            AgentStatus::ResourceKilled
2420        );
2421        assert_eq!(
2422            serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2423            AgentStatus::AgentUnavailable
2424        );
2425    }
2426
2427    /// Existing variants must keep their pre-existing lowercase wire form
2428    /// unchanged by the two new variants' additions.
2429    #[test]
2430    fn existing_variants_keep_wire_form() {
2431        assert_eq!(
2432            serde_json::to_string(&AgentStatus::Success).unwrap(),
2433            "\"success\""
2434        );
2435        assert_eq!(
2436            serde_json::to_string(&AgentStatus::Failed).unwrap(),
2437            "\"failed\""
2438        );
2439        assert_eq!(
2440            serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2441            "\"ratelimited\""
2442        );
2443        assert_eq!(
2444            serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2445            "\"unknown\""
2446        );
2447    }
2448
2449    /// review consensus #1: `as_wire_str()` must never diverge from the serde
2450    /// form for ANY variant — pin it for all six via a single round-trip
2451    /// assertion (quotes stripped).
2452    #[test]
2453    fn as_wire_str_matches_serde_form_for_every_variant() {
2454        for variant in [
2455            AgentStatus::Success,
2456            AgentStatus::Failed,
2457            AgentStatus::RateLimited,
2458            AgentStatus::Unknown,
2459            AgentStatus::ResourceKilled,
2460            AgentStatus::AgentUnavailable,
2461        ] {
2462            let serde_form = serde_json::to_string(&variant).unwrap();
2463            let stripped = serde_form.trim_matches('"');
2464            assert_eq!(
2465                variant.as_wire_str(),
2466                stripped,
2467                "as_wire_str() diverged from serde form for {variant:?}"
2468            );
2469        }
2470    }
2471
2472    #[test]
2473    fn evaluate_layer2_exit_137_is_resource_killed() {
2474        let dir = tempfile::tempdir().unwrap();
2475        init_repo_with_feature_commit(dir.path(), 20);
2476        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2477        std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2478        let state = state_in(dir.path(), 20);
2479
2480        let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2481            .unwrap()
2482            .unwrap();
2483
2484        assert_eq!(result.status, AgentStatus::ResourceKilled);
2485        assert_eq!(result.exit_code, Some(137));
2486    }
2487
2488    #[test]
2489    fn evaluate_layer2_exit_127_is_agent_unavailable() {
2490        let dir = tempfile::tempdir().unwrap();
2491        init_repo_with_feature_commit(dir.path(), 21);
2492        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2493        std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2494        let state = state_in(dir.path(), 21);
2495
2496        let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2497            .unwrap()
2498            .unwrap();
2499
2500        assert_eq!(result.status, AgentStatus::AgentUnavailable);
2501        assert_eq!(result.exit_code, Some(127));
2502    }
2503
2504    /// Unchanged-behavior guard: exit 0 with zero commits on a commit-gated
2505    /// stage is still Failed (the pre-existing "no work done" branch, not
2506    /// reclassified by the new 137/127 checks).
2507    #[test]
2508    fn evaluate_layer2_exit_0_zero_commits_still_failed() {
2509        let dir = tempfile::tempdir().unwrap();
2510        init_repo_with_feature_no_commit(dir.path(), 22);
2511        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2512        std::fs::write(exit_code_path(dir.path(), 22), "0").unwrap();
2513        let state = state_in(dir.path(), 22);
2514
2515        let result = evaluate_layer2(dir.path(), 22, &GitFlowConfig::default(), state.stage)
2516            .unwrap()
2517            .unwrap();
2518
2519        assert_eq!(result.status, AgentStatus::Failed);
2520    }
2521
2522    /// Unchanged-behavior guard: exit 1 is still Failed (not misclassified
2523    /// as ResourceKilled/AgentUnavailable).
2524    #[test]
2525    fn evaluate_layer2_exit_1_still_failed() {
2526        let dir = tempfile::tempdir().unwrap();
2527        init_repo_with_feature_commit(dir.path(), 23);
2528        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2529        std::fs::write(exit_code_path(dir.path(), 23), "1").unwrap();
2530        let state = state_in(dir.path(), 23);
2531
2532        let result = evaluate_layer2(dir.path(), 23, &GitFlowConfig::default(), state.stage)
2533            .unwrap()
2534            .unwrap();
2535
2536        assert_eq!(result.status, AgentStatus::Failed);
2537    }
2538}