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::git::git_command;
12use crate::stage::Stage;
13use crate::state::State;
14use std::path::{Path, PathBuf};
15
16/// Parsed agent completion result.
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct AgentResult {
19    pub status: AgentStatus,
20    pub exit_code: Option<i32>,
21    pub reason: Option<String>,
22    pub commits: Option<u32>,
23    pub summary: Option<String>,
24    /// The Validate stage's self-reported verdict — distinct from `status`.
25    /// `status` reports whether the stage's task (running `/gsd-validate-phase`)
26    /// completed; `verdict` reports whether validation ITSELF passed. Only
27    /// `Some(Verdict::Pass)` should advance Validate to Ship; `Some(Verdict::Gaps)`
28    /// and `None` both gate/loop back to Code (see `advance()`'s Validate arm).
29    /// Ignored entirely for non-Validate stages.
30    ///
31    /// Deserialized leniently via [`deserialize_verdict_lenient`]: an absent,
32    /// unknown, or mis-cased value becomes `None` rather than failing the
33    /// whole `AgentResult` parse (T-13-14) — a malformed verdict must never
34    /// silently drop a valid `status` to Layer 2.
35    #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
36    pub verdict: Option<Verdict>,
37    /// Which evaluation layer (0-3) produced this result (D-10, 17-01). Set by
38    /// every constructor in this module; `None` is reserved for test-only
39    /// fixture literals that don't route through the real cascade.
40    #[serde(default)]
41    pub decided_by_layer: Option<u8>,
42}
43
44/// Agent completion status determined by DevFlow.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum AgentStatus {
48    /// Agent self-reported success via DEVFLOW_RESULT.
49    Success,
50    /// Agent self-reported failure, or exit code + commit gate indicated failure.
51    Failed,
52    /// Agent stopped because an upstream API or usage quota rate-limited it.
53    RateLimited,
54    /// No signal received — fallback to exit code / commit heuristic.
55    Unknown,
56    /// Layer 2 classified the process as killed for resource exhaustion
57    /// (exit code 137, typically SIGKILL from an OOM killer) (D-07, 17b).
58    #[serde(rename = "resource_killed")]
59    ResourceKilled,
60    /// Layer 2 classified the process as unable to start (exit code 127,
61    /// typically "command not found") (D-07, 17b).
62    #[serde(rename = "agent_unavailable")]
63    AgentUnavailable,
64}
65
66impl AgentStatus {
67    /// The wire-format name for this variant, pinned equal to
68    /// `serde_json::to_string(&self)` with the surrounding quotes stripped
69    /// (see the `as_wire_str_matches_serde_form` test). Exhaustive match with
70    /// NO wildcard arm — adding a variant without updating this is a compile
71    /// error. This is the sanctioned replacement for
72    /// `format!("{:?}", status).to_ascii_lowercase()`, which collapses word
73    /// boundaries on multi-word variants (review consensus #1).
74    pub fn as_wire_str(&self) -> &'static str {
75        match self {
76            AgentStatus::Success => "success",
77            AgentStatus::Failed => "failed",
78            AgentStatus::RateLimited => "ratelimited",
79            AgentStatus::Unknown => "unknown",
80            AgentStatus::ResourceKilled => "resource_killed",
81            AgentStatus::AgentUnavailable => "agent_unavailable",
82        }
83    }
84}
85
86/// The Validate stage's self-reported verdict (13b verdict-vs-ran split).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum Verdict {
90    /// Validation found no gaps — ready to advance to Ship.
91    Pass,
92    /// Validation found gaps that still need fixing — must loop back to Code
93    /// (or gate, depending on the consecutive-failure threshold).
94    Gaps,
95}
96
97/// Deserialize `verdict` leniently: an absent, unknown, or mis-cased value
98/// (e.g. `"wat"`, `"Pass"`) becomes `Ok(None)` rather than an error, so a
99/// malformed verdict never fails the whole `from_str::<AgentResult>` parse
100/// and silently drops a valid `status` to Layer 2 (T-13-14, consensus #5).
101///
102/// Matching is intentionally exact-case (only the wire-format lowercase
103/// strings `"pass"`/`"gaps"` are accepted) — a mis-cased value like `"Pass"`
104/// is NOT case-folded into a match; it is treated the same as an unknown
105/// value and maps to `None`, so a subtly wrong-case verdict fails safe
106/// (gate/loop) instead of silently passing.
107///
108/// WR-09 (13-REVIEW.md): decodes as `serde_json::Value` first, then only
109/// pattern-matches the string case — a non-string JSON type (`true`, `123`,
110/// an object) is a wrong *type*, not a malformed string value, and must
111/// still fall through to `None` rather than erroring out the entire
112/// `AgentResult` parse (the same guarantee this deserializer already gives
113/// mis-cased/unknown string values).
114fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
115where
116    D: serde::Deserializer<'de>,
117{
118    let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
119    Ok(raw.and_then(|v| {
120        v.as_str().and_then(|s| match s {
121            "pass" => Some(Verdict::Pass),
122            "gaps" => Some(Verdict::Gaps),
123            _ => None,
124        })
125    }))
126}
127
128/// Errors produced by agent result evaluation.
129#[derive(Debug, thiserror::Error)]
130pub enum ResultError {
131    #[error("I/O error reading agent output: {0}")]
132    Io(#[from] std::io::Error),
133    #[error("phase directory not found")]
134    NoPhaseDir,
135}
136
137/// Search stdout for a DEVFLOW_RESULT marker.
138///
139/// The marker is a single line starting with `DEVFLOW_RESULT:` followed by
140/// a JSON object with at minimum a `status` field. Matching is case-insensitive.
141///
142/// When an agent is run with `--output-format json` (e.g. Claude), its final
143/// message is wrapped in a JSON result envelope with the text — and its
144/// embedded newlines — escaped inside a `result` field. In that case the
145/// marker never appears at the start of a line, so we first unwrap the
146/// envelope and search the inner text.
147pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
148    if let Some(inner) = extract_json_result_text(stdout)
149        && let Some(result) = parse_marker_lines(&inner)
150    {
151        return Some(result);
152    }
153    parse_marker_lines(stdout)
154}
155
156/// Detect agent-specific rate-limit output and return the retry description.
157///
158/// Claude can emit a JSON result envelope when run with `--output-format json`;
159/// Codex commonly emits plain text such as "Try again at ...". This function is
160/// intentionally conservative so ordinary progress text does not become a
161/// false positive.
162pub fn detect_rate_limit(stdout: &str) -> Option<String> {
163    detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
164}
165
166fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
167    let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
168    let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
169        || json_has_i64(&value, "api_error_status", 429)
170        || json_has_i64(&value, "status", 429)
171        || json_has_i64(&value, "status_code", 429);
172    if !rate_limited {
173        return None;
174    }
175    json_find_key(&value, "retry_after")
176        .and_then(json_scalar_to_string)
177        .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
178        .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
179        .or_else(|| Some("usage limit".to_string()))
180}
181
182fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
183    // This heuristic exists for Codex's PLAIN-TEXT output. JSONL event lines
184    // are authoritative and handled by parse_codex_event_result — scanning
185    // them here false-positives on document content echoed into events
186    // (13-06 dogfood finding: GSD reference tables mentioning "rate limiting"
187    // were read by the agent, echoed into an `item.completed` payload, and
188    // this scan returned that entire multi-KB line as the "retry time").
189    let stdout: String = stdout
190        .lines()
191        .filter(|line| {
192            serde_json::from_str::<serde_json::Value>(line)
193                .map(|v| !v.is_object())
194                .unwrap_or(true)
195        })
196        .collect::<Vec<_>>()
197        .join("\n");
198    let stdout = stdout.as_str();
199    let lower = stdout.to_ascii_lowercase();
200    if let Some(idx) = lower.find("try again at ") {
201        let start = idx + "try again at ".len();
202        let retry = stdout[start..]
203            .lines()
204            .next()
205            .unwrap_or_default()
206            .trim()
207            .trim_end_matches(['.', ',', ';'])
208            .trim();
209        if !retry.is_empty() {
210            return Some(retry.to_string());
211        }
212    }
213
214    if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
215        stdout
216            .lines()
217            .find(|line| {
218                let line = line.to_ascii_lowercase();
219                line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
220            })
221            .map(str::trim)
222            .filter(|line| !line.is_empty())
223            .map(str::to_string)
224            .or_else(|| Some("usage limit".to_string()))
225    } else {
226        None
227    }
228}
229
230/// If `stdout` is a JSON result envelope, return the decoded `result` text
231/// field (with escapes such as `\n` resolved). Returns `None` for plain text.
232fn extract_json_result_text(stdout: &str) -> Option<String> {
233    let trimmed = stdout.trim();
234    if !trimmed.starts_with('{') {
235        return None;
236    }
237    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
238    value.get("result")?.as_str().map(str::to_string)
239}
240
241/// Read the top-level `session_id` string from a Claude JSON result envelope
242/// (`--output-format json`). Returns `None` for plain-text stdout, a
243/// non-JSON-object envelope, an envelope with no `session_id` key, or a
244/// `session_id` of a non-string JSON type — never panics.
245///
246/// D-04 / T-28-04 (this plan's `<threat_model>`): deliberately reads ONLY the
247/// envelope's TOP-LEVEL `session_id` key via a direct [`serde_json::Value::get`],
248/// never the module's [`json_find_key`]/[`json_scan`] traversal helpers. Those
249/// helpers descend into nested objects, and the agent-authored `DEVFLOW_RESULT`
250/// marker payload — embedded inside this same envelope's `result` text and
251/// deserialized by [`parse_marker_lines`] directly into [`AgentResult`] — is
252/// reachable that way. A top-level `get` makes it true BY CONSTRUCTION that an
253/// agent cannot redirect the session DevFlow later resumes into by planting a
254/// different `session_id` key inside its own self-authored marker JSON.
255/// Regression test: `session_id_in_devflow_result_marker_is_not_returned`.
256///
257/// Deliberate deviation from RESEARCH.md § "Discretion Resolutions" item 5,
258/// which suggested adding a `session_id` field directly to [`AgentResult`].
259/// NOT done: `parse_marker_lines` deserializes the agent's own
260/// `DEVFLOW_RESULT` JSON straight into `AgentResult` via `serde_json::from_str`,
261/// so a `#[serde(default)]` field there would be agent-settable — the agent
262/// could name the session DevFlow resumes into (T-28-04). A standalone reader
263/// over the top-level envelope key carries no such surface and is equally
264/// available to every caller; D-04's persistence target (`State::session_id`)
265/// is unchanged, only the carrier differs.
266pub fn claude_session_id(stdout: &str) -> Option<String> {
267    let trimmed = stdout.trim();
268    if !trimmed.starts_with('{') {
269        return None;
270    }
271    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
272    value.get("session_id")?.as_str().map(str::to_string)
273}
274
275/// Thin file-reading wrapper over [`claude_session_id`]: reads the phase's
276/// captured stdout file (via [`stdout_path`]) and delegates. `None` for a
277/// missing capture file, never an `Err` — mirrors [`evaluate_layer1`]'s
278/// lossy-read convention (CR-01: one invalid UTF-8 byte from raw `sh`
279/// redirection must not silently disable this reader).
280pub fn session_id_from_capture(project_root: &Path, phase: u32) -> Option<String> {
281    let bytes = std::fs::read(stdout_path(project_root, phase)).ok()?;
282    let stdout = String::from_utf8_lossy(&bytes);
283    claude_session_id(&stdout)
284}
285
286// WR-12 (13-REVIEW.md), revised: these traversal helpers run on the coding
287// agent's raw stdout (via detect_claude_rate_limit, which every `devflow
288// advance` invocation runs through evaluate_layer1), so deeply nested JSON —
289// accidental or adversarial — must not stack-overflow the process. The
290// traversal is iterative (an explicit worklist), so nesting depth never
291// consumes call stack and no depth cap is needed. The first WR-12 fix capped
292// recursion at 64, which silently missed keys at depths 64–128 — nesting
293// serde_json's default 128-level parse recursion limit (the only producer of
294// these `Value`s) accepts just fine.
295
296/// Depth-first pre-order scan over every JSON object in `value`, returning
297/// the first `Some` produced by `visit` on an object's map.
298fn json_scan<'a, T>(
299    value: &'a serde_json::Value,
300    visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
301) -> Option<T> {
302    let mut stack = vec![value];
303    while let Some(current) = stack.pop() {
304        match current {
305            serde_json::Value::Object(map) => {
306                if let Some(found) = visit(map) {
307                    return Some(found);
308                }
309                // Push in reverse so pop order preserves document order.
310                for child in map.values().rev() {
311                    stack.push(child);
312                }
313            }
314            serde_json::Value::Array(values) => {
315                for child in values.iter().rev() {
316                    stack.push(child);
317                }
318            }
319            _ => {}
320        }
321    }
322    None
323}
324
325fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
326    json_scan(value, |map| {
327        (map.get(key)?.as_str()? == expected).then_some(())
328    })
329    .is_some()
330}
331
332fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
333    json_scan(value, |map| {
334        (map.get(key)?.as_i64()? == expected).then_some(())
335    })
336    .is_some()
337}
338
339fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
340    json_scan(value, |map| map.get(key))
341}
342
343fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
344    match value {
345        serde_json::Value::String(s) => Some(s.clone()),
346        serde_json::Value::Number(n) => Some(n.to_string()),
347        _ => None,
348    }
349}
350
351/// Read the top-level `is_error` boolean (and, if present, `num_turns`) from
352/// a Claude JSON result envelope (`--output-format json`) and treat
353/// `is_error: true` as an authoritative Layer-1 failure.
354///
355/// This is checked BEFORE the `DEVFLOW_RESULT` marker path in
356/// [`evaluate_layer1`], so `is_error: true` OVERRIDES a stale/echoed success
357/// marker embedded in the same envelope's `result` text — the envelope is
358/// authoritative for errors. `is_error` absent or `false` returns `None`,
359/// deferring to the marker path and, ultimately, Layer 2. It runs AFTER
360/// `detect_claude_rate_limit`, though: rate-limit envelopes also carry
361/// `is_error: true`, and the specific `RateLimited` classification (which
362/// drives the primary rate-limit resume cron) must win over this
363/// generic `Failed`.
364///
365/// Per RESEARCH Pitfall 5, `is_error` (not specific `subtype` strings) is
366/// the documented, stable signal — this does not special-case non-success
367/// subtype values beyond what already exists in `detect_claude_rate_limit`.
368fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
369    let trimmed = stdout.trim();
370    if !trimmed.starts_with('{') {
371        return None;
372    }
373    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
374    let is_error = value.get("is_error")?.as_bool()?;
375    if !is_error {
376        return None;
377    }
378
379    let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
380    let base_reason = value
381        .get("result")
382        .and_then(serde_json::Value::as_str)
383        .map(str::to_string)
384        .or_else(|| {
385            value
386                .get("subtype")
387                .and_then(serde_json::Value::as_str)
388                .map(str::to_string)
389        })
390        .unwrap_or_else(|| "agent reported is_error".to_string());
391    let reason = match num_turns {
392        Some(n) => format!("{base_reason} (num_turns: {n})"),
393        None => base_reason,
394    };
395
396    Some(AgentResult {
397        status: AgentStatus::Failed,
398        exit_code: None,
399        reason: Some(reason),
400        commits: None,
401        summary: None,
402        verdict: None,
403        decided_by_layer: Some(1),
404    })
405}
406
407/// The rendered VALUE of a human-blocking checkpoint's `**Gate:**` line.
408///
409/// **CONFIRMED against a live end-to-end run (2026-07-31).** Assumption A1 is
410/// closed. A real `devflow start` run drove a synthetic phase declaring a
411/// `gate="blocking-human"` task through DevFlow's own monitor process (not a
412/// Claude Code agent session, which is what blocked `28-PROBE.md`'s original
413/// attempt at the Bash-tool permission classifier). The checkpoint fired and
414/// `.devflow/phase-NN-stdout` captured it inside the JSON envelope's `result`
415/// text as:
416///
417/// ```text
418/// **Gate:** `blocking-human`
419/// ```
420///
421/// The VALUE is what this constant holds. The surrounding markdown — bold
422/// label, and a **code span around the value** — is handled by
423/// [`text_reports_human_gate`]'s trim set, not by this constant.
424///
425/// The code span is the part RESEARCH.md did not predict. Its § "Architecture
426/// Patterns / Pattern 2" derived the literal by reading the *emitting* source
427/// (`gsd-executor.md:356`, `execute-phase.md:1053`) and predicted a bare
428/// `**Gate:** blocking-human`. The real relay renders the value as a code
429/// span, which defeated the original matcher entirely — see
430/// [`text_reports_human_gate`] for that failure and its fix. Lesson worth
431/// keeping: the emitting source told us the value, not the rendering.
432const HUMAN_GATE_VALUE: &str = "blocking-human";
433
434/// Confirm whether captured stdout reports a human-blocking checkpoint, by
435/// searching for a `**Gate:**`-labeled line whose VALUE is exactly
436/// [`HUMAN_GATE_VALUE`] — see that constant's doc comment for the live
437/// observation (2026-07-31) the matched rendering is built from.
438///
439/// This is the CONFIRMATION half of D-01: it is only ever consulted AFTER
440/// [`crate::verify::phase_has_blocking_human_checkpoint`] has already
441/// returned `true` for the stage's plan(s) (D-01's static half, plan 28-01).
442/// A false negative here is the SAFE direction — it falls back to today's
443/// never-silent generic gate, losing nothing. A false positive is bounded by
444/// the resume ceiling (`mode::MAX_CHECKPOINT_RESUMES`, plan 28-03) and
445/// unconditionally recorded by the `checkpoint_auto_decided` audit event
446/// (plan 28-03) — it can never silently authorize anything.
447///
448/// Searches BOTH the raw stdout text and — when the stdout is a Claude JSON
449/// result envelope — the unescaped inner `result` text obtained via
450/// [`extract_json_result_text`], because the `Gate:` line typically crosses
451/// into the capture escaped inside that envelope (RESEARCH § "Common
452/// Pitfalls / Pitfall 2": two indirections, subagent emission → orchestrator
453/// relay → DevFlow's captured top-level stdout). Matching is
454/// case-insensitive on the `Gate` LABEL and tolerates surrounding markdown
455/// emphasis (`*`) and whitespace, but the VALUE comparison is exact — this
456/// deliberately does NOT widen into a general "does this look like a
457/// checkpoint" heuristic (D-02 rejected that class of predicate); the scope
458/// is one declared field label with one enumerated value.
459pub fn blocking_human_checkpoint_reported(stdout: &str) -> bool {
460    if text_reports_human_gate(stdout) {
461        return true;
462    }
463    extract_json_result_text(stdout)
464        .as_deref()
465        .is_some_and(text_reports_human_gate)
466}
467
468/// Core matcher shared by both search targets (raw stdout and the unescaped
469/// inner envelope text) in [`blocking_human_checkpoint_reported`]. Scans for
470/// a case-insensitive `gate` label, tolerating surrounding markdown emphasis
471/// (`*`), code-span backticks (`` ` ``), and whitespace up to the following
472/// `:`, then compares the VALUE token immediately after the colon exactly
473/// against [`HUMAN_GATE_VALUE`].
474///
475/// The backtick tolerance is not speculative — it is the single reason this
476/// matcher failed against the first real checkpoint ever observed. The live
477/// A1 run (2026-07-31) captured the value as a markdown code span,
478/// ``**Gate:** `blocking-human` ``, and the original trim set (`*` and space
479/// only) left the leading backtick in place, so the `take_while` below
480/// terminated immediately and produced an EMPTY value token. The reader
481/// returned `false` and a genuine checkpoint fell through to the generic
482/// gate. Trimming the backtick is what makes the observed rendering match;
483/// do not narrow this set back without re-running that live probe.
484///
485/// Note the closing backtick needs no handling: `take_while` already stops
486/// at it, since a backtick is neither alphanumeric nor `-`.
487fn text_reports_human_gate(text: &str) -> bool {
488    let lower = text.to_ascii_lowercase();
489    let mut search_from = 0;
490    while let Some(rel_idx) = lower[search_from..].find("gate") {
491        let idx = search_from + rel_idx;
492        let after_label = &lower[idx + "gate".len()..];
493        let after_label = after_label.trim_start_matches(['*', ' ', '`']);
494        if let Some(rest) = after_label.strip_prefix(':') {
495            let value_region = rest.trim_start_matches(['*', ' ', '`']);
496            let value_token: String = value_region
497                .chars()
498                .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
499                .collect();
500            if value_token == HUMAN_GATE_VALUE {
501                return true;
502            }
503        }
504        search_from = idx + "gate".len();
505    }
506    false
507}
508
509/// Thin file-reading wrapper over [`blocking_human_checkpoint_reported`]:
510/// reads the phase's captured stdout file (via [`stdout_path`]) and
511/// delegates. `false` for a missing capture file, never an error.
512pub fn checkpoint_reported_in_capture(project_root: &Path, phase: u32) -> bool {
513    let Ok(bytes) = std::fs::read(stdout_path(project_root, phase)) else {
514        return false;
515    };
516    let stdout = String::from_utf8_lossy(&bytes);
517    blocking_human_checkpoint_reported(&stdout)
518}
519
520/// Determine whether a set of parsed JSONL lines look like a Codex `--json`
521/// event stream (as opposed to a single-document Claude envelope or plain
522/// text) — i.e. at least one line is a `thread.started` or `turn.*` event.
523fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
524    events.iter().any(|v| {
525        v.get("type")
526            .and_then(serde_json::Value::as_str)
527            .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
528    })
529}
530
531/// Parse a Codex `--json` JSONL event stream (one JSON object per line) and
532/// look at the LAST terminal event (`turn.completed` / `turn.failed`).
533///
534/// Only decisive when the captured stdout is actually a Codex event stream
535/// (per [`is_codex_event_stream`]) — a single-document Claude envelope
536/// (`type: "result"`, no `turn.*` lines) is not consumed here and returns
537/// `None`, so the Claude envelope/marker paths handle it instead.
538///
539/// `turn.failed` is decisive: returns `AgentStatus::Failed` with `reason`
540/// from `error.message`. A final `turn.completed` with no `DEVFLOW_RESULT`
541/// marker returns `None` (defers to Layer 2) rather than an unconditional
542/// Success — a marker-less turn must not silently advance a stage (this is
543/// the composition fix that keeps a marker-less Validate run from
544/// false-passing to Ship).
545///
546/// NOTE: written against the documented `--json` event schema (thread.started
547/// / turn.started / item.* / turn.completed with usage / turn.failed with
548/// error.message) but not yet verified against the installed Codex CLI
549/// version — the 13-06 dogfood run captures real output and reconciles any
550/// delta, the same empirical practice 12-12-SUMMARY.md used for Claude.
551fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
552    let events: Vec<serde_json::Value> = stdout
553        .lines()
554        .filter(|line| !line.trim().is_empty())
555        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
556        .collect();
557
558    if !is_codex_event_stream(&events) {
559        return None;
560    }
561
562    // Codex delivers the agent's DEVFLOW_RESULT self-report inside an
563    // `agent_message` item's `text` — never as a raw stdout line — so the
564    // top-level marker scan cannot see it (13-06 dogfood finding: a Codex
565    // `DEVFLOW_RESULT: failed` was invisible and the run fell through to
566    // heuristics). The decoded `text` is a plain marker line; reuse the
567    // marker parser on it. Last marker wins, matching parse_marker_lines.
568    let marker = events.iter().rev().find_map(|v| {
569        if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
570            return None;
571        }
572        let item = v.get("item")?;
573        if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
574            return None;
575        }
576        let text = item.get("text").and_then(serde_json::Value::as_str)?;
577        parse_marker_lines(text)
578    });
579    if marker.is_some() {
580        return marker;
581    }
582
583    let terminal = events.iter().rev().find(|v| {
584        matches!(
585            v.get("type").and_then(serde_json::Value::as_str),
586            Some("turn.completed") | Some("turn.failed")
587        )
588    })?;
589
590    if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
591        // turn.completed (or any other terminal we don't recognize) defers
592        // to Layer 2 rather than an unconditional Success.
593        return None;
594    }
595
596    let reason = terminal
597        .get("error")
598        .and_then(|e| e.get("message"))
599        .and_then(serde_json::Value::as_str)
600        .map(str::to_string)
601        .unwrap_or_else(|| "codex turn failed".to_string());
602
603    Some(AgentResult {
604        status: AgentStatus::Failed,
605        exit_code: None,
606        reason: Some(reason),
607        commits: None,
608        summary: None,
609        verdict: None,
610        decided_by_layer: Some(1),
611    })
612}
613
614/// Scan the last ~4000 characters of `stdout` in reverse line order.
615///
616/// `DEVFLOW_RESULT` markers are ASCII. Searching the bounded tail and returning
617/// the last valid marker ensures the agent's final status wins over an earlier
618/// prompt echo without requiring the surrounding output to be ASCII.
619fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
620    // Only search the tail — agents may echo the marker in their prompt
621    // and we want the LAST occurrence (which is their actual final status).
622    let tail: String = stdout
623        .chars()
624        .rev()
625        .take(4000)
626        .collect::<Vec<_>>()
627        .into_iter()
628        .rev()
629        .collect();
630
631    for line in tail.lines().rev() {
632        let Some(json_str) = line
633            .strip_prefix("DEVFLOW_RESULT: ")
634            .or_else(|| line.strip_prefix("devflow_result: "))
635            .or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
636            .or_else(|| line.strip_prefix("devflow_result:"))
637        else {
638            continue;
639        };
640
641        let json_str = json_str.trim();
642        if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
643            return Some(result);
644        }
645    }
646    None
647}
648
649/// Layer 1: Try to detect agent result from the native per-adapter envelope
650/// or the DEVFLOW_RESULT marker in stdout.
651///
652/// Precedence: Claude rate-limit envelope (a SPECIFIC failure that must
653/// outrank the generic `is_error` check — rate-limit envelopes carry
654/// `is_error: true`, and classifying them `Failed` would kill the primary
655/// rate-limit resume cron path) → Claude envelope `is_error: true` (authoritative,
656/// overrides a success marker) → DEVFLOW_RESULT marker (portable; works for
657/// plain text and a Claude envelope's unwrapped `result` text) → Codex JSONL
658/// event stream (`turn.failed` decisive; `turn.completed` defers) → Codex
659/// plain-text rate-limit heuristic (least authoritative, stays last).
660pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
661    let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
662    // Read lossily: in monitor mode the agent's stdout reaches this file via
663    // raw sh redirection, so one invalid UTF-8 byte in a strict
664    // read_to_string would silently disable ALL Layer-1 detection (marker,
665    // envelope, rate limit) — the same failure class CR-01 (13-REVIEW.md)
666    // fixed in the blocking-mode capture.
667    let bytes = std::fs::read(&stdout_path).ok()?;
668    let stdout = String::from_utf8_lossy(&bytes);
669    detect_claude_rate_limit(&stdout)
670        .map(rate_limited_result)
671        .or_else(|| detect_claude_envelope_failure(&stdout))
672        .or_else(|| parse_devflow_result(&stdout))
673        .or_else(|| parse_codex_event_result(&stdout))
674        .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
675}
676
677/// Build the `RateLimited` result Layer 1 reports for a detected retry hint.
678fn rate_limited_result(retry: String) -> AgentResult {
679    AgentResult {
680        status: AgentStatus::RateLimited,
681        exit_code: None,
682        reason: Some(format!("rate limited until {retry}")),
683        commits: None,
684        summary: None,
685        verdict: None,
686        decided_by_layer: Some(1),
687    }
688}
689
690/// Layer 2: Use exit code + commit count to determine result.
691///
692/// Reads exit code from `.devflow/phase-NN-exit` file.
693/// Counts commits in `feature/phase-NN` branch (if it exists).
694///
695/// The commit-count gate ("no commits → failed") is scoped to `stage` — it
696/// only applies to `Stage::Plan`/`Stage::Code` (checked via an explicit
697/// `matches!`, NOT `Stage::is_agent_stage()`, since that also includes
698/// `Define`, which legitimately produces zero commits). `exit≠0` is ALWAYS
699/// `Failed`, for every stage — only the `exit=0`/zero-commits branch is
700/// stage-scoped.
701///
702/// Decision matrix:
703///   exit=137                                             → ResourceKilled (ALL stages, D-07)
704///   exit=127                                             → AgentUnavailable (ALL stages, D-07)
705///   exit≠0 (excluding 137/127)                           → Failed (ALL stages)
706///   exit=0, stage in {Plan, Code}, commits=0             → Failed ("no work done")
707///   exit=0, stage in {Plan, Code}, commits>0             → Success
708///   exit=0, stage NOT in {Plan, Code} (Define/Validate/Ship), commits=0 → Success
709///           (not commit-gated; Validate's real pass signal is its verdict,
710///           not a bare zero-commit — see Task 2's turn.completed deferral)
711///   exit unknown                                         → fall to Layer 3 (return None)
712///
713/// WR-06 (13-REVIEW.md): takes only the explicit `project_root` parameter
714/// for both the `.devflow/` file paths and the git subprocess `current_dir`
715/// — previously it also accepted `state: &State` and used `state.project_root`
716/// for the git calls, which every caller happened to pass consistently with
717/// `project_root` but which the function itself had no way to enforce.
718pub fn evaluate_layer2(
719    project_root: &Path,
720    phase: u32,
721    git_flow: &GitFlowConfig,
722    stage: Stage,
723) -> Result<Option<AgentResult>, ResultError> {
724    let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
725    let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
726        Ok(s) => s.trim().parse().unwrap_or(-1),
727        Err(_) => return Ok(None), // fall to Layer 3
728    };
729
730    let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
731
732    // Verify branch exists before counting commits.
733    let branch_exists = git_command(project_root)
734        .args(["rev-parse", "--verify", &branch])
735        .output()
736        .map(|o| o.status.success())
737        .unwrap_or(false);
738
739    let commits: u32 = if branch_exists {
740        let range = format!("{}..{branch}", git_flow.develop);
741        git_command(project_root)
742            .args(["rev-list", "--count", &range])
743            .output()
744            .ok()
745            .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
746            .unwrap_or(0)
747    } else {
748        0
749    };
750
751    let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
752    let no_work_done = commit_gated && commits == 0;
753
754    // 137 (SIGKILL, typically OOM) and 127 (command not found) are classified
755    // BEFORE the generic `exit_code != 0 -> Failed` catch-all, using the same
756    // trusted plain-i32 already parsed above from the monitor-written exit
757    // file (D-07, 17b — no ExitStatusExt/signal API per Pitfall 1a).
758    let status = if exit_code == 137 {
759        AgentStatus::ResourceKilled
760    } else if exit_code == 127 {
761        AgentStatus::AgentUnavailable
762    } else if exit_code != 0 || no_work_done {
763        AgentStatus::Failed
764    } else {
765        AgentStatus::Success
766    };
767
768    Ok(Some(AgentResult {
769        status,
770        exit_code: Some(exit_code),
771        reason: if exit_code == 137 {
772            Some(format!(
773                "agent process was killed (exit code 137, likely OOM) ({} commits on {})",
774                commits, branch
775            ))
776        } else if exit_code == 127 {
777            Some(format!(
778                "agent command was unavailable (exit code 127, command not found) ({} commits on {})",
779                commits, branch
780            ))
781        } else if exit_code != 0 {
782            Some(format!(
783                "agent exited with code {} ({} commits on {})",
784                exit_code, commits, branch
785            ))
786        } else if no_work_done {
787            Some(format!(
788                "no commits found on {} (agent exit code was {})",
789                branch, exit_code
790            ))
791        } else {
792            Some(format!(
793                "{} commits on {} (agent exit code was {})",
794                commits, branch, exit_code
795            ))
796        },
797        commits: Some(commits),
798        summary: None,
799        verdict: None,
800        decided_by_layer: Some(2),
801    }))
802}
803
804/// Layer 3: Last resort — agent process is gone.
805///
806/// Split per D-02/D-03 case 3 (17-03): "process gone, commits exist" stays
807/// `Unknown` — unverified but there is SOMETHING to account for, and Plan
808/// 04's never-advance dispatch gates it downstream (D-04) rather than
809/// reclassifying it here. "Process gone, zero commits, nothing declared" is
810/// no longer a blanket advanceable `Unknown` — it is reclassified to
811/// `Failed` so a vanished agent that produced and declared nothing cannot
812/// masquerade as ambiguous-but-fine; the reason flags that human review is
813/// needed. This only fires when neither Layer 1 nor Layer 2 produced a
814/// definitive result.
815pub fn evaluate_layer3(
816    project_root: &Path,
817    phase: u32,
818    git_flow: &GitFlowConfig,
819) -> Result<AgentResult, ResultError> {
820    let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
821    let commits = git_command(project_root)
822        .args([
823            "rev-list",
824            "--count",
825            &format!("{}..{branch}", git_flow.develop),
826        ])
827        .output()
828        .ok()
829        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
830        .unwrap_or(0);
831
832    let (status, reason) = if commits > 0 {
833        (
834            AgentStatus::Unknown,
835            format!(
836                "unverified — agent process is gone but {} commits exist on {}",
837                commits, branch
838            ),
839        )
840    } else {
841        (
842            AgentStatus::Failed,
843            "no work accounted for — agent process is gone with no commits and no declared \
844             external post-condition; human review needed"
845                .to_string(),
846        )
847    };
848
849    Ok(AgentResult {
850        status,
851        exit_code: None,
852        reason: Some(reason),
853        commits: Some(commits),
854        summary: None,
855        verdict: None,
856        decided_by_layer: Some(3),
857    })
858}
859
860/// Layer 0: run explicitly operator-approved external post-condition probes.
861///
862/// A failed probe outranks every agent-controlled signal. An approved,
863/// all-passing set of declared probes is itself affirmative completion
864/// evidence — `Success` — so a legitimately external-only stage with zero
865/// commits can still complete cleanly (D-05 gap 2). Evaluated for EVERY
866/// stage, not only Code (D-05 gap 1 / D-06). With no declarations (or when
867/// disabled), behavior is byte-for-byte the pre-Phase-16 cascade.
868///
869/// Two roots are intentionally kept distinct (review Plan 03 MEDIUM,
870/// OpenCode): `project_root` is used to DISCOVER the PLAN's declared
871/// commands (`.planning/phases/` lives there, not in a worktree checkout),
872/// while `execution_root` — the worktree, when one is set — is where probes
873/// actually RUN. Conflating the two previously meant a worktree-based phase
874/// could not find its own declaration and silently mis-hit the
875/// "PLAN removed" veto below.
876fn evaluate_layer0(
877    project_root: &Path,
878    state: &State,
879    approved_commands: Option<&[String]>,
880) -> Option<AgentResult> {
881    if !crate::config::external_verify_enabled(project_root) {
882        return None;
883    }
884
885    let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
886    let commands = crate::verify::external_verify_commands(project_root, state.phase);
887    if commands.is_empty() {
888        return approved_commands.map(|_| AgentResult {
889            status: AgentStatus::Failed,
890            exit_code: None,
891            reason: Some(
892                "external verification approval mismatch; PLAN declaration was removed".into(),
893            ),
894            commits: None,
895            summary: None,
896            verdict: None,
897            decided_by_layer: Some(0),
898        });
899    }
900    let Some(approved_commands) = approved_commands else {
901        return Some(AgentResult {
902            status: AgentStatus::Failed,
903            exit_code: None,
904            reason: Some(format!(
905                "external verification is not approved; set {} to the reviewed JSON command array",
906                crate::verify::TRUST_EXTERNAL_VERIFY_ENV
907            )),
908            commits: None,
909            summary: None,
910            verdict: None,
911            decided_by_layer: Some(0),
912        });
913    };
914    if commands != approved_commands {
915        return Some(AgentResult {
916            status: AgentStatus::Failed,
917            exit_code: None,
918            reason: Some("external verification approval mismatch; PLAN commands changed".into()),
919            commits: None,
920            summary: None,
921            verdict: None,
922            decided_by_layer: Some(0),
923        });
924    }
925    match commands
926        .into_iter()
927        .find(|command| !crate::verify::run_external_verification(command, execution_root))
928    {
929        Some(command) => Some(AgentResult {
930            status: AgentStatus::Failed,
931            exit_code: None,
932            reason: Some(format!("external verification failed: {command}")),
933            commits: None,
934            summary: None,
935            verdict: None,
936            decided_by_layer: Some(0),
937        }),
938        // Every declared, approved probe passed — affirmative completion
939        // evidence on its own (D-05 gap 2), even with zero commits.
940        None => Some(AgentResult {
941            status: AgentStatus::Success,
942            exit_code: None,
943            reason: Some(
944                "external verification passed — all declared, approved probes succeeded".into(),
945            ),
946            commits: None,
947            summary: None,
948            verdict: None,
949            decided_by_layer: Some(0),
950        }),
951    }
952}
953
954/// Reconciles Layer 0's affirmative-success result with Layer 1's
955/// self-reported verdict at `Stage::Validate` (18e).
956///
957/// Layer 0's affirmative-success arm above short-circuits the cascade before
958/// Layer 1 ever runs (`evaluate_agent_result_inner` returns immediately on
959/// any `Some(..)` from Layer 0), but Layer 1 is the ONLY carrier of a
960/// `verdict` — `status` reports whether the stage's task ran; `verdict`
961/// reports whether validation itself passed (see `AgentResult::verdict`'s
962/// doc comment). At `Stage::Validate` that meant an agent's explicit
963/// `verdict: pass` was silently discarded and `advance()` computed a failure
964/// from it — a regression introduced by this project's own 17-03, fixed
965/// here.
966///
967/// `decided_by_layer` deliberately stays `Some(0)` — Layer 0 still DECIDED
968/// the `status`; Layer 1 only supplies the `verdict`. The CLI relies on that
969/// value to tell an `external_verify` Validate apart from an ordinary one
970/// (`classify_validate_outcome`, 18e).
971///
972/// Scoped to `Stage::Validate` only (flagged assumption in 18-05-PLAN.md): at
973/// every other stage an affirmative Layer 0 success keeps `verdict: None`,
974/// unchanged from current behavior. A Layer 0 FAILURE is never passed here —
975/// only its affirmative-success arm is, so a failed probe still outranks
976/// every agent-controlled signal.
977fn reconcile_layer0_verdict(
978    project_root: &Path,
979    state: &State,
980    result: AgentResult,
981) -> AgentResult {
982    if state.stage != Stage::Validate
983        || result.status != AgentStatus::Success
984        || result.decided_by_layer != Some(0)
985    {
986        return result;
987    }
988    let verdict = evaluate_layer1(project_root, state.phase).and_then(|layer1| layer1.verdict);
989    AgentResult { verdict, ..result }
990}
991
992/// Full four-layer evaluation: returns the best available AgentResult.
993pub fn evaluate_agent_result(
994    project_root: &Path,
995    state: &State,
996    git_flow: &GitFlowConfig,
997) -> Result<AgentResult, ResultError> {
998    let approval = crate::verify::external_verification_approval();
999    evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
1000}
1001
1002fn evaluate_agent_result_inner(
1003    project_root: &Path,
1004    state: &State,
1005    git_flow: &GitFlowConfig,
1006    approved_commands: Option<&[String]>,
1007) -> Result<AgentResult, ResultError> {
1008    // Layer 0: operator-authored external post-condition (authoritative failure)
1009    if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
1010        return Ok(reconcile_layer0_verdict(project_root, state, result));
1011    }
1012
1013    // Layer 1: DEVFLOW_RESULT marker (authoritative)
1014    if let Some(result) = evaluate_layer1(project_root, state.phase) {
1015        return Ok(result);
1016    }
1017
1018    // Layer 2: Exit code + commit gate
1019    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
1020        return Ok(result);
1021    }
1022
1023    // Layer 3: Process existence + commits
1024    evaluate_layer3(project_root, state.phase, git_flow)
1025}
1026
1027/// Path to the .devflow directory for a project root.
1028fn devflow_dir(project_root: &Path) -> PathBuf {
1029    project_root.join(".devflow")
1030}
1031
1032/// Path to the stdout file for a given phase.
1033pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
1034    devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
1035}
1036
1037/// Path where the agent's stderr is captured for a given phase.
1038/// Lives alongside `stdout_path` under `.devflow/`.
1039pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
1040    devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
1041}
1042
1043/// Path to the exit code file for a given phase.
1044pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
1045    devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
1046}
1047
1048/// Path to the file where the monitor records the launched agent's PID.
1049pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
1050    devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
1051}
1052
1053/// Path to the archived-capture-history directory for a phase (16b).
1054///
1055/// `.devflow/history/phase-NN/` holds retained per-stage capture generations
1056/// so a false-positive self-report can be diagnosed after the fact. Exposed
1057/// as a constructor (rather than inlined at each call site) so downstream
1058/// tooling (16h in 16-07's correlation, 16i in 16-05's enumeration) always
1059/// derives the path from here instead of hardcoding it.
1060pub fn history_dir(project_root: &Path, phase: u32) -> PathBuf {
1061    devflow_dir(project_root)
1062        .join("history")
1063        .join(format!("phase-{:02}", phase))
1064}
1065
1066/// Monotonically increasing tie-breaker appended to the nanosecond timestamp
1067/// used to stamp archived generations, so two archives issued within the
1068/// same nanosecond (possible in a tight test loop) never collide.
1069static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1070
1071/// A stamp unique within this process, used to name an archived generation.
1072/// The outgoing stage's name is not available at the `archive_phase_files`
1073/// call site (see `launch_stage` in main.rs), so a monotonic timestamp is
1074/// used instead — sufficient to order and identify generations.
1075fn archive_stamp() -> String {
1076    let nanos = std::time::SystemTime::now()
1077        .duration_since(std::time::UNIX_EPOCH)
1078        .map(|d| d.as_nanos())
1079        .unwrap_or(0);
1080    let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1081    format!("{nanos}-{seq}")
1082}
1083
1084/// Archive the prior stage's stdout/exit captures into bounded per-phase
1085/// history instead of wiping them outright, so a false-positive self-report
1086/// can be diagnosed after the fact (16b). Replaces the old
1087/// `cleanup_phase_files`, which deleted these files unconditionally.
1088///
1089/// At most `retain` capture generations are kept per phase; older ones are
1090/// pruned (see [`prune_history`]). The agent-pid file is still removed
1091/// outright — it is process bookkeeping, not diagnostic output. When there
1092/// is nothing to archive (first launch), this is a no-op success.
1093pub fn archive_phase_files(
1094    project_root: &Path,
1095    evidence_root: &Path,
1096    phase: u32,
1097    retain: usize,
1098) -> Result<Option<String>, std::io::Error> {
1099    archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
1100}
1101
1102fn archive_phase_files_with_stamp(
1103    project_root: &Path,
1104    evidence_root: &Path,
1105    phase: u32,
1106    retain: usize,
1107    stamp: &str,
1108) -> Result<Option<String>, std::io::Error> {
1109    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
1110
1111    let stdout_src = stdout_path(project_root, phase);
1112    let exit_src = exit_code_path(project_root, phase);
1113    let stdout_exists = stdout_src.exists();
1114    let exit_exists = exit_src.exists();
1115    if !stdout_exists && !exit_exists {
1116        return Ok(None); // Nothing to archive — first launch.
1117    }
1118
1119    let history_dir = history_dir(project_root, phase);
1120    crate::workflow::ensure_devflow_dir(&history_dir)?;
1121
1122    let staging_dir = history_dir.join(format!(".pending-{stamp}"));
1123    std::fs::create_dir(&staging_dir)?;
1124    let stdout_stage = staging_dir.join("stdout");
1125    let exit_stage = staging_dir.join("exit");
1126    let review_stage = staging_dir.join("REVIEW.md");
1127    let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
1128    let exit_dest = history_dir.join(format!("{stamp}-exit"));
1129    let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
1130    let review_src = phase_review_path(evidence_root, phase);
1131
1132    let mut stdout_staged = false;
1133    let mut exit_staged = false;
1134    let mut stdout_published = false;
1135    let mut exit_published = false;
1136    let mut review_published = false;
1137
1138    let archive_result = (|| -> Result<(), std::io::Error> {
1139        if stdout_exists {
1140            std::fs::rename(&stdout_src, &stdout_stage)?;
1141            stdout_staged = true;
1142        }
1143        if exit_exists {
1144            std::fs::rename(&exit_src, &exit_stage)?;
1145            exit_staged = true;
1146        }
1147        if let Some(review) = &review_src {
1148            std::fs::copy(review, &review_stage)?;
1149        }
1150
1151        if stdout_exists {
1152            std::fs::rename(&stdout_stage, &stdout_dest)?;
1153            stdout_staged = false;
1154            stdout_published = true;
1155        }
1156        if exit_exists {
1157            std::fs::rename(&exit_stage, &exit_dest)?;
1158            exit_staged = false;
1159            exit_published = true;
1160        }
1161        if review_src.is_some() {
1162            std::fs::rename(&review_stage, &review_dest)?;
1163            review_published = true;
1164        }
1165        Ok(())
1166    })();
1167
1168    if let Err(error) = archive_result {
1169        let mut rollback_error = None;
1170        let mut restore = |from: &Path, to: &Path| {
1171            if let Err(error) = std::fs::rename(from, to)
1172                && rollback_error.is_none()
1173            {
1174                rollback_error = Some(error);
1175            }
1176        };
1177        if stdout_published {
1178            restore(&stdout_dest, &stdout_src);
1179        } else if stdout_staged {
1180            restore(&stdout_stage, &stdout_src);
1181        }
1182        if exit_published {
1183            restore(&exit_dest, &exit_src);
1184        } else if exit_staged {
1185            restore(&exit_stage, &exit_src);
1186        }
1187        if review_published {
1188            let _ = std::fs::remove_file(&review_dest);
1189        }
1190        let _ = std::fs::remove_dir_all(&staging_dir);
1191
1192        if let Some(rollback_error) = rollback_error {
1193            return Err(std::io::Error::new(
1194                error.kind(),
1195                format!("{error}; archive rollback failed: {rollback_error}"),
1196            ));
1197        }
1198        return Err(error);
1199    }
1200
1201    let _ = std::fs::remove_dir(&staging_dir);
1202
1203    prune_history(&history_dir, retain);
1204    Ok(Some(stamp.to_string()))
1205}
1206
1207fn phase_review_path(project_root: &Path, phase: u32) -> Option<PathBuf> {
1208    let phases = std::fs::read_dir(project_root.join(".planning/phases")).ok()?;
1209    let prefix = format!("{phase:02}-");
1210    for entry in phases.flatten() {
1211        if entry
1212            .file_name()
1213            .to_str()
1214            .is_some_and(|name| name.starts_with(&prefix))
1215        {
1216            let review = entry.path().join(format!("{phase:02}-REVIEW.md"));
1217            if review.exists() {
1218                return Some(review);
1219            }
1220        }
1221    }
1222    None
1223}
1224
1225/// Keep only the newest `retain` capture generations under `history_dir`,
1226/// deleting older ones. Generations are grouped by their stamp (the shared
1227/// prefix of a `{stamp}-stdout`/`{stamp}-exit` pair, split off the trailing
1228/// `-stdout`/`-exit` suffix via `rsplit_once`) and ordered lexicographically,
1229/// which matches numeric/chronological order for the fixed-width nanosecond
1230/// stamps `archive_stamp` produces. Ordering parses both numeric components;
1231/// the process-local sequence is intentionally not fixed-width.
1232fn prune_history(history_dir: &Path, retain: usize) {
1233    let Ok(entries) = std::fs::read_dir(history_dir) else {
1234        return;
1235    };
1236
1237    let mut stamps: Vec<String> = entries
1238        .flatten()
1239        .filter_map(|entry| {
1240            let name = entry.file_name().to_str()?.to_string();
1241            name.rsplit_once('-')
1242                .map(|(stamp, _suffix)| stamp.to_string())
1243        })
1244        .collect();
1245    stamps.sort_by_key(|stamp| {
1246        let mut parts = stamp.split('-');
1247        let nanos = parts
1248            .next()
1249            .and_then(|part| part.parse::<u128>().ok())
1250            .unwrap_or(0);
1251        let sequence = parts
1252            .next()
1253            .and_then(|part| part.parse::<u64>().ok())
1254            .unwrap_or(0);
1255        (nanos, sequence)
1256    });
1257    stamps.dedup();
1258
1259    if stamps.len() <= retain {
1260        return;
1261    }
1262
1263    let to_remove = stamps.len() - retain;
1264    for stamp in &stamps[..to_remove] {
1265        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
1266        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
1267        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
1268    }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use super::*;
1274    use crate::config::GitFlowConfig;
1275    use crate::mode::Mode;
1276    use crate::stage::Stage;
1277    use crate::state::{AgentKind, State};
1278
1279    fn state_in(root: &Path, phase: u32) -> State {
1280        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1281        state.stage = Stage::Code;
1282        state
1283    }
1284
1285    fn git(root: &Path, args: &[&str]) {
1286        let output = crate::test_support::git_command(root)
1287            .args(args)
1288            .output()
1289            .unwrap();
1290        assert!(
1291            output.status.success(),
1292            "git {:?} failed\nstdout: {}\nstderr: {}",
1293            args,
1294            String::from_utf8_lossy(&output.stdout),
1295            String::from_utf8_lossy(&output.stderr)
1296        );
1297    }
1298
1299    fn init_repo_with_feature_commit(root: &Path, phase: u32) {
1300        git(root, &["init"]);
1301        git(root, &["config", "user.email", "devflow@example.com"]);
1302        git(root, &["config", "user.name", "DevFlow Tests"]);
1303        git(root, &["config", "commit.gpgsign", "false"]);
1304        git(root, &["config", "tag.gpgsign", "false"]);
1305        git(root, &["config", "core.hooksPath", "/dev/null"]);
1306        git(root, &["checkout", "-b", "develop"]);
1307        std::fs::write(root.join("README.md"), "base\n").unwrap();
1308        git(root, &["add", "README.md"]);
1309        git(root, &["commit", "-m", "base"]);
1310
1311        let branch = format!("feature/phase-{phase:02}");
1312        git(root, &["checkout", "-b", &branch]);
1313        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
1314        git(root, &["add", "phase.txt"]);
1315        git(root, &["commit", "-m", "feature work"]);
1316    }
1317
1318    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
1319    /// develop's tip with **no** extra commit (0 commits ahead).
1320    fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
1321        git(root, &["init"]);
1322        git(root, &["config", "user.email", "devflow@example.com"]);
1323        git(root, &["config", "user.name", "DevFlow Tests"]);
1324        git(root, &["config", "commit.gpgsign", "false"]);
1325        git(root, &["config", "tag.gpgsign", "false"]);
1326        git(root, &["config", "core.hooksPath", "/dev/null"]);
1327        git(root, &["checkout", "-b", "develop"]);
1328        std::fs::write(root.join("README.md"), "base\n").unwrap();
1329        git(root, &["add", "README.md"]);
1330        git(root, &["commit", "-m", "base"]);
1331
1332        let branch = format!("feature/phase-{phase:02}");
1333        git(root, &["checkout", "-b", &branch]);
1334    }
1335
1336    #[test]
1337    fn parse_success_marker() {
1338        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1339        let result = parse_devflow_result(stdout).unwrap();
1340        assert_eq!(result.status, AgentStatus::Success);
1341    }
1342
1343    #[test]
1344    fn parse_failed_marker_with_reason() {
1345        let stdout =
1346            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
1347        let result = parse_devflow_result(stdout).unwrap();
1348        assert_eq!(result.status, AgentStatus::Failed);
1349        assert_eq!(result.reason.unwrap(), "clippy errors");
1350    }
1351
1352    #[test]
1353    fn parse_missing_marker_returns_none() {
1354        let stdout = "just some output\nno marker here\n";
1355        assert!(parse_devflow_result(stdout).is_none());
1356    }
1357
1358    #[test]
1359    fn parse_malformed_json_returns_none() {
1360        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
1361        assert!(parse_devflow_result(stdout).is_none());
1362    }
1363
1364    #[test]
1365    fn parse_lowercase_marker() {
1366        let stdout = "devflow_result: {\"status\":\"success\"}\n";
1367        let result = parse_devflow_result(stdout).unwrap();
1368        assert_eq!(result.status, AgentStatus::Success);
1369    }
1370
1371    #[test]
1372    fn parse_marker_without_space_after_colon() {
1373        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
1374        let result = parse_devflow_result(stdout).unwrap();
1375        assert_eq!(result.status, AgentStatus::Success);
1376    }
1377
1378    #[test]
1379    fn parse_lowercase_no_space_marker() {
1380        // Lowercase prefix AND no space after the colon — the combination that
1381        // the Phase 6 review flagged as uncovered.
1382        let stdout = "devflow_result:{\"status\":\"success\"}\n";
1383        let result = parse_devflow_result(stdout).unwrap();
1384        assert_eq!(result.status, AgentStatus::Success);
1385    }
1386
1387    #[test]
1388    fn parse_finds_last_marker_in_tail() {
1389        // Multiple markers — should find the last one.
1390        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1391        let result = parse_devflow_result(stdout).unwrap();
1392        assert_eq!(result.status, AgentStatus::Success);
1393    }
1394
1395    #[test]
1396    fn parse_marker_lines_returns_last_marker_in_long_output() {
1397        let stdout = format!(
1398            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
1399             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
1400            "prefix".repeat(900),
1401            "tail output".repeat(100)
1402        );
1403
1404        let result = parse_marker_lines(&stdout).unwrap();
1405
1406        assert_eq!(result.status, AgentStatus::Success);
1407    }
1408
1409    #[test]
1410    fn parse_marker_only_in_last_4000_chars() {
1411        // Marker beyond 4000 chars from end should not be found.
1412        let prefix = "a".repeat(5000);
1413        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
1414        assert!(parse_devflow_result(&stdout).is_none());
1415    }
1416
1417    #[test]
1418    fn parse_marker_with_commits_and_summary() {
1419        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
1420        let result = parse_devflow_result(stdout).unwrap();
1421        assert_eq!(result.status, AgentStatus::Success);
1422        assert_eq!(result.commits, Some(3));
1423        assert_eq!(result.summary.unwrap(), "added tests");
1424    }
1425
1426    #[test]
1427    fn parse_marker_inside_json_result_envelope() {
1428        // Claude --output-format json wraps the final text in a `result` field
1429        // with embedded newlines escaped.
1430        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
1431        let result = parse_devflow_result(stdout).unwrap();
1432        assert_eq!(result.status, AgentStatus::Success);
1433        assert_eq!(result.commits, Some(2));
1434    }
1435
1436    #[test]
1437    fn parse_failed_marker_inside_json_envelope() {
1438        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
1439        let result = parse_devflow_result(stdout).unwrap();
1440        assert_eq!(result.status, AgentStatus::Failed);
1441        assert_eq!(result.reason.unwrap(), "tests failed");
1442    }
1443
1444    #[test]
1445    fn parse_json_envelope_without_marker_returns_none() {
1446        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
1447        assert!(parse_devflow_result(stdout).is_none());
1448    }
1449
1450    #[test]
1451    fn detect_claude_json_rate_limit_by_subtype() {
1452        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
1453        assert_eq!(
1454            detect_rate_limit(stdout).as_deref(),
1455            Some("2026-06-18T15:45:30Z")
1456        );
1457    }
1458
1459    #[test]
1460    fn detect_claude_json_rate_limit_by_429() {
1461        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
1462        assert_eq!(
1463            detect_rate_limit(stdout).as_deref(),
1464            Some("Too many requests. Try later.")
1465        );
1466    }
1467
1468    #[test]
1469    fn detect_codex_try_again_rate_limit() {
1470        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
1471        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1472    }
1473
1474    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
1475    /// `json_find_key` run on the coding agent's raw stdout via
1476    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
1477    /// goes through. Deeply nested JSON — accidental or adversarial — must
1478    /// not stack-overflow the process, and a real marker at any depth
1479    /// serde_json will parse (its default recursion limit is exactly 128)
1480    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
1481    /// silently misclassified rate-limit markers at depths 64–128.
1482    #[test]
1483    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
1484        // 100 levels: parseable by serde_json (limit 128), deeper than the
1485        // removed 64-level traversal cap that used to hide the marker.
1486        const DEPTH: usize = 100;
1487        let mut stdout = String::new();
1488        for _ in 0..DEPTH {
1489            stdout.push_str(r#"{"nested":"#);
1490        }
1491        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
1492        for _ in 0..DEPTH {
1493            stdout.push('}');
1494        }
1495
1496        // Must return promptly without crashing AND find the buried marker —
1497        // the iterative worklist traversal has no silent-miss window.
1498        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
1499    }
1500
1501    #[test]
1502    fn detect_rate_limit_ignores_normal_stdout() {
1503        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
1504        assert!(detect_rate_limit(stdout).is_none());
1505    }
1506
1507    #[test]
1508    fn claude_envelope_is_error_detected() {
1509        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
1510        let result = detect_claude_envelope_failure(stdout).unwrap();
1511        assert_eq!(result.status, AgentStatus::Failed);
1512    }
1513
1514    #[test]
1515    fn claude_is_error_overrides_success_marker() {
1516        let dir = tempfile::tempdir().unwrap();
1517        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1518        std::fs::write(
1519            stdout_path(dir.path(), 9),
1520            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
1521        )
1522        .unwrap();
1523
1524        let result = evaluate_layer1(dir.path(), 9).unwrap();
1525
1526        assert_eq!(result.status, AgentStatus::Failed);
1527    }
1528
1529    #[test]
1530    fn claude_envelope_is_error_false_defers() {
1531        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
1532        assert!(detect_claude_envelope_failure(stdout).is_none());
1533    }
1534
1535    #[test]
1536    fn claude_envelope_marker_still_wins() {
1537        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
1538        assert!(detect_claude_envelope_failure(stdout).is_none());
1539        let result = parse_devflow_result(stdout).unwrap();
1540        assert_eq!(result.status, AgentStatus::Success);
1541        assert_eq!(result.commits, Some(2));
1542    }
1543
1544    #[test]
1545    fn session_id_reads_top_level_string() {
1546        let stdout = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
1547        assert_eq!(
1548            claude_session_id(stdout).as_deref(),
1549            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
1550        );
1551    }
1552
1553    /// T-28-04 forgery guard: the embedded `DEVFLOW_RESULT` marker carries a
1554    /// DIFFERENT session id than the envelope's own top-level key. The
1555    /// top-level id must win — an agent must not be able to redirect which
1556    /// session DevFlow resumes into by planting its own `session_id` inside
1557    /// its self-authored marker JSON.
1558    #[test]
1559    fn session_id_in_devflow_result_marker_is_not_returned() {
1560        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"session_id\": \"forged-by-agent\"}","session_id":"real-top-level-id"}"#;
1561        assert_eq!(
1562            claude_session_id(stdout).as_deref(),
1563            Some("real-top-level-id")
1564        );
1565    }
1566
1567    #[test]
1568    fn session_id_plain_text_stdout_returns_none() {
1569        let stdout = "just some plain text output, not JSON\n";
1570        assert!(claude_session_id(stdout).is_none());
1571    }
1572
1573    #[test]
1574    fn session_id_missing_key_returns_none() {
1575        let stdout = r#"{"type":"result","result":"done, no session key"}"#;
1576        assert!(claude_session_id(stdout).is_none());
1577    }
1578
1579    #[test]
1580    fn session_id_non_string_type_returns_none_not_panic() {
1581        let stdout = r#"{"type":"result","result":"done","session_id":12345}"#;
1582        assert!(claude_session_id(stdout).is_none());
1583    }
1584
1585    #[test]
1586    fn session_id_from_capture_missing_file_returns_none() {
1587        let dir = tempfile::tempdir().unwrap();
1588        assert!(session_id_from_capture(dir.path(), 42).is_none());
1589    }
1590
1591    #[test]
1592    fn session_id_from_capture_lossy_reads_invalid_utf8() {
1593        let dir = tempfile::tempdir().unwrap();
1594        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1595        let mut bytes = br#"{"type":"result","result":"done "#.to_vec();
1596        bytes.push(0xFF); // invalid UTF-8 byte
1597        bytes.extend_from_slice(br#"","session_id":"lossy-ok"}"#);
1598        std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1599
1600        assert_eq!(
1601            session_id_from_capture(dir.path(), 5).as_deref(),
1602            Some("lossy-ok")
1603        );
1604    }
1605
1606    /// Positive fixture built from RESEARCH's *predicted* `**Gate:**`
1607    /// rendering (a bare, un-spanned value). Kept as a tolerated shape, but
1608    /// note this is NOT what a real run emits — see
1609    /// `blocking_human_checkpoint_reported_matches_live_observed_rendering`
1610    /// for the rendering actually captured on 2026-07-31, which this
1611    /// prediction missed.
1612    #[test]
1613    fn blocking_human_checkpoint_reported_detects_human_gate_line() {
1614        let stdout = format!(
1615            "## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\n"
1616        );
1617        assert!(blocking_human_checkpoint_reported(&stdout));
1618    }
1619
1620    /// The Phase 26 near-miss distinction: a plain `blocking` gate must NOT
1621    /// be classified as a human-blocking checkpoint. `PLAIN_GATE_VALUE` is
1622    /// local to this test (not a module-level const) — it has no production
1623    /// use, only this negative fixture's.
1624    #[test]
1625    fn blocking_human_checkpoint_reported_false_for_plain_blocking() {
1626        const PLAIN_GATE_VALUE: &str = "blocking";
1627        let stdout = format!(
1628            "## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {PLAIN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\n"
1629        );
1630        assert!(!blocking_human_checkpoint_reported(&stdout));
1631    }
1632
1633    #[test]
1634    fn blocking_human_checkpoint_reported_false_when_no_gate_field() {
1635        let stdout = "some ordinary agent failure output, no checkpoint at all\n";
1636        assert!(!blocking_human_checkpoint_reported(stdout));
1637    }
1638
1639    /// The `Gate:` line arrives inside an escaped Claude JSON result
1640    /// envelope's `result` field — must be found via the unescaped inner
1641    /// text, not the raw (escaped) JSON string.
1642    #[test]
1643    fn blocking_human_checkpoint_reported_true_inside_escaped_envelope() {
1644        let inner = format!(
1645            "## CHECKPOINT REACHED\\n\\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\\n"
1646        );
1647        let stdout = format!(
1648            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"abc"}}"#
1649        );
1650        assert!(blocking_human_checkpoint_reported(&stdout));
1651    }
1652
1653    #[test]
1654    fn blocking_human_checkpoint_reported_tolerates_whitespace_and_emphasis() {
1655        let stdout = format!("  **Gate:**   {HUMAN_GATE_VALUE}   \n");
1656        assert!(blocking_human_checkpoint_reported(&stdout));
1657    }
1658
1659    /// REGRESSION — the rendering a real headless run actually produces.
1660    ///
1661    /// Transcribed verbatim from `.devflow/phase-91-stdout` of the live A1
1662    /// run on 2026-07-31 (a genuine `gate="blocking-human"` task driven
1663    /// through DevFlow's own monitor). The value arrives as a markdown CODE
1664    /// SPAN, not the bare token RESEARCH.md predicted.
1665    ///
1666    /// Before the backtick was added to `text_reports_human_gate`'s trim set
1667    /// this returned `false`: the leading backtick survived the trim, so the
1668    /// value `take_while` terminated at once and yielded an empty token. A
1669    /// real checkpoint was therefore never recognized, and the run fell
1670    /// through to the generic gate. If this test ever goes red, DevFlow has
1671    /// stopped recognizing real checkpoints — do not "fix" it by relaxing
1672    /// the assertion.
1673    #[test]
1674    fn blocking_human_checkpoint_reported_matches_live_observed_rendering() {
1675        let stdout = format!(
1676            "---\n\n## Checkpoint: Decision\n\n**Plan:** 91-01 Emit the checkpoint\n**Gate:** `{HUMAN_GATE_VALUE}`\n**Progress:** 0/1 tasks complete\n**Task:** Task 1 — Ask the operator to authorize writing the marker file\n"
1677        );
1678        assert!(
1679            blocking_human_checkpoint_reported(&stdout),
1680            "the live-observed code-span rendering must be recognized; \
1681             a false negative here means real checkpoints fall through to \
1682             the generic gate (the 2026-07-31 A1 defect)"
1683        );
1684    }
1685
1686    /// The same live rendering as it actually crosses into DevFlow's capture:
1687    /// escaped inside the Claude JSON result envelope. This is the exact
1688    /// path `checkpoint_reported_in_capture` reads in production.
1689    #[test]
1690    fn blocking_human_checkpoint_reported_matches_live_rendering_in_envelope() {
1691        let inner = format!(
1692            "## Checkpoint: Decision\\n\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Progress:** 0/1 tasks complete\\n"
1693        );
1694        let stdout = format!(
1695            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"live-a1"}}"#
1696        );
1697        assert!(
1698            blocking_human_checkpoint_reported(&stdout),
1699            "the code-span rendering must also be found inside the escaped envelope"
1700        );
1701    }
1702
1703    /// The backtick tolerance must not erode the Phase 26 near-miss
1704    /// distinction: a code-spanned PLAIN `blocking` gate is still not a
1705    /// human-blocking checkpoint.
1706    #[test]
1707    fn blocking_human_checkpoint_reported_false_for_code_spanned_plain_blocking() {
1708        let stdout = "## Checkpoint: Decision\n\n**Gate:** `blocking`\n";
1709        assert!(!blocking_human_checkpoint_reported(stdout));
1710    }
1711
1712    #[test]
1713    fn checkpoint_reported_in_capture_missing_file_returns_false() {
1714        let dir = tempfile::tempdir().unwrap();
1715        assert!(!checkpoint_reported_in_capture(dir.path(), 42));
1716    }
1717
1718    #[test]
1719    fn checkpoint_reported_in_capture_reads_true_from_file() {
1720        let dir = tempfile::tempdir().unwrap();
1721        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1722        std::fs::write(
1723            stdout_path(dir.path(), 11),
1724            format!("**Gate:** {HUMAN_GATE_VALUE}\n"),
1725        )
1726        .unwrap();
1727        assert!(checkpoint_reported_in_capture(dir.path(), 11));
1728    }
1729
1730    #[test]
1731    fn codex_event_stream_parses_turn_failed() {
1732        let stdout = concat!(
1733            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1734            "{\"type\":\"turn.started\"}\n",
1735            "{\"type\":\"item.started\",\"item\":{}}\n",
1736            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
1737        );
1738        let result = parse_codex_event_result(stdout).unwrap();
1739        assert_eq!(result.status, AgentStatus::Failed);
1740        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
1741    }
1742
1743    #[test]
1744    fn codex_turn_completed_no_marker_defers() {
1745        let stdout = concat!(
1746            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1747            "{\"type\":\"turn.started\"}\n",
1748            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1749        );
1750        assert!(parse_codex_event_result(stdout).is_none());
1751    }
1752
1753    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
1754    /// inside an `agent_message` item's text, never as a raw stdout line. A
1755    /// self-reported failure followed by a bare `turn.completed` must parse
1756    /// as Failed with the agent's reason — not defer to Layer 2 (which would
1757    /// see exit 0 and call it a success).
1758    #[test]
1759    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
1760        let stdout = concat!(
1761            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1762            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
1763            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1764        );
1765        let result = parse_codex_event_result(stdout).unwrap();
1766        assert_eq!(result.status, AgentStatus::Failed);
1767        assert_eq!(
1768            result.reason.as_deref(),
1769            Some("interactive input unavailable")
1770        );
1771    }
1772
1773    #[test]
1774    fn codex_agent_message_marker_success_short_circuits() {
1775        let stdout = concat!(
1776            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1777            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
1778            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1779        );
1780        let result = parse_codex_event_result(stdout).unwrap();
1781        assert_eq!(result.status, AgentStatus::Success);
1782    }
1783
1784    /// 13-06 dogfood regression: document content echoed into a JSONL event
1785    /// (GSD reference tables mentioning "rate limiting") must not trip the
1786    /// plain-text rate-limit heuristic — it returned the entire multi-KB
1787    /// event line as the "retry time" and that reached the desktop
1788    /// notification verbatim.
1789    #[test]
1790    fn detect_rate_limit_ignores_json_event_lines() {
1791        let stdout = concat!(
1792            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1793            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
1794            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
1795        );
1796        assert_eq!(detect_rate_limit(stdout), None);
1797    }
1798
1799    #[test]
1800    fn detect_rate_limit_still_reads_codex_plain_text() {
1801        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
1802        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
1803    }
1804
1805    #[test]
1806    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
1807        let stdout = concat!(
1808            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
1809            "not json at all\n",
1810            "{\"type\":\"item.started\",\"item\":{}}\n",
1811            "{\"type\":\"item.updated\",\"item\":{}}\n",
1812            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
1813        );
1814        let result = parse_codex_event_result(stdout).unwrap();
1815        assert_eq!(result.status, AgentStatus::Failed);
1816        assert_eq!(result.reason.as_deref(), Some("boom"));
1817    }
1818
1819    #[test]
1820    fn claude_envelope_not_consumed_by_codex_parser() {
1821        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
1822        assert!(parse_codex_event_result(stdout).is_none());
1823    }
1824
1825    #[test]
1826    fn evaluate_layer1_reports_rate_limited_without_marker() {
1827        let dir = tempfile::tempdir().unwrap();
1828        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1829        std::fs::write(
1830            stdout_path(dir.path(), 7),
1831            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
1832        )
1833        .unwrap();
1834
1835        let result = evaluate_layer1(dir.path(), 7).unwrap();
1836
1837        assert_eq!(result.status, AgentStatus::RateLimited);
1838        assert_eq!(
1839            result.reason.as_deref(),
1840            Some("rate limited until 2026-06-18T15:45:30Z")
1841        );
1842    }
1843
1844    /// A real Claude rate-limit envelope carries `is_error: true` alongside
1845    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
1846    /// must outrank the generic is_error → Failed path, or the primary
1847    /// rate-limit resume cron never triggers for the exact case it exists for.
1848    #[test]
1849    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
1850        let dir = tempfile::tempdir().unwrap();
1851        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1852        std::fs::write(
1853            stdout_path(dir.path(), 7),
1854            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
1855        )
1856        .unwrap();
1857
1858        let result = evaluate_layer1(dir.path(), 7).unwrap();
1859
1860        assert_eq!(result.status, AgentStatus::RateLimited);
1861        assert_eq!(
1862            result.reason.as_deref(),
1863            Some("rate limited until 2026-06-18T15:45:30Z")
1864        );
1865    }
1866
1867    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
1868    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
1869    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
1870    /// detection (the blocking-mode capture was fixed; the file read here is
1871    /// the other half of the same bug).
1872    #[test]
1873    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
1874        let dir = tempfile::tempdir().unwrap();
1875        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1876        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
1877        bytes.extend_from_slice(
1878            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
1879        );
1880        std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
1881
1882        let result = evaluate_layer1(dir.path(), 5).unwrap();
1883
1884        assert_eq!(result.status, AgentStatus::Failed);
1885        assert_eq!(result.reason.as_deref(), Some("review: bad"));
1886    }
1887
1888    #[test]
1889    fn failing_external_probe_outranks_success_marker() {
1890        let dir = tempfile::tempdir().unwrap();
1891        let phase_dir = dir
1892            .path()
1893            .join(".planning/phases/16-pipeline-reliability-hardening");
1894        std::fs::create_dir_all(&phase_dir).unwrap();
1895        std::fs::write(
1896            phase_dir.join("16-03-PLAN.md"),
1897            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
1898        )
1899        .unwrap();
1900        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1901        std::fs::write(
1902            stdout_path(dir.path(), 16),
1903            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1904        )
1905        .unwrap();
1906        let state = state_in(dir.path(), 16);
1907
1908        let approval = vec!["test -f externally-shipped".to_string()];
1909        let result = evaluate_agent_result_inner(
1910            dir.path(),
1911            &state,
1912            &GitFlowConfig::default(),
1913            Some(&approval),
1914        )
1915        .unwrap();
1916
1917        assert_eq!(result.status, AgentStatus::Failed);
1918        assert!(
1919            result
1920                .reason
1921                .as_deref()
1922                .is_some_and(|reason| reason.contains("external verification failed"))
1923        );
1924    }
1925
1926    /// D-05 gap 1 / D-06 (17-03): Layer 0 now evaluates on every stage, not
1927    /// only Code. Also covers the review-flagged worktree bug (Plan 03
1928    /// MEDIUM, OpenCode): PLAN discovery must read `project_root` (where
1929    /// `.planning/phases/` actually lives), while probe execution still
1930    /// reads `execution_root` (the worktree) — using the worktree for
1931    /// discovery would find zero commands and mis-fire the "PLAN removed"
1932    /// veto.
1933    #[test]
1934    fn external_probe_discovers_from_project_root_across_every_stage_and_executes_in_worktree() {
1935        let dir = tempfile::tempdir().unwrap();
1936        let worktree = dir.path().join("phase-worktree");
1937        std::fs::create_dir_all(&worktree).unwrap();
1938        let phase_dir = dir.path().join(".planning/phases/16-reliability");
1939        std::fs::create_dir_all(&phase_dir).unwrap();
1940        std::fs::write(
1941            phase_dir.join("16-01-PLAN.md"),
1942            "---\nexternal_verify: \"test -f implemented\"\n---\n",
1943        )
1944        .unwrap();
1945        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1946        std::fs::write(
1947            stdout_path(dir.path(), 16),
1948            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
1949        )
1950        .unwrap();
1951        let mut state = state_in(dir.path(), 16);
1952        state.worktree_path = Some(worktree.clone());
1953        state.stage = Stage::Plan;
1954
1955        let approval = vec!["test -f implemented".to_string()];
1956
1957        // Layer 0 now fires on Plan too — the probe file does not yet exist
1958        // in the worktree, so this must fail on the probe itself (NOT a
1959        // false PLAN-removed veto, which would mean discovery silently
1960        // returned zero commands).
1961        let plan_result = evaluate_agent_result_inner(
1962            dir.path(),
1963            &state,
1964            &GitFlowConfig::default(),
1965            Some(&approval),
1966        )
1967        .unwrap();
1968        assert_eq!(plan_result.status, AgentStatus::Failed);
1969        assert!(
1970            plan_result
1971                .reason
1972                .as_deref()
1973                .is_some_and(|reason| reason.contains("external verification failed")),
1974            "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
1975            plan_result.reason
1976        );
1977
1978        state.stage = Stage::Code;
1979        let code_result = evaluate_agent_result_inner(
1980            dir.path(),
1981            &state,
1982            &GitFlowConfig::default(),
1983            Some(&approval),
1984        )
1985        .unwrap();
1986        assert_eq!(code_result.status, AgentStatus::Failed);
1987
1988        // The probe still executes against execution_root (the worktree) —
1989        // only PLAN discovery moved to project_root.
1990        std::fs::write(worktree.join("implemented"), "done").unwrap();
1991        let passing = evaluate_agent_result_inner(
1992            dir.path(),
1993            &state,
1994            &GitFlowConfig::default(),
1995            Some(&approval),
1996        )
1997        .unwrap();
1998        assert_eq!(passing.status, AgentStatus::Success);
1999        assert_eq!(passing.decided_by_layer, Some(0));
2000    }
2001
2002    #[test]
2003    fn changed_external_probe_never_inherits_prior_approval() {
2004        let dir = tempfile::tempdir().unwrap();
2005        let phase_dir = dir.path().join(".planning/phases/16-reliability");
2006        std::fs::create_dir_all(&phase_dir).unwrap();
2007        std::fs::write(
2008            phase_dir.join("16-01-PLAN.md"),
2009            "---\nexternal_verify: \"touch escaped\"\n---\n",
2010        )
2011        .unwrap();
2012        let state = state_in(dir.path(), 16);
2013        let approved = vec!["test -f reviewed-artifact".to_string()];
2014
2015        let result = evaluate_agent_result_inner(
2016            dir.path(),
2017            &state,
2018            &GitFlowConfig::default(),
2019            Some(&approved),
2020        )
2021        .unwrap();
2022
2023        assert_eq!(result.status, AgentStatus::Failed);
2024        assert!(result.reason.unwrap().contains("approval mismatch"));
2025        assert!(!dir.path().join("escaped").exists());
2026    }
2027
2028    #[test]
2029    fn removed_external_probe_fails_closed_against_prior_approval() {
2030        let dir = tempfile::tempdir().unwrap();
2031        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2032        std::fs::write(
2033            stdout_path(dir.path(), 16),
2034            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
2035        )
2036        .unwrap();
2037        let state = state_in(dir.path(), 16);
2038        let approved = vec!["test -f shipped".to_string()];
2039
2040        let result = evaluate_agent_result_inner(
2041            dir.path(),
2042            &state,
2043            &GitFlowConfig::default(),
2044            Some(&approved),
2045        )
2046        .unwrap();
2047
2048        assert_eq!(result.status, AgentStatus::Failed);
2049        assert!(result.reason.unwrap().contains("declaration was removed"));
2050    }
2051
2052    #[test]
2053    fn no_external_declaration_preserves_layer1_result() {
2054        let dir = tempfile::tempdir().unwrap();
2055        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2056        std::fs::write(
2057            stdout_path(dir.path(), 16),
2058            "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
2059        )
2060        .unwrap();
2061        let state = state_in(dir.path(), 16);
2062        let layer1 = evaluate_layer1(dir.path(), 16).unwrap();
2063
2064        let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2065
2066        assert_eq!(
2067            serde_json::to_value(full).unwrap(),
2068            serde_json::to_value(layer1).unwrap()
2069        );
2070    }
2071
2072    /// D-05 gap 2 (17-03): a declared, operator-approved external
2073    /// post-condition whose probe passes is affirmative Success evidence on
2074    /// its own — even with zero commits and on a non-Code stage (Define
2075    /// here). No agent stdout is written at all, so if Layer 0 did not
2076    /// short-circuit, there would be nothing for Layer 1 to find and Layer 2
2077    /// would fall through for lack of an exit-code file.
2078    #[test]
2079    fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
2080        let dir = tempfile::tempdir().unwrap();
2081        let phase_dir = dir.path().join(".planning/phases/16-reliability");
2082        std::fs::create_dir_all(&phase_dir).unwrap();
2083        std::fs::write(
2084            phase_dir.join("16-01-PLAN.md"),
2085            "---\nexternal_verify: \"test -f shipped\"\n---\n",
2086        )
2087        .unwrap();
2088        std::fs::write(dir.path().join("shipped"), "done").unwrap();
2089        let mut state = state_in(dir.path(), 16);
2090        state.stage = Stage::Define;
2091
2092        let approval = vec!["test -f shipped".to_string()];
2093        let result = evaluate_agent_result_inner(
2094            dir.path(),
2095            &state,
2096            &GitFlowConfig::default(),
2097            Some(&approval),
2098        )
2099        .unwrap();
2100
2101        assert_eq!(result.status, AgentStatus::Success);
2102        assert_eq!(result.decided_by_layer, Some(0));
2103        assert_eq!(result.commits, None);
2104        // Off-Validate stage: verdict reconciliation does not apply (18e).
2105        assert_eq!(result.verdict, None);
2106    }
2107
2108    /// Review Plan 03 LOW (Codex+OpenCode), 16a: an approved all-passing
2109    /// Layer 0 probe intentionally outranks a Layer 1 self-reported failure
2110    /// marker — proven here at the cascade level (`evaluate_agent_result_inner`),
2111    /// not merely in isolation on `evaluate_layer0`.
2112    #[test]
2113    fn layer0_affirmative_success_outranks_layer1_failure_marker() {
2114        let dir = tempfile::tempdir().unwrap();
2115        let phase_dir = dir
2116            .path()
2117            .join(".planning/phases/16-pipeline-reliability-hardening");
2118        std::fs::create_dir_all(&phase_dir).unwrap();
2119        std::fs::write(
2120            phase_dir.join("16-03-PLAN.md"),
2121            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
2122        )
2123        .unwrap();
2124        std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
2125        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2126        std::fs::write(
2127            stdout_path(dir.path(), 16),
2128            "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
2129        )
2130        .unwrap();
2131        let state = state_in(dir.path(), 16);
2132
2133        let approval = vec!["test -f externally-shipped".to_string()];
2134        let result = evaluate_agent_result_inner(
2135            dir.path(),
2136            &state,
2137            &GitFlowConfig::default(),
2138            Some(&approval),
2139        )
2140        .unwrap();
2141
2142        assert_eq!(result.status, AgentStatus::Success);
2143        assert_eq!(result.decided_by_layer, Some(0));
2144        // Off-Validate stage (Code): verdict reconciliation does not apply,
2145        // even though Layer 1's marker here reports a (failure) status (18e).
2146        assert_eq!(result.verdict, None);
2147    }
2148
2149    /// D-05/18e: Layer 0's affirmative-success arm at `Stage::Validate` must
2150    /// consult Layer 1's verdict rather than discard it — the two-signal
2151    /// reconciliation `reconcile_layer0_verdict` adds. Covers all three
2152    /// verdict states Layer 1 can produce: pass, gaps, and no marker at all.
2153    #[test]
2154    fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
2155        let dir = tempfile::tempdir().unwrap();
2156        let phase_dir = dir.path().join(".planning/phases/16-reliability");
2157        std::fs::create_dir_all(&phase_dir).unwrap();
2158        std::fs::write(
2159            phase_dir.join("16-01-PLAN.md"),
2160            "---\nexternal_verify: \"test -f shipped\"\n---\n",
2161        )
2162        .unwrap();
2163        std::fs::write(dir.path().join("shipped"), "done").unwrap();
2164        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2165        let mut state = state_in(dir.path(), 16);
2166        state.stage = Stage::Validate;
2167        let approval = vec!["test -f shipped".to_string()];
2168
2169        std::fs::write(
2170            stdout_path(dir.path(), 16),
2171            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
2172        )
2173        .unwrap();
2174        let result = evaluate_agent_result_inner(
2175            dir.path(),
2176            &state,
2177            &GitFlowConfig::default(),
2178            Some(&approval),
2179        )
2180        .unwrap();
2181        assert_eq!(result.status, AgentStatus::Success);
2182        assert_eq!(result.decided_by_layer, Some(0));
2183        assert_eq!(result.verdict, Some(Verdict::Pass));
2184
2185        std::fs::write(
2186            stdout_path(dir.path(), 16),
2187            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
2188        )
2189        .unwrap();
2190        let result = evaluate_agent_result_inner(
2191            dir.path(),
2192            &state,
2193            &GitFlowConfig::default(),
2194            Some(&approval),
2195        )
2196        .unwrap();
2197        assert_eq!(result.verdict, Some(Verdict::Gaps));
2198
2199        std::fs::remove_file(stdout_path(dir.path(), 16)).unwrap();
2200        let result = evaluate_agent_result_inner(
2201            dir.path(),
2202            &state,
2203            &GitFlowConfig::default(),
2204            Some(&approval),
2205        )
2206        .unwrap();
2207        assert_eq!(result.verdict, None);
2208    }
2209
2210    /// 18e's reconciliation is scoped to `Stage::Validate` only (flagged
2211    /// assumption in 18-05-PLAN.md): at every other stage an affirmative
2212    /// Layer 0 success must keep `verdict: None`, even when Layer 1's marker
2213    /// carries an explicit verdict.
2214    #[test]
2215    fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
2216        let dir = tempfile::tempdir().unwrap();
2217        let phase_dir = dir.path().join(".planning/phases/16-reliability");
2218        std::fs::create_dir_all(&phase_dir).unwrap();
2219        std::fs::write(
2220            phase_dir.join("16-01-PLAN.md"),
2221            "---\nexternal_verify: \"test -f shipped\"\n---\n",
2222        )
2223        .unwrap();
2224        std::fs::write(dir.path().join("shipped"), "done").unwrap();
2225        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2226        std::fs::write(
2227            stdout_path(dir.path(), 16),
2228            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
2229        )
2230        .unwrap();
2231        let state = state_in(dir.path(), 16); // Stage::Code by default
2232        let approval = vec!["test -f shipped".to_string()];
2233
2234        let result = evaluate_agent_result_inner(
2235            dir.path(),
2236            &state,
2237            &GitFlowConfig::default(),
2238            Some(&approval),
2239        )
2240        .unwrap();
2241
2242        assert_eq!(result.status, AgentStatus::Success);
2243        assert_eq!(result.decided_by_layer, Some(0));
2244        assert_eq!(result.verdict, None);
2245    }
2246
2247    /// Ordering edge (17a): with multiple declared probes, ALL must pass for
2248    /// affirmative Success — the first failing probe vetoes the outcome
2249    /// regardless of which position it occupies among the declarations.
2250    #[test]
2251    fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
2252        let dir = tempfile::tempdir().unwrap();
2253        let phase_dir = dir.path().join(".planning/phases/16-reliability");
2254        std::fs::create_dir_all(&phase_dir).unwrap();
2255        // 16-01 comes first alphabetically and passes; 16-02 comes second and fails.
2256        std::fs::write(
2257            phase_dir.join("16-01-PLAN.md"),
2258            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2259        )
2260        .unwrap();
2261        std::fs::write(
2262            phase_dir.join("16-02-PLAN.md"),
2263            "---\nexternal_verify: \"test -f never-created\"\n---\n",
2264        )
2265        .unwrap();
2266        std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
2267        let mut state = state_in(dir.path(), 16);
2268        state.stage = Stage::Define;
2269
2270        let approval = vec![
2271            "test -f passing-artifact".to_string(),
2272            "test -f never-created".to_string(),
2273        ];
2274        let result_a = evaluate_agent_result_inner(
2275            dir.path(),
2276            &state,
2277            &GitFlowConfig::default(),
2278            Some(&approval),
2279        )
2280        .unwrap();
2281        assert_eq!(result_a.status, AgentStatus::Failed);
2282        assert!(
2283            result_a
2284                .reason
2285                .as_deref()
2286                .is_some_and(|reason| reason.contains("never-created")),
2287            "unexpected reason: {:?}",
2288            result_a.reason
2289        );
2290
2291        // Swap which position fails: 16-01 now fails, 16-02 passes. The
2292        // overall outcome must still veto — order of declaration must not
2293        // matter.
2294        std::fs::write(
2295            phase_dir.join("16-01-PLAN.md"),
2296            "---\nexternal_verify: \"test -f still-missing\"\n---\n",
2297        )
2298        .unwrap();
2299        std::fs::write(
2300            phase_dir.join("16-02-PLAN.md"),
2301            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
2302        )
2303        .unwrap();
2304        let approval_swapped = vec![
2305            "test -f still-missing".to_string(),
2306            "test -f passing-artifact".to_string(),
2307        ];
2308        let result_b = evaluate_agent_result_inner(
2309            dir.path(),
2310            &state,
2311            &GitFlowConfig::default(),
2312            Some(&approval_swapped),
2313        )
2314        .unwrap();
2315        assert_eq!(result_b.status, AgentStatus::Failed);
2316
2317        // Now make BOTH pass: only then is the outcome Success.
2318        std::fs::write(dir.path().join("still-missing"), "done").unwrap();
2319        let result_c = evaluate_agent_result_inner(
2320            dir.path(),
2321            &state,
2322            &GitFlowConfig::default(),
2323            Some(&approval_swapped),
2324        )
2325        .unwrap();
2326        assert_eq!(result_c.status, AgentStatus::Success);
2327        assert_eq!(result_c.decided_by_layer, Some(0));
2328    }
2329
2330    #[test]
2331    fn archive_moves_captures_into_history_and_removes_pid_file() {
2332        // 16b: prior-stage captures must survive a simulated next-launch by
2333        // appearing under .devflow/history/phase-NN/, not be wiped outright.
2334        let dir = tempfile::tempdir().unwrap();
2335        let root = dir.path();
2336        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2337        std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
2338        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2339        std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
2340
2341        archive_phase_files(root, root, 1, 5).unwrap();
2342
2343        // The live capture paths are gone (moved, not merely deleted).
2344        assert!(!root.join(".devflow/phase-01-stdout").exists());
2345        assert!(!root.join(".devflow/phase-01-exit").exists());
2346        // Agent-pid is bookkeeping, not diagnostic — still removed outright.
2347        assert!(!root.join(".devflow/phase-01-agent-pid").exists());
2348
2349        let history = history_dir(root, 1);
2350        let archived: Vec<_> = std::fs::read_dir(&history)
2351            .unwrap()
2352            .flatten()
2353            .map(|e| e.file_name().to_string_lossy().into_owned())
2354            .collect();
2355        let archived_stdout = archived
2356            .iter()
2357            .find(|name| name.ends_with("-stdout"))
2358            .expect("stdout capture should be archived into history");
2359        assert!(archived.iter().any(|name| name.ends_with("-exit")));
2360        let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
2361        assert_eq!(contents, "prior stdout");
2362    }
2363
2364    #[test]
2365    fn archive_is_noop_when_nothing_to_archive() {
2366        let dir = tempfile::tempdir().unwrap();
2367        let root = dir.path();
2368        // Should not panic when there is nothing to archive (first launch).
2369        archive_phase_files(root, root, 1, 5).unwrap();
2370        assert!(!history_dir(root, 1).exists());
2371    }
2372
2373    #[test]
2374    fn archive_handles_missing_devflow_dir() {
2375        let dir = tempfile::tempdir().unwrap();
2376        let root = dir.path();
2377        // No .devflow dir at all — should not panic.
2378        archive_phase_files(root, root, 1, 5).unwrap();
2379    }
2380
2381    #[test]
2382    fn archive_failure_preserves_live_capture_for_retry() {
2383        let dir = tempfile::tempdir().unwrap();
2384        let root = dir.path();
2385        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2386        std::fs::write(stdout_path(root, 1), "evidence").unwrap();
2387        // A file where the history directory must be forces create_dir_all
2388        // to fail before the live capture is moved or a monitor can truncate it.
2389        std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
2390
2391        assert!(archive_phase_files(root, root, 1, 5).is_err());
2392        assert_eq!(
2393            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2394            "evidence"
2395        );
2396    }
2397
2398    #[test]
2399    fn archive_second_publish_failure_rolls_back_complete_live_pair() {
2400        let dir = tempfile::tempdir().unwrap();
2401        let root = dir.path();
2402        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2403        std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2404        std::fs::write(exit_code_path(root, 1), "17").unwrap();
2405        let history = history_dir(root, 1);
2406        std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
2407
2408        assert!(archive_phase_files_with_stamp(root, root, 1, 5, "fixed").is_err());
2409
2410        assert_eq!(
2411            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2412            "stdout evidence"
2413        );
2414        assert_eq!(
2415            std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2416            "17"
2417        );
2418        assert!(!history.join("fixed-stdout").exists());
2419        assert!(!history.join(".pending-fixed").exists());
2420    }
2421
2422    #[test]
2423    fn archive_review_copy_failure_rolls_back_complete_live_pair() {
2424        let dir = tempfile::tempdir().unwrap();
2425        let root = dir.path();
2426        let evidence_root = root.join("phase-worktree");
2427        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2428        std::fs::write(stdout_path(root, 1), "stdout evidence").unwrap();
2429        std::fs::write(exit_code_path(root, 1), "23").unwrap();
2430        let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
2431        std::fs::create_dir_all(&review).unwrap();
2432
2433        assert!(archive_phase_files_with_stamp(root, &evidence_root, 1, 5, "review-copy").is_err());
2434
2435        assert_eq!(
2436            std::fs::read_to_string(stdout_path(root, 1)).unwrap(),
2437            "stdout evidence"
2438        );
2439        assert_eq!(
2440            std::fs::read_to_string(exit_code_path(root, 1)).unwrap(),
2441            "23"
2442        );
2443        let history = history_dir(root, 1);
2444        assert!(!history.join("review-copy-stdout").exists());
2445        assert!(!history.join("review-copy-exit").exists());
2446        assert!(!history.join(".pending-review-copy").exists());
2447    }
2448
2449    #[test]
2450    fn archive_snapshots_current_review_into_same_generation() {
2451        let dir = tempfile::tempdir().unwrap();
2452        let root = dir.path();
2453        let evidence_root = root.join("phase-worktree");
2454        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2455        std::fs::write(stdout_path(root, 1), "attempt").unwrap();
2456        let phase_dir = evidence_root.join(".planning/phases/01-example");
2457        std::fs::create_dir_all(&phase_dir).unwrap();
2458        std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
2459
2460        let stamp = archive_phase_files(root, &evidence_root, 1, 5)
2461            .unwrap()
2462            .unwrap();
2463
2464        assert_eq!(
2465            std::fs::read_to_string(history_dir(root, 1).join(format!("{stamp}-REVIEW.md")))
2466                .unwrap(),
2467            "review one"
2468        );
2469    }
2470
2471    #[test]
2472    fn archive_prunes_history_to_retain_count() {
2473        let dir = tempfile::tempdir().unwrap();
2474        let root = dir.path();
2475        std::fs::create_dir_all(root.join(".devflow")).unwrap();
2476
2477        for i in 0..7 {
2478            std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
2479            std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
2480            archive_phase_files(root, root, 1, 3).unwrap();
2481        }
2482
2483        let history = history_dir(root, 1);
2484        let stdout_count = std::fs::read_dir(&history)
2485            .unwrap()
2486            .flatten()
2487            .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
2488            .count();
2489        let exit_count = std::fs::read_dir(&history)
2490            .unwrap()
2491            .flatten()
2492            .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
2493            .count();
2494        assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
2495        assert_eq!(exit_count, 3, "expected at most 3 retained generations");
2496    }
2497
2498    #[test]
2499    fn evaluate_agent_result_reads_files_end_to_end() {
2500        let dir = tempfile::tempdir().unwrap();
2501        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2502        std::fs::write(
2503            stdout_path(dir.path(), 6),
2504            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
2505        )
2506        .unwrap();
2507        std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
2508        let state = state_in(dir.path(), 6);
2509
2510        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
2511
2512        assert_eq!(result.status, AgentStatus::Success);
2513        assert_eq!(result.commits, Some(2));
2514        assert_eq!(result.summary.as_deref(), Some("ok"));
2515    }
2516
2517    #[test]
2518    fn evaluate_layer1_finds_devflow_result_in_file() {
2519        let dir = tempfile::tempdir().unwrap();
2520        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2521        std::fs::write(
2522            stdout_path(dir.path(), 3),
2523            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
2524        )
2525        .unwrap();
2526
2527        let result = evaluate_layer1(dir.path(), 3).unwrap();
2528
2529        assert_eq!(result.status, AgentStatus::Failed);
2530        assert_eq!(result.reason.as_deref(), Some("bad output"));
2531    }
2532
2533    #[test]
2534    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
2535        let dir = tempfile::tempdir().unwrap();
2536        init_repo_with_feature_commit(dir.path(), 4);
2537        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2538        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2539        let state = state_in(dir.path(), 4);
2540
2541        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2542            .unwrap()
2543            .unwrap();
2544
2545        assert_eq!(result.status, AgentStatus::Success);
2546        assert_eq!(result.exit_code, Some(0));
2547        assert_eq!(result.commits, Some(1));
2548        assert!(result.reason.unwrap().contains("1 commits"));
2549    }
2550
2551    #[test]
2552    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
2553        // exit=0 but the feature branch has 0 commits ahead of develop →
2554        // "no work done" failure (the Layer 2 middle branch).
2555        let dir = tempfile::tempdir().unwrap();
2556        init_repo_with_feature_no_commit(dir.path(), 4);
2557        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2558        std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
2559        let state = state_in(dir.path(), 4);
2560
2561        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2562            .unwrap()
2563            .unwrap();
2564
2565        assert_eq!(result.status, AgentStatus::Failed);
2566        assert_eq!(result.exit_code, Some(0));
2567        assert_eq!(result.commits, Some(0));
2568        assert!(result.reason.unwrap().contains("no commits"));
2569    }
2570
2571    #[test]
2572    fn evaluate_layer2_nonzero_exit_is_failed() {
2573        // Non-zero exit code → failure regardless of commit count.
2574        let dir = tempfile::tempdir().unwrap();
2575        init_repo_with_feature_commit(dir.path(), 4);
2576        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2577        std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
2578        let state = state_in(dir.path(), 4);
2579
2580        let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
2581            .unwrap()
2582            .unwrap();
2583
2584        assert_eq!(result.status, AgentStatus::Failed);
2585        assert_eq!(result.exit_code, Some(1));
2586        assert!(result.reason.unwrap().contains("exited with code 1"));
2587    }
2588
2589    #[test]
2590    fn layer2_nonzero_exit_is_failed_all_stages() {
2591        // Non-zero exit is Failed regardless of stage — including Define and
2592        // Validate, which are exempt from the zero-commit gate but NOT from
2593        // the exit-code check.
2594        let dir = tempfile::tempdir().unwrap();
2595        init_repo_with_feature_no_commit(dir.path(), 10);
2596        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2597        std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
2598
2599        for stage in [
2600            Stage::Define,
2601            Stage::Plan,
2602            Stage::Code,
2603            Stage::Validate,
2604            Stage::Ship,
2605        ] {
2606            let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
2607                .unwrap()
2608                .unwrap();
2609            assert_eq!(
2610                result.status,
2611                AgentStatus::Failed,
2612                "stage {stage:?} should be Failed on nonzero exit"
2613            );
2614        }
2615    }
2616
2617    #[test]
2618    fn layer2_skips_commit_gate_for_define_and_validate() {
2619        let dir = tempfile::tempdir().unwrap();
2620        init_repo_with_feature_no_commit(dir.path(), 11);
2621        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2622        std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
2623
2624        for stage in [Stage::Define, Stage::Validate] {
2625            let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
2626                .unwrap()
2627                .unwrap();
2628            assert_ne!(
2629                result.status,
2630                AgentStatus::Failed,
2631                "stage {stage:?} should not be Failed for zero commits"
2632            );
2633        }
2634
2635        // Code stage with the same zero-commit inputs is still Failed
2636        // (existing behavior preserved).
2637        let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
2638            .unwrap()
2639            .unwrap();
2640        assert_eq!(result.status, AgentStatus::Failed);
2641    }
2642
2643    #[test]
2644    fn evaluate_layer3_falls_back_to_commit_count() {
2645        let dir = tempfile::tempdir().unwrap();
2646        init_repo_with_feature_commit(dir.path(), 5);
2647
2648        let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2649
2650        assert_eq!(result.status, AgentStatus::Unknown);
2651        assert_eq!(result.exit_code, None);
2652        assert_eq!(result.commits, Some(1));
2653        assert!(result.reason.unwrap().contains("1 commits"));
2654        assert_eq!(result.decided_by_layer, Some(3));
2655    }
2656
2657    /// D-02/D-03 case 3 (17-03): "process gone, nothing accounted for" — zero
2658    /// commits and no declared external post-condition — is a fail-closed
2659    /// `Failed` outcome that flags human review, not a blanket advanceable
2660    /// `Unknown`. The commits-present case above stays `Unknown` (gated
2661    /// downstream by Plan 04's never-advance dispatch, D-04) — only the
2662    /// zero-commit sub-case is reclassified here.
2663    #[test]
2664    fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
2665        let dir = tempfile::tempdir().unwrap();
2666        init_repo_with_feature_no_commit(dir.path(), 5);
2667
2668        let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
2669
2670        assert_eq!(result.status, AgentStatus::Failed);
2671        assert_eq!(result.exit_code, None);
2672        assert_eq!(result.commits, Some(0));
2673        assert_eq!(result.decided_by_layer, Some(3));
2674        let reason = result.reason.unwrap();
2675        assert!(reason.contains("no work"), "reason was: {reason}");
2676        assert!(
2677            reason.to_ascii_lowercase().contains("human review"),
2678            "reason was: {reason}"
2679        );
2680    }
2681
2682    #[test]
2683    fn parse_devflow_result_reads_verdict() {
2684        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
2685        let result = parse_devflow_result(stdout).unwrap();
2686        assert_eq!(result.status, AgentStatus::Success);
2687        assert_eq!(result.verdict, Some(Verdict::Gaps));
2688    }
2689
2690    #[test]
2691    fn parse_devflow_result_reads_verdict_pass() {
2692        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
2693        let result = parse_devflow_result(stdout).unwrap();
2694        assert_eq!(result.status, AgentStatus::Success);
2695        assert_eq!(result.verdict, Some(Verdict::Pass));
2696    }
2697
2698    #[test]
2699    fn parse_devflow_result_verdict_absent_is_none() {
2700        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
2701        let result = parse_devflow_result(stdout).unwrap();
2702        assert_eq!(result.status, AgentStatus::Success);
2703        assert_eq!(result.verdict, None);
2704    }
2705
2706    #[test]
2707    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
2708        // An unknown verdict string must not fail the whole marker parse —
2709        // status must still come through as Success with verdict None (T-13-14).
2710        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
2711        let result = parse_devflow_result(unknown).unwrap();
2712        assert_eq!(result.status, AgentStatus::Success);
2713        assert_eq!(result.verdict, None);
2714
2715        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
2716        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
2717        let result = parse_devflow_result(miscased).unwrap();
2718        assert_eq!(result.status, AgentStatus::Success);
2719        assert_eq!(result.verdict, None);
2720    }
2721
2722    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
2723    /// JSON *type* (bool, number, object) must be just as lenient as a
2724    /// malformed string value — before the fix, deserializing straight to
2725    /// `Option<String>` errored out the entire `AgentResult` parse for a
2726    /// type mismatch, defeating the doc comment's "a malformed verdict must
2727    /// never silently drop a valid status" guarantee for this specific case.
2728    #[test]
2729    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
2730        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
2731        let result = parse_devflow_result(bool_verdict).unwrap();
2732        assert_eq!(result.status, AgentStatus::Success);
2733        assert_eq!(result.verdict, None);
2734
2735        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
2736        let result = parse_devflow_result(numeric_verdict).unwrap();
2737        assert_eq!(result.status, AgentStatus::Success);
2738        assert_eq!(result.verdict, None);
2739
2740        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
2741        let result = parse_devflow_result(object_verdict).unwrap();
2742        assert_eq!(result.status, AgentStatus::Success);
2743        assert_eq!(result.verdict, None);
2744    }
2745
2746    /// D-07 (17-01): the two new multi-word variants must serialize with
2747    /// their word boundary preserved — `#[serde(rename_all = "lowercase")]`
2748    /// alone would collapse `ResourceKilled` to `"resourcekilled"` (Pitfall 1).
2749    #[test]
2750    fn multi_word_variants_serialize_with_word_boundary() {
2751        assert_eq!(
2752            serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
2753            "\"resource_killed\""
2754        );
2755        assert_eq!(
2756            serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
2757            "\"agent_unavailable\""
2758        );
2759        assert_eq!(
2760            serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
2761            AgentStatus::ResourceKilled
2762        );
2763        assert_eq!(
2764            serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
2765            AgentStatus::AgentUnavailable
2766        );
2767    }
2768
2769    /// Existing variants must keep their pre-existing lowercase wire form
2770    /// unchanged by the two new variants' additions.
2771    #[test]
2772    fn existing_variants_keep_wire_form() {
2773        assert_eq!(
2774            serde_json::to_string(&AgentStatus::Success).unwrap(),
2775            "\"success\""
2776        );
2777        assert_eq!(
2778            serde_json::to_string(&AgentStatus::Failed).unwrap(),
2779            "\"failed\""
2780        );
2781        assert_eq!(
2782            serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
2783            "\"ratelimited\""
2784        );
2785        assert_eq!(
2786            serde_json::to_string(&AgentStatus::Unknown).unwrap(),
2787            "\"unknown\""
2788        );
2789    }
2790
2791    /// review consensus #1: `as_wire_str()` must never diverge from the serde
2792    /// form for ANY variant — pin it for all six via a single round-trip
2793    /// assertion (quotes stripped).
2794    #[test]
2795    fn as_wire_str_matches_serde_form_for_every_variant() {
2796        for variant in [
2797            AgentStatus::Success,
2798            AgentStatus::Failed,
2799            AgentStatus::RateLimited,
2800            AgentStatus::Unknown,
2801            AgentStatus::ResourceKilled,
2802            AgentStatus::AgentUnavailable,
2803        ] {
2804            let serde_form = serde_json::to_string(&variant).unwrap();
2805            let stripped = serde_form.trim_matches('"');
2806            assert_eq!(
2807                variant.as_wire_str(),
2808                stripped,
2809                "as_wire_str() diverged from serde form for {variant:?}"
2810            );
2811        }
2812    }
2813
2814    #[test]
2815    fn evaluate_layer2_exit_137_is_resource_killed() {
2816        let dir = tempfile::tempdir().unwrap();
2817        init_repo_with_feature_commit(dir.path(), 20);
2818        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2819        std::fs::write(exit_code_path(dir.path(), 20), "137").unwrap();
2820        let state = state_in(dir.path(), 20);
2821
2822        let result = evaluate_layer2(dir.path(), 20, &GitFlowConfig::default(), state.stage)
2823            .unwrap()
2824            .unwrap();
2825
2826        assert_eq!(result.status, AgentStatus::ResourceKilled);
2827        assert_eq!(result.exit_code, Some(137));
2828    }
2829
2830    #[test]
2831    fn evaluate_layer2_exit_127_is_agent_unavailable() {
2832        let dir = tempfile::tempdir().unwrap();
2833        init_repo_with_feature_commit(dir.path(), 21);
2834        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2835        std::fs::write(exit_code_path(dir.path(), 21), "127").unwrap();
2836        let state = state_in(dir.path(), 21);
2837
2838        let result = evaluate_layer2(dir.path(), 21, &GitFlowConfig::default(), state.stage)
2839            .unwrap()
2840            .unwrap();
2841
2842        assert_eq!(result.status, AgentStatus::AgentUnavailable);
2843        assert_eq!(result.exit_code, Some(127));
2844    }
2845
2846    // -----------------------------------------------------------------
2847    // 27-03 (D-01/D-03): branch-exists + commit-count evidence resolves
2848    // the caller's own repository under a hostile GIT_DIR, not an
2849    // unrelated one.
2850    // -----------------------------------------------------------------
2851
2852    /// D-03/T-27-08: `evaluate_layer2`'s branch-exists and commit-count
2853    /// evidence (the two production sites at what were base-commit lines
2854    /// 574/583) resolves `project_root`'s own repository even when the
2855    /// process inherited a hostile `GIT_DIR` pointed at an unrelated
2856    /// repository — proven with a real spawned `git` process, not by
2857    /// inspecting a `Command` object alone. Mirrors
2858    /// `version::tests::tag_reads_resolve_caller_root_under_a_hostile_git_dir`
2859    /// (27-03) and `origin_main_ancestor_status_holds_under_a_hostile_git_dir`
2860    /// (`git.rs`, 27-01): the hostile `GIT_DIR` this test's own `<verify>`
2861    /// entries exercise (`GIT_DIR=<hostile>/.git cargo test ... this test`)
2862    /// is injected the same way any inherited-env attack reaches
2863    /// `evaluate_layer2` in production — via the whole process's
2864    /// environment, then down into the spawned child unless the
2865    /// constructor scrubs it.
2866    ///
2867    /// Deliberately tests the mirror direction from the plan's literal
2868    /// framing (real repo HAS the feature branch with a real commit;
2869    /// the standard hostile-`GIT_DIR` harness's throwaway repository does
2870    /// NOT), because the standard harness (`git init -q "$HOSTILE"`, no
2871    /// `feature/phase-NN` branch) cannot itself manufacture a false
2872    /// *positive* — an empty repository has no branch to spuriously
2873    /// report as present. It can, however, still prove the scrub's
2874    /// necessity by manufacturing a false *negative*: before this plan's
2875    /// migration, the two unmigrated `Command::new("git")` sites inherit
2876    /// the poisoned `GIT_DIR` and silently read the hostile repository
2877    /// instead of `project_root` — `rev-parse --verify` reports the real
2878    /// branch absent, the commit count is undercounted to zero, and a
2879    /// real agent's completed work is wrongly classified `Failed`. This
2880    /// is the same trust-boundary violation T-27-08 names (a foreign
2881    /// repository's state substituting for the real one), reached from
2882    /// the opposite direction; the scrub this plan adds removes `GIT_DIR`'s
2883    /// ability to redirect the spawned child at all, closing both
2884    /// directions identically.
2885    /// 27-REVIEW WR-01: this test previously set no hostile environment at
2886    /// all — it asserted ordinary-path behavior and claimed a hostile-
2887    /// `GIT_DIR` proof, so it passed identically with or without the scrub
2888    /// and could never have caught a regression back to a bare
2889    /// `Command::new("git")`. It now uses the spawned-child shape this
2890    /// phase established in `staleness.rs`
2891    /// (`embedded_commit_is_stale_resolves_execution_root_under_a_hostile_git_dir`):
2892    /// `GIT_DIR` is never set on this process (Rust 2024 `unsafe`, unsound
2893    /// under threaded tests — Phase 25 D-14), only on one freshly spawned
2894    /// child that re-invokes this same binary filtered to this one test.
2895    #[test]
2896    fn branch_evidence_resolves_caller_root_under_a_hostile_git_dir() {
2897        const INNER_ROOT: &str = "DEVFLOW_27_03_BRANCH_EVIDENCE_INNER_ROOT";
2898
2899        if let Ok(root) = std::env::var(INNER_ROOT) {
2900            // Inner mode: spawned by the outer half below with GIT_DIR
2901            // pointed at an unrelated foreign repository, scoped to this
2902            // child process only.
2903            let root = std::path::PathBuf::from(root);
2904            let phase = 27;
2905            let state = state_in(&root, phase);
2906
2907            let result = evaluate_layer2(&root, phase, &GitFlowConfig::default(), state.stage)
2908                .unwrap()
2909                .unwrap();
2910
2911            assert_eq!(
2912                result.status,
2913                AgentStatus::Success,
2914                "evaluate_layer2 must see project_root's own branch/commits, \
2915                 not a hostile GIT_DIR's repository: {result:?}"
2916            );
2917            assert_eq!(result.commits, Some(1));
2918            return;
2919        }
2920
2921        // Outer mode: build the real repository (which HAS the feature
2922        // branch and its commit) plus a second, unrelated foreign
2923        // repository that has neither. Unscrubbed, the child would read the
2924        // foreign repo, find no branch, count zero commits, and misreport a
2925        // real agent's completed work as Failed.
2926        let dir = tempfile::tempdir().unwrap();
2927        let phase = 27;
2928        init_repo_with_feature_commit(dir.path(), phase);
2929        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
2930        std::fs::write(exit_code_path(dir.path(), phase), "0").unwrap();
2931
2932        let foreign = tempfile::tempdir().unwrap();
2933        git(foreign.path(), &["init", "-q"]);
2934
2935        let exe = std::env::current_exe().expect("current_exe for child re-invocation");
2936        let out = std::process::Command::new(&exe)
2937            // Substring filter, NOT `--exact`: the binary's real test name is
2938            // module-qualified (`agent_result::tests::branch_evidence_...`),
2939            // so `--exact` against the bare name matches nothing, runs zero
2940            // tests, and still exits 0 — a false green.
2941            .arg("branch_evidence_resolves_caller_root_under_a_hostile_git_dir")
2942            .arg("--test-threads=1")
2943            .env(INNER_ROOT, dir.path().to_str().unwrap())
2944            .env("GIT_DIR", foreign.path().join(".git"))
2945            .output()
2946            .expect("spawn hostile child test process");
2947
2948        let stdout = String::from_utf8_lossy(&out.stdout);
2949        // Assert the child actually RAN the test, not merely that it exited
2950        // 0. A filter matching nothing exits 0 with "0 passed".
2951        assert!(
2952            stdout.contains("1 passed"),
2953            "child test process must have run exactly the inner test; \
2954             stdout:\n{stdout}"
2955        );
2956        assert!(
2957            out.status.success(),
2958            "child test process (hostile GIT_DIR pointed at an unrelated \
2959             foreign repository) must still resolve project_root's own \
2960             branch and commits; child exit status {:?}\nstdout:\n{stdout}",
2961            out.status
2962        );
2963    }
2964}