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