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