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::phase_id::PhaseId;
13use crate::stage::Stage;
14use crate::state::State;
15use std::path::{Path, PathBuf};
16
17/// Parsed agent completion result.
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct AgentResult {
20    pub status: AgentStatus,
21    pub exit_code: Option<i32>,
22    pub reason: Option<String>,
23    pub commits: Option<u32>,
24    pub summary: Option<String>,
25    /// The Validate stage's self-reported verdict — distinct from `status`.
26    /// `status` reports whether the stage's task (running `/gsd-validate-phase`)
27    /// completed; `verdict` reports whether validation ITSELF passed. Only
28    /// `Some(Verdict::Pass)` should advance Validate to Ship; `Some(Verdict::Gaps)`
29    /// and `None` both gate/loop back to Code (see `advance()`'s Validate arm).
30    /// Ignored entirely for non-Validate stages.
31    ///
32    /// Deserialized leniently via [`deserialize_verdict_lenient`]: an absent,
33    /// unknown, or mis-cased value becomes `None` rather than failing the
34    /// whole `AgentResult` parse (T-13-14) — a malformed verdict must never
35    /// silently drop a valid `status` to Layer 2.
36    #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
37    pub verdict: Option<Verdict>,
38    /// Which evaluation layer (0-3) produced this result (D-10, 17-01). Set by
39    /// every constructor in this module; `None` is reserved for test-only
40    /// fixture literals that don't route through the real cascade.
41    #[serde(default)]
42    pub decided_by_layer: Option<u8>,
43}
44
45/// Agent completion status determined by DevFlow.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum AgentStatus {
49    /// Agent self-reported success via DEVFLOW_RESULT.
50    Success,
51    /// Agent self-reported failure, or exit code + commit gate indicated failure.
52    Failed,
53    /// Agent stopped because an upstream API or usage quota rate-limited it.
54    RateLimited,
55    /// No signal received — fallback to exit code / commit heuristic.
56    Unknown,
57    /// Layer 2 classified the process as killed for resource exhaustion
58    /// (exit code 137, typically SIGKILL from an OOM killer) (D-07, 17b).
59    #[serde(rename = "resource_killed")]
60    ResourceKilled,
61    /// Layer 2 classified the process as unable to start (exit code 127,
62    /// typically "command not found") (D-07, 17b).
63    #[serde(rename = "agent_unavailable")]
64    AgentUnavailable,
65    /// The pipe-owning monitor gave up waiting: the child's stream went silent
66    /// for longer than the idle window and DevFlow terminated it (D-06, 31-02).
67    ///
68    /// Deliberately distinct from BOTH neighbours it would otherwise collapse
69    /// into. Against `Failed`: nothing reported a failure — the agent simply
70    /// stopped talking, and a graceful close would fall through to Layer 2,
71    /// which scores partial commits as `Success` (999.64 reborn inside its own
72    /// fix). Against `ResourceKilled`: the box did not run out of memory;
73    /// DevFlow itself did the killing. Only a third variant lets the completion
74    /// oracle tell "we gave up waiting" from either.
75    ///
76    /// The explicit `#[serde(rename)]` is required, not stylistic: the
77    /// enum-level `rename_all = "lowercase"` would collapse the two words into
78    /// `idletimeout`. The two existing two-word variants above carry the same
79    /// rename for the same reason.
80    #[serde(rename = "idle_timeout")]
81    IdleTimeout,
82}
83
84impl AgentStatus {
85    /// The wire-format name for this variant, pinned equal to
86    /// `serde_json::to_string(&self)` with the surrounding quotes stripped
87    /// (see the `as_wire_str_matches_serde_form` test). Exhaustive match with
88    /// NO wildcard arm — adding a variant without updating this is a compile
89    /// error. This is the sanctioned replacement for
90    /// `format!("{:?}", status).to_ascii_lowercase()`, which collapses word
91    /// boundaries on multi-word variants (review consensus #1).
92    pub fn as_wire_str(&self) -> &'static str {
93        match self {
94            AgentStatus::Success => "success",
95            AgentStatus::Failed => "failed",
96            AgentStatus::RateLimited => "ratelimited",
97            AgentStatus::Unknown => "unknown",
98            AgentStatus::ResourceKilled => "resource_killed",
99            AgentStatus::AgentUnavailable => "agent_unavailable",
100            AgentStatus::IdleTimeout => "idle_timeout",
101        }
102    }
103}
104
105/// The Validate stage's self-reported verdict (13b verdict-vs-ran split).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum Verdict {
109    /// Validation found no gaps — ready to advance to Ship.
110    Pass,
111    /// Validation found gaps that still need fixing — must loop back to Code
112    /// (or gate, depending on the consecutive-failure threshold).
113    Gaps,
114}
115
116/// Deserialize `verdict` leniently: an absent, unknown, or mis-cased value
117/// (e.g. `"wat"`, `"Pass"`) becomes `Ok(None)` rather than an error, so a
118/// malformed verdict never fails the whole `from_str::<AgentResult>` parse
119/// and silently drops a valid `status` to Layer 2 (T-13-14, consensus #5).
120///
121/// Matching is intentionally exact-case (only the wire-format lowercase
122/// strings `"pass"`/`"gaps"` are accepted) — a mis-cased value like `"Pass"`
123/// is NOT case-folded into a match; it is treated the same as an unknown
124/// value and maps to `None`, so a subtly wrong-case verdict fails safe
125/// (gate/loop) instead of silently passing.
126///
127/// WR-09 (13-REVIEW.md): decodes as `serde_json::Value` first, then only
128/// pattern-matches the string case — a non-string JSON type (`true`, `123`,
129/// an object) is a wrong *type*, not a malformed string value, and must
130/// still fall through to `None` rather than erroring out the entire
131/// `AgentResult` parse (the same guarantee this deserializer already gives
132/// mis-cased/unknown string values).
133fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
134where
135    D: serde::Deserializer<'de>,
136{
137    let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
138    Ok(raw.and_then(|v| {
139        v.as_str().and_then(|s| match s {
140            "pass" => Some(Verdict::Pass),
141            "gaps" => Some(Verdict::Gaps),
142            _ => None,
143        })
144    }))
145}
146
147/// Errors produced by agent result evaluation.
148#[derive(Debug, thiserror::Error)]
149pub enum ResultError {
150    #[error("I/O error reading agent output: {0}")]
151    Io(#[from] std::io::Error),
152    #[error("phase directory not found")]
153    NoPhaseDir,
154}
155
156/// Search stdout for a DEVFLOW_RESULT marker.
157///
158/// The marker is a single line starting with `DEVFLOW_RESULT:` followed by
159/// a JSON object with at minimum a `status` field. Matching is case-insensitive.
160///
161/// When an agent is run with `--output-format json` (e.g. Claude), its final
162/// message is wrapped in a JSON result envelope with the text — and its
163/// embedded newlines — escaped inside a `result` field. In that case the
164/// marker never appears at the start of a line, so we first unwrap the
165/// envelope and search the inner text.
166pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
167    // normalise_stream_marker_provenance on BOTH arms: parse_marker_lines
168    // deserializes the agent's own JSON, so without the overwrite an agent
169    // writing `"decided_by_layer":0` into its marker forges Layer-0
170    // external-verification provenance, which `classify_validate_outcome`
171    // (pipeline_outcomes.rs) trusts when classifying a Validate stage. The
172    // stream path has normalised since 30-01; this generic path — the one
173    // production hits today — did not (fourth adversarial pass, Medium 1;
174    // the class 999.67 tracks).
175    if let Some(inner) = extract_json_result_text(stdout)
176        && let Some(result) = parse_marker_lines(&inner)
177    {
178        return Some(normalise_stream_marker_provenance(result));
179    }
180    parse_marker_lines(stdout).map(normalise_stream_marker_provenance)
181}
182
183/// Detect agent-specific rate-limit output and return the retry description.
184///
185/// Claude can emit a JSON result envelope when run with `--output-format json`;
186/// Codex commonly emits plain text such as "Try again at ...". This function is
187/// intentionally conservative so ordinary progress text does not become a
188/// false positive.
189pub fn detect_rate_limit(stdout: &str) -> Option<String> {
190    detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
191}
192
193fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
194    // strip_corruption_padding, not trim(): this detector OUTRANKS the generic
195    // envelope-failure detector, and rate-limit envelopes carry `is_error:
196    // true`. When only the lower-precedence detector stripped edge corruption,
197    // one stray byte inverted the precedence — a RateLimited envelope (routes
198    // to auto-resume) decayed into a generic Failed (routes to review/gating).
199    // Fifth adversarial pass, Medium 1.
200    let value: serde_json::Value = serde_json::from_str(strip_corruption_padding(stdout)).ok()?;
201    let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
202        || json_has_i64(&value, "api_error_status", 429)
203        || json_has_i64(&value, "status", 429)
204        || json_has_i64(&value, "status_code", 429);
205    if !rate_limited {
206        return None;
207    }
208    json_find_key(&value, "retry_after")
209        .and_then(json_scalar_to_string)
210        .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
211        .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
212        .or_else(|| Some("usage limit".to_string()))
213}
214
215fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
216    // This heuristic exists for Codex's PLAIN-TEXT output. JSONL event lines
217    // are authoritative and handled by parse_codex_event_result — scanning
218    // them here false-positives on document content echoed into events
219    // (13-06 dogfood finding: GSD reference tables mentioning "rate limiting"
220    // were read by the agent, echoed into an `item.completed` payload, and
221    // this scan returned that entire multi-KB line as the "retry time").
222    // The JSON-line exclusion applies the SAME edge-strip policy as
223    // ParsedCapture::parse (sixth-pass Medium 4): an event line whose leading
224    // byte was corrupted to U+FFFD failed the bare parse here and was treated
225    // as prose — re-admitting the exact multi-KB echoed-document false
226    // positive this filter exists to exclude, after ParsedCapture had already
227    // correctly recovered the line as an event.
228    let stdout: String = stdout
229        .lines()
230        .filter(|line| {
231            serde_json::from_str::<serde_json::Value>(strip_corruption_padding(line))
232                .map(|v| !v.is_object())
233                .unwrap_or(true)
234        })
235        .collect::<Vec<_>>()
236        .join("\n");
237    let stdout = stdout.as_str();
238    let lower = stdout.to_ascii_lowercase();
239    if let Some(idx) = lower.find("try again at ") {
240        let start = idx + "try again at ".len();
241        let retry = stdout[start..]
242            .lines()
243            .next()
244            .unwrap_or_default()
245            .trim()
246            .trim_end_matches(['.', ',', ';'])
247            .trim();
248        if !retry.is_empty() {
249            return Some(retry.to_string());
250        }
251    }
252
253    // "429" counts as rate-limit evidence only as a STANDALONE token
254    // (sixth-pass Medium 5): a bare substring check fired on "processed issue
255    // #429 successfully" and any number containing 429, routing a healthy run
256    // into auto-resume. A neighbor that is alphanumeric or '#' means the
257    // digits belong to something else.
258    fn standalone_429(line: &str) -> bool {
259        let bytes = line.as_bytes();
260        line.match_indices("429").any(|(i, _)| {
261            let before_ok = i == 0 || {
262                let b = bytes[i - 1];
263                !b.is_ascii_alphanumeric() && b != b'#'
264            };
265            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_alphanumeric();
266            before_ok && after_ok
267        })
268    }
269
270    if lower.contains("usage limit") || lower.contains("rate limit") || standalone_429(&lower) {
271        stdout
272            .lines()
273            .find(|line| {
274                let line = line.to_ascii_lowercase();
275                line.contains("usage limit") || line.contains("rate limit") || standalone_429(&line)
276            })
277            .map(str::trim)
278            .filter(|line| !line.is_empty())
279            .map(str::to_string)
280            .or_else(|| Some("usage limit".to_string()))
281    } else {
282        None
283    }
284}
285
286/// If `stdout` is a JSON result envelope, return the decoded `result` text
287/// field (with escapes such as `\n` resolved). Returns `None` for plain text.
288fn extract_json_result_text(stdout: &str) -> Option<String> {
289    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
290    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
291    // (third-pass High). Interior corruption still fails the parse, by design.
292    let trimmed = strip_corruption_padding(stdout);
293    if !trimmed.starts_with('{') {
294        return None;
295    }
296    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
297    value.get("result")?.as_str().map(str::to_string)
298}
299
300/// Read the top-level `session_id` string from a Claude JSON result envelope
301/// (`--output-format json`). Returns `None` for plain-text stdout, a
302/// non-JSON-object envelope, an envelope with no `session_id` key, or a
303/// `session_id` of a non-string JSON type — never panics.
304///
305/// D-04 / T-28-04 (this plan's `<threat_model>`): deliberately reads ONLY the
306/// envelope's TOP-LEVEL `session_id` key via a direct [`serde_json::Value::get`],
307/// never the module's [`json_find_key`]/[`json_scan`] traversal helpers. Those
308/// helpers descend into nested objects, and the agent-authored `DEVFLOW_RESULT`
309/// marker payload — embedded inside this same envelope's `result` text and
310/// deserialized by [`parse_marker_lines`] directly into [`AgentResult`] — is
311/// reachable that way. A top-level `get` makes it true BY CONSTRUCTION that an
312/// agent cannot redirect the session DevFlow later resumes into by planting a
313/// different `session_id` key inside its own self-authored marker JSON.
314/// Regression test: `session_id_in_devflow_result_marker_is_not_returned`.
315///
316/// Deliberate deviation from RESEARCH.md § "Discretion Resolutions" item 5,
317/// which suggested adding a `session_id` field directly to [`AgentResult`].
318/// NOT done: `parse_marker_lines` deserializes the agent's own
319/// `DEVFLOW_RESULT` JSON straight into `AgentResult` via `serde_json::from_str`,
320/// so a `#[serde(default)]` field there would be agent-settable — the agent
321/// could name the session DevFlow resumes into (T-28-04). A standalone reader
322/// over the top-level envelope key carries no such surface and is equally
323/// available to every caller; D-04's persistence target (`State::session_id`)
324/// is unchanged, only the carrier differs.
325pub fn claude_session_id(stdout: &str) -> Option<String> {
326    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
327    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
328    // (third-pass High). Interior corruption still fails the parse, by design.
329    let trimmed = strip_corruption_padding(stdout);
330    if !trimmed.starts_with('{') {
331        return None;
332    }
333    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
334    value.get("session_id")?.as_str().map(str::to_string)
335}
336
337/// Read the CLI-emitted `session_id` from a Claude `--output-format
338/// stream-json` JSONL capture: the top-level `session_id` of the LAST
339/// `system`/`init` event. `None` for any other capture shape.
340///
341/// The stream sibling of [`claude_session_id`], and it carries that function's
342/// D-04 / T-28-04 discipline **for the same reason** — read its doc comment
343/// before changing anything here. Only the event's TOP-LEVEL `session_id` is
344/// read, via a direct [`serde_json::Value::get`]; the
345/// [`json_find_key`]/[`json_scan`] traversal helpers are NOT to be used. They
346/// descend into nested objects, and a stream carries agent-authored text in
347/// every `result` event — including the `DEVFLOW_RESULT` marker JSON that
348/// [`parse_marker_lines`] deserializes. A traversal would make a `session_id`
349/// the agent planted in its own marker reachable, handing it the ability to
350/// name the session DevFlow later resumes into (T-30-11). Regression test:
351/// `claude_stream_session_id_ignores_agent_planted_value`.
352///
353/// The LAST `init` event wins, consistent with the last-`result`-wins
354/// convention. Verified against the archived capture: its three `init` events
355/// (lines 5, 32 and 47) all carry the same `session_id`, so last-wins and
356/// first-wins agree on today's evidence — but only last-wins stays correct if a
357/// future capture rotates the value mid-stream. Three `init` events do NOT mean
358/// three sessions: session continuity must never be keyed off "have I seen an
359/// `init` event".
360///
361/// No `session_id` field is added to [`AgentResult`] — see
362/// [`claude_session_id`]'s doc comment for why that design stays rejected.
363pub fn claude_stream_session_id(stdout: &str) -> Option<String> {
364    let capture = ParsedCapture::parse(stdout);
365    if classify(&capture) != CaptureKind::ClaudeStream {
366        return None;
367    }
368
369    // A session can rotate mid-capture: each turn opens with its own `init`, and
370    // the LAST one carries the id a resume must target. A torn later `init` is
371    // invisible to the scan below, which would silently return an EARLIER
372    // session's id — resuming the wrong session with a token that looks
373    // perfectly valid. Fail closed on any TORN JSON line: it could have been a
374    // newer `init`. `None` costs a resume; the wrong id corrupts one. (Third
375    // adversarial pass, 2026-08-02.)
376    //
377    // Prose noise lines do NOT block recovery — an `init` is a JSON line, so a
378    // non-`{` line can never be a torn one. The first version of this guard
379    // failed closed on ANY unparsed line and rejected captures with benign
380    // interleaved progress output (fourth adversarial pass, Medium 3).
381    if capture.torn_json_line_present() {
382        return None;
383    }
384
385    capture
386        .events
387        .iter()
388        .rev()
389        .find(|v| {
390            v.get("type").and_then(serde_json::Value::as_str) == Some("system")
391                && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init")
392        })?
393        .get("session_id")?
394        .as_str()
395        .map(str::to_string)
396}
397
398/// Thin file-reading wrapper over the two session-id readers: reads the phase's
399/// captured stdout file (via [`stdout_path`]) and delegates. `None` for a
400/// missing capture file, never an `Err` — mirrors [`evaluate_layer1`]'s
401/// lossy-read convention (CR-01: one invalid UTF-8 byte from raw `sh`
402/// redirection must not silently disable this reader).
403///
404/// [`claude_stream_session_id`] is tried FIRST, then [`claude_session_id`].
405/// Stream-first is safe and behavior-preserving: the stream gate
406/// ([`is_claude_event_stream`]) declines a single-document envelope, so every
407/// capture shape that ships today still resolves through `claude_session_id`
408/// bit-for-bit. Without this chain the Phase 28 checkpoint-resume path — whose
409/// whole delivery is reconstructing a session via `claude --resume` — returns
410/// `None` for every `stream-json` capture.
411pub fn session_id_from_capture(project_root: &Path, phase: PhaseId) -> Option<String> {
412    let stdout = read_capture(&stdout_path(project_root, phase))?;
413    claude_stream_session_id(&stdout).or_else(|| claude_session_id(&stdout))
414}
415
416/// The ONE decode policy for capture files: read the bytes and replace invalid
417/// UTF-8 with U+FFFD. Every capture-file consumer (`evaluate_layer1`,
418/// `checkpoint_reported_in_capture`, `session_id_from_capture`) reads through
419/// here, so the policy cannot silently diverge per call site again.
420///
421/// REPLACE, never drop. A drop-based decode was tried (third adversarial pass
422/// remediation) and refuted by the fourth pass: deleting invalid bytes JOINS
423/// the tokens on either side, and `DEVFLOW_RESULT: {"status":"suc<FF>cess"}`
424/// decoded to a fabricated, VALID success marker that short-circuited a
425/// nonzero exit code. Replacement keeps corruption visible: the marker parser
426/// sees `suc\u{FFFD}cess`, which is not a recognized status, and correctly
427/// refuses to trust it. Consumers that need to tolerate corruption at the
428/// EDGES of a single-document capture strip it explicitly via
429/// [`strip_corruption_padding`] — bounded, and incapable of joining tokens.
430fn read_capture(path: &Path) -> Option<String> {
431    let bytes = std::fs::read(path).ok()?;
432    Some(String::from_utf8_lossy(&bytes).into_owned())
433}
434
435/// Trim whitespace and U+FFFD replacement characters from both ends of a
436/// single-document capture.
437///
438/// U+FFFD is what [`read_capture`] substitutes for invalid bytes, and it is a
439/// printing, non-whitespace character — so a stray byte written before or after
440/// the JSON envelope survives `trim()` and defeats every `starts_with('{')`
441/// guard. That was the third pass's High: Layer 1 abstained on an authoritative
442/// `is_error: true` and the exit-code fallback turned a reported failure into a
443/// Ship-gate success. Stripping only the EDGES is deliberate: corruption inside
444/// the envelope must stay visible and fail the parse, because "repairing" it is
445/// how the fourth pass's marker-fabrication High happened.
446fn strip_corruption_padding(s: &str) -> &str {
447    s.trim_matches(|c: char| c.is_whitespace() || c == '\u{FFFD}')
448}
449
450// WR-12 (13-REVIEW.md), revised: these traversal helpers run on the coding
451// agent's raw stdout (via detect_claude_rate_limit, which every `devflow
452// advance` invocation runs through evaluate_layer1), so deeply nested JSON —
453// accidental or adversarial — must not stack-overflow the process. The
454// traversal is iterative (an explicit worklist), so nesting depth never
455// consumes call stack and no depth cap is needed. The first WR-12 fix capped
456// recursion at 64, which silently missed keys at depths 64–128 — nesting
457// serde_json's default 128-level parse recursion limit (the only producer of
458// these `Value`s) accepts just fine.
459
460/// Depth-first pre-order scan over every JSON object in `value`, returning
461/// the first `Some` produced by `visit` on an object's map.
462fn json_scan<'a, T>(
463    value: &'a serde_json::Value,
464    visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
465) -> Option<T> {
466    let mut stack = vec![value];
467    while let Some(current) = stack.pop() {
468        match current {
469            serde_json::Value::Object(map) => {
470                if let Some(found) = visit(map) {
471                    return Some(found);
472                }
473                // Push in reverse so pop order preserves document order.
474                for child in map.values().rev() {
475                    stack.push(child);
476                }
477            }
478            serde_json::Value::Array(values) => {
479                for child in values.iter().rev() {
480                    stack.push(child);
481                }
482            }
483            _ => {}
484        }
485    }
486    None
487}
488
489fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
490    json_scan(value, |map| {
491        (map.get(key)?.as_str()? == expected).then_some(())
492    })
493    .is_some()
494}
495
496fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
497    json_scan(value, |map| {
498        (map.get(key)?.as_i64()? == expected).then_some(())
499    })
500    .is_some()
501}
502
503fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
504    json_scan(value, |map| map.get(key))
505}
506
507fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
508    match value {
509        serde_json::Value::String(s) => Some(s.clone()),
510        serde_json::Value::Number(n) => Some(n.to_string()),
511        _ => None,
512    }
513}
514
515/// Read the top-level `is_error` boolean (and, if present, `num_turns`) from
516/// a Claude JSON result envelope (`--output-format json`) and treat
517/// `is_error: true` as an authoritative Layer-1 failure.
518///
519/// This is checked BEFORE the `DEVFLOW_RESULT` marker path in
520/// [`evaluate_layer1`], so `is_error: true` OVERRIDES a stale/echoed success
521/// marker embedded in the same envelope's `result` text — the envelope is
522/// authoritative for errors. `is_error` absent or `false` returns `None`,
523/// deferring to the marker path and, ultimately, Layer 2. It runs AFTER
524/// `detect_claude_rate_limit`, though: rate-limit envelopes also carry
525/// `is_error: true`, and the specific `RateLimited` classification (which
526/// drives the primary rate-limit resume cron) must win over this
527/// generic `Failed`.
528///
529/// Per RESEARCH Pitfall 5, `is_error` (not specific `subtype` strings) is
530/// the documented, stable signal — this does not special-case non-success
531/// subtype values beyond what already exists in `detect_claude_rate_limit`.
532fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
533    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
534    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
535    // (third-pass High). Interior corruption still fails the parse, by design.
536    let trimmed = strip_corruption_padding(stdout);
537    if !trimmed.starts_with('{') {
538        return None;
539    }
540    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
541    let is_error = value.get("is_error")?.as_bool()?;
542    if !is_error {
543        return None;
544    }
545
546    let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
547    let base_reason = value
548        .get("result")
549        .and_then(serde_json::Value::as_str)
550        .map(str::to_string)
551        .or_else(|| {
552            value
553                .get("subtype")
554                .and_then(serde_json::Value::as_str)
555                .map(str::to_string)
556        })
557        .unwrap_or_else(|| "agent reported is_error".to_string());
558    let reason = match num_turns {
559        Some(n) => format!("{base_reason} (num_turns: {n})"),
560        None => base_reason,
561    };
562
563    Some(AgentResult {
564        status: AgentStatus::Failed,
565        exit_code: None,
566        reason: Some(reason),
567        commits: None,
568        summary: None,
569        verdict: None,
570        decided_by_layer: Some(1),
571    })
572}
573
574/// The rendered VALUE of a human-blocking checkpoint's `**Gate:**` line.
575///
576/// **CONFIRMED against a live end-to-end run (2026-07-31).** Assumption A1 is
577/// closed. A real `devflow start` run drove a synthetic phase declaring a
578/// `gate="blocking-human"` task through DevFlow's own monitor process (not a
579/// Claude Code agent session, which is what blocked `28-PROBE.md`'s original
580/// attempt at the Bash-tool permission classifier). The checkpoint fired and
581/// `.devflow/phase-NN-stdout` captured it inside the JSON envelope's `result`
582/// text as:
583///
584/// ```text
585/// **Gate:** `blocking-human`
586/// ```
587///
588/// The VALUE is what this constant holds. The surrounding markdown — bold
589/// label, and a **code span around the value** — is handled by
590/// [`text_reports_human_gate`]'s trim set, not by this constant.
591///
592/// The code span is the part RESEARCH.md did not predict. Its § "Architecture
593/// Patterns / Pattern 2" derived the literal by reading the *emitting* source
594/// (`gsd-executor.md:356`, `execute-phase.md:1053`) and predicted a bare
595/// `**Gate:** blocking-human`. The real relay renders the value as a code
596/// span, which defeated the original matcher entirely — see
597/// [`text_reports_human_gate`] for that failure and its fix. Lesson worth
598/// keeping: the emitting source told us the value, not the rendering.
599const HUMAN_GATE_VALUE: &str = "blocking-human";
600
601/// Confirm whether captured stdout reports a human-blocking checkpoint, by
602/// searching for a `**Gate:**`-labeled line whose VALUE is exactly
603/// [`HUMAN_GATE_VALUE`] — see that constant's doc comment for the live
604/// observation (2026-07-31) the matched rendering is built from.
605///
606/// This is the CONFIRMATION half of D-01: it is only ever consulted AFTER
607/// [`crate::verify::phase_has_blocking_human_checkpoint`] has already
608/// returned `true` for the stage's plan(s) (D-01's static half, plan 28-01).
609/// A false negative here is the SAFE direction — it falls back to today's
610/// never-silent generic gate, losing nothing. A false positive is bounded by
611/// the resume ceiling (`mode::MAX_CHECKPOINT_RESUMES`, plan 28-03) and
612/// unconditionally recorded by the `checkpoint_auto_decided` audit event
613/// (plan 28-03) — it can never silently authorize anything.
614///
615/// Searches BOTH the raw stdout text and — when the stdout is a Claude JSON
616/// result envelope — the unescaped inner `result` text obtained via
617/// [`extract_json_result_text`], because the `Gate:` line typically crosses
618/// into the capture escaped inside that envelope (RESEARCH § "Common
619/// Pitfalls / Pitfall 2": two indirections, subagent emission → orchestrator
620/// relay → DevFlow's captured top-level stdout). Matching is
621/// case-insensitive on the `Gate` LABEL and tolerates surrounding markdown
622/// emphasis (`*`) and whitespace, but the VALUE comparison is exact — this
623/// deliberately does NOT widen into a general "does this look like a
624/// checkpoint" heuristic (D-02 rejected that class of predicate); the scope
625/// is one declared field label with one enumerated value.
626///
627/// **A Claude `stream-json` capture takes a separate branch** and is answered
628/// by [`claude_stream_reports_human_gate`] ALONE — it never consults raw stdout.
629/// That is not an oversight to be "completed" later: under a stream capture the
630/// raw stdout contains the operator's prompt echoed back as a `user` event, so
631/// also scanning it would reinstate the exact false positive the branch exists
632/// to remove (review constraint 3 — the unbounded raw scan is the reader that
633/// "survives by accident" once the single-document invariant is gone). See that
634/// function for which events are eligible and why.
635///
636/// The branch is taken when [`classify`] says [`CaptureKind::ClaudeStream`], so
637/// a single-document envelope, plain text and a Codex stream all fall through to
638/// the two-target logic below, unchanged (T-30-25). Classification is
639/// deliberately weaker than [`is_claude_event_stream`]: requiring a parsed
640/// `system`/`init` here made a single torn line fail OPEN back to the raw scan,
641/// reinstating the echoed-prompt false positive this branch exists to remove.
642/// See [`classify`] for the full rule set and the defects each rule encodes;
643/// see [`is_claude_event_stream`] for why the verdict path keeps its stricter
644/// init-only gate.
645pub fn blocking_human_checkpoint_reported(stdout: &str) -> bool {
646    let capture = ParsedCapture::parse(stdout);
647    if classify(&capture) == CaptureKind::ClaudeStream {
648        return claude_stream_reports_human_gate(&capture.events);
649    }
650    if text_reports_human_gate(stdout) {
651        return true;
652    }
653    extract_json_result_text(stdout)
654        .as_deref()
655        .is_some_and(text_reports_human_gate)
656}
657
658/// Core matcher shared by both search targets (raw stdout and the unescaped
659/// inner envelope text) in [`blocking_human_checkpoint_reported`]. Scans for
660/// a case-insensitive `gate` label, tolerating surrounding markdown emphasis
661/// (`*`), code-span backticks (`` ` ``), and whitespace up to the following
662/// `:`, then compares the VALUE token immediately after the colon exactly
663/// against [`HUMAN_GATE_VALUE`].
664///
665/// The backtick tolerance is not speculative — it is the single reason this
666/// matcher failed against the first real checkpoint ever observed. The live
667/// A1 run (2026-07-31) captured the value as a markdown code span,
668/// ``**Gate:** `blocking-human` ``, and the original trim set (`*` and space
669/// only) left the leading backtick in place, so the `take_while` below
670/// terminated immediately and produced an EMPTY value token. The reader
671/// returned `false` and a genuine checkpoint fell through to the generic
672/// gate. Trimming the backtick is what makes the observed rendering match;
673/// do not narrow this set back without re-running that live probe.
674///
675/// Note the closing backtick needs no handling: `take_while` already stops
676/// at it, since a backtick is neither alphanumeric nor `-`.
677fn text_reports_human_gate(text: &str) -> bool {
678    let lower = text.to_ascii_lowercase();
679    let mut search_from = 0;
680    while let Some(rel_idx) = lower[search_from..].find("gate") {
681        let idx = search_from + rel_idx;
682        let after_label = &lower[idx + "gate".len()..];
683        let after_label = after_label.trim_start_matches(['*', ' ', '`']);
684        if let Some(rest) = after_label.strip_prefix(':') {
685            let value_region = rest.trim_start_matches(['*', ' ', '`']);
686            let value_token: String = value_region
687                .chars()
688                .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
689                .collect();
690            if value_token == HUMAN_GATE_VALUE {
691                return true;
692            }
693        }
694        search_from = idx + "gate".len();
695    }
696    false
697}
698
699/// Thin file-reading wrapper over [`blocking_human_checkpoint_reported`]:
700/// reads the phase's captured stdout file (via [`stdout_path`]) and
701/// delegates. `false` for a missing capture file, never an error.
702pub fn checkpoint_reported_in_capture(project_root: &Path, phase: PhaseId) -> bool {
703    let Some(stdout) = read_capture(&stdout_path(project_root, phase)) else {
704        return false;
705    };
706    blocking_human_checkpoint_reported(&stdout)
707}
708
709/// Determine whether a set of parsed JSONL lines look like a Codex `--json`
710/// event stream (as opposed to a single-document Claude envelope or plain
711/// text) — i.e. at least one line is a `thread.started` or `turn.*` event.
712pub(crate) fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
713    events.iter().any(|v| {
714        v.get("type")
715            .and_then(serde_json::Value::as_str)
716            .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
717    })
718}
719
720/// Parse a Codex `--json` JSONL event stream (one JSON object per line) and
721/// look at the LAST terminal event (`turn.completed` / `turn.failed`).
722///
723/// Only decisive when the captured stdout is actually a Codex event stream
724/// (per [`is_codex_event_stream`]) — a single-document Claude envelope
725/// (`type: "result"`, no `turn.*` lines) is not consumed here and returns
726/// `None`, so the Claude envelope/marker paths handle it instead.
727///
728/// `turn.failed` is decisive: returns `AgentStatus::Failed` with `reason`
729/// from `error.message`. A final `turn.completed` with no `DEVFLOW_RESULT`
730/// marker returns `None` (defers to Layer 2) rather than an unconditional
731/// Success — a marker-less turn must not silently advance a stage (this is
732/// the composition fix that keeps a marker-less Validate run from
733/// false-passing to Ship).
734///
735/// NOTE: written against the documented `--json` event schema (thread.started
736/// / turn.started / item.* / turn.completed with usage / turn.failed with
737/// error.message) but not yet verified against the installed Codex CLI
738/// version — the 13-06 dogfood run captures real output and reconciles any
739/// delta, the same empirical practice 12-12-SUMMARY.md used for Claude.
740pub(crate) fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
741    let capture = ParsedCapture::parse(stdout);
742    let events = &capture.events;
743
744    if !is_codex_event_stream(events) {
745        return None;
746    }
747
748    // Same trailing-torn rule as the Claude stream parser, same R1 root cause:
749    // a torn JSON line after the last parsed event means the capture's tail —
750    // where `turn.failed` would be — may be among the casualties. An earlier
751    // `agent_message` success marker must not decide the stage over a tail we
752    // provably failed to read. The Codex adapter is live in production, so
753    // this is not a Phase-31 deferral.
754    if capture.torn_json_after_last_matching(|_| true) {
755        return Some(indeterminate_capture_failure());
756    }
757
758    // 999.107 #1: a terminal `turn.failed` must not be overridden by an
759    // earlier `agent_message` success marker. Resolve BOTH the terminal event
760    // and the marker once, then apply precedence: `turn.failed` is decisive
761    // regardless of any success marker that preceded it (the pre-fix order
762    // returned the marker before ever reading the terminal, so a stream ending
763    // `success marker → turn.failed` was misread as Success).
764    let terminal = events.iter().rev().find(|v| {
765        matches!(
766            v.get("type").and_then(serde_json::Value::as_str),
767            Some("turn.completed") | Some("turn.failed")
768        )
769    });
770
771    // Codex delivers the agent's DEVFLOW_RESULT self-report inside an
772    // `agent_message` item's `text` — never as a raw stdout line — so the
773    // top-level marker scan cannot see it (13-06 dogfood finding). The decoded
774    // `text` is a plain marker line; reuse the marker parser on it. Last
775    // marker wins, matching parse_marker_lines.
776    let marker = events.iter().rev().find_map(|v| {
777        if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
778            return None;
779        }
780        let item = v.get("item")?;
781        if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
782            return None;
783        }
784        let text = item.get("text").and_then(serde_json::Value::as_str)?;
785        parse_marker_lines(text)
786    });
787
788    if let Some(terminal) = terminal
789        && terminal.get("type").and_then(serde_json::Value::as_str) == Some("turn.failed")
790    {
791        let reason = terminal
792            .get("error")
793            .and_then(|e| e.get("message"))
794            .and_then(serde_json::Value::as_str)
795            .map(str::to_string)
796            .unwrap_or_else(|| "codex turn failed".to_string());
797
798        // The failure direction is safe, but keep whatever the agent did
799        // self-report (commits/summary/verdict/exit_code) so the gate context
800        // isn't silently discarded (999.107 #1 review). `decided_by_layer` is
801        // deliberately NOT copied — it is the forgeable provenance field, and
802        // Layer 1 owns this verdict.
803        let mut result = AgentResult {
804            status: AgentStatus::Failed,
805            exit_code: None,
806            reason: Some(reason),
807            commits: None,
808            summary: None,
809            verdict: None,
810            decided_by_layer: Some(1),
811        };
812        if let Some(m) = marker.as_ref() {
813            result.exit_code = m.exit_code;
814            result.commits = m.commits;
815            result.summary = m.summary.clone();
816            result.verdict = m.verdict;
817        }
818        return Some(result);
819    }
820
821    if let Some(result) = marker {
822        // Same provenance overwrite as parse_devflow_result and the Claude
823        // stream path (T-30-26): this AgentResult was deserialized from the
824        // agent's own marker JSON, so a planted `"decided_by_layer":0` would
825        // otherwise forge Layer-0 external-verification provenance. Found by
826        // reading, while closing the identical hole one function over.
827        return Some(normalise_stream_marker_provenance(result));
828    }
829
830    // turn.completed (or no terminal event at all) with no marker defers to
831    // Layer 2 rather than an unconditional Success.
832    None
833}
834
835/// Parse a captured stdout as JSONL: one `serde_json::Value` per non-blank,
836/// parseable line. Lines that are not valid JSON are dropped, so a stream
837/// interleaved with plain-text progress noise still yields its events.
838///
839/// Shared by [`is_claude_event_stream`] and [`last_top_level_result`], which
840/// both need the same parsed vector. Deliberately NOT retrofitted into
841/// [`parse_codex_event_result`], which open-codes the identical idiom: that
842/// parser is correct and shipping, and rewriting it would put an unrelated
843/// adapter's behavior at risk for a cosmetic dedupe.
844/// Determine whether parsed JSONL lines are a Claude `--output-format
845/// stream-json` event stream, as opposed to a single-document Claude envelope,
846/// a Codex `--json` stream, or plain text.
847///
848/// **Gates on `type: "system"` + `subtype: "init"` and NOTHING ELSE.**
849/// 30-RESEARCH.md offered an alternative — also gate on `type: "result"`
850/// carrying a `session_id` — and that alternative is WRONG; do not "restore"
851/// it. The single-document envelope that ships today is literally
852/// `{"type":"result",...,"session_id":"abc"}`, so a `result`-keyed gate would
853/// swallow every production capture in use and silently displace
854/// [`parse_devflow_result`] in the [`evaluate_layer1`] cascade — a change to
855/// the shipped Layer-1 verdict path, disguised as adding stream support
856/// (T-30-02). The `init` event is both stronger and earlier: it opens the
857/// stream and is present in all three archived captures
858/// (`30a-evidence/raw_output_v3.jsonl` lines 5, 32 and 47).
859///
860/// `single_doc_envelope_not_consumed_by_claude_stream_parser` is the test that
861/// fails if this gate is widened.
862fn is_claude_event_stream(events: &[serde_json::Value]) -> bool {
863    events.iter().any(|v| {
864        v.get("type").and_then(serde_json::Value::as_str) == Some("system")
865            && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init")
866    })
867}
868
869/// The shape of one non-empty capture line after a parse attempt.
870///
871/// `TornJson` vs `Noise` is the load-bearing distinction everywhere below: a
872/// line that failed to parse but still opens with `{` could be a torn event —
873/// a truncated write, or a read of a capture still being appended to — while a
874/// prose line cannot be (every stream event line opens with `{`). Conflating
875/// the two produced both prior misclassification defects: requiring ALL lines
876/// to parse sent torn streams back to the raw scan (second-pass fail-open),
877/// and counting any malformed line as suspicious rejected benign interleaved
878/// progress noise (fourth-pass Medium 3).
879#[derive(Clone, Copy, PartialEq, Eq)]
880enum LineShape {
881    /// Parsed as JSON; the value lives at the same index in
882    /// [`ParsedCapture::events`]' insertion order.
883    Event,
884    /// Failed to parse but opens with `{` — potentially a torn event.
885    TornJson,
886    /// Failed to parse and does not open with `{` — cannot be a torn event.
887    Noise,
888}
889
890/// A capture parsed ONCE, keeping both the surviving events and the shape of
891/// every non-empty line — including the ones that did not parse.
892///
893/// This is the R1 root-cause fix from the phase-30 adversarial series: the old
894/// `claude_stream_events` returned a bare `Vec<Value>`, so "I dropped
895/// something" was unrepresentable and every consumer silently assumed the
896/// survivors were complete. Four separate defects came from that assumption
897/// (torn-init gate fail-open, stale-success verdict resurrection, stale
898/// session-id resurrection, torn-user gate reopening). Consumers now see the
899/// full line record and must decide explicitly what a torn line means for them.
900struct ParsedCapture {
901    events: Vec<serde_json::Value>,
902    line_shapes: Vec<LineShape>,
903}
904
905impl ParsedCapture {
906    fn parse(stdout: &str) -> Self {
907        let mut events = Vec::new();
908        let mut line_shapes = Vec::new();
909        for line in stdout.lines() {
910            let trimmed = line.trim();
911            if trimmed.is_empty() {
912                continue;
913            }
914            match serde_json::from_str::<serde_json::Value>(trimmed) {
915                Ok(v) => {
916                    events.push(v);
917                    line_shapes.push(LineShape::Event);
918                }
919                Err(_) => {
920                    // Apply the SAME edge-corruption policy per line that
921                    // strip_corruption_padding applies per capture. Without
922                    // this, `read_capture`'s U+FFFD replacement in front of an
923                    // otherwise-intact line made it classify as Noise — not
924                    // `{`-prefixed — so the torn-tail guard could not see a
925                    // corrupt superseding event and an earlier success marker
926                    // decided the stage (fifth adversarial pass, High 1).
927                    //
928                    // Retry the parse on the stripped line first: edge
929                    // corruption around an intact event RECOVERS the event and
930                    // its true verdict. Stripping edges cannot join tokens —
931                    // the fabrication hazard was DROPPING bytes inside content
932                    // (fourth pass) — and interior corruption still fails the
933                    // parse. A line that strips to empty was pure corruption:
934                    // torn, fail closed.
935                    let stripped = strip_corruption_padding(trimmed);
936                    if stripped != trimmed
937                        && let Ok(v) = serde_json::from_str::<serde_json::Value>(stripped)
938                    {
939                        events.push(v);
940                        line_shapes.push(LineShape::Event);
941                    } else {
942                        line_shapes.push(if stripped.starts_with('{') || stripped.is_empty() {
943                            LineShape::TornJson
944                        } else {
945                            LineShape::Noise
946                        });
947                    }
948                }
949            }
950        }
951        Self {
952            events,
953            line_shapes,
954        }
955    }
956
957    fn torn_json_line_present(&self) -> bool {
958        self.line_shapes.contains(&LineShape::TornJson)
959    }
960
961    /// Whether a torn JSON line sits AFTER the last parsed event matching
962    /// `pred` — or anywhere at all, when no event matches.
963    ///
964    /// This is the question behind constraint 9 item 1: the capture's REAL
965    /// final verdict may be among the casualties, so nothing that survives
966    /// before the tear is allowed to stand in for it. Prose noise lines are
967    /// not counted — they cannot be a torn event (events open with `{`).
968    fn torn_json_after_last_matching(&self, pred: impl Fn(&serde_json::Value) -> bool) -> bool {
969        let mut last_match_line = None;
970        let mut event_idx = 0usize;
971        for (line_idx, shape) in self.line_shapes.iter().enumerate() {
972            if *shape == LineShape::Event {
973                if pred(&self.events[event_idx]) {
974                    last_match_line = Some(line_idx);
975                }
976                event_idx += 1;
977            }
978        }
979        self.line_shapes
980            .iter()
981            .enumerate()
982            .any(|(line_idx, shape)| {
983                *shape == LineShape::TornJson && last_match_line.is_none_or(|last| line_idx > last)
984            })
985    }
986}
987
988/// What kind of capture this is — decided ONCE, here, instead of re-derived by
989/// per-call-site heuristics.
990///
991/// This is the R2 root-cause fix from the phase-30 adversarial series. Four
992/// generations of ad-hoc shape checks (`starts_with('{')` guards, "any event of
993/// type X", all-lines-JSON, line counts) each got one case wrong: a torn `init`
994/// un-recognised a stream (fail-open), one stray JSON line hijacked plain text
995/// (V-01, fail-closed), a torn gate-bearing `user` event un-recognised a stream
996/// again, and an interleaved prose line was treated as tearing. One classifier
997/// carries all of those lessons in one place.
998#[derive(Clone, Copy, PartialEq, Eq)]
999enum CaptureKind {
1000    /// Not JSONL-shaped in the majority — the raw-scan paths own it.
1001    PlainText,
1002    /// Exactly one parsed `{"type":"result",…}` line: the envelope the shipped
1003    /// `--output-format json` adapter emits (T-30-25). Raw-scan paths own it.
1004    SingleDocEnvelope,
1005    /// A Claude `stream-json` capture — possibly torn, possibly noisy.
1006    ClaudeStream,
1007    /// A Codex `--json` capture: dotted top-level types (`thread.started`,
1008    /// `item.completed`, `turn.*`). Raw-scan paths own it, as before.
1009    CodexStream,
1010}
1011
1012/// Classification rules, in order — each carries the defect that forced it:
1013///
1014/// 1. **Majority of non-empty lines must be JSON-shaped** (parsed OR torn-`{`),
1015///    else `PlainText`. Counting only PARSED lines fails: truncating a real
1016///    stream drops its parsed count below any threshold while every surviving
1017///    line is still `{`-shaped (the truncation sweep caught exactly that). One
1018///    stray JSON line in prose stays under the majority (V-01).
1019/// 2. **Any parsed `system`/`user`/`assistant` event → `ClaudeStream`.** Claude
1020///    types win over dotted deterministically — the old event loop returned
1021///    whichever it happened to iterate first. Real Codex captures never carry
1022///    these types, and on a corrupt mixed capture the scoped path is the
1023///    fail-closed direction for the gate.
1024/// 3. **Any parsed dotted type → `CodexStream`.**
1025/// 4. **A single parsed `result` line → `SingleDocEnvelope`** — today's shipped
1026///    format, which must keep the raw-scan path (T-30-02 / T-30-25).
1027/// 5. **Multi-line with a `result` event or a torn JSON line → `ClaudeStream`.**
1028///    A stream whose gate-bearing `user` event tore, leaving only a later
1029///    `result`, is still a stream (fourth-pass Low / third-pass Medium shape).
1030/// 6. Everything else → `PlainText`.
1031///
1032/// A LONE torn JSON line is deliberately `PlainText`, not `ClaudeStream`: under
1033/// today's format that shape is a torn single-document envelope, and raw-scanning
1034/// it preserves detection of a REAL gate declaration inside (dropping one is the
1035/// T-30-24 harm — worse than the echo false positive). The residual — a stream
1036/// that died with only its echoed-prompt line, torn, and nothing else — requires
1037/// the `init` line to have never flushed while the echo line partially did.
1038/// Accepted and recorded rather than silently traded away.
1039fn classify(capture: &ParsedCapture) -> CaptureKind {
1040    let total = capture.line_shapes.len();
1041    if total == 0 {
1042        return CaptureKind::PlainText;
1043    }
1044    let noise = capture
1045        .line_shapes
1046        .iter()
1047        .filter(|s| **s == LineShape::Noise)
1048        .count();
1049    if (total - noise) * 2 <= total {
1050        return CaptureKind::PlainText;
1051    }
1052
1053    if capture.events.iter().any(|v| {
1054        matches!(
1055            v.get("type").and_then(serde_json::Value::as_str),
1056            Some("system" | "user" | "assistant")
1057        )
1058    }) {
1059        return CaptureKind::ClaudeStream;
1060    }
1061    if capture.events.iter().any(|v| {
1062        v.get("type")
1063            .and_then(serde_json::Value::as_str)
1064            .is_some_and(|t| t.contains('.'))
1065    }) {
1066        return CaptureKind::CodexStream;
1067    }
1068
1069    let result_events = capture
1070        .events
1071        .iter()
1072        .filter(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
1073        .count();
1074    if total == 1 {
1075        return if result_events == 1 {
1076            CaptureKind::SingleDocEnvelope
1077        } else {
1078            CaptureKind::PlainText
1079        };
1080    }
1081    if result_events > 0 || capture.torn_json_line_present() {
1082        CaptureKind::ClaudeStream
1083    } else {
1084        CaptureKind::PlainText
1085    }
1086}
1087
1088/// Test-only accessor: does [`classify`] call this capture text a Claude
1089/// `stream-json` capture?
1090///
1091/// Exists so `monitor.rs`'s end-to-end tracer test can assert on the REAL
1092/// classifier rather than re-deriving "looks like a stream" with its own
1093/// heuristic — which is precisely the per-call-site divergence [`classify`]
1094/// was introduced to end. `classify`/`CaptureKind`/`ParsedCapture` stay
1095/// private; only this yes/no question crosses the module boundary, and only
1096/// under `cfg(test)`.
1097#[cfg(test)]
1098pub(crate) fn capture_is_claude_stream(capture: &str) -> bool {
1099    classify(&ParsedCapture::parse(capture)) == CaptureKind::ClaudeStream
1100}
1101
1102/// Whether an event is TOP-LEVEL — authored by the orchestrator session, not
1103/// forwarded from a subagent. `parent_tool_use_id` JSON-null or absent.
1104///
1105/// The ONE provenance predicate, shared by gate scanning and verdict selection
1106/// (constraint 9 item 2 / code-review M2: the two paths previously held
1107/// different notions — gate scanning enforced provenance while
1108/// [`last_top_level_result`] silently did not, despite its name and doc).
1109///
1110/// The absent case must stay top-level: `result` events carry no such key at
1111/// all in any archived capture. Treating absence as positive provenance remains
1112/// NECESSARY for today's captures and UNPROVEN safe — no archived capture
1113/// contains a subagent-origin `result`, so if one can omit the key it would be
1114/// admitted. Recorded, not solved; the type filter is the second, independent
1115/// guard on the gate path.
1116fn is_top_level(event: &serde_json::Value) -> bool {
1117    matches!(
1118        event.get("parent_tool_use_id"),
1119        None | Some(serde_json::Value::Null)
1120    )
1121}
1122
1123/// The LAST top-level `type: "result"` event in a Claude stream capture.
1124///
1125/// One capture can hold several: a session kept alive across turns emits one
1126/// terminal `result` per turn (the archived v3 stream carries three, at lines
1127/// 19, 37 and 54, produced across task-notification wake-ups). The last is the
1128/// session's final verdict, so an earlier turn must never decide the stage.
1129///
1130/// T-30-01: selection runs over TOP-LEVEL objects only — each value here is one
1131/// whole JSONL line. A `result`-shaped structure the agent writes inside its own
1132/// message text is inert string content and structurally unreachable from this
1133/// scan. Never route this through [`json_scan`]/[`json_find_key`], which descend
1134/// into nested objects; that is the same protection class as D-04/T-28-04's
1135/// top-level-only `session_id` read.
1136///
1137/// Provenance is ENFORCED via [`is_top_level`], not merely documented — the
1138/// first version of this function selected on `type == "result"` alone, so a
1139/// subagent-origin `result` event would have decided the stage (code-review
1140/// M2, constraint 9 item 2).
1141fn last_top_level_result(events: &[serde_json::Value]) -> Option<&serde_json::Value> {
1142    events.iter().rev().find(|v| {
1143        v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1144    })
1145}
1146
1147/// Whether a declared canary `token` came back inside a TOP-LEVEL `result`
1148/// event of this capture (D-13).
1149///
1150/// **Why this takes capture TEXT rather than a project root and phase**, unlike
1151/// its siblings [`checkpoint_reported_in_capture`] and
1152/// [`session_id_from_capture`]: the delivery canary runs against its own
1153/// throwaway capture file, not the phase capture. A canary that read (and
1154/// therefore implied writing) `stdout_path(project_root, phase)` would clobber
1155/// the stage's own capture — the one artifact the entire Layer 1 cascade
1156/// decides on.
1157///
1158/// **D-13 trap 1 — this may not be a NEW trust path.** The CLI echoes the
1159/// operator's prompt back into the same stdout as a `user` event, so the
1160/// planted token *will* appear in the stream regardless of whether anything was
1161/// delivered. That echo is exactly what produced the checkpoint false positive
1162/// 30-05 fixed. Matching is therefore confined to events that are both
1163/// `type: "result"` and [`is_top_level`] — the same provenance predicate
1164/// [`last_top_level_result`] enforces, reused rather than reinvented.
1165///
1166/// **D-13 trap 2 — a match proves DELIVERY, never WORK.** The agent can see the
1167/// token in its own prompt and emit it without doing anything (999.67's shape).
1168/// A hit means "the task-notification path is alive"; it never means the
1169/// dispatched work happened. Summaries and merges remain the evidence of work
1170/// (D-16/D-18).
1171///
1172/// Scans EVERY top-level `result`, not just the last one, which is the one
1173/// place this deliberately differs from [`last_top_level_result`]. That
1174/// function selects the session's final *verdict*, so later turns must
1175/// supersede earlier ones. The canary asks a different question — "did the
1176/// token ever come back?" — and a token returned on an earlier
1177/// task-notification turn is a complete answer to it.
1178pub fn token_reported_in_capture(capture: &str, token: &str) -> bool {
1179    ParsedCapture::parse(capture)
1180        .events
1181        .iter()
1182        .filter(|v| {
1183            v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1184        })
1185        .any(|v| {
1186            v.get("result")
1187                .and_then(serde_json::Value::as_str)
1188                .is_some_and(|text| text.contains(token))
1189        })
1190}
1191
1192/// Whether ONE parsed stream event is a top-level `result` carrying a
1193/// `DEVFLOW_RESULT` marker in its `result` text.
1194///
1195/// Exposed for the pipe-owning monitor's close rule (Phase 31, constraint 4),
1196/// which must decide line-by-line and in real time whether the marker arm is
1197/// satisfied — it cannot wait for a whole capture and re-parse it.
1198///
1199/// This is a COMPOSITION of the two existing predicates, deliberately not a
1200/// second implementation of either. T-31-01: the CLI echoes the operator's
1201/// prompt back into the same stdout as a `user` event — that echo is what
1202/// produced the checkpoint false positive 30-05 fixed — so a marker seen
1203/// anywhere but inside an event that is BOTH `type: "result"` AND
1204/// [`is_top_level`] must not close the stream. Reusing [`parse_marker_lines`]
1205/// keeps the marker grammar (case-insensitive prefix, edge-corruption
1206/// stripping, JSON body) in one place rather than letting the monitor grow a
1207/// looser `contains("DEVFLOW_RESULT")` of its own.
1208pub(crate) fn event_is_top_level_result_marker(event: &serde_json::Value) -> bool {
1209    event.get("type").and_then(serde_json::Value::as_str) == Some("result")
1210        && is_top_level(event)
1211        && event
1212            .get("result")
1213            .and_then(serde_json::Value::as_str)
1214            .and_then(parse_marker_lines)
1215            .is_some()
1216}
1217
1218/// Whether any AGENT-AUTHORED text in a Claude stream capture declares a
1219/// human-blocking gate. The stream-capture half of
1220/// [`blocking_human_checkpoint_reported`]; the pure matcher it delegates to,
1221/// [`text_reports_human_gate`], is unchanged.
1222///
1223/// **Why this exists (review constraint 3).** Scanning raw stdout is safe under
1224/// the single-document envelope, because the only place gate text can appear
1225/// there is the one `result` field the agent authored. A stream capture breaks
1226/// that invariant: the operator's prompt is echoed back into the same stdout as
1227/// a `user` event, so a prompt that merely DOCUMENTS a checkpoint gate
1228/// rendering becomes textually indistinguishable from a live declaration. The
1229/// failure is silent — a checkpoint auto-decide fires, or the resume ceiling is
1230/// consumed, on a stage whose prompt only discussed checkpoints. DevFlow's own
1231/// planning documents are exactly that kind of prompt content.
1232///
1233/// Two independent filters, both required, neither a substitute for the other:
1234///
1235/// 1. **Type — keep ONLY `result` events.** `user` events are always either the
1236///    echoed prompt or a `task_notification` summary re-injected as user-role
1237///    content; neither is the agent declaring anything. `system` events carry
1238///    the `init` tool and agent inventory, inert text with no business in a gate
1239///    scan. `assistant` events are excluded too, and that exclusion is
1240///    deliberate — do NOT "restore" it for completeness. Turn-FINAL assistant
1241///    text is duplicated verbatim into the `result` event that follows it
1242///    (`30a-evidence/raw_output_v3.jsonl` lines 17→19, 36→37, 53→54), so
1243///    admitting the class buys no detection the `result` events do not already
1244///    give. What it buys is a new false-positive surface: v3 line 6's top-level
1245///    assistant narration ("I'll spawn both subagents in the background now.")
1246///    reaches no `result` event at all, so an agent narrating "next I'll handle
1247///    the task whose gate the plan declares" would recreate the prompt-echo
1248///    false positive one layer inward.
1249/// 2. **Provenance — keep only top-level events.** An event is top-level when
1250///    `parent_tool_use_id` is JSON null OR the key is absent entirely. The
1251///    absent case is load-bearing: `result` events carry no such key at all
1252///    (confirmed across all three archived captures), so a naive presence check
1253///    would drop exactly the events that matter most. Mistaking
1254///    subagent-forwarded narration for orchestrator output is the error that
1255///    invalidated the v1 experiment outright. Kept even though filter 1 already
1256///    makes it redundant for today's captures — the two guards are meant to
1257///    fail independently, so a future widening of the type filter cannot
1258///    silently inherit subagent content.
1259///
1260/// **ALL eligible `result` events are scanned, not only the last.** This
1261/// deliberately diverges from [`last_top_level_result`]'s last-result-wins
1262/// verdict semantics, and the two conventions must not be "harmonised": a
1263/// verdict is a single final answer, whereas this asks whether a gate was
1264/// reported ANYWHERE in the stage's output. A gate declared in turn N followed
1265/// by task-notification wake-up turns N+1/N+2 — the exact turn shape the v3
1266/// capture archives — would be silently dropped by last-result-only, losing a
1267/// human authorization request to the generic gate. That is the
1268/// opposite-direction harm, and the worse of the two.
1269///
1270/// Text is read with a direct [`serde_json::Value::get`] chain. Never route
1271/// this through [`json_scan`]/[`json_find_key`]: a recursive traversal descends
1272/// straight back into the nested message content both filters just excluded,
1273/// silently undoing the fix while the tests on the outer shape still pass
1274/// (T-30-23).
1275///
1276/// Returns `bool` and short-circuits on the first match rather than collecting
1277/// the eligible text: this runs on every `devflow advance` over a capture that
1278/// grows for the whole stage, and there is no reason to allocate a copy of it.
1279fn claude_stream_reports_human_gate(events: &[serde_json::Value]) -> bool {
1280    events
1281        .iter()
1282        .filter(|event| event.get("type").and_then(serde_json::Value::as_str) == Some("result"))
1283        .filter(|event| is_top_level(event))
1284        .filter_map(|event| event.get("result").and_then(serde_json::Value::as_str))
1285        .any(text_reports_human_gate)
1286}
1287
1288/// The `rate_limit_info.status` values that mean the CLI DENIED the request.
1289///
1290/// Provenance, per entry — required reading before adding one:
1291///
1292/// - `rejected` — drawn from the observed vocabulary of this schema: it is the
1293///   value the CLI writes for `overageStatus` in the only archived
1294///   `rate_limit_event`
1295///   (`.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`
1296///   line 15), so it is the denial token this schema actually speaks. It has
1297///   NOT been observed as a `status` value — no archived capture is of a
1298///   blocked stream, and every capture DevFlow has taken carries
1299///   `status: "allowed"`.
1300///
1301/// Nothing else is listed, deliberately. Speculatively adding tokens is how the
1302/// false positive this list exists to prevent comes back: an unrecognised
1303/// status must DEFER (see [`detect_claude_stream_rate_limit`]), never classify.
1304/// Correct this list the first time a real blocked capture is archived — that
1305/// is the only evidence that settles the vocabulary.
1306const CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES: &[&str] = &["rejected"];
1307
1308/// Detect an explicit quota DENIAL in a Claude `stream-json` capture and return
1309/// the retry description, mirroring what [`detect_claude_rate_limit`] returns
1310/// for the single-document envelope.
1311///
1312/// **A `rate_limit_event` is not a rate limit.** The CLI emits these routinely
1313/// as quota telemetry on healthy streams: the only archived one
1314/// (`raw_output_v3.jsonl` line 15) says `rate_limit_info.status: "allowed"` and
1315/// sits in a stream that then completed three turns successfully. Classifying
1316/// on the event's PRESENCE would mark every healthy Claude stream stage
1317/// `RateLimited`, and `outcome_policy.rs` maps that to `Action::AutoResume` —
1318/// so every stage would be auto-resumed against a fabricated retry time
1319/// instead of advancing (T-30-26). Note the second trap in the same object:
1320/// `overageStatus` is `rejected` one level below `status: "allowed"`, so any
1321/// nested search for the token also false-positives. Hence every field here is
1322/// read with a direct [`serde_json::Value::get`] on the top-level event and its
1323/// `rate_limit_info` child — never [`json_find_key`]/[`json_scan`], which
1324/// descend into nested (and, elsewhere in the stream, agent-authored) content
1325/// and would let the agent supply the retry hint that drives the resume cron's
1326/// scheduling (T-30-12).
1327///
1328/// Two independent guards, both required, neither a substitute for the other:
1329///
1330/// 1. **Positional** — only events after the SECOND-TO-LAST `result` event are
1331///    eligible, i.e. the final turn. A session kept alive across turns emits one
1332///    `result` per turn, and rate-limit chatter from an earlier turn must never
1333///    outrank the outcome of a turn that finished later. (In the archived
1334///    capture the rate event is at line 15 and the results at 19/37/54, so it is
1335///    excluded on position alone.) With fewer than two `result` events the whole
1336///    stream IS the final turn.
1337/// 2. **Semantic** — only a `status` in
1338///    [`CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES`] classifies. A missing
1339///    `rate_limit_info`, a missing or non-string `status`, or any unrecognised
1340///    value returns `None`.
1341///
1342/// **Deferring is the deliberately safe direction, not an oversight.**
1343/// Under-classifying means an unknown denial status falls through to the
1344/// envelope-failure path and is reported `Failed` — a real degradation (the
1345/// operator loses automatic resume) but a never-silent one that still gates.
1346/// Over-classifying means a healthy stream is auto-resumed against a retry time
1347/// the parser invented. The asymmetry is the whole reason this function reads
1348/// one field instead of matching a shape.
1349fn detect_claude_stream_rate_limit(events: &[serde_json::Value]) -> Option<String> {
1350    // Index of the second-to-last `result` event: everything at or before it is
1351    // previous-turn history. `None` (fewer than two results) means the whole
1352    // stream is the final turn.
1353    let boundary = events
1354        .iter()
1355        .enumerate()
1356        .filter(|(_, v)| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
1357        .map(|(idx, _)| idx)
1358        .rev()
1359        .nth(1);
1360    let eligible = match boundary {
1361        Some(idx) => &events[idx + 1..],
1362        None => events,
1363    };
1364
1365    // Last eligible event wins, matching the last-`result`-wins convention.
1366    let event = eligible
1367        .iter()
1368        .rev()
1369        .find(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("rate_limit_event"))?;
1370
1371    let info = event.get("rate_limit_info")?;
1372    let status = info.get("status")?.as_str()?;
1373    if !CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES.contains(&status) {
1374        return None;
1375    }
1376
1377    // `resetsAt` is epoch seconds, rendered from the JSON number as-is: nothing
1378    // parses this string. `outcome_policy.rs` routes on the
1379    // `AgentStatus::RateLimited` variant alone and the `reason` text is
1380    // operator-facing. Mirrors `detect_claude_rate_limit`'s `retry_after` →
1381    // `message` → `error` chain; its final `"usage limit"` default has no
1382    // counterpart here because a matched `status` is by construction one of the
1383    // non-empty enumerated strings above, so a third rung would be unreachable.
1384    Some(
1385        info.get("resetsAt")
1386            .and_then(json_scalar_to_string)
1387            .unwrap_or_else(|| status.to_string()),
1388    )
1389}
1390
1391/// The stream-path counterpart of [`detect_claude_envelope_failure`]: treat
1392/// `is_error: true` on a stream's last `result` event as an authoritative
1393/// Layer-1 failure.
1394///
1395/// The `reason` shape is reproduced deliberately rather than shared — `result`
1396/// text, else `subtype`, else `agent reported is_error`, with a
1397/// ` (num_turns: {n})` suffix when present. This phase's scope fence keeps the
1398/// four shipped single-document parsers unmodified, so factoring the common
1399/// body out of `detect_claude_envelope_failure` is out of bounds here; the two
1400/// must be kept in step by hand. `is_error` absent, non-bool, or `false`
1401/// returns `None`, deferring exactly as the single-document path does.
1402fn claude_stream_envelope_failure(result_event: &serde_json::Value) -> Option<AgentResult> {
1403    if !result_event.get("is_error")?.as_bool()? {
1404        return None;
1405    }
1406
1407    let num_turns = result_event
1408        .get("num_turns")
1409        .and_then(serde_json::Value::as_u64);
1410    let base_reason = result_event
1411        .get("result")
1412        .and_then(serde_json::Value::as_str)
1413        .map(str::to_string)
1414        .or_else(|| {
1415            result_event
1416                .get("subtype")
1417                .and_then(serde_json::Value::as_str)
1418                .map(str::to_string)
1419        })
1420        .unwrap_or_else(|| "agent reported is_error".to_string());
1421    let reason = match num_turns {
1422        Some(n) => format!("{base_reason} (num_turns: {n})"),
1423        None => base_reason,
1424    };
1425
1426    Some(AgentResult {
1427        status: AgentStatus::Failed,
1428        exit_code: None,
1429        reason: Some(reason),
1430        commits: None,
1431        summary: None,
1432        verdict: None,
1433        decided_by_layer: Some(1),
1434    })
1435}
1436
1437/// Parse a Claude `--output-format stream-json` JSONL capture and read the
1438/// `DEVFLOW_RESULT` marker out of its LAST `result` event.
1439///
1440/// The new sibling of [`parse_codex_event_result`], mirroring its shape. Only
1441/// decisive when the capture is actually a Claude event stream (per
1442/// [`is_claude_event_stream`]); every other shape returns `None` and falls
1443/// through to the parser that owns it. Before this existed, a JSONL capture
1444/// returned `None` from all four single-document parsers —
1445/// `serde_json::from_str` on the whole multi-line document is a hard "trailing
1446/// characters" error — so every Claude-driven stage fell through to Layer 2's
1447/// coarse exit-code+commit heuristic.
1448///
1449/// **Precedence, mirroring [`evaluate_layer1`]'s single-document ordering
1450/// rather than inventing a new one** — do not reshuffle without reading the
1451/// reasons:
1452///
1453/// 1. Format gate ([`is_claude_event_stream`]); every other shape declines here.
1454/// 2. [`detect_claude_stream_rate_limit`] — a final-turn explicit quota denial
1455///    wins over EVERYTHING below it, for the same reason `evaluate_layer1`
1456///    already puts `detect_claude_rate_limit` ahead of the generic failure
1457///    check: a rate-limited run classified as plain `Failed` kills the primary
1458///    rate-limit resume cron, the one automated path that exists to recover
1459///    from it (T-30-13). The precedence is narrow, not broad — the detector
1460///    only fires on an explicit denial inside the final turn, so it cannot
1461///    shadow the outcome of a stream that completed.
1462/// 3. The `DEVFLOW_RESULT` marker in the last `result` event. A non-success
1463///    marker is decisive and returns immediately; a success marker is HELD, not
1464///    returned, because step 4 may override it.
1465/// 4. [`claude_stream_envelope_failure`] — `is_error: true` on that same event
1466///    overrides a held success marker, matching the single-document rule that
1467///    the envelope is authoritative for errors and a stale or echoed success
1468///    marker must not win (T-30-15).
1469/// 5. The held success marker, else `None`.
1470///
1471/// A last `result` event with no marker and no `is_error` returns `None`
1472/// (defer to Layer 2) rather than an unconditional Success, matching the
1473/// `turn.completed` convention: a marker-less turn must never silently advance
1474/// a stage.
1475///
1476/// Passing the isolated `result` text to [`parse_marker_lines`] is the correct
1477/// scoping, not a workaround. The marker is JSON-escaped inside a
1478/// `"result":"..."` string value, so it can never appear as a line starting
1479/// with `DEVFLOW_RESULT:` in the raw capture, and that parser's 4000-character
1480/// tail window is smaller than a single stream `result` line. Once serde
1481/// decodes the field the escaped newlines become real newlines and the existing
1482/// tail scan works on it as designed.
1483fn parse_claude_event_result(stdout: &str) -> Option<AgentResult> {
1484    let capture = ParsedCapture::parse(stdout);
1485    if !is_claude_event_stream(&capture.events) {
1486        return None;
1487    }
1488
1489    // Constraint 9 item 1 (code-review H1): a torn JSON line at or after the
1490    // last surviving top-level result means the session's REAL final verdict
1491    // may be among the casualties — a capture read while the CLI was still
1492    // appending, or a truncated write. Nothing that survives before the tear
1493    // is allowed to stand in for it; in particular an earlier turn's SUCCESS
1494    // must never advance the stage. Returning a Failed verdict rather than
1495    // None is deliberate: None would fall through to `parse_devflow_result`'s
1496    // raw tail scan, which can find the stale marker TEXT inside the surviving
1497    // JSON lines and resurrect it through the back door. The cost is a false
1498    // failure when the torn trailing line was a quiet task-notification turn;
1499    // that reads as loop-back noise, not a silent wrong advance.
1500    if capture.torn_json_after_last_matching(|v| {
1501        v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1502    }) {
1503        return Some(indeterminate_capture_failure());
1504    }
1505
1506    if let Some(retry) = detect_claude_stream_rate_limit(&capture.events) {
1507        return Some(rate_limited_result(retry));
1508    }
1509
1510    let last_result = last_top_level_result(&capture.events)?;
1511
1512    let marker = last_result
1513        .get("result")
1514        .and_then(serde_json::Value::as_str)
1515        .and_then(parse_marker_lines)
1516        .map(normalise_stream_marker_provenance);
1517
1518    let held_success = match marker {
1519        // A non-success marker is the agent's own final word and nothing below
1520        // can improve on it.
1521        //
1522        // 31-02 audit (non-exhaustive equality site 1 of 3). This `!= Success`
1523        // is CORRECT AS-IS for `AgentStatus::IdleTimeout` and is deliberately
1524        // left unchanged. The compiler cannot flag this site — an equality test
1525        // compiles fine against a new variant — so it is audited by hand here
1526        // rather than left to the wildcard-free-match mechanism, which does not
1527        // reach it.
1528        //
1529        // The only way `IdleTimeout` arrives here is an agent writing
1530        // `DEVFLOW_RESULT: {"status":"idle_timeout"}` into its own output,
1531        // claiming a verdict only DevFlow's monitor is supposed to produce.
1532        // The predicate handles that in the fail-safe direction: it is not
1533        // `Success`, so it returns immediately as decisive non-success and
1534        // `decide_action` gates it for review. A forged idle timeout can
1535        // therefore only make a run gate, never advance. The REAL
1536        // monitor-produced verdict does not travel this path at all — it is
1537        // read from its own side-channel file at the top of `evaluate_layer1`,
1538        // before this parser ever runs.
1539        Some(result) if result.status != AgentStatus::Success => return Some(result),
1540        other => other,
1541    };
1542
1543    if let Some(failure) = claude_stream_envelope_failure(last_result) {
1544        return Some(failure);
1545    }
1546
1547    held_success
1548}
1549
1550/// The Layer-1 verdict for a stream capture whose TAIL is provably unreadable:
1551/// a torn JSON line after the last surviving result (constraint 9 item 1).
1552///
1553/// Failed, not `None`, and not the pre-tear result. `None` hands the same
1554/// stdout to `parse_devflow_result`'s raw tail scan, which can resurrect the
1555/// stale marker text out of the surviving JSON lines; the pre-tear result is
1556/// exactly the stale-success defect this exists to close. A false failure on a
1557/// torn-but-benign tail surfaces as a retried stage, never as a silent wrong
1558/// advance — the asymmetry this whole module is built around.
1559fn indeterminate_capture_failure() -> AgentResult {
1560    AgentResult {
1561        status: AgentStatus::Failed,
1562        exit_code: None,
1563        reason: Some(
1564            "stream capture ends in an unparseable line; the final verdict is indeterminate"
1565                .to_string(),
1566        ),
1567        commits: None,
1568        summary: None,
1569        verdict: None,
1570        decided_by_layer: Some(1),
1571    }
1572}
1573
1574/// T-30-26: overwrite the agent-supplied `decided_by_layer` unconditionally.
1575///
1576/// [`parse_marker_lines`] deserializes the agent's own marker JSON straight
1577/// into [`AgentResult`], and the field is `#[serde(default)]`, so an ordinary
1578/// `{"status":"success"}` marker leaves it `None` while a hostile
1579/// `{"status":"success","decided_by_layer":0}` leaves it `Some(0)`. Neither is
1580/// acceptable: every other Layer-1 constructor in this module sets `Some(1)`
1581/// explicitly, and `Some(0)` is a Layer-0 external-probe provenance that
1582/// `classify_validate_outcome` (devflow-cli's `pipeline_outcomes.rs`) reads as
1583/// `external` when classifying a Validate stage. An agent must not be able to
1584/// claim a probe verdict it did not earn, so the value is derived here rather
1585/// than trusted.
1586fn normalise_stream_marker_provenance(mut result: AgentResult) -> AgentResult {
1587    result.decided_by_layer = Some(1);
1588    result
1589}
1590
1591/// Scan a bounded tail of `stdout` in reverse line order for the last
1592/// `DEVFLOW_RESULT` marker.
1593///
1594/// `DEVFLOW_RESULT` markers are ASCII. Searching the bounded tail and returning
1595/// the last valid marker ensures the agent's final status wins over an earlier
1596/// prompt echo without requiring the surrounding output to be ASCII.
1597///
1598/// Three sixth-pass corrections, each with a paired regression:
1599/// - The tail budget counts WHOLE LINES, never bisecting one (High 2): the old
1600///   fixed 4000-char window could cut through the final marker line itself
1601///   when it carried a long `reason`, silently dropping the authoritative
1602///   failure and handing the verdict to the exit code.
1603/// - Each line is edge-stripped before prefix matching (High 1): the capture
1604///   is read lossily, so one stray byte became U+FFFD glued to the prefix or
1605///   the JSON and the marker vanished. Same policy as every other reader:
1606///   edges stripped, interior corruption stays visible and untrusted.
1607/// - The prefix match is genuinely case-insensitive (High 3), as this
1608///   parser's contract has promised all along — the old strip_prefix chain
1609///   accepted only ALL-upper or ALL-lower.
1610fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
1611    const TAIL_BUDGET_CHARS: usize = 4000;
1612    const PREFIX: &str = "DEVFLOW_RESULT:";
1613
1614    let mut budget_used = 0usize;
1615    for line in stdout.lines().rev() {
1616        // The line that crosses the budget is still scanned whole; only the
1617        // NEXT one stops the walk. The last line is always scanned, however
1618        // long — that is the line the fixed window used to bisect.
1619        if budget_used > TAIL_BUDGET_CHARS {
1620            break;
1621        }
1622        budget_used += line.chars().count() + 1;
1623
1624        let line = strip_corruption_padding(line);
1625        let Some(head) = line.get(..PREFIX.len()) else {
1626            continue;
1627        };
1628        if !head.eq_ignore_ascii_case(PREFIX) {
1629            continue;
1630        }
1631
1632        let json_str = line[PREFIX.len()..].trim();
1633        if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
1634            return Some(result);
1635        }
1636    }
1637    None
1638}
1639
1640/// One commit the agent made before its stream went silent (D-07, 31-02).
1641///
1642/// The subject is carried alongside the sha because a bare sha list is not
1643/// operator-actionable — D-07's requirement is that the commits be *named*, so
1644/// that a silent miscount becomes something a human can act on.
1645#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1646pub struct IdleTimeoutCommit {
1647    /// Full commit sha, as `git log --format=%H` emits it.
1648    pub sha: String,
1649    /// Commit subject line (`%s`).
1650    pub subject: String,
1651}
1652
1653/// The pipe-owning monitor's authoritative idle-timeout verdict, as written to
1654/// [`idle_timeout_path`] BEFORE the child is terminated (D-05, 31-02).
1655///
1656/// This is a SIDE CHANNEL, deliberately not the stdout capture. See
1657/// [`parse_idle_timeout_side_channel`] for why that distinction is a
1658/// correctness requirement rather than a filing preference.
1659#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1660pub struct IdleTimeoutRecord {
1661    /// Always [`AgentStatus::IdleTimeout`]'s wire string. Recorded so the file
1662    /// is self-describing to a human reading `.devflow/` by hand.
1663    pub status: String,
1664    /// The idle window that elapsed with no line on the child's stdout.
1665    pub idle_secs: u64,
1666    /// The supervised child's pid, from the in-memory `Child` handle — never
1667    /// re-read from the on-disk pid file, which is exposed to pid reuse
1668    /// (T-31-07).
1669    pub agent_pid: u32,
1670    /// Unix seconds at which the monitor wrote this record.
1671    pub written_at: u64,
1672    /// Every commit on the phase branch when the timeout fired. NONE of these
1673    /// is rolled back — see [`parse_idle_timeout_side_channel`].
1674    pub commits: Vec<IdleTimeoutCommit>,
1675}
1676
1677/// Read the monitor's own idle-timeout verdict, if it wrote one.
1678///
1679/// **This is consulted as the FIRST statement of [`evaluate_layer1`], before
1680/// `read_capture` and before every marker parser. That placement is
1681/// load-bearing and must not be "tidied" into the `.or_else` chain below it.**
1682///
1683/// The obvious-looking alternative — appending the verdict to the stdout
1684/// capture — is a real correctness bug, not a style choice.
1685/// `evaluate_layer1`'s chain reaches `parse_devflow_result`'s tail scan only
1686/// when `parse_claude_event_result` returns `None`, and that parser resolves to
1687/// the LAST top-level `result` event regardless of what text follows it. On any
1688/// stream that already completed one successful turn — the normal shape of a
1689/// run long enough to idle out at all — an appended verdict is therefore never
1690/// reached, and a stale success stands as the recorded outcome of a run DevFlow
1691/// itself killed (T-31-06, 31-RESEARCH Pitfall 3).
1692///
1693/// Reading before `read_capture` matters for a second reason: that call is an
1694/// early `return None` when the capture is missing, so a timeout that fired
1695/// before the child emitted anything at all would otherwise be discarded
1696/// entirely.
1697///
1698/// `decided_by_layer` stays `1`. This is a Layer-1-CLASS authoritative verdict
1699/// — it just comes from the monitor that supervised the run rather than from
1700/// parsing what the agent said about itself. It is emphatically not `0`, which
1701/// is reserved for operator-authored external probe provenance that
1702/// `classify_validate_outcome` reads as `external`.
1703///
1704/// **The file's PRESENCE is the signal; its contents are enrichment.** A record
1705/// that exists but cannot be read still returns an `IdleTimeout` verdict,
1706/// carrying a reason that says the details were lost. Returning `None` there
1707/// would drop the verdict back into the cascade and let precisely the stale
1708/// success above win — turning a corrupt file into a silent wrong advance,
1709/// which is the exact failure this function exists to prevent. The asymmetry is
1710/// the one this whole module is built around: a false failure surfaces as a
1711/// gate, never as a wrong advance.
1712///
1713/// **Nothing here rolls anything back** (D-07, T-31-09). The commits are read
1714/// and named, never reverted: an idle timeout may be a false positive, and
1715/// destroying real work on a false positive is unrecoverable.
1716/// Whether the phase's live capture already carries an explicit quota DENIAL.
1717///
1718/// Exists for exactly one caller: [`crate::monitor`]'s idle-timeout path, which
1719/// must know *why* the stream went quiet before it records a verdict about the
1720/// silence.
1721///
1722/// **The problem this solves.** A quota denial makes the agent go silent — it
1723/// has nothing left to say. The monitor's idle timer then fires, writes an
1724/// idle-timeout record, and kills the child. Because
1725/// [`parse_idle_timeout_side_channel`] is `evaluate_layer1`'s first statement
1726/// and returns unconditionally (T-31-06), that record shadows
1727/// [`detect_claude_stream_rate_limit`] — which was sitting in the same capture
1728/// with the answer. The run is then reported as an idle timeout, "TERMINAL and
1729/// not retried automatically", when the truth is `RateLimited`, which
1730/// `outcome_policy` routes to auto-resume.
1731///
1732/// Observed 2026-08-08 on a real Code stage: `rate_limit_event` with
1733/// `status: "rejected"`, `rateLimitType: "seven_day"`,
1734/// `overageDisabledReason: "out_of_credits"`. Replaying the classifier over that
1735/// capture returns the denial; the operator was instead told the stream had been
1736/// silent for 120s. Running out of quota is the likeliest way a long unattended
1737/// run stops, so it is the failure this phase can least afford to misreport.
1738///
1739/// Deliberately delegates to the SAME detector the read path uses rather than
1740/// re-implementing the check. Two independent notions of "is this a rate limit"
1741/// would be free to disagree, and the disagreement would be invisible.
1742#[must_use]
1743pub fn capture_shows_rate_limit_denial(project_root: &Path, phase: PhaseId) -> bool {
1744    let Some(raw) = read_capture(&stdout_path(project_root, phase)) else {
1745        return false;
1746    };
1747    detect_claude_stream_rate_limit(&ParsedCapture::parse(&raw).events).is_some()
1748}
1749
1750fn parse_idle_timeout_side_channel(project_root: &Path, phase: PhaseId) -> Option<AgentResult> {
1751    let path = idle_timeout_path(project_root, phase);
1752    let raw = read_capture(&path)?;
1753
1754    let Ok(record) = serde_json::from_str::<IdleTimeoutRecord>(&raw) else {
1755        return Some(idle_timeout_result(
1756            format!(
1757                "idle timeout: DevFlow's monitor recorded a timeout verdict at {} but the \
1758                 record itself is unreadable, so the commit list and idle duration are lost. \
1759                 The timeout stands regardless — the file's presence is the authoritative \
1760                 signal. Inspect the phase branch by hand; nothing was rolled back.",
1761                path.display()
1762            ),
1763            None,
1764        ));
1765    };
1766
1767    let named: Vec<String> = record
1768        .commits
1769        .iter()
1770        .map(|commit| {
1771            let short: String = commit.sha.chars().take(7).collect();
1772            format!("{short} {}", commit.subject)
1773        })
1774        .collect();
1775
1776    let commit_phrase = if named.is_empty() {
1777        "No commits were found on the phase branch.".to_string()
1778    } else {
1779        format!(
1780            "The agent made {} commit(s) before going quiet and NONE of them were rolled \
1781             back: {}.",
1782            named.len(),
1783            named.join("; ")
1784        )
1785    };
1786
1787    Some(idle_timeout_result(
1788        format!(
1789            "idle timeout: the agent's output stream was silent for {}s, so DevFlow \
1790             terminated it (agent pid {}). {commit_phrase} Review the branch before deciding \
1791             what to keep — this run is TERMINAL and is not retried automatically.",
1792            record.idle_secs, record.agent_pid
1793        ),
1794        Some(record.commits.len() as u32),
1795    ))
1796}
1797
1798/// Build the `IdleTimeout` verdict Layer 1 reports for a monitor-recorded
1799/// timeout.
1800///
1801/// `verdict` stays `None` deliberately: at `Stage::Validate`,
1802/// `classify_validate_outcome` matches `Some(Verdict::Pass)` FIRST and would
1803/// classify the stage as passed on the strength of that field alone, whatever
1804/// the status says. A timeout has no verdict to offer, and inventing one here
1805/// would advance a run that never reported.
1806fn idle_timeout_result(reason: String, commits: Option<u32>) -> AgentResult {
1807    AgentResult {
1808        status: AgentStatus::IdleTimeout,
1809        exit_code: None,
1810        reason: Some(reason),
1811        commits,
1812        summary: None,
1813        verdict: None,
1814        decided_by_layer: Some(1),
1815    }
1816}
1817
1818/// Layer 1: Try to detect agent result from the native per-adapter envelope
1819/// or the DEVFLOW_RESULT marker in stdout.
1820///
1821/// The monitor's own idle-timeout side channel is consulted FIRST, ahead of
1822/// everything below including `read_capture` itself — see
1823/// [`parse_idle_timeout_side_channel`], where that ordering is a correctness
1824/// requirement rather than a preference.
1825///
1826/// Precedence: Claude rate-limit envelope (a SPECIFIC failure that must
1827/// outrank the generic `is_error` check — rate-limit envelopes carry
1828/// `is_error: true`, and classifying them `Failed` would kill the primary
1829/// rate-limit resume cron path) → Claude envelope `is_error: true` (authoritative,
1830/// overrides a success marker) → Claude `stream-json` JSONL event stream (the
1831/// last `result` event's marker decides; a marker-less last turn defers) →
1832/// DEVFLOW_RESULT marker (portable; works for plain text and a Claude
1833/// envelope's unwrapped `result` text) → Codex JSONL event stream
1834/// (`turn.failed` decisive; `turn.completed` defers) → Codex plain-text
1835/// rate-limit heuristic (least authoritative, stays last).
1836///
1837/// The Claude stream parser's position is load-bearing in BOTH directions
1838/// (T-30-03). The two single-document detectors stay ahead of it because they
1839/// remain authoritative for the `--output-format json` envelope that ships
1840/// today. It goes ahead of `parse_devflow_result` so that an adapter-specific
1841/// stream capture is owned whole by the parser that understands its framing,
1842/// rather than letting the generic 4000-character tail scan take a bite of a
1843/// mid-line window of JSONL first.
1844pub fn evaluate_layer1(project_root: &Path, phase: PhaseId) -> Option<AgentResult> {
1845    // FIRST STATEMENT, before `read_capture` and before every parser below.
1846    // Do not move this into the `.or_else` chain: `parse_claude_event_result`
1847    // resolves the LAST top-level `result` event and would shadow it on any
1848    // stream that already had one successful turn. See
1849    // `parse_idle_timeout_side_channel`'s doc comment (T-31-06).
1850    if let Some(timed_out) = parse_idle_timeout_side_channel(project_root, phase) {
1851        return Some(timed_out);
1852    }
1853
1854    let stdout = read_capture(&stdout_path(project_root, phase))?;
1855    detect_claude_rate_limit(&stdout)
1856        .map(rate_limited_result)
1857        .or_else(|| detect_claude_envelope_failure(&stdout))
1858        .or_else(|| parse_claude_event_result(&stdout))
1859        .or_else(|| parse_devflow_result(&stdout))
1860        .or_else(|| parse_codex_event_result(&stdout))
1861        .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
1862}
1863
1864/// Build the `RateLimited` result Layer 1 reports for a detected retry hint.
1865fn rate_limited_result(retry: String) -> AgentResult {
1866    AgentResult {
1867        status: AgentStatus::RateLimited,
1868        exit_code: None,
1869        reason: Some(format!("rate limited until {retry}")),
1870        commits: None,
1871        summary: None,
1872        verdict: None,
1873        decided_by_layer: Some(1),
1874    }
1875}
1876
1877/// Commits on the phase's feature branch that are not on `develop`.
1878///
1879/// Derives the branch name from `git_flow.feature_prefix` and the zero-padded
1880/// `phase`, verifies the branch exists with `rev-parse --verify`, and on
1881/// success counts `{git_flow.develop}..{branch}` with `rev-list --count`.
1882/// This is the single implementation of that count — [`evaluate_layer2`],
1883/// [`evaluate_layer3`] and `pipeline_outcomes::handle_validate_outcome`'s
1884/// forward-progress check all call it rather than each re-deriving the branch
1885/// name and re-running the same two git commands, which is what made the
1886/// counts able to silently diverge before this extraction. That claim was
1887/// aspirational until 35-01: [`evaluate_layer3`] carried its own inline
1888/// `rev-list --count` with an independent copy of the lossy zero collapse, and
1889/// deleting it is what makes "single implementation" true.
1890///
1891/// Must be called with the main `project_root`, never a worktree path — git
1892/// worktrees share refs and the object database, so a commit made inside a
1893/// linked worktree is immediately visible to a count run from the main
1894/// checkout, which is the property every caller already relies on.
1895///
1896/// The return distinguishes a MEASUREMENT from a measurement FAILURE, which
1897/// is the whole point of the `Option` (999.77 / D-08, A-06):
1898///
1899/// - `Some(n)` — git ran and reported a real number. This includes
1900///   `Some(0)` for a branch that genuinely does not exist yet, which is
1901///   normal on a phase's first Validate and is a real observation, not a
1902///   failure to observe.
1903/// - `None` — the count could not be established: either the `git` child
1904///   could not be executed at all (`.output()` returned `Err`), or it ran but
1905///   produced stdout that does not parse as a `u32`. A-06 splits only the
1906///   ran/did-not-run axis; the unparseable case is mapped to `None` here
1907///   because the child produced no usable count, and reporting a forged zero
1908///   for it would recreate exactly the hazard this signature removes.
1909///
1910/// **The two consumers now handle `None` distinctly, and neither collapses it
1911/// to zero.** `pipeline_outcomes::handle_validate_outcome` treats an
1912/// unmeasurable cycle as not-progress and leaves its persisted baseline
1913/// untouched, so the next real measurement still compares against the last
1914/// real observation. [`evaluate_layer2`] returns `Ok(None)` and falls through
1915/// to [`evaluate_layer3`], which classifies an unmeasurable count as
1916/// [`AgentStatus::Unknown`] rather than asserting the negative that no work
1917/// was done.
1918///
1919/// # Changed in v2.5.0 — breaking
1920///
1921/// The return type was `u32` before this release; it is now `Option<u32>`
1922/// (999.77 / 999.87). A call site updating from the old form must decide which
1923/// of the two states it means, because the old type conflated them:
1924///
1925/// - `Some(0)` — git RAN and the branch genuinely has no commits. This is the
1926///   old `0` in its legitimate sense, and is normal on a phase's first Validate.
1927/// - `None` — no count was established at all. This is the case the old
1928///   signature could not express, and `.unwrap_or(0)` is precisely the wrong
1929///   way to restore it: collapsing it back to zero is the defect this change
1930///   exists to remove. A transient `git` failure then reads as "no work done",
1931///   which forged a `consecutive_failures` baseline reset (999.77) and made the
1932///   result cascade classify a successful agent as `Failed` (999.87).
1933///
1934/// The enumeration of this and every other public-surface change in the release
1935/// is in `CHANGELOG.md` under 2.5.0.
1936pub fn phase_commit_count(
1937    project_root: &Path,
1938    git_flow: &GitFlowConfig,
1939    phase: PhaseId,
1940) -> Option<u32> {
1941    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
1942
1943    // A-06: split on whether the command RAN, not on what it answered. An
1944    // `Err` means the child could not be executed — a measurement failure. An
1945    // `Ok` with an unsuccessful status means git ran and reported the branch
1946    // absent, which is a real observation of zero commits.
1947    match git_command(project_root)
1948        .args(["rev-parse", "--verify", &branch])
1949        .output()
1950    {
1951        Err(_) => return None,
1952        Ok(output) if !output.status.success() => return Some(0),
1953        Ok(_) => {}
1954    }
1955
1956    let range = format!("{}..{branch}", git_flow.develop);
1957    // A-06 again, applied to the second step (CR-01, 35-REVIEW). This arm used
1958    // to be `.output().ok()?` followed by `.parse().ok()`, which split on
1959    // whether the output PARSED rather than on whether the command RAN — the
1960    // opposite of the rule the `rev-parse` step above states and follows. A
1961    // `rev-list` that runs and exits non-zero writes an empty stdout, so any
1962    // condition making the range invalid (the configured `develop` absent from
1963    // the checkout, a shallow clone) parsed to nothing and returned `None`
1964    // *permanently*, not transiently. That is a measurement the command DID
1965    // make; it belongs with the branch-absent case above as a real zero.
1966    let output = match git_command(project_root)
1967        .args(["rev-list", "--count", &range])
1968        .output()
1969    {
1970        Err(_) => return None,
1971        Ok(output) => output,
1972    };
1973    if !output.status.success() {
1974        return Some(0);
1975    }
1976    // A success whose stdout does not parse is a different animal: git ran,
1977    // succeeded, and said something this function cannot read. Nothing was
1978    // established, so it stays `None` rather than being asserted as zero.
1979    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
1980}
1981
1982/// Layer 2: Use exit code + commit count to determine result.
1983///
1984/// Reads exit code from `.devflow/phase-NN-exit` file.
1985/// Counts commits in `feature/phase-NN` branch (if it exists), via
1986/// [`phase_commit_count`].
1987///
1988/// The commit-count gate ("no commits → failed") is scoped to `stage` — it
1989/// only applies to `Stage::Plan`/`Stage::Code` (checked via an explicit
1990/// `matches!`, NOT `Stage::is_agent_stage()`, since that also includes
1991/// `Define`, which legitimately produces zero commits). `exit≠0` is ALWAYS
1992/// `Failed`, for every stage — only the `exit=0`/zero-commits branch is
1993/// stage-scoped.
1994///
1995/// Decision matrix:
1996///   exit=137                                             → ResourceKilled (ALL stages, D-07)
1997///   exit=127                                             → AgentUnavailable (ALL stages, D-07)
1998///   exit≠0 (excluding 137/127)                           → Failed (ALL stages)
1999///   exit=0, stage in {Plan, Code}, commits=0             → Failed ("no work done")
2000///   exit=0, stage in {Plan, Code}, commits>0             → Success
2001///   exit=0, stage NOT in {Plan, Code} (Define/Validate/Ship), commits=0 → Success
2002///           (not commit-gated; Validate's real pass signal is its verdict,
2003///           not a bare zero-commit — see Task 2's turn.completed deferral)
2004///   exit unknown                                         → fall to Layer 3 (return None)
2005///   exit=0, stage in {Plan, Code}, commits UNMEASURABLE  → fall to Layer 3 (return None)
2006///           (CR-01: the ONLY row an unmeasurable count changes. Every other
2007///           row above is decided by the exit code alone and keeps its verdict
2008///           with the count rendered as "unknown" in the reason string.)
2009///
2010/// WR-06 (13-REVIEW.md): takes only the explicit `project_root` parameter
2011/// for both the `.devflow/` file paths and the git subprocess `current_dir`
2012/// — previously it also accepted `state: &State` and used `state.project_root`
2013/// for the git calls, which every caller happened to pass consistently with
2014/// `project_root` but which the function itself had no way to enforce.
2015pub fn evaluate_layer2(
2016    project_root: &Path,
2017    phase: PhaseId,
2018    git_flow: &GitFlowConfig,
2019    stage: Stage,
2020) -> Result<Option<AgentResult>, ResultError> {
2021    let exit_path = devflow_dir(project_root).join(format!("phase-{}-exit", phase.padded()));
2022    let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
2023        Ok(s) => s.trim().parse().unwrap_or(-1),
2024        Err(_) => return Ok(None), // fall to Layer 3
2025    };
2026
2027    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
2028    let commits = phase_commit_count(project_root, git_flow, phase);
2029    let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
2030
2031    // D-09 (999.87): an unmeasurable commit count is NOT evidence that no work
2032    // was done, and the commit gate below would classify it as
2033    // `Failed — no work done` if it were collapsed to zero.
2034    //
2035    // CR-01 (35-REVIEW): the guard belongs HERE, not above the exit-code
2036    // classification. `commits` is load-bearing for exactly one term —
2037    // `no_work_done`, which only exists when `commit_gated` holds. Returning
2038    // early on any `None` also discarded the 137 / 127 / `exit != 0` verdicts
2039    // and the non-commit-gated `Success`, none of which read the count at all.
2040    // That mattered because Layer 2 is the SOLE classifier for 137 and 127
2041    // (Layer 1 sees no marker from a SIGKILLed or never-launched agent, and
2042    // Layer 3 has no ResourceKilled/AgentUnavailable arm), and the same host
2043    // fault that OOM-kills an agent also makes the `fork` for `git` fail — so
2044    // the two observations arrive together, and an infra fault was routed into
2045    // the Validate loop it is explicitly forbidden from entering
2046    // (`pipeline_launch.rs`, review consensus #4 / D-08).
2047    //
2048    // Fall through ONLY when the missing count is what would have decided.
2049    if commit_gated && exit_code == 0 && commits.is_none() {
2050        return Ok(None); // fall to Layer 3
2051    }
2052
2053    let no_work_done = commit_gated && commits == Some(0);
2054    // Reason strings must not invent a number they do not have. Every
2055    // surviving arm below interpolates the count for context only.
2056    let commits_desc = match commits {
2057        Some(n) => format!("{n} commits"),
2058        None => "an unmeasurable number of commits".to_string(),
2059    };
2060
2061    // 137 (SIGKILL, typically OOM) and 127 (command not found) are classified
2062    // BEFORE the generic `exit_code != 0 -> Failed` catch-all, using the same
2063    // trusted plain-i32 already parsed above from the monitor-written exit
2064    // file (D-07, 17b — no ExitStatusExt/signal API per Pitfall 1a).
2065    let status = if exit_code == 137 {
2066        AgentStatus::ResourceKilled
2067    } else if exit_code == 127 {
2068        AgentStatus::AgentUnavailable
2069    } else if exit_code != 0 || no_work_done {
2070        AgentStatus::Failed
2071    } else {
2072        AgentStatus::Success
2073    };
2074
2075    Ok(Some(AgentResult {
2076        status,
2077        exit_code: Some(exit_code),
2078        reason: if exit_code == 137 {
2079            Some(format!(
2080                "agent process was killed (exit code 137, likely OOM) ({commits_desc} on {branch})"
2081            ))
2082        } else if exit_code == 127 {
2083            Some(format!(
2084                "agent command was unavailable (exit code 127, command not found) \
2085                 ({commits_desc} on {branch})"
2086            ))
2087        } else if exit_code != 0 {
2088            Some(format!(
2089                "agent exited with code {exit_code} ({commits_desc} on {branch})"
2090            ))
2091        } else if no_work_done {
2092            Some(format!(
2093                "no commits found on {branch} (agent exit code was {exit_code})"
2094            ))
2095        } else {
2096            Some(format!(
2097                "{commits_desc} on {branch} (agent exit code was {exit_code})"
2098            ))
2099        },
2100        commits,
2101        summary: None,
2102        verdict: None,
2103        decided_by_layer: Some(2),
2104    }))
2105}
2106
2107/// Layer 3: Last resort — agent process is gone.
2108///
2109/// Split per D-02/D-03 case 3 (17-03): "process gone, commits exist" stays
2110/// `Unknown` — unverified but there is SOMETHING to account for, and Plan
2111/// 04's never-advance dispatch gates it downstream (D-04) rather than
2112/// reclassifying it here. "Process gone, zero commits, nothing declared" is
2113/// no longer a blanket advanceable `Unknown` — it is reclassified to
2114/// `Failed` so a vanished agent that produced and declared nothing cannot
2115/// masquerade as ambiguous-but-fine; the reason flags that human review is
2116/// needed. This only fires when neither Layer 1 nor Layer 2 produced a
2117/// definitive result.
2118///
2119/// **The split is three-way, not two-way (35-01/F-4).** The two cases above
2120/// both assume the commit count was actually established. A third case —
2121/// the count could not be MEASURED at all — is classified `Unknown` with
2122/// `commits` left absent and a reason naming the measurement failure. It is
2123/// not `Failed`: that asserts a negative the evidence does not support, and
2124/// on a transient `git` fault it is the exact misclassification this layer
2125/// used to produce. An unmeasurable count is strictly less certain than the
2126/// `commits > 0` case already called `Unknown`, so `Unknown` is the
2127/// consistent answer.
2128///
2129/// The count now comes from [`phase_commit_count`] rather than a second
2130/// inline derivation. This layer previously ran its own `rev-list --count`
2131/// that fell soft to a zero default, an independent copy of the same lossy
2132/// collapse — so fixing only [`evaluate_layer2`] relocated the
2133/// misclassification here instead of removing it. The two measurable arms'
2134/// behaviour and reason strings are unchanged.
2135pub fn evaluate_layer3(
2136    project_root: &Path,
2137    phase: PhaseId,
2138    git_flow: &GitFlowConfig,
2139) -> Result<AgentResult, ResultError> {
2140    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
2141    // F-4 (35-01): this layer used to run its OWN inline `rev-list --count`
2142    // that fell soft to a zero default, an independent copy of the same lossy
2143    // collapse `phase_commit_count` carried. Because every path that reaches
2144    // Layer 2 also reaches Layer 3, fixing only Layer 2 relocated the
2145    // misclassification here instead of removing it. Routed through the shared
2146    // counter so the cascade's last layer measures the same way every other
2147    // consumer does.
2148    let commits = phase_commit_count(project_root, git_flow, phase);
2149
2150    let (status, reason) = match commits {
2151        Some(n) if n > 0 => (
2152            AgentStatus::Unknown,
2153            format!(
2154                "unverified — agent process is gone but {} commits exist on {}",
2155                n, branch
2156            ),
2157        ),
2158        Some(_) => (
2159            AgentStatus::Failed,
2160            "no work accounted for — agent process is gone with no commits and no declared \
2161             external post-condition; human review needed"
2162                .to_string(),
2163        ),
2164        // F-4: an unmeasurable count is not evidence of absent work here
2165        // either. `Failed` asserts a negative the evidence does not support,
2166        // and it is the classification this phase exists to stop producing on
2167        // a transient fault. Layer 3 already reserves `Unknown` for "there is
2168        // something here I cannot verify"; a count that could not be taken at
2169        // all is strictly less certain than that, so `Unknown` is the
2170        // consistent answer. `commits` is left absent rather than forged to
2171        // zero — "no work" and "could not tell" are different facts.
2172        None => (
2173            AgentStatus::Unknown,
2174            format!(
2175                "unverified — agent process is gone and the work could not be accounted for: \
2176                 the commit count on {} could not be measured; human review needed",
2177                branch
2178            ),
2179        ),
2180    };
2181
2182    Ok(AgentResult {
2183        status,
2184        exit_code: None,
2185        reason: Some(reason),
2186        commits,
2187        summary: None,
2188        verdict: None,
2189        decided_by_layer: Some(3),
2190    })
2191}
2192
2193/// Layer 0: run explicitly operator-approved external post-condition probes.
2194///
2195/// A failed probe outranks every agent-controlled signal. An approved,
2196/// all-passing set of declared probes is itself affirmative completion
2197/// evidence — `Success` — so a legitimately external-only stage with zero
2198/// commits can still complete cleanly (D-05 gap 2). Evaluated for EVERY
2199/// stage, not only Code (D-05 gap 1 / D-06). With no declarations (or when
2200/// disabled), behavior is byte-for-byte the pre-Phase-16 cascade.
2201///
2202/// Both DISCOVERY and probe EXECUTION read `execution_root` — the worktree
2203/// when one is set, `project_root` otherwise (999.76, ROADMAP criterion 6).
2204///
2205/// This knowingly OVERTURNS a recorded prior peer-review decision
2206/// (review Plan 03 MEDIUM, OpenCode). That decision held the two roots must
2207/// stay distinct, discovery reading `project_root` because
2208/// `.planning/phases/` "lives there, not in a worktree checkout". **The
2209/// premise has the direction backwards.** `.planning/` is TRACKED content,
2210/// so an in-flight phase's `{N}-PLAN.md` is committed on `feature/phase-{N}`
2211/// and therefore exists INSIDE the worktree while absent from the main checkout for
2212/// the phase's whole duration. Discovering from `project_root` meant a
2213/// correctly-declared probe set silently never ran in worktree mode —
2214/// DevFlow's default operating shape — with no error and no log, and the
2215/// "PLAN removed" veto below fired in its place. Recorded as an overturn
2216/// rather than patched quietly, so a later reader can see the direction was
2217/// reconsidered on evidence rather than overlooked.
2218///
2219/// Three sibling reads deliberately KEEP `project_root` and must not be
2220/// "corrected" to match: [`phase_commit_count`] (git worktrees share refs and
2221/// the object database, so counting from the main checkout is right), and
2222/// [`checkpoint_reported_in_capture`] and [`evaluate_layer1`] (both read the
2223/// stdout capture under `.devflow/`, which lives in the project root).
2224fn evaluate_layer0(
2225    project_root: &Path,
2226    state: &State,
2227    approved_commands: Option<&[String]>,
2228) -> Option<AgentResult> {
2229    if !crate::config::external_verify_enabled(project_root) {
2230        return None;
2231    }
2232
2233    let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
2234    let commands = crate::verify::external_verify_commands(execution_root, state.phase);
2235    if commands.is_empty() {
2236        return approved_commands.map(|_| AgentResult {
2237            status: AgentStatus::Failed,
2238            exit_code: None,
2239            reason: Some(
2240                "external verification approval mismatch; PLAN declaration was removed".into(),
2241            ),
2242            commits: None,
2243            summary: None,
2244            verdict: None,
2245            decided_by_layer: Some(0),
2246        });
2247    }
2248    let Some(approved_commands) = approved_commands else {
2249        return Some(AgentResult {
2250            status: AgentStatus::Failed,
2251            exit_code: None,
2252            reason: Some(format!(
2253                "external verification is not approved; set {} to the reviewed JSON command array",
2254                crate::verify::TRUST_EXTERNAL_VERIFY_ENV
2255            )),
2256            commits: None,
2257            summary: None,
2258            verdict: None,
2259            decided_by_layer: Some(0),
2260        });
2261    };
2262    if commands != approved_commands {
2263        return Some(AgentResult {
2264            status: AgentStatus::Failed,
2265            exit_code: None,
2266            reason: Some("external verification approval mismatch; PLAN commands changed".into()),
2267            commits: None,
2268            summary: None,
2269            verdict: None,
2270            decided_by_layer: Some(0),
2271        });
2272    }
2273    match commands
2274        .into_iter()
2275        .find(|command| !crate::verify::run_external_verification(command, execution_root))
2276    {
2277        Some(command) => Some(AgentResult {
2278            status: AgentStatus::Failed,
2279            exit_code: None,
2280            reason: Some(format!("external verification failed: {command}")),
2281            commits: None,
2282            summary: None,
2283            verdict: None,
2284            decided_by_layer: Some(0),
2285        }),
2286        // Every declared, approved probe passed — affirmative completion
2287        // evidence on its own (D-05 gap 2), even with zero commits.
2288        None => Some(AgentResult {
2289            status: AgentStatus::Success,
2290            exit_code: None,
2291            reason: Some(
2292                "external verification passed — all declared, approved probes succeeded".into(),
2293            ),
2294            commits: None,
2295            summary: None,
2296            verdict: None,
2297            decided_by_layer: Some(0),
2298        }),
2299    }
2300}
2301
2302/// Reconciles Layer 0's affirmative-success result with Layer 1's
2303/// self-reported verdict at `Stage::Validate` (18e).
2304///
2305/// Layer 0's affirmative-success arm above short-circuits the cascade before
2306/// Layer 1 ever runs (`evaluate_agent_result_inner` returns immediately on
2307/// any `Some(..)` from Layer 0), but Layer 1 is the ONLY carrier of a
2308/// `verdict` — `status` reports whether the stage's task ran; `verdict`
2309/// reports whether validation itself passed (see `AgentResult::verdict`'s
2310/// doc comment). At `Stage::Validate` that meant an agent's explicit
2311/// `verdict: pass` was silently discarded and `advance()` computed a failure
2312/// from it — a regression introduced by this project's own 17-03, fixed
2313/// here.
2314///
2315/// `decided_by_layer` deliberately stays `Some(0)` — Layer 0 still DECIDED
2316/// the `status`; Layer 1 only supplies the `verdict`. The CLI relies on that
2317/// value to tell an `external_verify` Validate apart from an ordinary one
2318/// (`classify_validate_outcome`, 18e).
2319///
2320/// Scoped to `Stage::Validate` only (flagged assumption in 18-05-PLAN.md): at
2321/// every other stage an affirmative Layer 0 success keeps `verdict: None`,
2322/// unchanged from current behavior. A Layer 0 FAILURE is never passed here —
2323/// only its affirmative-success arm is, so a failed probe still outranks
2324/// every agent-controlled signal.
2325///
2326/// 31-02 audit (non-exhaustive equality site 2 of 3). The `!= Success` guard
2327/// below is CORRECT AS-IS for `AgentStatus::IdleTimeout` and is left unchanged.
2328/// The compiler cannot flag an equality test against a new variant, so this is
2329/// audited by hand. An idle-timeout result is rejected here by BOTH independent
2330/// guards, not just one: its status is not `Success`, and its
2331/// `decided_by_layer` is `Some(1)` (the monitor's side-channel verdict is a
2332/// Layer-1-class fact), never `Some(0)`. It returns unchanged, which is right —
2333/// this function exists only to graft Layer 1's `verdict` onto an affirmative
2334/// Layer 0 probe success, and a timeout is neither.
2335///
2336/// # This function is 999.74's real defect site (D-15, ROADMAP criterion 4)
2337///
2338/// Until 34-01 the graft read Layer 1's `verdict` and nothing else. A marker of
2339/// `{"status":"failed","verdict":"pass"}` therefore produced `(Success,
2340/// Some(Pass), Some(0))`: an agent's self-reported FAILURE laundered into an
2341/// affirmative pair, which `outcome_policy::decide_action` advances and
2342/// `classify_validate_outcome` reads as `Passed` — Ship, in `Mode::Auto`, on a
2343/// run whose agent said it had failed. The status was never inspected, so
2344/// nothing downstream could see the contradiction; by the time the classifier
2345/// ran, the status genuinely WAS `Success`.
2346///
2347/// The fix consults Layer 1's own `AgentStatus` before transplanting its
2348/// verdict, because **a verdict attached to a self-reported failure is not a
2349/// pass**. Only `AgentStatus::Success` from Layer 1 may contribute a verdict;
2350/// everything else leaves `verdict: None` and the stage classifies `Ambiguous`,
2351/// which gates.
2352///
2353/// The classifier fix (plan 34-03, ROADMAP criterion 3) does **not** close this
2354/// and never could: gating `classify_validate_outcome`'s `Passed` arm on the
2355/// derived status passes cleanly here, because the derived status is `Success`.
2356/// Criterion 3 and criterion 4 are separate deliverables. Regression-pinned by
2357/// `layer0_verdict_graft_declines_when_layer1_status_is_not_success`, with
2358/// `layer0_verdict_graft_still_transplants_a_passing_layer1_verdict` as its
2359/// mandatory opposite-result control.
2360///
2361/// `evaluate_layer1` is called on `project_root`, NOT on the execution root,
2362/// and that asymmetry is deliberate rather than an oversight: Layer 1 reads the
2363/// stdout capture under `.devflow/`, which lives in the project root, while
2364/// Layer 0 above DISCOVERS declarations in `.planning/phases/` (project root)
2365/// and RUNS probes in the worktree. Plan 34-04 moves Layer 0's *discovery* to
2366/// the execution root; this call stays on `project_root` and is still correct
2367/// afterwards. Recorded here so a later reader does not "fix" the asymmetry.
2368fn reconcile_layer0_verdict(
2369    project_root: &Path,
2370    state: &State,
2371    result: AgentResult,
2372) -> AgentResult {
2373    if state.stage != Stage::Validate
2374        || result.status != AgentStatus::Success
2375        || result.decided_by_layer != Some(0)
2376    {
2377        return result;
2378    }
2379    let verdict = evaluate_layer1(project_root, state.phase)
2380        .filter(|layer1| layer1.status == AgentStatus::Success)
2381        .and_then(|layer1| layer1.verdict);
2382    AgentResult { verdict, ..result }
2383}
2384
2385/// Refuse to let a stream-derived `Success` outrank a contradicting exit code
2386/// (constraint 9's residual, T-31-15, 31-04).
2387///
2388/// # Why this cannot be a parser assertion
2389///
2390/// Constraint 9's items 1 and 2 — a torn line at or after the last surviving
2391/// top-level `result`, and provenance on verdict selection — were closed at the
2392/// root by the `a557805` refactor that made lossiness and capture kind
2393/// first-class ([`ParsedCapture`], [`classify`]). What survives is precisely
2394/// the case no parser can detect: **a capture cut at an exact line boundary is
2395/// byte-identical to a healthy shorter run.** There is nothing in the bytes to
2396/// assert on. The writer that died between flushing turn N and turn N+1 also
2397/// died non-zero, so the exit code is the only remaining signal — and it lives
2398/// one layer up, in the wiring, which is where this defence had to go.
2399///
2400/// # Why the fix is narrow rather than a cascade reordering
2401///
2402/// [`evaluate_agent_result_inner`] consults Layer 2 only when Layer 1 abstains,
2403/// which is why a Layer 1 `Success` wins over a contradicting exit code today.
2404/// That ordering is correct in the ordinary case: Layer 1 is authoritative
2405/// precisely so it does not need Layer 2's slower `git rev-list` fallback.
2406/// Making Layer 2 run first would trade a rare wrong answer for a slow one on
2407/// every stage. So this arbitrates one verdict rather than reordering anything.
2408///
2409/// # Scope
2410///
2411/// Fires ONLY on `AgentStatus::Success`. `RateLimited`, `IdleTimeout`,
2412/// `ResourceKilled`, `AgentUnavailable`, `Failed` and `Unknown` all return
2413/// untouched, each with a named test. Two of those exclusions are load-bearing
2414/// rather than tidy: a `RateLimited` downgraded to `Failed` would route the run
2415/// to a human gate instead of the auto-resume cron it needs, and an
2416/// `IdleTimeout` downgraded to `Failed` would erase the distinction plan 31-02
2417/// exists to create — 999.64 reborn inside its own fix.
2418///
2419/// 31-02 audit convention (non-exhaustive equality site): the `!= Success`
2420/// guard below is correct as-is for every current and future variant. Anything
2421/// that is not an affirmative claimed success has nothing to arbitrate, so
2422/// passing it through unchanged is the right default for a variant added later.
2423///
2424/// # `verdict: None` is load-bearing — do not carry it over for symmetry
2425///
2426/// `classify_validate_outcome` (`devflow-cli/src/pipeline_outcomes.rs`) matches
2427/// `(_, Some(Verdict::Pass)) => ValidateOutcome::Passed` FIRST, with `_`
2428/// discarding the status entirely. A downgraded result has no verdict to offer
2429/// and must not invent one. [`idle_timeout_result`] dodges the same trap the
2430/// same way, and says so. That instruction is unchanged and still binding.
2431///
2432/// **Correction (34-01, D-15).** An earlier version of this note went further
2433/// and claimed a kept `verdict: Pass` on a `status: Failed` "would still
2434/// classify Validate as **Passed**", making this function a no-op at Validate.
2435/// That overstated the reachability. `outcome_policy::decide_action` intercepts
2436/// every non-`Success` status and routes it to a gate BEFORE
2437/// `classify_validate_outcome` is ever reached, so THIS path is protected and
2438/// this function is not a no-op. The `verdict: None` above is defence in depth,
2439/// which is why it stays.
2440///
2441/// The route into the inversion that IS reachable is
2442/// [`reconcile_layer0_verdict`]'s graft — it produced `status: Success` with a
2443/// self-reported failure's verdict attached, so `decide_action` had nothing to
2444/// intercept. See that function's own doc comment for the full record. It is
2445/// closed in plan 34-01; the classifier's own structural fix (gating the
2446/// `Passed` arm on the derived status) lands in plan 34-03.
2447///
2448/// **999.74 / DEN-95** is therefore being CLOSED in Phase 34 rather than
2449/// deliberately deferred. The caution that motivated the earlier deferral still
2450/// applies to the classifier half and is discharged there, not here: changing
2451/// that match arm re-routes `Failed`, `Unknown` and `ResourceKilled`, so 34-03
2452/// audits all of them explicitly.
2453///
2454/// # Exit-code fidelity
2455///
2456/// 137 → `ResourceKilled` and 127 → `AgentUnavailable` are preserved rather
2457/// than collapsed into `Failed`, mirroring [`evaluate_layer2`] exactly:
2458/// `outcome_policy::decide_action` routes those two to `GateInfra` rather than
2459/// `GateReview`, and the same exit code must not reach two different operator
2460/// gates depending on whether a stale Layer 1 success happened to be present.
2461///
2462/// Note the `ResourceKilled` arm is currently **unreachable via the
2463/// `MonitorLaunch::PipeOwning` path**: `run_pipe_owning_monitor` records
2464/// `status.code().unwrap_or(-1)`, so a SIGKILLed child writes `-1`, not `137`.
2465/// Recorded rather than silently relabelling a real OOM as `Failed` — the arm
2466/// is still reachable from the `Legacy` arm's `sh` monitor, whose `$?` does
2467/// carry `128 + signal`.
2468///
2469/// Unreadable or unparseable exit-file content is tolerated exactly as
2470/// [`evaluate_layer2`] tolerates it — a missing file returns the result
2471/// unchanged (an absent file is not evidence of failure), and garbage parses to
2472/// `-1`. Neither is invented behaviour; both match the sibling reader.
2473fn reconcile_stream_success_against_exit_code(
2474    project_root: &Path,
2475    phase: PhaseId,
2476    result: AgentResult,
2477) -> AgentResult {
2478    if result.status != AgentStatus::Success {
2479        return result;
2480    }
2481
2482    let Ok(raw) = std::fs::read_to_string(exit_code_path(project_root, phase)) else {
2483        return result;
2484    };
2485    let exit_code: i32 = raw.trim().parse().unwrap_or(-1);
2486    if exit_code == 0 {
2487        return result;
2488    }
2489
2490    let (status, lead) = if exit_code == 137 {
2491        (
2492            AgentStatus::ResourceKilled,
2493            format!(
2494                "the agent's output stream reported SUCCESS but the process was killed \
2495                 (exit code {exit_code}, likely OOM)"
2496            ),
2497        )
2498    } else if exit_code == 127 {
2499        (
2500            AgentStatus::AgentUnavailable,
2501            format!(
2502                "the agent's output stream reported SUCCESS but the agent command was \
2503                 unavailable (exit code {exit_code}, command not found)"
2504            ),
2505        )
2506    } else {
2507        (
2508            AgentStatus::Failed,
2509            format!(
2510                "the agent's output stream reported SUCCESS but the agent exited with \
2511                 code {exit_code}"
2512            ),
2513        )
2514    };
2515
2516    AgentResult {
2517        status,
2518        exit_code: Some(exit_code),
2519        reason: Some(format!(
2520            "{lead}. A capture cut at an exact line boundary is byte-identical to a healthy \
2521             shorter run, so no parser assertion can tell the two apart — the exit code is the \
2522             only remaining signal, and it contradicts the claim. Review the phase branch before \
2523             deciding what to keep; nothing was rolled back."
2524        )),
2525        verdict: None,
2526        ..result
2527    }
2528}
2529
2530/// Full four-layer evaluation: returns the best available AgentResult.
2531pub fn evaluate_agent_result(
2532    project_root: &Path,
2533    state: &State,
2534    git_flow: &GitFlowConfig,
2535) -> Result<AgentResult, ResultError> {
2536    let approval = crate::verify::external_verification_approval();
2537    evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
2538}
2539
2540fn evaluate_agent_result_inner(
2541    project_root: &Path,
2542    state: &State,
2543    git_flow: &GitFlowConfig,
2544    approved_commands: Option<&[String]>,
2545) -> Result<AgentResult, ResultError> {
2546    // Layer 0: operator-authored external post-condition (authoritative failure)
2547    if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
2548        return Ok(reconcile_layer0_verdict(project_root, state, result));
2549    }
2550
2551    // Layer 1: DEVFLOW_RESULT marker (authoritative)
2552    //
2553    // Authoritative, but not unconditionally: a CLAIMED success is arbitrated
2554    // against the recorded exit code before it is returned (31-04, T-31-15).
2555    // The cascade below is deliberately NOT reordered — see
2556    // `reconcile_stream_success_against_exit_code` for why Layer 2 running
2557    // first would be the wrong trade.
2558    if let Some(result) = evaluate_layer1(project_root, state.phase) {
2559        return Ok(reconcile_stream_success_against_exit_code(
2560            project_root,
2561            state.phase,
2562            result,
2563        ));
2564    }
2565
2566    // Layer 2: Exit code + commit gate
2567    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
2568        return Ok(result);
2569    }
2570
2571    // Layer 3: Process existence + commits
2572    evaluate_layer3(project_root, state.phase, git_flow)
2573}
2574
2575/// Path to the .devflow directory for a project root.
2576fn devflow_dir(project_root: &Path) -> PathBuf {
2577    project_root.join(".devflow")
2578}
2579
2580/// Path to the stdout file for a given phase.
2581pub fn stdout_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2582    devflow_dir(project_root).join(format!("phase-{}-stdout", phase.padded()))
2583}
2584
2585/// Path where the agent's stderr is captured for a given phase.
2586/// Lives alongside `stdout_path` under `.devflow/`.
2587pub fn stderr_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2588    devflow_dir(project_root).join(format!(
2589        "phase-{padded}-stderr.log",
2590        padded = phase.padded()
2591    ))
2592}
2593
2594/// Path to the exit code file for a given phase.
2595pub fn exit_code_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2596    devflow_dir(project_root).join(format!("phase-{}-exit", phase.padded()))
2597}
2598
2599/// Path to the file where the monitor records the launched agent's PID.
2600pub fn agent_pid_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2601    devflow_dir(project_root).join(format!("phase-{}-agent-pid", phase.padded()))
2602}
2603
2604/// Path to the file holding the stage prompt handed to the pipe-owning
2605/// monitor (Phase 31).
2606///
2607/// The prompt travels `spawn_monitor` → detached monitor process as a FILE,
2608/// never as argv: DevFlow stage prompts are large and argv has a hard length
2609/// ceiling, so a prompt passed positionally would fail on exactly the
2610/// context-heavy stages that matter most.
2611pub fn prompt_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2612    devflow_dir(project_root).join(format!("phase-{}-prompt", phase.padded()))
2613}
2614
2615/// Path to the pipe-owning monitor's own log for a phase (Phase 31).
2616///
2617/// The monitor is a detached process whose stdio is not the operator's
2618/// terminal — anything it prints to its own stdout goes nowhere. Every "log
2619/// loudly" obligation in this phase (the D-04 idle-timeout clamp, the D-11
2620/// opt-out notice) writes here instead, so a loud message is actually
2621/// readable after the fact.
2622pub fn monitor_log_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2623    devflow_dir(project_root).join(format!("phase-{}-monitor.log", phase.padded()))
2624}
2625
2626/// Path to the pipe-owning monitor's idle-timeout verdict for a phase
2627/// (D-05/D-06, 31-02).
2628///
2629/// A SIDE CHANNEL, deliberately separate from the stdout capture: the capture
2630/// is the agent's own narration, and a verdict appended to it is shadowed by
2631/// any earlier genuine `result` event the stream already contained. See
2632/// [`parse_idle_timeout_side_channel`] — that separation is a correctness
2633/// requirement (T-31-06), not a filing convention.
2634///
2635/// Holds a JSON [`IdleTimeoutRecord`]. Written and fsynced by the monitor
2636/// BEFORE the child is signalled, so nothing can race the verdict.
2637pub fn idle_timeout_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2638    devflow_dir(project_root).join(format!("phase-{}-idle-timeout", phase.padded()))
2639}
2640
2641/// Path to the archived-capture-history directory for a phase (16b).
2642///
2643/// `.devflow/history/phase-NN/` holds retained per-stage capture generations
2644/// so a false-positive self-report can be diagnosed after the fact. Exposed
2645/// as a constructor (rather than inlined at each call site) so downstream
2646/// tooling (16h in 16-07's correlation, 16i in 16-05's enumeration) always
2647/// derives the path from here instead of hardcoding it.
2648pub fn history_dir(project_root: &Path, phase: PhaseId) -> PathBuf {
2649    devflow_dir(project_root)
2650        .join("history")
2651        .join(format!("phase-{}", phase.padded()))
2652}
2653
2654/// Monotonically increasing tie-breaker appended to the nanosecond timestamp
2655/// used to stamp archived generations, so two archives issued within the
2656/// same nanosecond (possible in a tight test loop) never collide.
2657static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2658
2659/// A stamp unique within this process, used to name an archived generation.
2660/// The outgoing stage's name is not available at the `archive_phase_files`
2661/// call site (see `launch_stage` in main.rs), so a monotonic timestamp is
2662/// used instead — sufficient to order and identify generations.
2663fn archive_stamp() -> String {
2664    let nanos = std::time::SystemTime::now()
2665        .duration_since(std::time::UNIX_EPOCH)
2666        .map(|d| d.as_nanos())
2667        .unwrap_or(0);
2668    let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2669    format!("{nanos}-{seq}")
2670}
2671
2672/// Archive the prior stage's stdout/exit captures into bounded per-phase
2673/// history instead of wiping them outright, so a false-positive self-report
2674/// can be diagnosed after the fact (16b). Replaces the old
2675/// `cleanup_phase_files`, which deleted these files unconditionally.
2676///
2677/// At most `retain` capture generations are kept per phase; older ones are
2678/// pruned (see [`prune_history`]). The agent-pid file is still removed
2679/// outright — it is process bookkeeping, not diagnostic output. When there
2680/// is nothing to archive (first launch), this is a no-op success.
2681pub fn archive_phase_files(
2682    project_root: &Path,
2683    evidence_root: &Path,
2684    phase: PhaseId,
2685    retain: usize,
2686) -> Result<Option<String>, std::io::Error> {
2687    archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
2688}
2689
2690fn archive_phase_files_with_stamp(
2691    project_root: &Path,
2692    evidence_root: &Path,
2693    phase: PhaseId,
2694    retain: usize,
2695    stamp: &str,
2696) -> Result<Option<String>, std::io::Error> {
2697    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
2698    // The idle-timeout record has the SAME lifetime as the pid file above: it
2699    // describes one stage attempt and must not outlive it. Clearing it here was
2700    // simply missed when the side channel was introduced (31-02), and the
2701    // omission is not benign — [`parse_idle_timeout_side_channel`] is
2702    // `evaluate_layer1`'s FIRST statement and returns unconditionally
2703    // (T-31-06), by design, so that nothing can shadow a real timeout. A record
2704    // that survives its attempt therefore outranks every later stage's real
2705    // result, forever, for that phase.
2706    //
2707    // Observed 2026-08-08: a record written at 22:48 by a killed Plan stage
2708    // condemned a Define stage that had succeeded 15s earlier, in a 22-second
2709    // stage, with a message quoting a 120s silence and a pid dead for 14
2710    // minutes. It survived both `gate reject --note abort` and
2711    // `devflow start --force`, because nothing anywhere unlinked it.
2712    //
2713    // Deleting rather than archiving loses nothing: the verdict is already
2714    // durable in `advance_evaluated`'s `reason` in `events.jsonl` and in the
2715    // gate context that quotes it.
2716    //
2717    // This must stay ABOVE the "nothing to archive" early return below — the
2718    // case with a stale record and no capture beside it is exactly the one that
2719    // needs clearing.
2720    let _ = std::fs::remove_file(idle_timeout_path(project_root, phase));
2721
2722    let stdout_src = stdout_path(project_root, phase);
2723    let exit_src = exit_code_path(project_root, phase);
2724    let stdout_exists = stdout_src.exists();
2725    let exit_exists = exit_src.exists();
2726    if !stdout_exists && !exit_exists {
2727        return Ok(None); // Nothing to archive — first launch.
2728    }
2729
2730    let history_dir = history_dir(project_root, phase);
2731    crate::workflow::ensure_devflow_dir(&history_dir)?;
2732
2733    let staging_dir = history_dir.join(format!(".pending-{stamp}"));
2734    std::fs::create_dir(&staging_dir)?;
2735    let stdout_stage = staging_dir.join("stdout");
2736    let exit_stage = staging_dir.join("exit");
2737    let review_stage = staging_dir.join("REVIEW.md");
2738    let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
2739    let exit_dest = history_dir.join(format!("{stamp}-exit"));
2740    let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
2741    let review_src = phase_review_path(evidence_root, phase);
2742
2743    let mut stdout_staged = false;
2744    let mut exit_staged = false;
2745    let mut stdout_published = false;
2746    let mut exit_published = false;
2747    let mut review_published = false;
2748
2749    let archive_result = (|| -> Result<(), std::io::Error> {
2750        if stdout_exists {
2751            std::fs::rename(&stdout_src, &stdout_stage)?;
2752            stdout_staged = true;
2753        }
2754        if exit_exists {
2755            std::fs::rename(&exit_src, &exit_stage)?;
2756            exit_staged = true;
2757        }
2758        if let Some(review) = &review_src {
2759            std::fs::copy(review, &review_stage)?;
2760        }
2761
2762        if stdout_exists {
2763            std::fs::rename(&stdout_stage, &stdout_dest)?;
2764            stdout_staged = false;
2765            stdout_published = true;
2766        }
2767        if exit_exists {
2768            std::fs::rename(&exit_stage, &exit_dest)?;
2769            exit_staged = false;
2770            exit_published = true;
2771        }
2772        if review_src.is_some() {
2773            std::fs::rename(&review_stage, &review_dest)?;
2774            review_published = true;
2775        }
2776        Ok(())
2777    })();
2778
2779    if let Err(error) = archive_result {
2780        let mut rollback_error = None;
2781        let mut restore = |from: &Path, to: &Path| {
2782            if let Err(error) = std::fs::rename(from, to)
2783                && rollback_error.is_none()
2784            {
2785                rollback_error = Some(error);
2786            }
2787        };
2788        if stdout_published {
2789            restore(&stdout_dest, &stdout_src);
2790        } else if stdout_staged {
2791            restore(&stdout_stage, &stdout_src);
2792        }
2793        if exit_published {
2794            restore(&exit_dest, &exit_src);
2795        } else if exit_staged {
2796            restore(&exit_stage, &exit_src);
2797        }
2798        if review_published {
2799            let _ = std::fs::remove_file(&review_dest);
2800        }
2801        let _ = std::fs::remove_dir_all(&staging_dir);
2802
2803        if let Some(rollback_error) = rollback_error {
2804            return Err(std::io::Error::new(
2805                error.kind(),
2806                format!("{error}; archive rollback failed: {rollback_error}"),
2807            ));
2808        }
2809        return Err(error);
2810    }
2811
2812    let _ = std::fs::remove_dir(&staging_dir);
2813
2814    prune_history(&history_dir, retain);
2815    Ok(Some(stamp.to_string()))
2816}
2817
2818fn phase_review_path(evidence_root: &Path, phase: PhaseId) -> Option<PathBuf> {
2819    let phases = std::fs::read_dir(evidence_root.join(".planning/phases")).ok()?;
2820    let prefix = format!("{padded}-", padded = phase.padded());
2821    for entry in phases.flatten() {
2822        if entry
2823            .file_name()
2824            .to_str()
2825            .is_some_and(|name| name.starts_with(&prefix))
2826        {
2827            let review = entry
2828                .path()
2829                .join(format!("{padded}-REVIEW.md", padded = phase.padded()));
2830            if review.exists() {
2831                return Some(review);
2832            }
2833        }
2834    }
2835    None
2836}
2837
2838/// Whether `/gsd-verify-work` has produced a `{phase:02}-VERIFICATION.md`
2839/// artifact for `phase` yet.
2840///
2841/// Per D-01 (33-CONTEXT.md), this is the sole mid-arc-vs-genuine-gaps signal
2842/// a Validate→Code loop-back consults: a phase with no verification artifact
2843/// is still mid-arc (its remaining plans have not been judged at all), so a
2844/// loop-back must re-run the phase in full rather than dispatch `--gaps-only`,
2845/// which matches zero plans and gates unresolvably. Mirrors
2846/// [`phase_review_path`]'s directory-prefix-scan idiom exactly, but returns a
2847/// `bool` — no caller needs the artifact's path, only whether it exists. A
2848/// missing `.planning/phases` directory returns `false` rather than panicking.
2849///
2850/// `evidence_root` is the root the Validate agent actually wrote to — the
2851/// phase's worktree when `state.worktree_path` is set, else the project root.
2852/// `.planning/` is tracked, so in worktree mode the artifact lands on
2853/// `feature/phase-N` and is invisible from the main checkout for the phase's
2854/// entire in-flight duration. Passing the project root in worktree mode is
2855/// exactly the defect this parameter name exists to prevent (33-CONTEXT.md
2856/// CR-01); it is NOT interchangeable with the root used for git reads such as
2857/// [`phase_commit_count`], whose refs and object database are shared across
2858/// worktrees and which therefore correctly takes the project root.
2859pub fn phase_verification_exists(evidence_root: &Path, phase: PhaseId) -> bool {
2860    phase_verification_path(evidence_root, phase).is_some()
2861}
2862
2863/// The `{phase:02}-VERIFICATION.md` artifact's path under `evidence_root`, or
2864/// `None` when no phase directory carries one.
2865///
2866/// Extracted from [`phase_verification_exists`] (999.79, 35-05) so the
2867/// existence probe and the content fingerprint below scan for the artifact in
2868/// exactly ONE place. Duplicating the prefix scan would let the two answer
2869/// about different files after any future change to the directory layout — and
2870/// the freshness rule is only sound while "does it exist" and "what are its
2871/// bytes" are questions about the same path.
2872///
2873/// `evidence_root` carries the same meaning and the same prohibition as it does
2874/// for [`phase_verification_exists`]: it is the root the Validate agent
2875/// actually wrote to, never the main checkout in worktree mode.
2876fn phase_verification_path(evidence_root: &Path, phase: PhaseId) -> Option<PathBuf> {
2877    let phases = std::fs::read_dir(evidence_root.join(".planning/phases")).ok()?;
2878    let prefix = format!("{padded}-", padded = phase.padded());
2879    for entry in phases.flatten() {
2880        if entry
2881            .file_name()
2882            .to_str()
2883            .is_some_and(|name| name.starts_with(&prefix))
2884        {
2885            let verification = entry
2886                .path()
2887                .join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
2888            if verification.exists() {
2889                return Some(verification);
2890            }
2891        }
2892    }
2893    None
2894}
2895
2896/// A content fingerprint of the phase's `{phase:02}-VERIFICATION.md`, or `None`
2897/// when no such artifact exists under `evidence_root` (999.79, 35-05).
2898///
2899/// **What it is for.** Nothing deletes, dates or invalidates the artifact, and
2900/// `devflow start --phase N --force` checks out a branch that still carries the
2901/// PREVIOUS run's committed copy. That re-run is mid-arc by construction, so its
2902/// first Validate failure would find the stale artifact, read it as a verdict,
2903/// and dispatch a `--gaps-only` pass against zero matching plans — gating
2904/// unresolvably. Comparing this value against the one recorded at the start of
2905/// the run distinguishes "the Validate agent authored this during this run"
2906/// from "this was inherited".
2907///
2908/// **Why the algorithm is written out rather than borrowed from `std`.** This
2909/// value is persisted by one process (`devflow start`) and compared by a later
2910/// one (`devflow advance`), so it must mean the same thing in both.
2911/// `std::collections::hash_map::DefaultHasher` explicitly does NOT guarantee a
2912/// stable output across toolchain versions, so an operator who upgraded Rust
2913/// mid-phase would see every artifact read as "changed" — which is the
2914/// fail-OPEN direction, dispatching gaps-only exactly where a full execute was
2915/// correct. This is FNV-1a/64, fixed by these two constants and nothing else.
2916///
2917/// **No security property is claimed.** This is change detection over a
2918/// planning document that is already committed to the repository. It is not
2919/// collision-resistant and must never be used to authenticate anything; an
2920/// adversary who can write the artifact can already write whatever verdict they
2921/// like into it.
2922///
2923/// # Companion: [`phase_verification_mtime_nanos`]
2924///
2925/// Content alone cannot see an IDEMPOTENT rewrite (WR-06, 35-REVIEW): a
2926/// Validate agent that re-authors byte-identical content on a later cycle
2927/// produces the same fingerprint as an artifact nobody touched, and the
2928/// consumer then classifies its own agent's work as inherited. The mtime is
2929/// the second input that separates "unchanged because inherited" from
2930/// "unchanged because idempotent"; it is read from the same resolved path and
2931/// returns `None` on exactly the same "no artifact" condition, so the two are
2932/// always consistent about whether an artifact exists.
2933pub fn phase_verification_fingerprint(evidence_root: &Path, phase: PhaseId) -> Option<u64> {
2934    let path = phase_verification_path(evidence_root, phase)?;
2935    let bytes = std::fs::read(path).ok()?;
2936    // FNV-1a, 64-bit: offset basis and prime are the published constants.
2937    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2938    for byte in bytes {
2939        hash ^= u64::from(byte);
2940        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2941    }
2942    Some(hash)
2943}
2944
2945/// The mtime of the same `{phase:02}-VERIFICATION.md`
2946/// [`phase_verification_fingerprint`] hashes, in nanoseconds since the Unix
2947/// epoch, or `None` when no such artifact exists under `evidence_root`.
2948///
2949/// WR-06 (35-REVIEW): the second input the freshness rule needs. A content
2950/// fingerprint cannot see an IDEMPOTENT rewrite — a Validate agent that
2951/// re-authors byte-identical content on a later cycle produces the same hash as
2952/// an artifact nobody touched — so a hash-only rule classifies its own agent's
2953/// work as inherited and re-runs every plan in the phase from then on. An
2954/// inherited file's mtime does not advance during a run; a rewritten one's
2955/// does, whatever the bytes say.
2956///
2957/// Resolved through the same [`phase_verification_path`] and returning `None`
2958/// on the same "no artifact" condition, so the two readings can never disagree
2959/// about whether the artifact exists.
2960///
2961/// **This is not provenance either.** Any writer advances an mtime, so the
2962/// limitation the fingerprint's doc comment records — a mid-run branch switch
2963/// or an operator edit reading as authored-this-run — is not closed by this and
2964/// is marginally widened by it: a checkout restoring byte-identical content
2965/// used to read as inherited and now reads as authored. That is accepted
2966/// deliberately, because the case it fixes (a deterministic verification writer
2967/// on cycle 2 of an unresolved gap) is ordinary rather than exotic.
2968pub fn phase_verification_mtime_nanos(evidence_root: &Path, phase: PhaseId) -> Option<u64> {
2969    let path = phase_verification_path(evidence_root, phase)?;
2970    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
2971    let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
2972    // `as` would silently wrap past year 2554; a `None` here degrades to the
2973    // content-only comparison, which is the pre-WR-06 behaviour.
2974    u64::try_from(since_epoch.as_nanos()).ok()
2975}
2976
2977/// Keep only the newest `retain` capture generations under `history_dir`,
2978/// deleting older ones. Generations are grouped by their stamp (the shared
2979/// prefix of a `{stamp}-stdout`/`{stamp}-exit` pair, split off the trailing
2980/// `-stdout`/`-exit` suffix via `rsplit_once`) and ordered lexicographically,
2981/// which matches numeric/chronological order for the fixed-width nanosecond
2982/// stamps `archive_stamp` produces. Ordering parses both numeric components;
2983/// the process-local sequence is intentionally not fixed-width.
2984fn prune_history(history_dir: &Path, retain: usize) {
2985    let Ok(entries) = std::fs::read_dir(history_dir) else {
2986        return;
2987    };
2988
2989    let mut stamps: Vec<String> = entries
2990        .flatten()
2991        .filter_map(|entry| {
2992            let name = entry.file_name().to_str()?.to_string();
2993            name.rsplit_once('-')
2994                .map(|(stamp, _suffix)| stamp.to_string())
2995        })
2996        .collect();
2997    stamps.sort_by_key(|stamp| {
2998        let mut parts = stamp.split('-');
2999        let nanos = parts
3000            .next()
3001            .and_then(|part| part.parse::<u128>().ok())
3002            .unwrap_or(0);
3003        let sequence = parts
3004            .next()
3005            .and_then(|part| part.parse::<u64>().ok())
3006            .unwrap_or(0);
3007        (nanos, sequence)
3008    });
3009    stamps.dedup();
3010
3011    if stamps.len() <= retain {
3012        return;
3013    }
3014
3015    let to_remove = stamps.len() - retain;
3016    for stamp in &stamps[..to_remove] {
3017        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
3018        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
3019        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
3020    }
3021}
3022
3023#[cfg(test)]
3024mod tests {
3025    use super::*;
3026    use crate::config::GitFlowConfig;
3027    use crate::mode::Mode;
3028    use crate::stage::Stage;
3029    use crate::state::{AgentKind, State};
3030
3031    fn state_in(root: &Path, phase: PhaseId) -> State {
3032        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
3033        state.stage = Stage::Code;
3034        state
3035    }
3036
3037    fn git(root: &Path, args: &[&str]) {
3038        let output = crate::test_support::git_command(root)
3039            .args(args)
3040            .output()
3041            .unwrap();
3042        assert!(
3043            output.status.success(),
3044            "git {:?} failed\nstdout: {}\nstderr: {}",
3045            args,
3046            String::from_utf8_lossy(&output.stdout),
3047            String::from_utf8_lossy(&output.stderr)
3048        );
3049    }
3050
3051    fn init_repo_with_feature_commit(root: &Path, phase: PhaseId) {
3052        git(root, &["init"]);
3053        git(root, &["config", "user.email", "devflow@example.com"]);
3054        git(root, &["config", "user.name", "DevFlow Tests"]);
3055        git(root, &["config", "commit.gpgsign", "false"]);
3056        git(root, &["config", "tag.gpgsign", "false"]);
3057        git(root, &["config", "core.hooksPath", "/dev/null"]);
3058        git(root, &["checkout", "-b", "develop"]);
3059        std::fs::write(root.join("README.md"), "base\n").unwrap();
3060        git(root, &["add", "README.md"]);
3061        git(root, &["commit", "-m", "base"]);
3062
3063        let branch = format!("feature/phase-{padded}", padded = phase.padded());
3064        git(root, &["checkout", "-b", &branch]);
3065        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
3066        git(root, &["add", "phase.txt"]);
3067        git(root, &["commit", "-m", "feature work"]);
3068    }
3069
3070    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
3071    /// develop's tip with **no** extra commit (0 commits ahead).
3072    fn init_repo_with_feature_no_commit(root: &Path, phase: PhaseId) {
3073        git(root, &["init"]);
3074        git(root, &["config", "user.email", "devflow@example.com"]);
3075        git(root, &["config", "user.name", "DevFlow Tests"]);
3076        git(root, &["config", "commit.gpgsign", "false"]);
3077        git(root, &["config", "tag.gpgsign", "false"]);
3078        git(root, &["config", "core.hooksPath", "/dev/null"]);
3079        git(root, &["checkout", "-b", "develop"]);
3080        std::fs::write(root.join("README.md"), "base\n").unwrap();
3081        git(root, &["add", "README.md"]);
3082        git(root, &["commit", "-m", "base"]);
3083
3084        let branch = format!("feature/phase-{padded}", padded = phase.padded());
3085        git(root, &["checkout", "-b", &branch]);
3086    }
3087
3088    #[test]
3089    fn parse_success_marker() {
3090        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
3091        let result = parse_devflow_result(stdout).unwrap();
3092        assert_eq!(result.status, AgentStatus::Success);
3093    }
3094
3095    #[test]
3096    fn parse_failed_marker_with_reason() {
3097        let stdout =
3098            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
3099        let result = parse_devflow_result(stdout).unwrap();
3100        assert_eq!(result.status, AgentStatus::Failed);
3101        assert_eq!(result.reason.unwrap(), "clippy errors");
3102    }
3103
3104    #[test]
3105    fn parse_missing_marker_returns_none() {
3106        let stdout = "just some output\nno marker here\n";
3107        assert!(parse_devflow_result(stdout).is_none());
3108    }
3109
3110    #[test]
3111    fn parse_malformed_json_returns_none() {
3112        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
3113        assert!(parse_devflow_result(stdout).is_none());
3114    }
3115
3116    #[test]
3117    fn parse_lowercase_marker() {
3118        let stdout = "devflow_result: {\"status\":\"success\"}\n";
3119        let result = parse_devflow_result(stdout).unwrap();
3120        assert_eq!(result.status, AgentStatus::Success);
3121    }
3122
3123    #[test]
3124    fn parse_marker_without_space_after_colon() {
3125        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
3126        let result = parse_devflow_result(stdout).unwrap();
3127        assert_eq!(result.status, AgentStatus::Success);
3128    }
3129
3130    #[test]
3131    fn parse_lowercase_no_space_marker() {
3132        // Lowercase prefix AND no space after the colon — the combination that
3133        // the Phase 6 review flagged as uncovered.
3134        let stdout = "devflow_result:{\"status\":\"success\"}\n";
3135        let result = parse_devflow_result(stdout).unwrap();
3136        assert_eq!(result.status, AgentStatus::Success);
3137    }
3138
3139    #[test]
3140    fn parse_finds_last_marker_in_tail() {
3141        // Multiple markers — should find the last one.
3142        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
3143        let result = parse_devflow_result(stdout).unwrap();
3144        assert_eq!(result.status, AgentStatus::Success);
3145    }
3146
3147    #[test]
3148    fn parse_marker_lines_returns_last_marker_in_long_output() {
3149        let stdout = format!(
3150            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
3151             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
3152            "prefix".repeat(900),
3153            "tail output".repeat(100)
3154        );
3155
3156        let result = parse_marker_lines(&stdout).unwrap();
3157
3158        assert_eq!(result.status, AgentStatus::Success);
3159    }
3160
3161    #[test]
3162    fn parse_marker_only_in_last_4000_chars() {
3163        // Marker beyond 4000 chars from end should not be found.
3164        let prefix = "a".repeat(5000);
3165        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
3166        assert!(parse_devflow_result(&stdout).is_none());
3167    }
3168
3169    #[test]
3170    fn parse_marker_with_commits_and_summary() {
3171        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
3172        let result = parse_devflow_result(stdout).unwrap();
3173        assert_eq!(result.status, AgentStatus::Success);
3174        assert_eq!(result.commits, Some(3));
3175        assert_eq!(result.summary.unwrap(), "added tests");
3176    }
3177
3178    #[test]
3179    fn parse_marker_inside_json_result_envelope() {
3180        // Claude --output-format json wraps the final text in a `result` field
3181        // with embedded newlines escaped.
3182        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
3183        let result = parse_devflow_result(stdout).unwrap();
3184        assert_eq!(result.status, AgentStatus::Success);
3185        assert_eq!(result.commits, Some(2));
3186    }
3187
3188    #[test]
3189    fn parse_failed_marker_inside_json_envelope() {
3190        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
3191        let result = parse_devflow_result(stdout).unwrap();
3192        assert_eq!(result.status, AgentStatus::Failed);
3193        assert_eq!(result.reason.unwrap(), "tests failed");
3194    }
3195
3196    #[test]
3197    fn parse_json_envelope_without_marker_returns_none() {
3198        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
3199        assert!(parse_devflow_result(stdout).is_none());
3200    }
3201
3202    #[test]
3203    fn detect_claude_json_rate_limit_by_subtype() {
3204        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
3205        assert_eq!(
3206            detect_rate_limit(stdout).as_deref(),
3207            Some("2026-06-18T15:45:30Z")
3208        );
3209    }
3210
3211    #[test]
3212    fn detect_claude_json_rate_limit_by_429() {
3213        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
3214        assert_eq!(
3215            detect_rate_limit(stdout).as_deref(),
3216            Some("Too many requests. Try later.")
3217        );
3218    }
3219
3220    #[test]
3221    fn detect_codex_try_again_rate_limit() {
3222        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
3223        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
3224    }
3225
3226    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
3227    /// `json_find_key` run on the coding agent's raw stdout via
3228    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
3229    /// goes through. Deeply nested JSON — accidental or adversarial — must
3230    /// not stack-overflow the process, and a real marker at any depth
3231    /// serde_json will parse (its default recursion limit is exactly 128)
3232    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
3233    /// silently misclassified rate-limit markers at depths 64–128.
3234    #[test]
3235    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
3236        // 100 levels: parseable by serde_json (limit 128), deeper than the
3237        // removed 64-level traversal cap that used to hide the marker.
3238        const DEPTH: usize = 100;
3239        let mut stdout = String::new();
3240        for _ in 0..DEPTH {
3241            stdout.push_str(r#"{"nested":"#);
3242        }
3243        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
3244        for _ in 0..DEPTH {
3245            stdout.push('}');
3246        }
3247
3248        // Must return promptly without crashing AND find the buried marker —
3249        // the iterative worklist traversal has no silent-miss window.
3250        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
3251    }
3252
3253    #[test]
3254    fn detect_rate_limit_ignores_normal_stdout() {
3255        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
3256        assert!(detect_rate_limit(stdout).is_none());
3257    }
3258
3259    #[test]
3260    fn claude_envelope_is_error_detected() {
3261        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
3262        let result = detect_claude_envelope_failure(stdout).unwrap();
3263        assert_eq!(result.status, AgentStatus::Failed);
3264    }
3265
3266    #[test]
3267    fn claude_is_error_overrides_success_marker() {
3268        let dir = tempfile::tempdir().unwrap();
3269        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3270        std::fs::write(
3271            stdout_path(dir.path(), PhaseId::new(9)),
3272            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
3273        )
3274        .unwrap();
3275
3276        let result = evaluate_layer1(dir.path(), PhaseId::new(9)).unwrap();
3277
3278        assert_eq!(result.status, AgentStatus::Failed);
3279    }
3280
3281    #[test]
3282    fn claude_envelope_is_error_false_defers() {
3283        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
3284        assert!(detect_claude_envelope_failure(stdout).is_none());
3285    }
3286
3287    #[test]
3288    fn claude_envelope_marker_still_wins() {
3289        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
3290        assert!(detect_claude_envelope_failure(stdout).is_none());
3291        let result = parse_devflow_result(stdout).unwrap();
3292        assert_eq!(result.status, AgentStatus::Success);
3293        assert_eq!(result.commits, Some(2));
3294    }
3295
3296    #[test]
3297    fn session_id_reads_top_level_string() {
3298        let stdout = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
3299        assert_eq!(
3300            claude_session_id(stdout).as_deref(),
3301            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
3302        );
3303    }
3304
3305    /// T-28-04 forgery guard: the embedded `DEVFLOW_RESULT` marker carries a
3306    /// DIFFERENT session id than the envelope's own top-level key. The
3307    /// top-level id must win — an agent must not be able to redirect which
3308    /// session DevFlow resumes into by planting its own `session_id` inside
3309    /// its self-authored marker JSON.
3310    #[test]
3311    fn session_id_in_devflow_result_marker_is_not_returned() {
3312        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"}"#;
3313        assert_eq!(
3314            claude_session_id(stdout).as_deref(),
3315            Some("real-top-level-id")
3316        );
3317    }
3318
3319    #[test]
3320    fn session_id_plain_text_stdout_returns_none() {
3321        let stdout = "just some plain text output, not JSON\n";
3322        assert!(claude_session_id(stdout).is_none());
3323    }
3324
3325    #[test]
3326    fn session_id_missing_key_returns_none() {
3327        let stdout = r#"{"type":"result","result":"done, no session key"}"#;
3328        assert!(claude_session_id(stdout).is_none());
3329    }
3330
3331    #[test]
3332    fn session_id_non_string_type_returns_none_not_panic() {
3333        let stdout = r#"{"type":"result","result":"done","session_id":12345}"#;
3334        assert!(claude_session_id(stdout).is_none());
3335    }
3336
3337    #[test]
3338    fn session_id_from_capture_missing_file_returns_none() {
3339        let dir = tempfile::tempdir().unwrap();
3340        assert!(session_id_from_capture(dir.path(), PhaseId::new(42)).is_none());
3341    }
3342
3343    #[test]
3344    fn session_id_from_capture_lossy_reads_invalid_utf8() {
3345        let dir = tempfile::tempdir().unwrap();
3346        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3347        let mut bytes = br#"{"type":"result","result":"done "#.to_vec();
3348        bytes.push(0xFF); // invalid UTF-8 byte
3349        bytes.extend_from_slice(br#"","session_id":"lossy-ok"}"#);
3350        std::fs::write(stdout_path(dir.path(), PhaseId::new(5)), bytes).unwrap();
3351
3352        assert_eq!(
3353            session_id_from_capture(dir.path(), PhaseId::new(5)).as_deref(),
3354            Some("lossy-ok")
3355        );
3356    }
3357
3358    /// Positive fixture built from RESEARCH's *predicted* `**Gate:**`
3359    /// rendering (a bare, un-spanned value). Kept as a tolerated shape, but
3360    /// note this is NOT what a real run emits — see
3361    /// `blocking_human_checkpoint_reported_matches_live_observed_rendering`
3362    /// for the rendering actually captured on 2026-07-31, which this
3363    /// prediction missed.
3364    #[test]
3365    fn blocking_human_checkpoint_reported_detects_human_gate_line() {
3366        let stdout = format!(
3367            "## 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"
3368        );
3369        assert!(blocking_human_checkpoint_reported(&stdout));
3370    }
3371
3372    /// The Phase 26 near-miss distinction: a plain `blocking` gate must NOT
3373    /// be classified as a human-blocking checkpoint. `PLAIN_GATE_VALUE` is
3374    /// local to this test (not a module-level const) — it has no production
3375    /// use, only this negative fixture's.
3376    #[test]
3377    fn blocking_human_checkpoint_reported_false_for_plain_blocking() {
3378        const PLAIN_GATE_VALUE: &str = "blocking";
3379        let stdout = format!(
3380            "## 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"
3381        );
3382        assert!(!blocking_human_checkpoint_reported(&stdout));
3383    }
3384
3385    #[test]
3386    fn blocking_human_checkpoint_reported_false_when_no_gate_field() {
3387        let stdout = "some ordinary agent failure output, no checkpoint at all\n";
3388        assert!(!blocking_human_checkpoint_reported(stdout));
3389    }
3390
3391    /// The `Gate:` line arrives inside an escaped Claude JSON result
3392    /// envelope's `result` field — must be found via the unescaped inner
3393    /// text, not the raw (escaped) JSON string.
3394    #[test]
3395    fn blocking_human_checkpoint_reported_true_inside_escaped_envelope() {
3396        let inner = format!(
3397            "## CHECKPOINT REACHED\\n\\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\\n"
3398        );
3399        let stdout = format!(
3400            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"abc"}}"#
3401        );
3402        assert!(blocking_human_checkpoint_reported(&stdout));
3403    }
3404
3405    #[test]
3406    fn blocking_human_checkpoint_reported_tolerates_whitespace_and_emphasis() {
3407        let stdout = format!("  **Gate:**   {HUMAN_GATE_VALUE}   \n");
3408        assert!(blocking_human_checkpoint_reported(&stdout));
3409    }
3410
3411    /// REGRESSION — the rendering a real headless run actually produces.
3412    ///
3413    /// Transcribed verbatim from `.devflow/phase-91-stdout` of the live A1
3414    /// run on 2026-07-31 (a genuine `gate="blocking-human"` task driven
3415    /// through DevFlow's own monitor). The value arrives as a markdown CODE
3416    /// SPAN, not the bare token RESEARCH.md predicted.
3417    ///
3418    /// Before the backtick was added to `text_reports_human_gate`'s trim set
3419    /// this returned `false`: the leading backtick survived the trim, so the
3420    /// value `take_while` terminated at once and yielded an empty token. A
3421    /// real checkpoint was therefore never recognized, and the run fell
3422    /// through to the generic gate. If this test ever goes red, DevFlow has
3423    /// stopped recognizing real checkpoints — do not "fix" it by relaxing
3424    /// the assertion.
3425    #[test]
3426    fn blocking_human_checkpoint_reported_matches_live_observed_rendering() {
3427        let stdout = format!(
3428            "---\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"
3429        );
3430        assert!(
3431            blocking_human_checkpoint_reported(&stdout),
3432            "the live-observed code-span rendering must be recognized; \
3433             a false negative here means real checkpoints fall through to \
3434             the generic gate (the 2026-07-31 A1 defect)"
3435        );
3436    }
3437
3438    /// The same live rendering as it actually crosses into DevFlow's capture:
3439    /// escaped inside the Claude JSON result envelope. This is the exact
3440    /// path `checkpoint_reported_in_capture` reads in production.
3441    #[test]
3442    fn blocking_human_checkpoint_reported_matches_live_rendering_in_envelope() {
3443        let inner = format!(
3444            "## Checkpoint: Decision\\n\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Progress:** 0/1 tasks complete\\n"
3445        );
3446        let stdout = format!(
3447            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"live-a1"}}"#
3448        );
3449        assert!(
3450            blocking_human_checkpoint_reported(&stdout),
3451            "the code-span rendering must also be found inside the escaped envelope"
3452        );
3453    }
3454
3455    /// The backtick tolerance must not erode the Phase 26 near-miss
3456    /// distinction: a code-spanned PLAIN `blocking` gate is still not a
3457    /// human-blocking checkpoint.
3458    #[test]
3459    fn blocking_human_checkpoint_reported_false_for_code_spanned_plain_blocking() {
3460        let stdout = "## Checkpoint: Decision\n\n**Gate:** `blocking`\n";
3461        assert!(!blocking_human_checkpoint_reported(stdout));
3462    }
3463
3464    #[test]
3465    fn checkpoint_reported_in_capture_missing_file_returns_false() {
3466        let dir = tempfile::tempdir().unwrap();
3467        assert!(!checkpoint_reported_in_capture(
3468            dir.path(),
3469            PhaseId::new(42)
3470        ));
3471    }
3472
3473    #[test]
3474    fn checkpoint_reported_in_capture_reads_true_from_file() {
3475        let dir = tempfile::tempdir().unwrap();
3476        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3477        std::fs::write(
3478            stdout_path(dir.path(), PhaseId::new(11)),
3479            format!("**Gate:** {HUMAN_GATE_VALUE}\n"),
3480        )
3481        .unwrap();
3482        assert!(checkpoint_reported_in_capture(dir.path(), PhaseId::new(11)));
3483    }
3484
3485    // ---- stream-capture gate scoping (plan 30-05) --------------------------
3486    //
3487    // Fixtures for this cluster live with the other v3 envelopes further down:
3488    // `V3_USER_EVENT`, `V3_ASSISTANT_TOP_LEVEL_EVENT`,
3489    // `V3_ASSISTANT_SUBAGENT_EVENT`, `gate_declaration_text` and
3490    // `gate_documenting_text`. Read their doc comments before adding a case —
3491    // they record which capture line each envelope came from and that every
3492    // gate payload is synthetic.
3493    //
3494    // Each negative asserts a NEGATIVE CONTROL first: `text_reports_human_gate`
3495    // must still match the raw capture. Without it a negative would also pass
3496    // against a fixture that simply contains no gate text, and would keep
3497    // passing if someone deleted the gate line from the fixture.
3498
3499    /// **REGRESSION — review constraint 3, the prompt-echo false positive.**
3500    ///
3501    /// Under a single-document envelope the only place gate text can appear is
3502    /// the one `result` field the agent authored, so scanning raw stdout is
3503    /// safe. A stream capture breaks that invariant: text DevFlow never
3504    /// authored is echoed back into the same stdout, and a substring scan
3505    /// cannot tell which event it is inside.
3506    ///
3507    /// A failure here means a checkpoint auto-decide can fire, or the resume
3508    /// ceiling be consumed, on a stage whose prompt merely DISCUSSED
3509    /// checkpoints — and DevFlow's own planning documents are exactly that kind
3510    /// of prompt content.
3511    #[test]
3512    fn blocking_human_checkpoint_reported_false_for_gate_text_in_user_event() {
3513        let capture = stream_capture_of(&[
3514            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3515            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3516        ]);
3517        assert!(
3518            text_reports_human_gate(&capture),
3519            "negative control: the raw capture must still contain matchable \
3520             gate text, or this test asserts nothing"
3521        );
3522        assert!(
3523            !blocking_human_checkpoint_reported(&capture),
3524            "gate text inside a `user` event is echoed input, not an agent \
3525             declaration (review constraint 3)"
3526        );
3527    }
3528
3529    /// **REGRESSION — T-30-22.** Gate text forwarded from a subagent is not the
3530    /// orchestrator declaring a gate. Mistaking subagent-forwarded content for
3531    /// orchestrator output is the error that invalidated the v1 experiment
3532    /// outright and got its whole capture discarded.
3533    ///
3534    /// Two independent guards reject this event — the type filter (it is an
3535    /// `assistant` event) and the provenance filter (its `parent_tool_use_id`
3536    /// is non-null). The case is kept even though either alone suffices: they
3537    /// are meant to fail independently, so a future widening of the type filter
3538    /// cannot silently inherit subagent content.
3539    #[test]
3540    fn blocking_human_checkpoint_reported_false_for_subagent_forwarded_gate_text() {
3541        let capture = stream_capture_of(&[
3542            &v3_message_event(V3_ASSISTANT_SUBAGENT_EVENT, &gate_documenting_text()),
3543            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3544        ]);
3545        assert!(
3546            text_reports_human_gate(&capture),
3547            "negative control: the raw capture must still contain matchable \
3548             gate text, or this test asserts nothing"
3549        );
3550        assert!(
3551            !blocking_human_checkpoint_reported(&capture),
3552            "a subagent's forwarded text cannot present as the orchestrator's \
3553             own gate declaration"
3554        );
3555    }
3556
3557    /// **REGRESSION — T-30-26, the surface cross-AI review found.**
3558    ///
3559    /// Narration is not declaration. The envelope is v3 line 6, whose captured
3560    /// payload is mid-turn narration appearing in NO `result` event of the
3561    /// capture — so admitting top-level `assistant` events would add a
3562    /// genuinely new trusted surface, not merely duplicate the result text.
3563    /// An agent narrating the checkpoint task it is about to work on would then
3564    /// trip the gate: the prompt-echo false positive, recreated one layer
3565    /// inward.
3566    ///
3567    /// Nothing observed is lost by excluding the class: turn-FINAL assistant
3568    /// text is duplicated verbatim into the `result` event that follows it
3569    /// (v3 lines 17→19, 36→37, 53→54).
3570    #[test]
3571    fn blocking_human_checkpoint_reported_false_for_top_level_assistant_narration() {
3572        let capture = stream_capture_of(&[
3573            &v3_message_event(V3_ASSISTANT_TOP_LEVEL_EVENT, &gate_documenting_text()),
3574            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3575        ]);
3576        assert!(
3577            text_reports_human_gate(&capture),
3578            "negative control: the raw capture must still contain matchable \
3579             gate text, or this test asserts nothing"
3580        );
3581        assert!(
3582            !blocking_human_checkpoint_reported(&capture),
3583            "intermediate assistant narration discussing a gate is not a live \
3584             gate declaration"
3585        );
3586    }
3587
3588    /// The positive that stops the scoping from degenerating into always-false
3589    /// — which would pass every negative above while silently dropping every
3590    /// real human authorization request (T-30-24).
3591    #[test]
3592    fn blocking_human_checkpoint_reported_true_for_top_level_result_declaration() {
3593        let capture = stream_capture_of(&[
3594            &v3_message_event(V3_USER_EVENT, "Execute the plan."),
3595            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3596        ]);
3597        assert!(
3598            blocking_human_checkpoint_reported(&capture),
3599            "a gate declared in a top-level `result` event's own result text \
3600             must still be detected under a stream capture"
3601        );
3602    }
3603
3604    /// **T-30-27.** Detection asks whether a gate fired ANYWHERE in the stage,
3605    /// so it deliberately does NOT inherit plan 30-01's last-result-wins
3606    /// verdict semantics. A gate declared in turn 1 followed by
3607    /// task-notification wake-up turns — the exact turn shape the v3 capture
3608    /// archives — must not be dropped in favour of the later, silent results.
3609    ///
3610    /// Losing a checkpoint report is the opposite-direction harm from the false
3611    /// positive this plan closes, and the worse of the two: it silently drops a
3612    /// request for human authorization to the generic gate.
3613    #[test]
3614    fn blocking_human_checkpoint_reported_true_when_only_first_result_declares_gate() {
3615        let capture = v3_stream_capture(&gate_declaration_text(), NO_MARKER, NO_MARKER);
3616        assert!(
3617            blocking_human_checkpoint_reported(&capture),
3618            "detection must scan every top-level `result` event, not only the \
3619             last one"
3620        );
3621    }
3622
3623    /// The overcorrection guard: an echo and a genuine declaration can coexist
3624    /// in one capture, and the scoping must resolve per event rather than
3625    /// suppressing any capture that contains an echo.
3626    #[test]
3627    fn blocking_human_checkpoint_reported_true_when_echo_co_occurs_with_declaration() {
3628        let capture = stream_capture_of(&[
3629            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3630            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3631        ]);
3632        assert!(
3633            blocking_human_checkpoint_reported(&capture),
3634            "an echoed prompt in the same capture must not suppress a genuine \
3635             declaration"
3636        );
3637    }
3638
3639    /// The same scoping, proven on the path production actually consumes —
3640    /// `checkpoint_reported_in_capture` reading `.devflow/phase-NN-stdout` from
3641    /// disk. Both directions are asserted in one test on purpose: the negative
3642    /// alone cannot distinguish correct scoping from a wrapper that stopped
3643    /// reading the file at all.
3644    #[test]
3645    fn checkpoint_reported_in_capture_scopes_stream_gate_text_to_result_events() {
3646        let dir = tempfile::tempdir().unwrap();
3647        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3648
3649        let echo_only = stream_capture_of(&[
3650            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3651            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3652        ]);
3653        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), &echo_only).unwrap();
3654        assert!(
3655            !checkpoint_reported_in_capture(dir.path(), PhaseId::new(30)),
3656            "an echoed gate mention read from the capture file must not report \
3657             a checkpoint"
3658        );
3659
3660        let declared = stream_capture_of(&[
3661            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3662            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3663        ]);
3664        std::fs::write(stdout_path(dir.path(), PhaseId::new(31)), &declared).unwrap();
3665        assert!(
3666            checkpoint_reported_in_capture(dir.path(), PhaseId::new(31)),
3667            "a genuine declaration read from the capture file must still \
3668             report a checkpoint"
3669        );
3670    }
3671
3672    /// **The fail-open regression.** A torn `system`/`init` line must not send
3673    /// gate scanning back to raw stdout.
3674    ///
3675    /// `claude_stream_events` silently drops any line that fails to parse, and
3676    /// recognition used to require a successfully parsed `init`. So one
3677    /// truncated first line — a partial write, or a read of a capture still
3678    /// being appended to — made the whole capture unrecognised, and
3679    /// `blocking_human_checkpoint_reported` fell back to scanning raw stdout,
3680    /// which under a stream capture contains the echoed prompt. The constraint-3
3681    /// scoping failed OPEN, into the exact false positive it exists to close.
3682    /// Found by cross-AI code review (gpt-5.6-sol, 2026-08-02, High finding 2).
3683    ///
3684    /// Envelopes are real (v3 `user` + `result`); the `init` line is a real one
3685    /// truncated mid-token, and the gate text payload is synthetic — no archived
3686    /// capture contains gate text or a prompt echo.
3687    #[test]
3688    fn blocking_human_checkpoint_reported_false_when_init_is_torn() {
3689        let torn_init = &V3_INIT_EVENT[..40];
3690        assert!(
3691            serde_json::from_str::<serde_json::Value>(torn_init).is_err(),
3692            "fixture precondition: the truncated init must actually fail to parse"
3693        );
3694
3695        let capture = format!(
3696            "{}\n{}\n{}\n",
3697            torn_init,
3698            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3699            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3700        );
3701        assert!(
3702            !blocking_human_checkpoint_reported(&capture),
3703            "a torn init must not re-enable the raw-stdout scan and let the \
3704             echoed prompt read as a gate declaration"
3705        );
3706
3707        // Same capture, init intact — proves the negative above is the torn-init
3708        // path being handled, not the fixture simply lacking gate text.
3709        let intact = stream_capture_of(&[
3710            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3711            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3712        ]);
3713        assert!(
3714            !blocking_human_checkpoint_reported(&intact),
3715            "control: the same capture with a valid init is also false"
3716        );
3717
3718        // And a real declaration is still detected with the init torn, so the
3719        // fix did not degenerate into always-false (T-30-24).
3720        let declared = format!(
3721            "{}\n{}\n{}\n",
3722            torn_init,
3723            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3724            v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3725        );
3726        assert!(
3727            blocking_human_checkpoint_reported(&declared),
3728            "a genuine declaration must still be detected when init is torn"
3729        );
3730    }
3731
3732    /// A stream with NO `init` at all is likewise scoped rather than raw-scanned.
3733    /// Same fail-open class as the torn-init case; reported by the same review.
3734    #[test]
3735    fn blocking_human_checkpoint_reported_false_when_init_is_absent() {
3736        let capture = format!(
3737            "{}\n{}\n",
3738            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3739            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3740        );
3741        assert!(
3742            !blocking_human_checkpoint_reported(&capture),
3743            "an init-less stream must still scope the gate scan to result events"
3744        );
3745    }
3746
3747    /// **The mandatory over-correction controls.** Widening stream recognition
3748    /// must not divert the three non-stream inputs off the raw-scan path they
3749    /// have always used (T-30-25). Each carries genuine gate text and must
3750    /// still report `true`; if any flips to `false`, the widening has started
3751    /// suppressing real gates.
3752    #[test]
3753    fn non_stream_captures_still_use_the_raw_scan_after_widening() {
3754        let plain = format!("Some narration.\n{}\n", gate_declaration_text());
3755        assert!(
3756            blocking_human_checkpoint_reported(&plain),
3757            "plain text must still be raw-scanned"
3758        );
3759
3760        let single_doc = v3_result_event(V3_RESULT_TURN1, &gate_declaration_text());
3761        assert!(
3762            blocking_human_checkpoint_reported(&single_doc),
3763            "a single-document envelope must still be raw-scanned — it is \
3764             `{{\"type\":\"result\"}}`, which claude_stream_gate_shape excludes"
3765        );
3766
3767        let codex = format!(
3768            "{{\"type\":\"thread.started\",\"thread_id\":\"t1\"}}\n\
3769             {{\"type\":\"item.completed\",\"item\":{{\"type\":\"agent_message\",\
3770             \"text\":\"{}\"}}}}\n",
3771            gate_declaration_text().replace('"', "\\\"")
3772        );
3773        assert!(
3774            blocking_human_checkpoint_reported(&codex),
3775            "a Codex stream must still be raw-scanned — its top-level types are \
3776             dotted, so claude_stream_gate_shape excludes it"
3777        );
3778    }
3779
3780    /// **Fourth-pass High.** Decoding must never JOIN tokens across corrupt
3781    /// bytes. The third pass's remediation dropped invalid bytes, and
3782    /// `DEVFLOW_RESULT: {"status":"suc<FF>cess"}` with exit 1 decoded to a
3783    /// fabricated, VALID success marker — Layer 1 then short-circuited the
3784    /// nonzero exit. Replacement (U+FFFD) keeps the corruption visible: the
3785    /// status reads `suc\u{FFFD}cess`, no parser trusts it, and the exit code
3786    /// decides. Edge corruption stays covered by [`strip_corruption_padding`]
3787    /// — see the sibling third-pass test, which must pass alongside this one.
3788    #[test]
3789    fn corrupt_byte_inside_a_marker_is_never_repaired_into_success() {
3790        let dir = tempfile::tempdir().unwrap();
3791        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3792
3793        let mut poisoned = b"DEVFLOW_RESULT: {\"status\":\"suc".to_vec();
3794        poisoned.push(0xff);
3795        poisoned.extend_from_slice(b"cess\"}");
3796        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), &poisoned).unwrap();
3797        assert_ne!(
3798            evaluate_layer1(dir.path(), PhaseId::new(30)).map(|r| r.status),
3799            Some(AgentStatus::Success),
3800            "a corrupt capture with no valid success marker must not be \
3801             repaired into an authoritative one"
3802        );
3803
3804        // Control: the same marker with the byte absent IS a real success.
3805        std::fs::write(
3806            stdout_path(dir.path(), PhaseId::new(31)),
3807            br#"DEVFLOW_RESULT: {"status":"success"}"#,
3808        )
3809        .unwrap();
3810        assert_eq!(
3811            evaluate_layer1(dir.path(), PhaseId::new(31)).map(|r| r.status),
3812            Some(AgentStatus::Success),
3813            "control: the intact marker must still parse as success"
3814        );
3815    }
3816
3817    /// **Third-pass High.** A stray invalid byte outside the JSON envelope must
3818    /// not convert an authoritative failure into a Layer-2 success.
3819    ///
3820    /// `from_utf8_lossy` substitutes U+FFFD, which survives `trim()`, so
3821    /// `detect_claude_envelope_failure`'s `starts_with('{')` guard went false and
3822    /// Layer 1 abstained on `is_error: true`. The cascade then fell through to
3823    /// the exit-code check — Ship proceeding on a reported failure. Reachable on
3824    /// the shipped `--output-format json` envelope; nothing to do with
3825    /// stream-json.
3826    #[test]
3827    fn stray_invalid_byte_does_not_hide_an_envelope_failure() {
3828        let envelope = br#"{"type":"result","subtype":"error","is_error":true,"result":"boom","session_id":"s"}"#;
3829        let dir = tempfile::tempdir().unwrap();
3830        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3831
3832        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), envelope).unwrap();
3833        assert_eq!(
3834            evaluate_layer1(dir.path(), PhaseId::new(30)).map(|r| r.status),
3835            Some(AgentStatus::Failed),
3836            "control: the intact envelope is an authoritative Layer-1 failure"
3837        );
3838
3839        let mut poisoned = vec![0xffu8];
3840        poisoned.extend_from_slice(envelope);
3841        std::fs::write(stdout_path(dir.path(), PhaseId::new(31)), &poisoned).unwrap();
3842        assert_eq!(
3843            evaluate_layer1(dir.path(), PhaseId::new(31)).map(|r| r.status),
3844            Some(AgentStatus::Failed),
3845            "one invalid byte before the envelope must not make Layer 1 abstain \
3846             and hand a FAILURE to the exit-code fallback"
3847        );
3848    }
3849
3850    /// **Third-pass Medium.** A torn gate-bearing `user` event must not reopen
3851    /// raw-stdout scanning.
3852    ///
3853    /// `claude_stream_gate_shape` keyed stream recognition on system/user/
3854    /// assistant events. If the echoed `user` event tore *after* carrying the
3855    /// full gate text and only a later `result` parsed, none of those types
3856    /// survived, the capture stopped looking like a stream, and the raw scan
3857    /// read the echoed prompt as a declaration. Every line is still `{`-shaped,
3858    /// so this is neither the torn-`init` case nor V-01.
3859    #[test]
3860    fn torn_gate_bearing_user_event_does_not_reopen_raw_scanning() {
3861        let echo = v3_message_event(V3_USER_EVENT, &gate_documenting_text());
3862        let quiet_result = v3_result_event(V3_RESULT_TURN1, NO_MARKER);
3863
3864        let closed = format!("{}\n{}\n{}\n", V3_INIT_EVENT, echo, quiet_result);
3865        assert!(
3866            !blocking_human_checkpoint_reported(&closed),
3867            "control: with the echo intact the gate mention is correctly scoped out"
3868        );
3869
3870        let torn = format!("{}\n{}\n", &echo[..echo.len() - 12], quiet_result);
3871        assert!(
3872            !blocking_human_checkpoint_reported(&torn),
3873            "a torn echo leaving only a result must stay scoped, not fall back to \
3874             the raw scan that reads the echoed prompt as a declaration"
3875        );
3876
3877        // The shipped single-document envelope is ONE result line and must keep
3878        // taking the raw path (T-30-25).
3879        let single_doc = v3_result_event(V3_RESULT_TURN1, &gate_declaration_text());
3880        assert!(
3881            blocking_human_checkpoint_reported(&single_doc),
3882            "control: the single-document envelope still uses the raw scan"
3883        );
3884    }
3885
3886    /// **Fourth-pass Medium 3.** Benign prose noise must not block session
3887    /// recovery — only a torn JSON line can conceal a newer `init`.
3888    ///
3889    /// The first fail-closed guard rejected the capture when ANY non-empty line
3890    /// failed to parse, so one interleaved progress line disabled checkpoint
3891    /// auto-resume while the verdict parser accepted the same capture. An
3892    /// `init` is a JSON line; a non-`{` line can never be a torn one.
3893    #[test]
3894    fn prose_noise_does_not_block_session_recovery() {
3895        let stream = format!(
3896            "{}\nprogress: still working…\n{}\n",
3897            V3_INIT_EVENT,
3898            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3899        );
3900        assert!(
3901            claude_stream_session_id(&stream).is_some(),
3902            "a prose progress line must not fail session recovery closed"
3903        );
3904
3905        // Control: the same capture with the noise line made JSON-shaped-but-torn
3906        // MUST fail closed — that shape could be a torn newer init.
3907        let torn = format!(
3908            "{}\n{{\"type\":\"system\",\"subty\n{}\n",
3909            V3_INIT_EVENT,
3910            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3911        );
3912        assert!(
3913            claude_stream_session_id(&torn).is_none(),
3914            "a torn JSON line could be a newer init and must fail closed"
3915        );
3916    }
3917
3918    /// **Third-pass High.** A torn *later* `init` must not resurrect an earlier
3919    /// session's id.
3920    ///
3921    /// Each turn opens its own `init`; the last carries the id a resume must
3922    /// target. Dropped lines are invisible, so the scan returned the last
3923    /// PARSEABLE init — a stale token that looks entirely valid. Fails closed
3924    /// now: `None` costs a resume, the wrong id corrupts one.
3925    #[test]
3926    fn torn_later_init_does_not_resurrect_a_stale_session_id() {
3927        let init =
3928            |id: &str| format!(r#"{{"type":"system","subtype":"init","session_id":"{id}"}}"#);
3929
3930        let rotated = format!("{}\n{}\n", init("session-a"), init("session-b"));
3931        assert_eq!(
3932            claude_stream_session_id(&rotated).as_deref(),
3933            Some("session-b"),
3934            "control: with both init events intact the LAST id wins"
3935        );
3936
3937        let init_c = init("session-c");
3938        let torn = format!(
3939            "{}\n{}\n{}\n",
3940            init("session-a"),
3941            init("session-b"),
3942            &init_c[..init_c.len() - 10],
3943        );
3944        assert_ne!(
3945            claude_stream_session_id(&torn).as_deref(),
3946            Some("session-b"),
3947            "a torn newer init must not hand back the previous session's id"
3948        );
3949    }
3950
3951    /// **V-01 regression.** One stray JSONL-shaped line must not divert a
3952    /// plain-text capture onto the stream branch and suppress a real gate.
3953    ///
3954    /// The first `claude_stream_gate_shape` asked only whether ANY event carried
3955    /// a stream type. Since the stream branch never consults raw stdout, a single
3956    /// `{"type":"assistant",…}` line was enough to hide a genuine declaration
3957    /// sitting in the surrounding plain text — turning the fail-OPEN this
3958    /// predicate was written to close into a fail-CLOSED that drops a human
3959    /// authorization request. Found by phase-30 verification after the fix
3960    /// shipped in `06675da`.
3961    #[test]
3962    fn one_stray_json_line_does_not_suppress_a_plain_text_gate() {
3963        let gate = gate_declaration_text();
3964
3965        assert!(
3966            blocking_human_checkpoint_reported(&gate),
3967            "positive control: the gate text alone must be detected"
3968        );
3969
3970        let poisoned =
3971            format!("{gate}\n{{\"type\":\"assistant\",\"message\":{{\"content\":[]}}}}\n");
3972        assert!(
3973            blocking_human_checkpoint_reported(&poisoned),
3974            "one stray JSONL line must not suppress a real plain-text gate (V-01)"
3975        );
3976
3977        // The torn-init capture is still recognised as a stream — the majority
3978        // rule must not undo the fail-open fix it was added to preserve.
3979        let torn_init = &V3_INIT_EVENT[..40];
3980        let torn = format!(
3981            "{}\n{}\n{}\n",
3982            torn_init,
3983            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3984            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3985        );
3986        assert!(
3987            !blocking_human_checkpoint_reported(&torn),
3988            "control: a torn-init stream must still be scoped, not raw-scanned"
3989        );
3990    }
3991
3992    /// Every byte-prefix of a capture, fed to the gate scanner.
3993    ///
3994    /// **Why a sweep and not more hand-written cases.** Phase 30 shipped 116
3995    /// green tests, seven of them written specifically to prove the prompt-echo
3996    /// false positive was closed — and a cross-AI review then found that ONE
3997    /// torn line reverted the whole protection to the raw-stdout path. Every
3998    /// test fed the parser well-formed input; none fed it a broken one. Hand
3999    /// -picking more malformed cases would repeat that bias. Truncating at every
4000    /// offset removes the judgment call: the inputs are generated, not chosen.
4001    ///
4002    /// The invariant is one-directional — a prefix may lose detection (it has
4003    /// strictly less information), but it must never *gain* permissiveness.
4004    #[test]
4005    fn truncation_sweep_never_widens_gate_detection() {
4006        let intact = stream_capture_of(&[
4007            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
4008            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4009        ]);
4010        assert!(
4011            !blocking_human_checkpoint_reported(&intact),
4012            "precondition: the intact capture must report no gate, or the sweep \
4013             below proves nothing"
4014        );
4015
4016        let mut checked = 0usize;
4017        for n in 0..=intact.len() {
4018            if !intact.is_char_boundary(n) {
4019                continue;
4020            }
4021            checked += 1;
4022            assert!(
4023                !blocking_human_checkpoint_reported(&intact[..n]),
4024                "truncating to {n} bytes made an echoed gate MENTION read as a \
4025                 live declaration — the fail-open class (constraint 9)"
4026            );
4027        }
4028        assert!(
4029            checked > 500,
4030            "sweep degenerated to {checked} offsets; it is no longer exercising \
4031             the capture"
4032        );
4033    }
4034
4035    /// Same sweep against the session-id reader. Truncation may degrade it to
4036    /// `None` (a failed resume — fail-closed, acceptable); it must never yield a
4037    /// DIFFERENT id, which would resume the wrong session.
4038    #[test]
4039    fn truncation_sweep_never_forges_session_id() {
4040        let intact = stream_capture_of(&[
4041            &v3_message_event(V3_USER_EVENT, "session_id: forged-by-agent-text"),
4042            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4043        ]);
4044        let real = claude_stream_session_id(&intact);
4045        assert!(
4046            real.is_some(),
4047            "precondition: the intact capture yields an id"
4048        );
4049
4050        for n in 0..=intact.len() {
4051            if !intact.is_char_boundary(n) {
4052                continue;
4053            }
4054            let got = claude_stream_session_id(&intact[..n]);
4055            assert!(
4056                got.is_none() || got == real,
4057                "truncating to {n} bytes produced session id {got:?}, which is \
4058                 neither None nor the CLI-emitted {real:?}"
4059            );
4060        }
4061    }
4062
4063    /// **Constraint 9 item 2, closed.** A subagent-origin `result` event must
4064    /// never decide the stage verdict — `last_top_level_result`'s name and doc
4065    /// always claimed top-level selection, but the first implementation
4066    /// selected on `type == "result"` alone (code-review M2). Envelope real
4067    /// (v3 result turn), planted `parent_tool_use_id` synthetic: no archived
4068    /// capture contains a subagent-origin result, so this pins deterministic
4069    /// behavior for an unobserved-but-legal shape.
4070    #[test]
4071    fn subagent_result_event_never_decides_the_verdict() {
4072        let subagent_success = v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS).replacen(
4073            "{",
4074            "{\"parent_tool_use_id\":\"toolu_child\",",
4075            1,
4076        );
4077        let capture = format!(
4078            "{}\n{}\n{}\n",
4079            V3_INIT_EVENT,
4080            v3_result_event_is_error(V3_RESULT_TURN1, MARKER_FAILED),
4081            subagent_success,
4082        );
4083        assert_eq!(
4084            parse_claude_event_result(&capture).map(|r| r.status),
4085            Some(AgentStatus::Failed),
4086            "a subagent-origin success result must not override the last \
4087             top-level failure"
4088        );
4089
4090        // Control: the same final event WITHOUT the planted parent id is
4091        // top-level and legitimately wins.
4092        let top_level = format!(
4093            "{}\n{}\n{}\n",
4094            V3_INIT_EVENT,
4095            v3_result_event_is_error(V3_RESULT_TURN1, MARKER_FAILED),
4096            v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS),
4097        );
4098        assert_eq!(
4099            parse_claude_event_result(&top_level).map(|r| r.status),
4100            Some(AgentStatus::Success),
4101            "control: the same event without a parent id is the final verdict"
4102        );
4103    }
4104
4105    /// D-13 trap 1, pinned: the delivery canary's declared token appears in the
4106    /// stream as a PROMPT ECHO before it can ever appear as an answer, so a
4107    /// naive text scan reports delivery on every run — including runs where the
4108    /// notification path is dead. That echo is what produced the checkpoint
4109    /// false positive 30-05 fixed.
4110    ///
4111    /// Three cases, and the first two are the negative controls that give the
4112    /// third its meaning: the same token, in the same capture shape, must read
4113    /// `false` from an echo and from a subagent-origin result, and `true` only
4114    /// from a top-level `result`.
4115    #[test]
4116    fn token_matches_only_inside_top_level_result() {
4117        const TOKEN: &str = "DEVFLOW-CANARY-7f3a";
4118
4119        // 1. Echo only: the token is in the operator's own turn, forwarded back
4120        //    into stdout, and in no result at all.
4121        let echoed = format!(
4122            "{}\n{}\n{}\n",
4123            V3_INIT_EVENT,
4124            V3_USER_EVENT.replace("__MARKER__", &format!("please return {TOKEN} when done")),
4125            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4126        );
4127        assert!(
4128            !token_reported_in_capture(&echoed, TOKEN),
4129            "a token echoed back in the prompt is not delivery evidence — \
4130             the CLI forwards the operator's own turn into the same stdout"
4131        );
4132
4133        // 2. Subagent-origin result: right event type, wrong provenance.
4134        let subagent = format!(
4135            "{}\n{}\n",
4136            V3_INIT_EVENT,
4137            v3_result_event(V3_RESULT_TURN2, TOKEN).replacen(
4138                "{",
4139                "{\"parent_tool_use_id\":\"toolu_child\",",
4140                1,
4141            ),
4142        );
4143        assert!(
4144            !token_reported_in_capture(&subagent, TOKEN),
4145            "a subagent-origin result must not satisfy the canary — it is the \
4146             same provenance hole constraint 9 item 2 closed for the verdict"
4147        );
4148
4149        // 3. Authoritative: a top-level `result` carrying the token.
4150        let authoritative = format!(
4151            "{}\n{}\n",
4152            V3_INIT_EVENT,
4153            v3_result_event(V3_RESULT_TURN1, TOKEN),
4154        );
4155        assert!(
4156            token_reported_in_capture(&authoritative, TOKEN),
4157            "a token inside a top-level result IS the canary's answer"
4158        );
4159    }
4160
4161    /// The Codex arm of the trailing-torn rule — same R1 root cause, and the
4162    /// Codex adapter is live in production.
4163    ///
4164    /// A torn tail must not resurrect an earlier success marker: the torn-tail
4165    /// check runs before both the terminal and marker scans. (The
4166    /// terminal-vs-marker precedence is now `turn.failed`-over-marker —
4167    /// 999.107 #1 — superseding the pre-fix marker-first order.)
4168    #[test]
4169    fn codex_torn_tail_does_not_resurrect_earlier_success_marker() {
4170        let intact = concat!(
4171            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4172            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4173            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
4174            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4175            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"failed\\\"}\"}}\n",
4176        );
4177        assert_eq!(
4178            parse_codex_event_result(intact).map(|r| r.status),
4179            Some(AgentStatus::Failed),
4180            "control: intact capture — the LAST marker wins and it is a failure"
4181        );
4182
4183        let torn = &intact[..intact.len() - 20];
4184        assert_ne!(
4185            parse_codex_event_result(torn).map(|r| r.status),
4186            Some(AgentStatus::Success),
4187            "a torn superseding marker must not let the earlier success marker \
4188             decide the stage"
4189        );
4190    }
4191
4192    /// **Sixth-pass Highs 1–3.** The marker tail scanner — the reader that
4193    /// decides most production stages today — must survive edge corruption, a
4194    /// marker line longer than the tail budget, and mixed-case prefixes (its
4195    /// contract has always said case-insensitive).
4196    #[test]
4197    fn marker_tail_scan_survives_corruption_length_and_case() {
4198        let m = "DEVFLOW_RESULT: {\"status\":\"failed\"}";
4199        assert_eq!(
4200            parse_devflow_result(m).map(|r| r.status),
4201            Some(AgentStatus::Failed),
4202            "control: the plain marker parses"
4203        );
4204
4205        // High 1 — edge corruption on either side must not hide the marker.
4206        for poisoned in [format!("\u{FFFD}{m}"), format!("{m}\u{FFFD}")] {
4207            assert_eq!(
4208                parse_devflow_result(&poisoned).map(|r| r.status),
4209                Some(AgentStatus::Failed),
4210                "one stray byte at a line edge must not hide a failure marker"
4211            );
4212        }
4213        // …while interior corruption stays untrusted (fourth-pass hazard).
4214        assert!(
4215            parse_devflow_result("DEVFLOW_RESULT: {\"status\":\"fai\u{FFFD}led\"}").is_none(),
4216            "interior corruption must not parse as a valid status"
4217        );
4218
4219        // High 2 — a marker line longer than the tail budget is scanned whole.
4220        let long_reason = "x".repeat(5000);
4221        let long =
4222            format!("DEVFLOW_RESULT: {{\"status\":\"failed\",\"reason\":\"{long_reason}\"}}");
4223        assert_eq!(
4224            parse_devflow_result(&long).map(|r| r.status),
4225            Some(AgentStatus::Failed),
4226            "the tail budget must never bisect the final marker line"
4227        );
4228        // …and the budget still bounds the walk: a marker buried beyond the
4229        // budget with newer non-marker output after it stays out of reach.
4230        let buried = format!("{m}\n{}\n", "y\n".repeat(4100));
4231        assert!(
4232            parse_devflow_result(&buried).is_none(),
4233            "control: the budget still cuts off markers deep in old output"
4234        );
4235
4236        // High 3 — mixed case matches, per the documented contract.
4237        assert_eq!(
4238            parse_devflow_result("DevFlow_Result: {\"status\":\"failed\"}").map(|r| r.status),
4239            Some(AgentStatus::Failed),
4240            "mixed-case prefix must match — the contract says case-insensitive"
4241        );
4242    }
4243
4244    /// **Sixth-pass Mediums 4–5.** The codex plain-text rate-limit heuristic:
4245    /// an edge-corrupt JSON event line must stay excluded from prose scanning,
4246    /// and "429" only counts as a standalone token.
4247    #[test]
4248    fn codex_rate_limit_heuristic_excludes_recovered_json_and_embedded_429() {
4249        // M4 — a corrupt-prefixed event line is still a JSON line, not prose.
4250        let doc_line = concat!(
4251            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4252            "\"text\":\"docs mention rate limiting policies\"}}",
4253        );
4254        let poisoned =
4255            format!("{{\"type\":\"thread.started\",\"thread_id\":\"t\"}}\n\u{FFFD}{doc_line}\n");
4256        assert!(
4257            detect_codex_rate_limit(&poisoned).is_none(),
4258            "an edge-corrupt event line must not be prose-scanned for \
4259             rate-limit vocabulary"
4260        );
4261        // Control: genuine plain-text rate-limit output is still detected.
4262        assert!(
4263            detect_codex_rate_limit("Rate limit exceeded. Try again at 17:00.").is_some(),
4264            "control: real plain-text rate-limit output must still be detected"
4265        );
4266
4267        // M5 — embedded digits are not rate-limit evidence…
4268        assert!(
4269            detect_codex_rate_limit("processed issue #429 successfully").is_none(),
4270            "'#429' is an issue number, not a rate limit"
4271        );
4272        assert!(
4273            detect_codex_rate_limit("transferred 14290 bytes").is_none(),
4274            "digits containing 429 are not a rate limit"
4275        );
4276        // …while a genuine standalone 429 still is.
4277        assert!(
4278            detect_codex_rate_limit("HTTP 429 Too Many Requests").is_some(),
4279            "control: a standalone 429 status is still detected"
4280        );
4281    }
4282
4283    /// **Fifth-pass High 1.** A replacement-character-prefixed event line must
4284    /// not classify as prose Noise and slip past the torn-tail guard.
4285    ///
4286    /// `read_capture` turns an invalid byte into U+FFFD; a line reading
4287    /// `\u{FFFD}{"type":…}` fails to parse and does not start with `{`, so it
4288    /// became Noise — invisible to `torn_json_after_last_matching`. A corrupt
4289    /// byte in front of a superseding failed marker let the earlier success
4290    /// marker decide the stage, with the contradicting exit code never
4291    /// consulted. Live today on the Codex `--json` adapter. The fix recovers
4292    /// an edge-corrupt-but-intact event by re-parsing the stripped line, so
4293    /// the TRUE verdict decides — better than merely failing indeterminate.
4294    #[test]
4295    fn corruption_prefixed_event_line_is_not_prose_noise() {
4296        let good = concat!(
4297            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4298            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4299            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
4300        );
4301        let failed_line = concat!(
4302            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4303            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"failed\\\"}\"}}\n",
4304        );
4305
4306        let intact = format!("{good}{failed_line}");
4307        assert_eq!(
4308            parse_codex_event_result(&intact).map(|r| r.status),
4309            Some(AgentStatus::Failed),
4310            "control: intact capture — the last (failed) marker decides"
4311        );
4312
4313        let poisoned = format!("{good}\u{FFFD}{failed_line}");
4314        assert_eq!(
4315            parse_codex_event_result(&poisoned).map(|r| r.status),
4316            Some(AgentStatus::Failed),
4317            "an edge-corrupt superseding marker must be recovered (or at worst \
4318             fail indeterminate) — never let the earlier success decide"
4319        );
4320
4321        // Interior corruption stays visible and untrusted: a FFFD INSIDE the
4322        // marker's status string must not parse as a valid status (the
4323        // fourth-pass fabrication hazard, still guarded).
4324        let interior = concat!(
4325            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4326            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4327            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"suc\u{FFFD}cess\\\"}\"}}\n",
4328        );
4329        assert_ne!(
4330            parse_codex_event_result(interior).map(|r| r.status),
4331            Some(AgentStatus::Success),
4332            "interior corruption must never be repaired into a success"
4333        );
4334    }
4335
4336    /// **Fifth-pass Medium 1.** An edge-corrupt rate-limit envelope must stay
4337    /// `RateLimited`, not decay into a generic `Failed`.
4338    ///
4339    /// The rate-limit detector outranks the generic envelope-failure detector
4340    /// precisely because rate-limit envelopes carry `is_error: true`. It was
4341    /// the one single-document reader without `strip_corruption_padding`, so a
4342    /// stray byte inverted the precedence — auto-resume became review/gating.
4343    #[test]
4344    fn edge_corrupt_rate_limit_envelope_stays_rate_limited() {
4345        let envelope = r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"result":"rate limited","retry_after":"17:00"}"#;
4346        assert!(
4347            detect_claude_rate_limit(envelope).is_some(),
4348            "control: the intact envelope is detected as a rate limit"
4349        );
4350        assert!(
4351            detect_claude_rate_limit(&format!("\u{FFFD}{envelope}")).is_some(),
4352            "one stray byte must not demote RateLimited to generic Failed"
4353        );
4354    }
4355
4356    /// **Fourth-pass Medium 1.** The generic marker path — the one production
4357    /// hits today — must overwrite a planted `decided_by_layer`, exactly as the
4358    /// stream path has since 30-01. `Some(0)` is Layer-0 external-verification
4359    /// provenance, which `classify_validate_outcome` trusts when classifying a
4360    /// Validate stage: an agent could force the ambiguous-validation gate with
4361    /// a verdict it never earned (999.67's class, live instance).
4362    #[test]
4363    fn generic_marker_cannot_forge_layer0_provenance() {
4364        let stdout = r#"DEVFLOW_RESULT: {"status":"success","decided_by_layer":0}"#;
4365        let result = parse_devflow_result(stdout).unwrap();
4366        assert_eq!(
4367            result.decided_by_layer,
4368            Some(1),
4369            "a planted decided_by_layer:0 must be overwritten to Layer 1"
4370        );
4371
4372        // Control: an honest marker without the field also normalises to
4373        // Some(1) — provenance is DERIVED here, never deserialized.
4374        let honest = r#"DEVFLOW_RESULT: {"status":"success"}"#;
4375        assert_eq!(
4376            parse_devflow_result(honest).unwrap().decided_by_layer,
4377            Some(1)
4378        );
4379    }
4380
4381    /// Codex arm of the T-30-26 provenance overwrite (fourth-pass Medium 1's
4382    /// class): a `decided_by_layer` planted in the codex marker JSON must be
4383    /// overwritten, exactly as on the generic and Claude-stream marker paths.
4384    #[test]
4385    fn codex_marker_cannot_forge_layer0_provenance() {
4386        let capture = concat!(
4387            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4388            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4389            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\",\\\"decided_by_layer\\\":0}\"}}\n",
4390        );
4391        let result = parse_codex_event_result(capture).unwrap();
4392        assert_eq!(
4393            result.decided_by_layer,
4394            Some(1),
4395            "a planted decided_by_layer:0 must be overwritten to Layer 1"
4396        );
4397    }
4398
4399    /// **Constraint 9 item 1, closed for every DETECTABLE truncation**
4400    /// (originally committed `#[ignore]`d as a known-red deferral to Phase 31;
4401    /// the operator's "fix root causes before proceeding" decision pulled it
4402    /// back into phase 30).
4403    ///
4404    /// A truncated terminal `result` used to vanish from the parsed events, so
4405    /// `last_top_level_result` returned an EARLIER turn's result — a stale
4406    /// SUCCESS advancing a stage whose real terminal turn failed. Now every
4407    /// prefix with a torn trailing line yields an indeterminate FAILURE.
4408    ///
4409    /// **The named residual — line-boundary truncation is UNDETECTABLE from
4410    /// content.** A prefix cut exactly at the newline after the success turn is
4411    /// a well-formed capture: two parsed events, no torn line, byte-identical
4412    /// to a healthy one-turn-success capture plus nothing. The evidence of loss
4413    /// is in the bytes that never arrived, so no parser assertion can exist for
4414    /// it. The remaining defense belongs to the layer that HAS the missing
4415    /// information: Phase 31's wiring must not let a stream-derived Success
4416    /// short-circuit a contradicting exit code (a writer that died between
4417    /// flushing turn N and turn N+1 also died with a non-zero exit). Recorded
4418    /// in ROADMAP constraint 9.
4419    #[test]
4420    fn truncation_sweep_never_upgrades_verdict_to_success() {
4421        let intact = format!(
4422            "{}\n{}\n{}\n",
4423            V3_INIT_EVENT,
4424            v3_result_event(V3_RESULT_TURN1, MARKER_SUCCESS),
4425            v3_result_event_is_error(V3_RESULT_TURN2, MARKER_FAILED),
4426        );
4427        assert_eq!(
4428            parse_claude_event_result(&intact).map(|r| r.status),
4429            Some(AgentStatus::Failed),
4430            "precondition: intact capture ends in a failure verdict"
4431        );
4432
4433        let mut torn_prefixes = 0usize;
4434        let mut clean_prefixes = 0usize;
4435        for n in 0..=intact.len() {
4436            if !intact.is_char_boundary(n) {
4437                continue;
4438            }
4439            let prefix = &intact[..n];
4440            let got = parse_claude_event_result(prefix).map(|r| r.status);
4441            if ParsedCapture::parse(prefix).torn_json_line_present() {
4442                torn_prefixes += 1;
4443                assert_ne!(
4444                    got,
4445                    Some(AgentStatus::Success),
4446                    "truncating to {n} bytes left a torn tail yet resurrected \
4447                     an earlier turn's SUCCESS over a failed terminal turn"
4448                );
4449            } else {
4450                clean_prefixes += 1;
4451            }
4452        }
4453        // Negative controls on the sweep itself: both branches must have been
4454        // exercised, or the loop is asserting over nothing.
4455        assert!(
4456            torn_prefixes > 500,
4457            "sweep degenerated: only {torn_prefixes} torn prefixes"
4458        );
4459        assert!(
4460            clean_prefixes > 2,
4461            "sweep never produced a well-formed prefix; the residual case \
4462             documented above is not being exercised"
4463        );
4464    }
4465
4466    #[test]
4467    fn codex_event_stream_parses_turn_failed() {
4468        let stdout = concat!(
4469            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4470            "{\"type\":\"turn.started\"}\n",
4471            "{\"type\":\"item.started\",\"item\":{}}\n",
4472            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
4473        );
4474        let result = parse_codex_event_result(stdout).unwrap();
4475        assert_eq!(result.status, AgentStatus::Failed);
4476        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
4477    }
4478
4479    #[test]
4480    fn codex_turn_completed_no_marker_defers() {
4481        let stdout = concat!(
4482            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4483            "{\"type\":\"turn.started\"}\n",
4484            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4485        );
4486        assert!(parse_codex_event_result(stdout).is_none());
4487    }
4488
4489    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
4490    /// inside an `agent_message` item's text, never as a raw stdout line. A
4491    /// self-reported failure followed by a bare `turn.completed` must parse
4492    /// as Failed with the agent's reason — not defer to Layer 2 (which would
4493    /// see exit 0 and call it a success).
4494    #[test]
4495    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
4496        let stdout = concat!(
4497            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4498            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
4499            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4500        );
4501        let result = parse_codex_event_result(stdout).unwrap();
4502        assert_eq!(result.status, AgentStatus::Failed);
4503        assert_eq!(
4504            result.reason.as_deref(),
4505            Some("interactive input unavailable")
4506        );
4507    }
4508
4509    #[test]
4510    fn codex_agent_message_marker_success_short_circuits() {
4511        let stdout = concat!(
4512            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4513            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
4514            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4515        );
4516        let result = parse_codex_event_result(stdout).unwrap();
4517        assert_eq!(result.status, AgentStatus::Success);
4518    }
4519
4520    /// 999.107 #1: the pre-fix parser returned the `agent_message` success
4521    /// marker before examining the terminal event, so a stream that ended
4522    /// `success marker → turn.failed` was misread as Success and the stage
4523    /// could advance despite the terminal failure. A terminal `turn.failed`
4524    /// must win over any earlier success marker.
4525    #[test]
4526    fn codex_turn_failed_beats_an_earlier_success_marker() {
4527        let stdout = concat!(
4528            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4529            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
4530            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
4531        );
4532        let result = parse_codex_event_result(stdout).unwrap();
4533        assert_eq!(result.status, AgentStatus::Failed);
4534        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
4535    }
4536
4537    /// 13-06 dogfood regression: document content echoed into a JSONL event
4538    /// (GSD reference tables mentioning "rate limiting") must not trip the
4539    /// plain-text rate-limit heuristic — it returned the entire multi-KB
4540    /// event line as the "retry time" and that reached the desktop
4541    /// notification verbatim.
4542    #[test]
4543    fn detect_rate_limit_ignores_json_event_lines() {
4544        let stdout = concat!(
4545            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4546            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
4547            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4548        );
4549        assert_eq!(detect_rate_limit(stdout), None);
4550    }
4551
4552    #[test]
4553    fn detect_rate_limit_still_reads_codex_plain_text() {
4554        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
4555        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
4556    }
4557
4558    #[test]
4559    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
4560        let stdout = concat!(
4561            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4562            "not json at all\n",
4563            "{\"type\":\"item.started\",\"item\":{}}\n",
4564            "{\"type\":\"item.updated\",\"item\":{}}\n",
4565            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
4566        );
4567        let result = parse_codex_event_result(stdout).unwrap();
4568        assert_eq!(result.status, AgentStatus::Failed);
4569        assert_eq!(result.reason.as_deref(), Some("boom"));
4570    }
4571
4572    #[test]
4573    fn claude_envelope_not_consumed_by_codex_parser() {
4574        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
4575        assert!(parse_codex_event_result(stdout).is_none());
4576    }
4577
4578    /// The highest-value isolation test in plan 30-01 (T-30-02).
4579    ///
4580    /// The single-document `--output-format json` envelope that ships TODAY
4581    /// carries `type: "result"` AND a `session_id` — precisely the gate shape
4582    /// 30-RESEARCH.md offered as an alternative to `system`/`init`. If anyone
4583    /// widens [`is_claude_event_stream`] to accept it, the stream parser starts
4584    /// consuming every production capture in use and silently displaces
4585    /// `parse_devflow_result` in the Layer-1 cascade. This test fails first.
4586    ///
4587    /// The first literal is reused verbatim from
4588    /// `claude_envelope_not_consumed_by_codex_parser` above so the two read as
4589    /// a matched pair.
4590    #[test]
4591    fn single_doc_envelope_not_consumed_by_claude_stream_parser() {
4592        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
4593        assert!(parse_claude_event_result(stdout).is_none());
4594
4595        // Non-vacuity: the literal above carries no marker, so it would return
4596        // None even from a WRONGLY-widened gate — on its own it proves little.
4597        // This envelope does carry one, so it can only return None because the
4598        // gate declined the document, not because the marker scan came up dry.
4599        let with_marker = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"Done.\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#;
4600        assert!(parse_claude_event_result(with_marker).is_none());
4601
4602        // ...and the shipped path still owns it, so declining costs no verdict.
4603        assert_eq!(
4604            parse_devflow_result(with_marker).unwrap().status,
4605            AgentStatus::Success
4606        );
4607    }
4608
4609    /// Cross-adapter isolation: a Codex `--json` event stream is not consumed
4610    /// by the Claude stream parser. The two gates are mutually exclusive by
4611    /// construction — Codex keys on `thread.started`/`turn.*`, Claude on
4612    /// `system`/`init` — and this pins that.
4613    #[test]
4614    fn codex_stream_not_consumed_by_claude_stream_parser() {
4615        let stdout = concat!(
4616            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4617            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
4618            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4619        );
4620        assert!(parse_claude_event_result(stdout).is_none());
4621
4622        // The Codex parser still decides it — isolation costs no verdict.
4623        assert_eq!(
4624            parse_codex_event_result(stdout).unwrap().status,
4625            AgentStatus::Success
4626        );
4627    }
4628
4629    /// The same isolation claim in the other direction: a Claude stream capture
4630    /// is not consumed by the Codex parser, so the two never collide.
4631    #[test]
4632    fn claude_stream_not_consumed_by_codex_parser() {
4633        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
4634        assert!(parse_codex_event_result(&capture).is_none());
4635    }
4636
4637    /// Plain-text stdout is not consumed by the Claude stream parser.
4638    ///
4639    /// Non-vacuous by construction: the text carries a real marker, so a gate
4640    /// that wrongly fired on non-JSON input would change the verdict rather
4641    /// than merely returning None. The second assertion pins that the marker
4642    /// path still decides it — the cascade must lose nothing.
4643    #[test]
4644    fn plain_text_not_consumed_by_claude_stream_parser() {
4645        let stdout = "Running the plan...\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
4646        assert!(parse_claude_event_result(stdout).is_none());
4647        assert_eq!(
4648            parse_devflow_result(stdout).unwrap().status,
4649            AgentStatus::Success
4650        );
4651    }
4652
4653    // ---- Claude `--output-format stream-json` fixtures (plan 30-01) --------
4654    //
4655    // Sourced from the archived capture
4656    // `.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`,
4657    // a real 54-line stream from a session that survived three orchestrator
4658    // turns via task-notification wake-ups. The `init` event is line 5; the
4659    // three `result` events are lines 19, 37 and 54.
4660    //
4661    // TWO documented modifications, both labelled where they occur:
4662    //   1. Each envelope's `result` string value is replaced with the sentinel
4663    //      `__MARKER__`, which each test fills in. NO archived capture contains
4664    //      a real `DEVFLOW_RESULT` marker — the v3 harness produced
4665    //      acknowledgment prose, not GSD stage output — so every marker payload
4666    //      below is SYNTHETIC. Envelope shape is real; marker text is not.
4667    //   2. The `init` event's three inert array payloads are truncated and its
4668    //      `cwd` is redacted (see `V3_INIT_EVENT`).
4669    // Everything else is byte-for-byte as captured, including field ORDER —
4670    // note that `"type":"result"` appears near the END of each result line,
4671    // long after `result` itself, which is exactly why the parser must key on
4672    // the parsed object rather than on textual position.
4673
4674    /// v3 line 5 — the `system`/`init` event that opens the stream and is the
4675    /// ONLY thing `is_claude_event_stream` gates on.
4676    ///
4677    /// Modification 2: verbatim except that `tools`, `mcp_servers` and
4678    /// `slash_commands` are truncated to a real prefix (verbatim they run to
4679    /// 5,523 characters of tool and slash-command names that no code path here
4680    /// reads) and `cwd` is redacted to a neutral path — the captured value
4681    /// embeds a developer's home directory, and `devflow-core` is published to
4682    /// crates.io. Both fields are inert for every function under test.
4683    const V3_INIT_EVENT: &str = r#"{"type":"system","subtype":"init","cwd":"/tmp/scratchpad/999.64-experiment","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","tools":["Task","Bash","Read","Write"],"mcp_servers":[{"name":"github","status":"pending"}],"model":"claude-opus-5[1m]","permissionMode":"bypassPermissions","slash_commands":["gsd-execute-phase"],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"uuid":"597e1613-77cb-4cdd-a716-2aa75dc58c0b"}"#;
4684
4685    /// v3 line 19 — the FIRST turn's terminal `result` event.
4686    const V3_RESULT_TURN1: &str = r#"{"is_error":false,"duration_api_ms":8087,"num_turns":3,"stop_reason":"end_turn","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","total_cost_usd":0.2401795,"usage":{"input_tokens":4,"cache_creation_input_tokens":20120,"cache_read_input_tokens":49219,"output_tokens":574,"service_tier":"standard","inference_geo":"not_available","speed":"standard"},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","subtype":"success","api_error_status":null,"result":"__MARKER__","ttft_ms":1381,"time_to_request_ms":91,"type":"result","duration_ms":8315,"uuid":"3dce3044-2d33-4c4d-bfcb-80e1756a5522"}"#;
4687
4688    /// v3 line 37 — the SECOND turn's terminal `result` event, produced after a
4689    /// task-notification wake-up. Carries the `origin` key the later turns have
4690    /// and the first does not.
4691    const V3_RESULT_TURN2: &str = r#"{"is_error":false,"duration_api_ms":27809,"num_turns":1,"stop_reason":"end_turn","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","total_cost_usd":0.53654625,"usage":{"input_tokens":2,"cache_creation_input_tokens":3147,"cache_read_input_tokens":35393,"output_tokens":124,"service_tier":"standard","inference_geo":"not_available","speed":"standard"},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","origin":{"kind":"task-notification"},"subtype":"success","api_error_status":null,"result":"__MARKER__","ttft_ms":5476,"time_to_request_ms":18,"type":"result","duration_ms":6195,"uuid":"ca58693c-2599-4eb6-955b-e9d1e7444255"}"#;
4692
4693    /// v3 line 54 — the THIRD and LAST turn's terminal `result` event. This is
4694    /// the one whose marker must decide the stage.
4695    const V3_RESULT_TURN3: &str = r#"{"is_error":false,"duration_api_ms":39273,"num_turns":2,"stop_reason":"end_turn","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","total_cost_usd":0.6599295,"usage":{"input_tokens":4,"cache_creation_input_tokens":999,"cache_read_input_tokens":77871,"output_tokens":302,"service_tier":"standard","inference_geo":"not_available","speed":"standard"},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","origin":{"kind":"task-notification"},"subtype":"success","api_error_status":null,"result":"__MARKER__","ttft_ms":2099,"time_to_request_ms":14,"type":"result","duration_ms":5276,"uuid":"dc76186e-3e9a-4d52-9152-27aa5012bc41"}"#;
4696
4697    // ---- prompt-echo regression fixtures (plan 30-05) ----------------------
4698    //
4699    // Message-event envelopes from the same archived capture. Same sentinel
4700    // discipline as the `result` envelopes above — the innermost text payload
4701    // is replaced with `__MARKER__` and each test fills it — plus a third
4702    // documented modification noted per constant where inert bulk is dropped.
4703    // The ENVELOPE is real: every `type`, `parent_tool_use_id`, `session_id`
4704    // and `uuid` value, and the nesting shape the extraction path walks, is
4705    // exactly as captured.
4706    //
4707    // NO archived capture contains checkpoint gate text at all — the 30a
4708    // harness prompt was about background tasks and never mentioned gates. So
4709    // every gate payload below is SYNTHETIC and must not be described as an
4710    // observed rendering. What IS observed is the gate VALUE's markdown
4711    // code-span rendering, transcribed from the live 2026-07-31 A1 run (see
4712    // `HUMAN_GATE_VALUE`), which every fixture here reproduces.
4713
4714    /// v3 line 10 — a TOP-LEVEL `user` event (`parent_tool_use_id` null).
4715    ///
4716    /// Modification 3: the trailing `tool_use_result` object is dropped. It is
4717    /// inert for every function under test and embeds both a developer home
4718    /// directory and the child agent's full prompt; `devflow-core` is published
4719    /// to crates.io.
4720    ///
4721    /// **The archived capture contains no echoed prompt.** Every `user` event
4722    /// in it is a `tool_result` relay, because the 30a harness ran a single
4723    /// prompt with no re-injection. This fixture's payload therefore STANDS IN
4724    /// for an echoed prompt rather than reproducing one. The substitution is
4725    /// sound for what is under test: the scan's first filter keys on the
4726    /// event's `type`, which is `user` in both cases, and
4727    /// `claude_stream_reports_human_gate` excludes that whole class — an echoed
4728    /// prompt and a re-injected notification summary are the two members of it.
4729    const V3_USER_EVENT: &str = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FVk15W8zxiazXutJYn8rsv","type":"tool_result","content":[{"type":"text","text":"__MARKER__"}]}]},"parent_tool_use_id":null,"session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","uuid":"60c5839e-40b3-492a-83e7-00882189f1d3","timestamp":"2026-08-02T00:22:22.603Z"}"#;
4730
4731    /// v3 line 6 — a TOP-LEVEL `assistant` event (`parent_tool_use_id` null).
4732    ///
4733    /// Its captured payload is `I'll spawn both subagents in the background
4734    /// now.` — mid-turn narration that appears in NO `result` event of the
4735    /// capture, re-confirmed by re-parsing all 54 lines at execution time. That
4736    /// property is the entire reason this envelope was chosen: it proves
4737    /// top-level assistant text is not merely a preview of the result text, so
4738    /// admitting the class would add a genuinely new trusted surface.
4739    ///
4740    /// Modification 3: the `usage.cache_creation` sub-object is dropped (inert).
4741    const V3_ASSISTANT_TOP_LEVEL_EVENT: &str = r#"{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cdcy3oC1a4rcmbp3avDYX","type":"message","role":"assistant","content":[{"type":"text","text":"__MARKER__"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":18673,"cache_read_input_tokens":15273,"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","uuid":"85e8747b-e551-47b7-af38-fcd3bb1e06f8","timestamp":"2026-08-02T00:22:18.742Z","request_id":"req_011Cdcy3ngzpMCk3bijt1nkE"}"#;
4742
4743    /// v3 line 11 — a SUBAGENT-forwarded `assistant` event. Its captured
4744    /// `parent_tool_use_id` (`toolu_01FVk15W8zxiazXutJYn8rsv`, the Task call
4745    /// that spawned child A) is preserved verbatim: it is the whole point of
4746    /// the fixture, and the discrimination whose absence invalidated the v1
4747    /// experiment outright.
4748    ///
4749    /// Modification 3: the `usage.cache_creation` sub-object is dropped (inert).
4750    const V3_ASSISTANT_SUBAGENT_EVENT: &str = r#"{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cdcy4BNkfziogNMFM8V7K","type":"message","role":"assistant","content":[{"type":"text","text":"__MARKER__"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":17705,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":"toolu_01FVk15W8zxiazXutJYn8rsv","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","uuid":"3fb37d43-86af-48b1-ace4-55147ed47b15","timestamp":"2026-08-02T00:22:23.850Z","request_id":"req_011Cdcy4ASSvG8gf8fRwiWZW","subagent_type":"general-purpose","task_description":"Signal A after 10s"}"#;
4751
4752    /// Fill a message envelope's innermost text payload. Mirrors
4753    /// [`v3_result_event`] and is kept separate from it so the assertion names
4754    /// the right fixture family when a sentinel is lost.
4755    fn v3_message_event(envelope: &str, text: &str) -> String {
4756        assert!(
4757            envelope.contains("__MARKER__"),
4758            "fixture envelope lost its message-text sentinel"
4759        );
4760        envelope.replace("__MARKER__", text)
4761    }
4762
4763    /// A checkpoint DECLARATION, as an agent's final message would render it,
4764    /// escaped for a JSON string field (literal `\n`, the way `claude` emits
4765    /// an agent's result text).
4766    ///
4767    /// The gate value carries the markdown CODE SPAN the live 2026-07-31 run
4768    /// captured — see [`HUMAN_GATE_VALUE`]. A bare unquoted value would test a
4769    /// rendering that has never been observed in production.
4770    fn gate_declaration_text() -> String {
4771        format!(
4772            "## CHECKPOINT REACHED\\n\\n**Type:** decision\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Plan:** 30-05\\n"
4773        )
4774    }
4775
4776    /// Text that merely DOCUMENTS a gate rendering — the shape a plan file, a
4777    /// GSD reference document, or an agent narrating its next task carries.
4778    /// Same code-span rendering as a real declaration, which is precisely why a
4779    /// substring scan cannot tell the two apart and the EVENT must decide.
4780    ///
4781    /// Single line, no double quotes, so it drops into a JSON string field
4782    /// without further escaping.
4783    fn gate_documenting_text() -> String {
4784        format!(
4785            "The next task is declared **Gate:** `{HUMAN_GATE_VALUE}` in the plan, so the executor must stop rather than auto-select."
4786        )
4787    }
4788
4789    // Synthetic `result`-text payloads (modification 1). Written exactly as
4790    // they appear INSIDE the envelope's `result` JSON string — escaped quotes
4791    // and an escaped newline — because that is how `claude` emits an agent's
4792    // final message. Once serde decodes the field the `\n` becomes a real
4793    // newline and `parse_marker_lines`' line scan works on it unmodified.
4794    const MARKER_SUCCESS: &str = r#"Plan complete.\nDEVFLOW_RESULT: {\"status\":\"success\"}"#;
4795    const MARKER_FAILED: &str =
4796        r#"Blocked.\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"earlier turn aborted\"}"#;
4797    const MARKER_PLANTED_LAYER: &str =
4798        r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"decided_by_layer\":0}"#;
4799    const NO_MARKER: &str = r#"Acknowledged; nothing to report."#;
4800
4801    /// Fill one real envelope's `result` field with a synthetic payload.
4802    fn v3_result_event(envelope: &str, escaped_result_text: &str) -> String {
4803        assert!(
4804            envelope.contains("__MARKER__"),
4805            "fixture envelope lost its result-text sentinel"
4806        );
4807        envelope.replace("__MARKER__", escaped_result_text)
4808    }
4809
4810    /// Assemble a three-turn Claude stream capture: the real `init` event
4811    /// followed by all three real `result` envelopes, each carrying the given
4812    /// payload. Three result events (not two) is load-bearing — a two-event
4813    /// fixture cannot tell "last wins" apart from "highest index of two".
4814    fn v3_stream_capture(turn1: &str, turn2: &str, turn3: &str) -> String {
4815        format!(
4816            "{}\n{}\n{}\n{}\n",
4817            V3_INIT_EVENT,
4818            v3_result_event(V3_RESULT_TURN1, turn1),
4819            v3_result_event(V3_RESULT_TURN2, turn2),
4820            v3_result_event(V3_RESULT_TURN3, turn3),
4821        )
4822    }
4823
4824    // ---- rate-limit / envelope-failure fixtures (plan 30-03) --------------
4825
4826    /// v3 line 15, **VERBATIM** — the only `rate_limit_event` in any archived
4827    /// capture, and the reason this plan exists in its current form.
4828    ///
4829    /// Read it before touching [`detect_claude_stream_rate_limit`]: its
4830    /// `rate_limit_info.status` is **`allowed`**. The CLI emits these events as
4831    /// routine quota telemetry on healthy streams — this one sits at line 15 of
4832    /// a capture that then completed three turns successfully (results at 19,
4833    /// 37 and 54). Presence of the event type carries NO information about
4834    /// whether the run was blocked.
4835    ///
4836    /// Note the second trap one level down: `overageStatus` is `rejected`. Any
4837    /// nested search for the token `rejected` (e.g. via [`json_find_key`]) also
4838    /// misclassifies this healthy event, which is why the classifier reads
4839    /// `rate_limit_info.status` and nothing else, by direct `.get()`.
4840    const V3_RATE_LIMIT_EVENT_ALLOWED: &str = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785645600,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"out_of_credits","isUsingOverage":false},"uuid":"e73e6774-a79d-4cdf-90bd-53a695f44f5a","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790"}"#;
4841
4842    /// A `rate_limit_event` with the given `rate_limit_info.status`, built by
4843    /// substituting one field of the real archived event above.
4844    ///
4845    /// **SYNTHETIC for every status except `allowed`.** No archived capture
4846    /// contains a blocked stream — the denial fixtures below are constructed,
4847    /// not observed, and are labelled as such at each use. Every other field
4848    /// (including `resetsAt`, which supplies the retry hint) is exactly as
4849    /// captured.
4850    fn v3_rate_limit_event(status: &str) -> String {
4851        assert!(
4852            V3_RATE_LIMIT_EVENT_ALLOWED.contains(r#""status":"allowed""#),
4853            "fixture lost its status field"
4854        );
4855        V3_RATE_LIMIT_EVENT_ALLOWED
4856            .replace(r#""status":"allowed""#, &format!(r#""status":"{status}""#))
4857    }
4858
4859    /// One real `result` envelope with its captured `is_error":false` flipped
4860    /// to `true`, every other field untouched. The assertion makes the
4861    /// substitution non-silent: if the fixture text ever changes, the test
4862    /// fails loudly rather than quietly testing an `is_error: false` envelope.
4863    fn v3_result_event_is_error(envelope: &str, escaped_result_text: &str) -> String {
4864        let filled = v3_result_event(envelope, escaped_result_text);
4865        assert!(
4866            filled.contains(r#""is_error":false"#),
4867            "fixture envelope lost its is_error field"
4868        );
4869        filled.replace(r#""is_error":false"#, r#""is_error":true"#)
4870    }
4871
4872    /// Assemble a capture from the real `init` event followed by the given
4873    /// lines in order. Unlike [`v3_stream_capture`] this lets a test position a
4874    /// `rate_limit_event` at an arbitrary index, which is the whole point of
4875    /// the final-turn scoping assertions.
4876    fn stream_capture_of(lines: &[&str]) -> String {
4877        let mut out = String::from(V3_INIT_EVENT);
4878        for line in lines {
4879            out.push('\n');
4880            out.push_str(line);
4881        }
4882        out.push('\n');
4883        out
4884    }
4885
4886    /// **The mandatory negative regression.** The real archived stream — whose
4887    /// `rate_limit_event` says `status: "allowed"` and which then completed
4888    /// three turns — must NOT classify as `RateLimited`.
4889    ///
4890    /// This event is routine quota telemetry, not a block. Classifying its mere
4891    /// presence as a rate limit would route EVERY healthy Claude stream stage
4892    /// into `Action::AutoResume` against a fabricated retry time, instead of
4893    /// advancing the pipeline. That mapping is
4894    /// `crates/devflow-core/src/outcome_policy.rs:41` — `AgentStatus::RateLimited
4895    /// => Action::AutoResume`, re-read in this crate at execution time; 30-03's
4896    /// plan and threat register cite it as `outcome_policy.rs:41` without a
4897    /// crate, and it is NOT in `devflow-cli`. This is a denial of service on
4898    /// the whole product, produced by a one-line "detect the event type"
4899    /// shortcut.
4900    ///
4901    /// Two independent guards must both hold here, and the second assertion
4902    /// pins the one the positioning guard alone would hide: the event is placed
4903    /// at its real position (before the first `result`, mirroring line 15 vs
4904    /// 19), AND its status is not a denial. `detect_claude_stream_rate_limit`
4905    /// is asserted directly on a final-turn placement of the same real event so
4906    /// the status guard cannot be dropped without this test failing.
4907    #[test]
4908    fn claude_stream_real_allowed_rate_limit_event_is_not_rate_limited() {
4909        let capture = stream_capture_of(&[
4910            V3_RATE_LIMIT_EVENT_ALLOWED,
4911            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4912            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
4913            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
4914        ]);
4915
4916        let result = parse_claude_event_result(&capture)
4917            .expect("the final turn's success marker still decides this stream");
4918        assert_eq!(result.status, AgentStatus::Success);
4919        assert_ne!(result.status, AgentStatus::RateLimited);
4920
4921        // The status guard on its own: the SAME real event moved into the final
4922        // turn (after the second-to-last `result`) is still not a rate limit.
4923        // Without this, deleting the status check would leave the test green.
4924        let final_turn = stream_capture_of(&[
4925            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4926            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
4927            V3_RATE_LIMIT_EVENT_ALLOWED,
4928            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
4929        ]);
4930        assert!(
4931            detect_claude_stream_rate_limit(&ParsedCapture::parse(&final_turn).events).is_none()
4932        );
4933    }
4934
4935    /// The positive: an explicit quota DENIAL inside the final turn classifies
4936    /// as `RateLimited`, so the rate-limit resume path stays reachable under
4937    /// `stream-json`.
4938    ///
4939    /// **The denial fixture is SYNTHETIC.** No archived capture contains a
4940    /// blocked stream, so the `rejected` status is constructed from the
4941    /// observed vocabulary of this schema rather than observed in the wild —
4942    /// the same honest-fixture rule this phase applies to marker payloads. The
4943    /// retry hint comes from the real `resetsAt` value.
4944    #[test]
4945    fn claude_stream_final_turn_denial_rate_limit_event_is_rate_limited() {
4946        let denial = v3_rate_limit_event("rejected");
4947        let capture = stream_capture_of(&[
4948            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4949            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
4950            &denial,
4951            &v3_result_event(V3_RESULT_TURN3, NO_MARKER),
4952        ]);
4953
4954        let result = parse_claude_event_result(&capture)
4955            .expect("a final-turn quota denial must produce a Layer-1 verdict");
4956        assert_eq!(result.status, AgentStatus::RateLimited);
4957        assert_eq!(
4958            result.reason.as_deref(),
4959            Some("rate limited until 1785645600")
4960        );
4961        assert_eq!(result.decided_by_layer, Some(1));
4962
4963        // Fewer than two `result` events means the whole stream IS the final
4964        // turn — a run blocked before it ever completed a turn must still
4965        // classify, or the boundary logic silently swallows the common case.
4966        let single_turn =
4967            stream_capture_of(&[&denial, &v3_result_event(V3_RESULT_TURN1, NO_MARKER)]);
4968        assert_eq!(
4969            parse_claude_event_result(&single_turn).map(|r| r.status),
4970            Some(AgentStatus::RateLimited)
4971        );
4972    }
4973
4974    /// Scoping: a denial that predates the final turn cannot outrank the final
4975    /// turn's own outcome. Rate-limit chatter from an earlier turn must not
4976    /// decide a stream that later completed — in the real capture the rate
4977    /// event (line 15) precedes all three results, so an unscoped detector
4978    /// would let a first-turn event decide a stream that finished forty seconds
4979    /// later.
4980    ///
4981    /// The denial status here is the SAME one the positive test proves does
4982    /// classify, so this test can only pass because of the POSITION guard.
4983    #[test]
4984    fn claude_stream_denial_before_final_turn_does_not_outrank_final_result() {
4985        let capture = stream_capture_of(&[
4986            &v3_rate_limit_event("rejected"),
4987            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4988            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
4989            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
4990        ]);
4991
4992        let result = parse_claude_event_result(&capture)
4993            .expect("the final turn's success marker decides this stream");
4994        assert_eq!(result.status, AgentStatus::Success);
4995    }
4996
4997    /// An unrecognised `rate_limit_info.status` DEFERS rather than classifying.
4998    ///
4999    /// Deferring is the deliberately safe direction: an unknown denial status
5000    /// falls through to the envelope/marker paths and is reported `Failed` — a
5001    /// real degradation (the operator loses automatic resume) but a never-silent
5002    /// one that still gates. The opposite error auto-resumes a healthy stream
5003    /// against a retry time the parser invented.
5004    ///
5005    /// Positioned in the FINAL turn, so only the status check can decline it.
5006    #[test]
5007    fn claude_stream_unrecognised_rate_limit_status_defers() {
5008        let capture = stream_capture_of(&[
5009            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5010            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5011            &v3_rate_limit_event("some_future_status"),
5012            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5013        ]);
5014
5015        assert!(detect_claude_stream_rate_limit(&ParsedCapture::parse(&capture).events).is_none());
5016        let result = parse_claude_event_result(&capture)
5017            .expect("the parser must fall through to the marker path");
5018        assert_eq!(result.status, AgentStatus::Success);
5019    }
5020
5021    /// Precedence (T-30-13): when the detector fires, rate limit outranks the
5022    /// marker path. A rate-limited run classified as generic `Failed` kills the
5023    /// primary rate-limit resume cron — the one path that exists to recover
5024    /// from it — which is exactly why `evaluate_layer1` already orders
5025    /// `detect_claude_rate_limit` ahead of `detect_claude_envelope_failure` for
5026    /// the single-document path.
5027    ///
5028    /// Non-vacuous: the same capture WITHOUT the rate event yields `Failed`, so
5029    /// this test fails the moment the ordering is reshuffled.
5030    #[test]
5031    fn claude_stream_final_turn_denial_outranks_failed_marker() {
5032        let with_denial = stream_capture_of(&[
5033            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5034            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5035            &v3_rate_limit_event("rejected"),
5036            &v3_result_event(V3_RESULT_TURN3, MARKER_FAILED),
5037        ]);
5038        assert_eq!(
5039            parse_claude_event_result(&with_denial).map(|r| r.status),
5040            Some(AgentStatus::RateLimited)
5041        );
5042
5043        let without_denial = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_FAILED);
5044        assert_eq!(
5045            parse_claude_event_result(&without_denial).map(|r| r.status),
5046            Some(AgentStatus::Failed)
5047        );
5048    }
5049
5050    /// A last `result` event with `is_error: true` and NO marker is an
5051    /// authoritative Layer-1 failure, not a deferral to Layer 2's coarse
5052    /// exit-code heuristic — matching `detect_claude_envelope_failure` for the
5053    /// single-document envelope. The reason is drawn from the event's own
5054    /// `result` text with the `num_turns` suffix, the same shape that function
5055    /// produces.
5056    #[test]
5057    fn claude_stream_last_result_is_error_without_marker_is_failed() {
5058        let capture = stream_capture_of(&[
5059            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5060            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5061            &v3_result_event_is_error(V3_RESULT_TURN3, r#"Execution error: context exhausted"#),
5062        ]);
5063
5064        let result = parse_claude_event_result(&capture)
5065            .expect("is_error on the last result must not defer to Layer 2");
5066        assert_eq!(result.status, AgentStatus::Failed);
5067        assert_eq!(
5068            result.reason.as_deref(),
5069            Some("Execution error: context exhausted (num_turns: 2)")
5070        );
5071        assert_eq!(result.decided_by_layer, Some(1));
5072    }
5073
5074    /// Envelope-over-marker (T-30-15): `is_error: true` overrides a SUCCESS
5075    /// marker in the same event, matching `detect_claude_envelope_failure`'s
5076    /// documented precedence over a stale or echoed success marker.
5077    ///
5078    /// Non-vacuous: the identical capture with `is_error: false` yields
5079    /// `Success`, so the assertion below can only pass because the envelope
5080    /// check overrode the marker.
5081    #[test]
5082    fn claude_stream_is_error_overrides_success_marker() {
5083        let capture = stream_capture_of(&[
5084            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5085            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5086            &v3_result_event_is_error(V3_RESULT_TURN3, MARKER_SUCCESS),
5087        ]);
5088        let result = parse_claude_event_result(&capture)
5089            .expect("is_error must produce a verdict even with a success marker");
5090        assert_eq!(result.status, AgentStatus::Failed);
5091
5092        let healthy = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
5093        assert_eq!(
5094            parse_claude_event_result(&healthy).map(|r| r.status),
5095            Some(AgentStatus::Success)
5096        );
5097    }
5098
5099    // ---- session id from a stream capture (plan 30-03 Task 2) -------------
5100
5101    /// The single `session_id` every event in the archived v3 capture carries —
5102    /// all three `init` events (lines 5, 32 and 47) and all three `result`
5103    /// events agree on it, confirmed by reading the capture.
5104    const V3_SESSION_ID: &str = "559fef4d-2053-459e-b7a7-f3200c3b3790";
5105
5106    /// The real `init` event with its `session_id` substituted. Used only to
5107    /// build a SYNTHETIC mid-stream rotation — no archived capture rotates.
5108    fn v3_init_event_with_session(session_id: &str) -> String {
5109        assert!(
5110            V3_INIT_EVENT.contains(V3_SESSION_ID),
5111            "fixture lost its session_id"
5112        );
5113        V3_INIT_EVENT.replace(V3_SESSION_ID, session_id)
5114    }
5115
5116    /// `claude_stream_session_id` reads the CLI-emitted id out of a JSONL
5117    /// capture built from the archived `init` events (v3 lines 5, 32 and 47 —
5118    /// all three carry this same value).
5119    ///
5120    /// The second half pins LAST-init-wins with a synthetic rotation: the real
5121    /// capture's three `init` events are identical, so first-wins and last-wins
5122    /// agree on today's evidence and a fixture built only from it cannot tell
5123    /// the two apart. Three `init` events do NOT mean three sessions.
5124    #[test]
5125    fn claude_stream_session_id_reads_cli_emitted_init_value() {
5126        let capture = stream_capture_of(&[
5127            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5128            V3_INIT_EVENT,
5129            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5130            V3_INIT_EVENT,
5131            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5132        ]);
5133        assert_eq!(
5134            claude_stream_session_id(&capture).as_deref(),
5135            Some(V3_SESSION_ID)
5136        );
5137
5138        let rotated = stream_capture_of(&[
5139            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5140            &v3_init_event_with_session("second-session-id"),
5141            &v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS),
5142        ]);
5143        assert_eq!(
5144            claude_stream_session_id(&rotated).as_deref(),
5145            Some("second-session-id")
5146        );
5147    }
5148
5149    /// D-04 / T-28-04 forgery guard for the stream path — the analog of
5150    /// `session_id_in_devflow_result_marker_is_not_returned`, which pins the
5151    /// same contract for the single-document envelope.
5152    ///
5153    /// The fixture defeats BOTH plausible wrong implementations at once: a
5154    /// nested traversal (`json_find_key`/`json_scan`) would reach the
5155    /// `session_id` the agent planted inside its own `DEVFLOW_RESULT` marker
5156    /// text, and a "last event carrying a `session_id`" scan would return the
5157    /// final `result` event's own key. Both are wrong; only the `init` event's
5158    /// top-level value is CLI-emitted. The divergence between the `result`
5159    /// event's id and the `init` event's is synthetic — no archived capture
5160    /// diverges — and exists purely so those two implementations cannot pass.
5161    #[test]
5162    fn claude_stream_session_id_ignores_agent_planted_value() {
5163        const PLANTED_MARKER: &str =
5164            r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"session_id\":\"forged-by-agent\"}"#;
5165
5166        let last_result = v3_result_event(V3_RESULT_TURN3, PLANTED_MARKER)
5167            .replace(V3_SESSION_ID, "result-event-session-id");
5168        let capture =
5169            stream_capture_of(&[&v3_result_event(V3_RESULT_TURN1, NO_MARKER), &last_result]);
5170
5171        // Non-vacuity: both decoys really are present in the capture text, so a
5172        // wrong implementation has something wrong to find.
5173        assert!(capture.contains("forged-by-agent"));
5174        assert!(capture.contains("result-event-session-id"));
5175
5176        assert_eq!(
5177            claude_stream_session_id(&capture).as_deref(),
5178            Some(V3_SESSION_ID)
5179        );
5180    }
5181
5182    /// The stream reader does not shadow or duplicate `claude_session_id`: it
5183    /// declines the single-document envelope (the exact literal
5184    /// `session_id_reads_top_level_string` asserts on) and plain text, so the
5185    /// wrapper's stream-first ordering cannot change today's behavior.
5186    #[test]
5187    fn claude_stream_session_id_declines_non_stream_shapes() {
5188        let envelope = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
5189        assert!(claude_stream_session_id(envelope).is_none());
5190        // ...and the shipped reader still owns it, so declining costs nothing.
5191        assert_eq!(
5192            claude_session_id(envelope).as_deref(),
5193            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
5194        );
5195
5196        assert!(claude_stream_session_id("just some plain text output\n").is_none());
5197    }
5198
5199    /// The wiring that matters: `session_id_from_capture` — the Phase 28
5200    /// checkpoint-resume reader (`claude --resume` needs an id DevFlow can
5201    /// read) — returns an id for a JSONL capture, where before this plan it
5202    /// returned `None` for every stream capture.
5203    #[test]
5204    fn claude_stream_session_id_from_capture_reads_jsonl() {
5205        let dir = tempfile::tempdir().unwrap();
5206        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5207        std::fs::write(
5208            stdout_path(dir.path(), PhaseId::new(30)),
5209            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
5210        )
5211        .unwrap();
5212
5213        assert_eq!(
5214            session_id_from_capture(dir.path(), PhaseId::new(30)).as_deref(),
5215            Some(V3_SESSION_ID)
5216        );
5217    }
5218
5219    /// The other half of the wiring claim: a single-document envelope capture
5220    /// still yields exactly what it did before the stream reader was inserted
5221    /// ahead of `claude_session_id` in the fallback chain.
5222    #[test]
5223    fn claude_stream_wiring_leaves_single_document_capture_unchanged() {
5224        let dir = tempfile::tempdir().unwrap();
5225        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5226        let envelope = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
5227        std::fs::write(stdout_path(dir.path(), PhaseId::new(8)), envelope).unwrap();
5228
5229        assert_eq!(
5230            session_id_from_capture(dir.path(), PhaseId::new(8)).as_deref(),
5231            claude_session_id(envelope).as_deref()
5232        );
5233        assert_eq!(
5234            session_id_from_capture(dir.path(), PhaseId::new(8)).as_deref(),
5235            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
5236        );
5237    }
5238
5239    /// The tracer: a real archived `stream-json` capture written to
5240    /// `.devflow/phase-NN-stdout` produces a Layer-1 verdict out of
5241    /// `evaluate_layer1`. Before plan 30-01 this returned `None` for every
5242    /// JSONL capture — `serde_json::from_str` on the whole multi-line document
5243    /// is a hard "trailing characters" error, so all four single-document
5244    /// parsers declined it and the stage fell through to Layer 2's coarse
5245    /// exit-code+commit heuristic.
5246    ///
5247    /// Fixture provenance and its two modifications are documented on
5248    /// `V3_INIT_EVENT` / `V3_RESULT_TURN1..3` above.
5249    #[test]
5250    fn evaluate_layer1_parses_claude_stream_capture() {
5251        let dir = tempfile::tempdir().unwrap();
5252        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5253        std::fs::write(
5254            stdout_path(dir.path(), PhaseId::new(30)),
5255            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
5256        )
5257        .unwrap();
5258
5259        let result = evaluate_layer1(dir.path(), PhaseId::new(30)).unwrap();
5260
5261        assert_eq!(result.status, AgentStatus::Success);
5262        assert_eq!(result.decided_by_layer, Some(1));
5263
5264        // Non-vacuity guard for the assertion above: this marker omits
5265        // `decided_by_layer`, and the field is `#[serde(default)]`, so
5266        // `parse_marker_lines` alone yields `None`. `Some(1)` can therefore
5267        // only have come from the parser's explicit overwrite.
5268        assert_eq!(
5269            parse_marker_lines(r#"DEVFLOW_RESULT: {"status":"success"}"#)
5270                .unwrap()
5271                .decided_by_layer,
5272            None
5273        );
5274    }
5275
5276    // ---- idle-timeout side channel (31-02, D-05/D-06/D-07) ---------------
5277
5278    /// Write a monitor-shaped idle-timeout record. Field names and types match
5279    /// `IdleTimeoutRecord` exactly; the monitor writes it via serde, so a drift
5280    /// between the two shows up as a failing deserialize here.
5281    fn write_idle_timeout_record(root: &Path, phase: PhaseId, commits: &[(&str, &str)]) {
5282        let record = IdleTimeoutRecord {
5283            status: AgentStatus::IdleTimeout.as_wire_str().to_string(),
5284            idle_secs: 30,
5285            agent_pid: 4242,
5286            written_at: 1_700_000_000,
5287            commits: commits
5288                .iter()
5289                .map(|(sha, subject)| IdleTimeoutCommit {
5290                    sha: (*sha).to_string(),
5291                    subject: (*subject).to_string(),
5292                })
5293                .collect(),
5294        };
5295        std::fs::write(
5296            idle_timeout_path(root, phase),
5297            serde_json::to_string(&record).unwrap(),
5298        )
5299        .unwrap();
5300    }
5301
5302    /// T-31-06, and the single most important test in plan 31-02.
5303    ///
5304    /// The fixture is a REAL archived three-turn capture in which every
5305    /// top-level `result` event carries a success marker — the normal shape of
5306    /// a run that got far enough to idle out. A fixture without a prior
5307    /// `result` event would pass vacuously while the same mechanism silently
5308    /// failed in production.
5309    ///
5310    /// The negative control is encoded INSIDE the test rather than described in
5311    /// prose: the same fixture is evaluated first WITHOUT the side channel and
5312    /// must return `Success`. If that ever stops holding, the `IdleTimeout`
5313    /// assertion below is proving a verdict nothing was competing with.
5314    #[test]
5315    fn idle_timeout_side_channel_wins_over_stale_stream_result() {
5316        let dir = tempfile::tempdir().unwrap();
5317        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5318        std::fs::write(
5319            stdout_path(dir.path(), PhaseId::new(40)),
5320            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
5321        )
5322        .unwrap();
5323
5324        // NEGATIVE CONTROL — must produce the OPPOSITE result.
5325        assert_eq!(
5326            evaluate_layer1(dir.path(), PhaseId::new(40))
5327                .unwrap()
5328                .status,
5329            AgentStatus::Success,
5330            "negative control: without the side channel this fixture must decide Success, \
5331             otherwise the assertion below is vacuous"
5332        );
5333
5334        write_idle_timeout_record(
5335            dir.path(),
5336            PhaseId::new(40),
5337            &[("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "feat: partial")],
5338        );
5339
5340        let result = evaluate_layer1(dir.path(), PhaseId::new(40)).unwrap();
5341        assert_eq!(
5342            result.status,
5343            AgentStatus::IdleTimeout,
5344            "a stale success already in the capture must not shadow the monitor's verdict"
5345        );
5346        assert_eq!(result.decided_by_layer, Some(1));
5347    }
5348
5349    /// The read must precede `read_capture`'s early `return None`, so a
5350    /// timeout that fired before the child emitted anything at all is still
5351    /// authoritative rather than discarded.
5352    #[test]
5353    fn idle_timeout_side_channel_is_read_even_when_the_capture_is_missing() {
5354        let dir = tempfile::tempdir().unwrap();
5355        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5356        assert!(
5357            !stdout_path(dir.path(), PhaseId::new(41)).exists(),
5358            "fixture precondition: there must be no capture at all"
5359        );
5360
5361        // NEGATIVE CONTROL: with neither file present Layer 1 abstains, so the
5362        // verdict below can only have come from the side channel.
5363        assert!(evaluate_layer1(dir.path(), PhaseId::new(41)).is_none());
5364
5365        write_idle_timeout_record(dir.path(), PhaseId::new(41), &[]);
5366
5367        let result = evaluate_layer1(dir.path(), PhaseId::new(41)).unwrap();
5368        assert_eq!(result.status, AgentStatus::IdleTimeout);
5369        assert_eq!(result.commits, Some(0));
5370    }
5371
5372    /// D-07: the verdict names the commits, and says they were not rolled back.
5373    #[test]
5374    fn idle_timeout_result_carries_the_commits_it_enumerated() {
5375        let dir = tempfile::tempdir().unwrap();
5376        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5377        write_idle_timeout_record(
5378            dir.path(),
5379            PhaseId::new(42),
5380            &[
5381                ("1111111abcdef0000000000000000000000000000", "feat: first"),
5382                ("2222222abcdef0000000000000000000000000000", "fix: second"),
5383            ],
5384        );
5385
5386        let result = evaluate_layer1(dir.path(), PhaseId::new(42)).unwrap();
5387
5388        assert_eq!(result.commits, Some(2));
5389        let reason = result.reason.expect("an idle timeout must explain itself");
5390        for fragment in [
5391            "1111111",     // short sha, first commit
5392            "feat: first", // its subject
5393            "2222222",
5394            "fix: second",
5395            "30s",                           // how long the stream was silent
5396            "NONE of them were rolled back", // D-07's non-destruction promise
5397        ] {
5398            assert!(
5399                reason.contains(fragment),
5400                "reason must name {fragment:?}; got: {reason}"
5401            );
5402        }
5403        // The full sha must not be what is printed — a 40-char sha in a gate
5404        // message is noise, and the short form is what an operator pastes.
5405        assert!(!reason.contains("1111111abcdef0000000000000000000000000000"));
5406    }
5407
5408    /// Nothing about the pre-existing cascade changes when no timeout fired.
5409    /// Three shapes, each asserted against the verdict it produced before this
5410    /// plan existed, with the side channel confirmed absent in every one.
5411    #[test]
5412    fn absent_side_channel_leaves_the_cascade_unchanged() {
5413        let dir = tempfile::tempdir().unwrap();
5414        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5415
5416        std::fs::write(
5417            stdout_path(dir.path(), PhaseId::new(43)),
5418            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
5419        )
5420        .unwrap();
5421        std::fs::write(
5422            stdout_path(dir.path(), PhaseId::new(44)),
5423            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_FAILED),
5424        )
5425        .unwrap();
5426
5427        for (phase, expected) in [
5428            (PhaseId::new(43), Some(AgentStatus::Success)),
5429            (PhaseId::new(44), Some(AgentStatus::Failed)),
5430            (PhaseId::new(45), None), // no capture, no side channel
5431        ] {
5432            assert!(
5433                !idle_timeout_path(dir.path(), phase).exists(),
5434                "fixture precondition: phase {phase} must have no side channel"
5435            );
5436            assert_eq!(
5437                evaluate_layer1(dir.path(), phase).map(|r| r.status),
5438                expected,
5439                "the cascade changed for phase {phase} with no timeout on disk"
5440            );
5441        }
5442    }
5443
5444    /// The file's PRESENCE is the signal; its contents are enrichment.
5445    ///
5446    /// A corrupt record must NOT fall back into the cascade — that would let
5447    /// the stale success in the capture win, converting a damaged file into a
5448    /// silent wrong advance. This is the same fixture as
5449    /// `idle_timeout_side_channel_wins_over_stale_stream_result`, so the
5450    /// Success it would otherwise decide is real and not hypothetical.
5451    #[test]
5452    fn an_unreadable_idle_timeout_record_still_produces_the_verdict() {
5453        let dir = tempfile::tempdir().unwrap();
5454        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5455        std::fs::write(
5456            stdout_path(dir.path(), PhaseId::new(46)),
5457            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
5458        )
5459        .unwrap();
5460
5461        // NEGATIVE CONTROL: this capture decides Success on its own.
5462        assert_eq!(
5463            evaluate_layer1(dir.path(), PhaseId::new(46))
5464                .unwrap()
5465                .status,
5466            AgentStatus::Success
5467        );
5468
5469        std::fs::write(
5470            idle_timeout_path(dir.path(), PhaseId::new(46)),
5471            "{ this is not json",
5472        )
5473        .unwrap();
5474
5475        let result = evaluate_layer1(dir.path(), PhaseId::new(46)).unwrap();
5476        assert_eq!(result.status, AgentStatus::IdleTimeout);
5477        assert_eq!(
5478            result.commits, None,
5479            "an unreadable record must not invent a commit count"
5480        );
5481        assert!(result.reason.unwrap().contains("unreadable"));
5482    }
5483
5484    /// Last-result-wins. A session kept alive across turns emits one `result`
5485    /// event per turn; only the final one is the session's verdict.
5486    ///
5487    /// Asserts BOTH directions so the test cannot pass by a parser that merely
5488    /// prefers `success`: failed-then-success yields Success, and
5489    /// success-then-failed yields Failed. The middle event carries the same
5490    /// payload as the first, so a parser that stopped at index 1 would also
5491    /// fail.
5492    #[test]
5493    fn claude_stream_last_result_event_wins_over_earlier_results() {
5494        let last_success = v3_stream_capture(MARKER_FAILED, MARKER_FAILED, MARKER_SUCCESS);
5495        let result = parse_claude_event_result(&last_success).unwrap();
5496        assert_eq!(result.status, AgentStatus::Success);
5497
5498        let last_failed = v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_FAILED);
5499        let result = parse_claude_event_result(&last_failed).unwrap();
5500        assert_eq!(result.status, AgentStatus::Failed);
5501        assert_eq!(result.reason.as_deref(), Some("earlier turn aborted"));
5502    }
5503
5504    /// T-30-26: `decided_by_layer` is provenance, not decoration.
5505    /// `crates/devflow-cli/src/pipeline_outcomes.rs` (`classify_validate_outcome`)
5506    /// computes `external = decided_by_layer == Some(0) && status == Success`
5507    /// and uses it to tell an externally-probe-verified Validate stage apart
5508    /// from an ordinary one. An agent that writes `"decided_by_layer": 0` into
5509    /// its own marker is claiming a Layer-0 probe provenance it did not earn,
5510    /// so the stream parser overwrites the field unconditionally.
5511    ///
5512    /// This is a runtime assertion on the returned struct, not a source grep —
5513    /// it fails the moment the overwrite is dropped.
5514    #[test]
5515    fn claude_stream_overwrites_agent_planted_decided_by_layer() {
5516        // Non-vacuity guard: prove the planted value really would survive
5517        // deserialization, so the `Some(1)` below is the overwrite at work and
5518        // not an artifact of a marker that failed to parse.
5519        assert_eq!(
5520            parse_marker_lines(r#"DEVFLOW_RESULT: {"status":"success","decided_by_layer":0}"#)
5521                .unwrap()
5522                .decided_by_layer,
5523            Some(0)
5524        );
5525
5526        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_PLANTED_LAYER);
5527        let result = parse_claude_event_result(&capture).unwrap();
5528
5529        assert_eq!(result.status, AgentStatus::Success);
5530        assert_eq!(result.decided_by_layer, Some(1));
5531    }
5532
5533    /// A marker-less final turn defers to Layer 2 rather than reporting an
5534    /// unconditional Success — the same convention `parse_codex_event_result`
5535    /// applies to a bare `turn.completed`. A marker-less turn must never
5536    /// silently advance a stage.
5537    ///
5538    /// The FIRST turn carries a success marker, so this also proves the parser
5539    /// does not fall back to an earlier turn's marker when the last one has
5540    /// none.
5541    ///
5542    /// Plan 30-03 addendum: the deferral must hold specifically for
5543    /// `is_error: false`, which is what the real captured envelope carries —
5544    /// asserted below so this reads as a deliberate is_error case rather than
5545    /// an incidental one. Only `is_error: true` may promote a marker-less turn
5546    /// to `Failed`.
5547    #[test]
5548    fn claude_stream_last_result_without_marker_defers() {
5549        let capture = v3_stream_capture(MARKER_SUCCESS, NO_MARKER, NO_MARKER);
5550        assert!(
5551            capture.contains(r#""is_error":false"#),
5552            "the archived envelopes carry is_error:false; this test is about that case"
5553        );
5554        assert!(parse_claude_event_result(&capture).is_none());
5555    }
5556
5557    #[test]
5558    fn evaluate_layer1_reports_rate_limited_without_marker() {
5559        let dir = tempfile::tempdir().unwrap();
5560        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5561        std::fs::write(
5562            stdout_path(dir.path(), PhaseId::new(7)),
5563            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
5564        )
5565        .unwrap();
5566
5567        let result = evaluate_layer1(dir.path(), PhaseId::new(7)).unwrap();
5568
5569        assert_eq!(result.status, AgentStatus::RateLimited);
5570        assert_eq!(
5571            result.reason.as_deref(),
5572            Some("rate limited until 2026-06-18T15:45:30Z")
5573        );
5574    }
5575
5576    /// A real Claude rate-limit envelope carries `is_error: true` alongside
5577    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
5578    /// must outrank the generic is_error → Failed path, or the primary
5579    /// rate-limit resume cron never triggers for the exact case it exists for.
5580    #[test]
5581    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
5582        let dir = tempfile::tempdir().unwrap();
5583        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5584        std::fs::write(
5585            stdout_path(dir.path(), PhaseId::new(7)),
5586            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
5587        )
5588        .unwrap();
5589
5590        let result = evaluate_layer1(dir.path(), PhaseId::new(7)).unwrap();
5591
5592        assert_eq!(result.status, AgentStatus::RateLimited);
5593        assert_eq!(
5594            result.reason.as_deref(),
5595            Some("rate limited until 2026-06-18T15:45:30Z")
5596        );
5597    }
5598
5599    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
5600    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
5601    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
5602    /// detection (the blocking-mode capture was fixed; the file read here is
5603    /// the other half of the same bug).
5604    #[test]
5605    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
5606        let dir = tempfile::tempdir().unwrap();
5607        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5608        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
5609        bytes.extend_from_slice(
5610            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
5611        );
5612        std::fs::write(stdout_path(dir.path(), PhaseId::new(5)), bytes).unwrap();
5613
5614        let result = evaluate_layer1(dir.path(), PhaseId::new(5)).unwrap();
5615
5616        assert_eq!(result.status, AgentStatus::Failed);
5617        assert_eq!(result.reason.as_deref(), Some("review: bad"));
5618    }
5619
5620    #[test]
5621    fn failing_external_probe_outranks_success_marker() {
5622        let dir = tempfile::tempdir().unwrap();
5623        let phase_dir = dir
5624            .path()
5625            .join(".planning/phases/16-pipeline-reliability-hardening");
5626        std::fs::create_dir_all(&phase_dir).unwrap();
5627        std::fs::write(
5628            phase_dir.join("16-03-PLAN.md"),
5629            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
5630        )
5631        .unwrap();
5632        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5633        std::fs::write(
5634            stdout_path(dir.path(), PhaseId::new(16)),
5635            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
5636        )
5637        .unwrap();
5638        let state = state_in(dir.path(), PhaseId::new(16));
5639
5640        let approval = vec!["test -f externally-shipped".to_string()];
5641        let result = evaluate_agent_result_inner(
5642            dir.path(),
5643            &state,
5644            &GitFlowConfig::default(),
5645            Some(&approval),
5646        )
5647        .unwrap();
5648
5649        assert_eq!(result.status, AgentStatus::Failed);
5650        assert!(
5651            result
5652                .reason
5653                .as_deref()
5654                .is_some_and(|reason| reason.contains("external verification failed"))
5655        );
5656    }
5657
5658    /// D-05 gap 1 / D-06 (17-03): Layer 0 now evaluates on every stage, not
5659    /// only Code.
5660    ///
5661    /// This is the MAIN-CHECKOUT MIRROR of
5662    /// `external_probe_discovers_from_the_worktree_when_the_main_checkout_lacks_the_plan`,
5663    /// and the two must be read together: with no worktree set, discovery and
5664    /// probe execution resolve to the SAME root, so 999.76's relocation of
5665    /// discovery to `execution_root` provably leaves this path untouched.
5666    /// Without this mirror the worktree fixture alone could not distinguish
5667    /// "discovery reads the execution root" from "discovery reads any root
5668    /// that happens to hold the PLAN".
5669    ///
5670    /// It previously set `state.worktree_path` and asserted the opposite
5671    /// direction — that discovery must read `project_root` while probes run in
5672    /// the worktree (review Plan 03 MEDIUM, OpenCode). 999.76 overturned that
5673    /// premise (see [`evaluate_layer0`]'s doc comment), so the fixture was
5674    /// converted rather than deleted: every assertion below is the original
5675    /// one, including the `"external verification failed"` reason text and the
5676    /// final `Success` assertion. Only the two roots' coincidence changed.
5677    #[test]
5678    fn external_probe_discovers_from_project_root_across_every_stage_without_a_worktree() {
5679        let dir = tempfile::tempdir().unwrap();
5680        let phase_dir = dir.path().join(".planning/phases/16-reliability");
5681        std::fs::create_dir_all(&phase_dir).unwrap();
5682        std::fs::write(
5683            phase_dir.join("16-01-PLAN.md"),
5684            "---\nexternal_verify: \"test -f implemented\"\n---\n",
5685        )
5686        .unwrap();
5687        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5688        std::fs::write(
5689            stdout_path(dir.path(), PhaseId::new(16)),
5690            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
5691        )
5692        .unwrap();
5693        let mut state = state_in(dir.path(), PhaseId::new(16));
5694        // No worktree: `execution_root` falls back to `project_root`, so
5695        // discovery and probe execution read the same directory.
5696        state.worktree_path = None;
5697        state.stage = Stage::Plan;
5698
5699        let approval = vec!["test -f implemented".to_string()];
5700
5701        // Layer 0 now fires on Plan too — the probe file does not yet exist,
5702        // so this must fail on the probe itself (NOT a false PLAN-removed
5703        // veto, which would mean discovery silently returned zero commands).
5704        let plan_result = evaluate_agent_result_inner(
5705            dir.path(),
5706            &state,
5707            &GitFlowConfig::default(),
5708            Some(&approval),
5709        )
5710        .unwrap();
5711        assert_eq!(plan_result.status, AgentStatus::Failed);
5712        assert!(
5713            plan_result
5714                .reason
5715                .as_deref()
5716                .is_some_and(|reason| reason.contains("external verification failed")),
5717            "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
5718            plan_result.reason
5719        );
5720
5721        state.stage = Stage::Code;
5722        let code_result = evaluate_agent_result_inner(
5723            dir.path(),
5724            &state,
5725            &GitFlowConfig::default(),
5726            Some(&approval),
5727        )
5728        .unwrap();
5729        assert_eq!(code_result.status, AgentStatus::Failed);
5730
5731        // The probe executes against execution_root, which without a worktree
5732        // IS project_root — the coincidence this mirror exists to pin.
5733        std::fs::write(dir.path().join("implemented"), "done").unwrap();
5734        let passing = evaluate_agent_result_inner(
5735            dir.path(),
5736            &state,
5737            &GitFlowConfig::default(),
5738            Some(&approval),
5739        )
5740        .unwrap();
5741        assert_eq!(passing.status, AgentStatus::Success);
5742        assert_eq!(passing.decided_by_layer, Some(0));
5743    }
5744
5745    /// 999.76 (ROADMAP criterion 6): the INVERSE of the fixture above. The
5746    /// PLAN lives only under the worktree and `project_root`'s own
5747    /// `.planning/phases/` is absent entirely — which is what an in-flight
5748    /// phase actually looks like. `.planning/` is tracked content, so a phase's
5749    /// `{N}-PLAN.md` sits on `feature/phase-{N}` INSIDE the worktree and is
5750    /// absent from the main checkout for the phase's whole duration.
5751    ///
5752    /// The live provenance measurement for that layout claim is **NC-7**,
5753    /// recorded in this phase's `34-04-SUMMARY.md`: `git ls-tree -r develop`
5754    /// vs `git ls-tree -r HEAD` over `.planning/phases`, reported with both
5755    /// refs' counts. NC-7 is evidence that the layout manufactured here is the
5756    /// real one — it says nothing about whether this code is correct. That
5757    /// claim is carried by this fixture and by its main-checkout mirror
5758    /// `external_probe_discovers_from_project_root_across_every_stage_without_a_worktree`,
5759    /// which must be read together with it.
5760    #[test]
5761    fn external_probe_discovers_from_the_worktree_when_the_main_checkout_lacks_the_plan() {
5762        let dir = tempfile::tempdir().unwrap();
5763        let worktree = dir.path().join("phase-worktree");
5764        // The PLAN exists ONLY under the worktree — `dir.path()`'s own
5765        // `.planning/phases/` is deliberately never created.
5766        let phase_dir = worktree.join(".planning/phases/16-reliability");
5767        std::fs::create_dir_all(&phase_dir).unwrap();
5768        std::fs::write(
5769            phase_dir.join("16-01-PLAN.md"),
5770            "---\nexternal_verify: \"test -f implemented\"\n---\n",
5771        )
5772        .unwrap();
5773        // Captures live in the project root, not the worktree.
5774        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5775        std::fs::write(
5776            stdout_path(dir.path(), PhaseId::new(16)),
5777            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
5778        )
5779        .unwrap();
5780        let mut state = state_in(dir.path(), PhaseId::new(16));
5781        state.worktree_path = Some(worktree.clone());
5782
5783        let approval = vec!["test -f implemented".to_string()];
5784
5785        // The probe file does not exist yet, so this must fail ON THE PROBE.
5786        let failing = evaluate_agent_result_inner(
5787            dir.path(),
5788            &state,
5789            &GitFlowConfig::default(),
5790            Some(&approval),
5791        )
5792        .unwrap();
5793        assert_eq!(failing.status, AgentStatus::Failed);
5794        assert!(
5795            failing
5796                .reason
5797                .as_deref()
5798                .is_some_and(|reason| reason.contains("external verification failed")),
5799            "expected a failing-probe reason; a PLAN-removed reason means discovery \
5800             silently returned zero commands — i.e. discovery still reads project_root \
5801             and 999.76's fix did not land: {:?}",
5802            failing.reason
5803        );
5804
5805        std::fs::write(worktree.join("implemented"), "done").unwrap();
5806        let passing = evaluate_agent_result_inner(
5807            dir.path(),
5808            &state,
5809            &GitFlowConfig::default(),
5810            Some(&approval),
5811        )
5812        .unwrap();
5813        assert_eq!(passing.status, AgentStatus::Success);
5814        assert_eq!(passing.decided_by_layer, Some(0));
5815    }
5816
5817    #[test]
5818    fn changed_external_probe_never_inherits_prior_approval() {
5819        let dir = tempfile::tempdir().unwrap();
5820        let phase_dir = dir.path().join(".planning/phases/16-reliability");
5821        std::fs::create_dir_all(&phase_dir).unwrap();
5822        std::fs::write(
5823            phase_dir.join("16-01-PLAN.md"),
5824            "---\nexternal_verify: \"touch escaped\"\n---\n",
5825        )
5826        .unwrap();
5827        let state = state_in(dir.path(), PhaseId::new(16));
5828        let approved = vec!["test -f reviewed-artifact".to_string()];
5829
5830        let result = evaluate_agent_result_inner(
5831            dir.path(),
5832            &state,
5833            &GitFlowConfig::default(),
5834            Some(&approved),
5835        )
5836        .unwrap();
5837
5838        assert_eq!(result.status, AgentStatus::Failed);
5839        assert!(result.reason.unwrap().contains("approval mismatch"));
5840        assert!(!dir.path().join("escaped").exists());
5841    }
5842
5843    #[test]
5844    fn removed_external_probe_fails_closed_against_prior_approval() {
5845        let dir = tempfile::tempdir().unwrap();
5846        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5847        std::fs::write(
5848            stdout_path(dir.path(), PhaseId::new(16)),
5849            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
5850        )
5851        .unwrap();
5852        let state = state_in(dir.path(), PhaseId::new(16));
5853        let approved = vec!["test -f shipped".to_string()];
5854
5855        let result = evaluate_agent_result_inner(
5856            dir.path(),
5857            &state,
5858            &GitFlowConfig::default(),
5859            Some(&approved),
5860        )
5861        .unwrap();
5862
5863        assert_eq!(result.status, AgentStatus::Failed);
5864        assert!(result.reason.unwrap().contains("declaration was removed"));
5865    }
5866
5867    #[test]
5868    fn no_external_declaration_preserves_layer1_result() {
5869        let dir = tempfile::tempdir().unwrap();
5870        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5871        std::fs::write(
5872            stdout_path(dir.path(), PhaseId::new(16)),
5873            "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
5874        )
5875        .unwrap();
5876        let state = state_in(dir.path(), PhaseId::new(16));
5877        let layer1 = evaluate_layer1(dir.path(), PhaseId::new(16)).unwrap();
5878
5879        let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
5880
5881        assert_eq!(
5882            serde_json::to_value(full).unwrap(),
5883            serde_json::to_value(layer1).unwrap()
5884        );
5885    }
5886
5887    /// D-05 gap 2 (17-03): a declared, operator-approved external
5888    /// post-condition whose probe passes is affirmative Success evidence on
5889    /// its own — even with zero commits and on a non-Code stage (Define
5890    /// here). No agent stdout is written at all, so if Layer 0 did not
5891    /// short-circuit, there would be nothing for Layer 1 to find and Layer 2
5892    /// would fall through for lack of an exit-code file.
5893    #[test]
5894    fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
5895        let dir = tempfile::tempdir().unwrap();
5896        let phase_dir = dir.path().join(".planning/phases/16-reliability");
5897        std::fs::create_dir_all(&phase_dir).unwrap();
5898        std::fs::write(
5899            phase_dir.join("16-01-PLAN.md"),
5900            "---\nexternal_verify: \"test -f shipped\"\n---\n",
5901        )
5902        .unwrap();
5903        std::fs::write(dir.path().join("shipped"), "done").unwrap();
5904        let mut state = state_in(dir.path(), PhaseId::new(16));
5905        state.stage = Stage::Define;
5906
5907        let approval = vec!["test -f shipped".to_string()];
5908        let result = evaluate_agent_result_inner(
5909            dir.path(),
5910            &state,
5911            &GitFlowConfig::default(),
5912            Some(&approval),
5913        )
5914        .unwrap();
5915
5916        assert_eq!(result.status, AgentStatus::Success);
5917        assert_eq!(result.decided_by_layer, Some(0));
5918        assert_eq!(result.commits, None);
5919        // Off-Validate stage: verdict reconciliation does not apply (18e).
5920        assert_eq!(result.verdict, None);
5921    }
5922
5923    /// Review Plan 03 LOW (Codex+OpenCode), 16a: an approved all-passing
5924    /// Layer 0 probe intentionally outranks a Layer 1 self-reported failure
5925    /// marker — proven here at the cascade level (`evaluate_agent_result_inner`),
5926    /// not merely in isolation on `evaluate_layer0`.
5927    #[test]
5928    fn layer0_affirmative_success_outranks_layer1_failure_marker() {
5929        let dir = tempfile::tempdir().unwrap();
5930        let phase_dir = dir
5931            .path()
5932            .join(".planning/phases/16-pipeline-reliability-hardening");
5933        std::fs::create_dir_all(&phase_dir).unwrap();
5934        std::fs::write(
5935            phase_dir.join("16-03-PLAN.md"),
5936            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
5937        )
5938        .unwrap();
5939        std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
5940        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5941        std::fs::write(
5942            stdout_path(dir.path(), PhaseId::new(16)),
5943            "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
5944        )
5945        .unwrap();
5946        let state = state_in(dir.path(), PhaseId::new(16));
5947
5948        let approval = vec!["test -f externally-shipped".to_string()];
5949        let result = evaluate_agent_result_inner(
5950            dir.path(),
5951            &state,
5952            &GitFlowConfig::default(),
5953            Some(&approval),
5954        )
5955        .unwrap();
5956
5957        assert_eq!(result.status, AgentStatus::Success);
5958        assert_eq!(result.decided_by_layer, Some(0));
5959        // Off-Validate stage (Code): verdict reconciliation does not apply,
5960        // even though Layer 1's marker here reports a (failure) status (18e).
5961        assert_eq!(result.verdict, None);
5962    }
5963
5964    /// D-05/18e: Layer 0's affirmative-success arm at `Stage::Validate` must
5965    /// consult Layer 1's verdict rather than discard it — the two-signal
5966    /// reconciliation `reconcile_layer0_verdict` adds. Covers all three
5967    /// verdict states Layer 1 can produce: pass, gaps, and no marker at all.
5968    ///
5969    /// D-15 (34-01) adds a FOURTH case: the self-contradictory marker
5970    /// `{"status":"failed","verdict":"pass"}`. "Consult Layer 1's verdict" was
5971    /// implemented as "read Layer 1's verdict and nothing else", so an agent
5972    /// that reported its own failure while claiming a passing verdict had that
5973    /// verdict grafted onto Layer 0's `Success` — 999.74's real route. The
5974    /// fourth case pins `verdict: None` for it; before the fix it observed
5975    /// `Some(Pass)`.
5976    #[test]
5977    fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
5978        let dir = tempfile::tempdir().unwrap();
5979        let phase_dir = dir.path().join(".planning/phases/16-reliability");
5980        std::fs::create_dir_all(&phase_dir).unwrap();
5981        std::fs::write(
5982            phase_dir.join("16-01-PLAN.md"),
5983            "---\nexternal_verify: \"test -f shipped\"\n---\n",
5984        )
5985        .unwrap();
5986        std::fs::write(dir.path().join("shipped"), "done").unwrap();
5987        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5988        let mut state = state_in(dir.path(), PhaseId::new(16));
5989        state.stage = Stage::Validate;
5990        let approval = vec!["test -f shipped".to_string()];
5991
5992        std::fs::write(
5993            stdout_path(dir.path(), PhaseId::new(16)),
5994            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
5995        )
5996        .unwrap();
5997        let result = evaluate_agent_result_inner(
5998            dir.path(),
5999            &state,
6000            &GitFlowConfig::default(),
6001            Some(&approval),
6002        )
6003        .unwrap();
6004        assert_eq!(result.status, AgentStatus::Success);
6005        assert_eq!(result.decided_by_layer, Some(0));
6006        assert_eq!(result.verdict, Some(Verdict::Pass));
6007
6008        std::fs::write(
6009            stdout_path(dir.path(), PhaseId::new(16)),
6010            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
6011        )
6012        .unwrap();
6013        let result = evaluate_agent_result_inner(
6014            dir.path(),
6015            &state,
6016            &GitFlowConfig::default(),
6017            Some(&approval),
6018        )
6019        .unwrap();
6020        assert_eq!(result.verdict, Some(Verdict::Gaps));
6021
6022        std::fs::remove_file(stdout_path(dir.path(), PhaseId::new(16))).unwrap();
6023        let result = evaluate_agent_result_inner(
6024            dir.path(),
6025            &state,
6026            &GitFlowConfig::default(),
6027            Some(&approval),
6028        )
6029        .unwrap();
6030        assert_eq!(result.verdict, None);
6031
6032        // D-15: the self-contradictory marker. Layer 1 reports its own run
6033        // FAILED and simultaneously claims a passing verdict. Pre-fix the graft
6034        // read only `.verdict` and produced `Some(Pass)`, i.e. an affirmative
6035        // pair `decide_action` advances and `classify_validate_outcome` reads
6036        // as Passed — Ship, unattended, on a run whose agent reported failure.
6037        std::fs::write(
6038            stdout_path(dir.path(), PhaseId::new(16)),
6039            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
6040        )
6041        .unwrap();
6042        let result = evaluate_agent_result_inner(
6043            dir.path(),
6044            &state,
6045            &GitFlowConfig::default(),
6046            Some(&approval),
6047        )
6048        .unwrap();
6049        assert_eq!(
6050            result.verdict, None,
6051            "a verdict attached to a self-reported failure must not be grafted (D-15)"
6052        );
6053        // The fix touches `.verdict` only — Layer 0 still decided the status.
6054        assert_eq!(result.status, AgentStatus::Success);
6055        assert_eq!(result.decided_by_layer, Some(0));
6056    }
6057
6058    /// D-15 / ROADMAP criterion 4: `reconcile_layer0_verdict` must consult
6059    /// Layer 1's own `AgentStatus` before transplanting its `verdict`.
6060    ///
6061    /// A regression here costs an unattended Ship on a run whose agent reported
6062    /// failure: the graft would rebuild `(Success, Some(Pass), Some(0))` from a
6063    /// self-contradictory marker, `decide_action` would advance it, and
6064    /// `classify_validate_outcome` would classify Validate as `Passed`.
6065    ///
6066    /// Also carries NC-5's two discrimination cases, which share this fixture.
6067    /// The exploit needs BOTH marker fields; removing either must not reach an
6068    /// affirmative pair. The mandatory opposite-result control lives in
6069    /// `layer0_verdict_graft_still_transplants_a_passing_layer1_verdict` — if
6070    /// that test also produced `None` the fix would be indiscriminate and this
6071    /// one would prove nothing.
6072    #[test]
6073    fn layer0_verdict_graft_declines_when_layer1_status_is_not_success() {
6074        let dir = tempfile::tempdir().unwrap();
6075        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6076        std::fs::create_dir_all(&phase_dir).unwrap();
6077        std::fs::write(
6078            phase_dir.join("16-01-PLAN.md"),
6079            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6080        )
6081        .unwrap();
6082        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6083        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6084        let mut state = state_in(dir.path(), PhaseId::new(16));
6085        state.stage = Stage::Validate;
6086        let approval = vec!["test -f shipped".to_string()];
6087
6088        // The exploit itself: both fields present and mutually contradictory.
6089        std::fs::write(
6090            stdout_path(dir.path(), PhaseId::new(16)),
6091            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
6092        )
6093        .unwrap();
6094        let result = evaluate_agent_result_inner(
6095            dir.path(),
6096            &state,
6097            &GitFlowConfig::default(),
6098            Some(&approval),
6099        )
6100        .unwrap();
6101        assert_eq!(
6102            result.verdict, None,
6103            "self-contradictory marker: the verdict must be declined (D-15)"
6104        );
6105        assert_eq!(result.status, AgentStatus::Success);
6106        assert_eq!(result.decided_by_layer, Some(0));
6107
6108        // NC-5a: removes the `verdict` FIELD, keeps the failed status. `None`
6109        // both pre- and post-fix, so this case cannot discriminate the fix —
6110        // that is the point. The failed status alone is not the exploit.
6111        std::fs::write(
6112            stdout_path(dir.path(), PhaseId::new(16)),
6113            "DEVFLOW_RESULT: {\"status\":\"failed\"}\n",
6114        )
6115        .unwrap();
6116        let result = evaluate_agent_result_inner(
6117            dir.path(),
6118            &state,
6119            &GitFlowConfig::default(),
6120            Some(&approval),
6121        )
6122        .unwrap();
6123        assert_eq!(
6124            result.verdict, None,
6125            "NC-5a removes the `verdict` field: there is no verdict to graft, \
6126             so the result must be None whether or not the fix is present"
6127        );
6128
6129        // NC-5b: removes `verdict: pass` SPECIFICALLY by downgrading it to
6130        // `gaps`, keeping both fields present. Pre-fix this grafted
6131        // `Some(Gaps)`; post-fix it declines like any other non-Success
6132        // Layer 1. Neither state is an affirmative pair — the exploit needs
6133        // `pass`, not merely any verdict.
6134        std::fs::write(
6135            stdout_path(dir.path(), PhaseId::new(16)),
6136            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"gaps\"}\n",
6137        )
6138        .unwrap();
6139        let result = evaluate_agent_result_inner(
6140            dir.path(),
6141            &state,
6142            &GitFlowConfig::default(),
6143            Some(&approval),
6144        )
6145        .unwrap();
6146        assert_ne!(
6147            result.verdict,
6148            Some(Verdict::Pass),
6149            "NC-5b removes `verdict: pass` by downgrading it to `gaps`: this \
6150             case must never reach an affirmative pair"
6151        );
6152        assert_eq!(result.verdict, None);
6153    }
6154
6155    /// NC-5's positive half: the fix declines ONLY when Layer 1's own status is
6156    /// not `Success`, never indiscriminately.
6157    ///
6158    /// This is the case that must produce the OPPOSITE result from
6159    /// `layer0_verdict_graft_declines_when_layer1_status_is_not_success`. If
6160    /// both produced `None` the fix would have disabled 18e's legitimate
6161    /// reconciliation wholesale — re-introducing the 17-03 regression that
6162    /// `reconcile_layer0_verdict` exists to fix — and the pair would prove
6163    /// nothing about D-15, because a measurement whose two arms agree is
6164    /// broken rather than informative.
6165    #[test]
6166    fn layer0_verdict_graft_still_transplants_a_passing_layer1_verdict() {
6167        let dir = tempfile::tempdir().unwrap();
6168        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6169        std::fs::create_dir_all(&phase_dir).unwrap();
6170        std::fs::write(
6171            phase_dir.join("16-01-PLAN.md"),
6172            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6173        )
6174        .unwrap();
6175        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6176        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6177        let mut state = state_in(dir.path(), PhaseId::new(16));
6178        state.stage = Stage::Validate;
6179        let approval = vec!["test -f shipped".to_string()];
6180
6181        std::fs::write(
6182            stdout_path(dir.path(), PhaseId::new(16)),
6183            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
6184        )
6185        .unwrap();
6186        let result = evaluate_agent_result_inner(
6187            dir.path(),
6188            &state,
6189            &GitFlowConfig::default(),
6190            Some(&approval),
6191        )
6192        .unwrap();
6193        assert_eq!(
6194            result.verdict,
6195            Some(Verdict::Pass),
6196            "a passing verdict from a Layer 1 that reported its OWN success \
6197             must still be transplanted (18e); a None here would mean the \
6198             D-15 fix is indiscriminate"
6199        );
6200        assert_eq!(result.status, AgentStatus::Success);
6201        assert_eq!(result.decided_by_layer, Some(0));
6202    }
6203
6204    /// NC-6: with Layer 0 disabled, the same self-contradictory marker never
6205    /// gets laundered at all — Layer 1 reports `Failed` verbatim and
6206    /// `decide_action` routes it to `GateReview`.
6207    ///
6208    /// What the control proves: the GRAFT is the mechanism, not the classifier
6209    /// and not `decide_action`. Removing Layer 0 removes the laundering
6210    /// entirely, so the exploit's precondition is an affirmative Layer-0 probe
6211    /// success — which is exactly why plan 34-04 (999.76), by making
6212    /// `decided_by_layer == Some(0)` common in worktree mode, must not land
6213    /// without the fix this test pins.
6214    ///
6215    /// The routing consequence is asserted here rather than assumed, so a
6216    /// future change to `decide_action`'s `Failed` arm breaks this test rather
6217    /// than silently invalidating the control.
6218    #[test]
6219    fn layer0_disabled_routes_a_self_reported_failure_to_gate_review() {
6220        let dir = tempfile::tempdir().unwrap();
6221        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6222        std::fs::create_dir_all(&phase_dir).unwrap();
6223        // The difference from the fixtures above: Layer 0 is switched off, so
6224        // the cascade falls through to Layer 1 instead of short-circuiting on
6225        // an affirmative probe success.
6226        std::fs::write(
6227            dir.path().join("devflow.toml"),
6228            "external_verify_enabled = false\n",
6229        )
6230        .unwrap();
6231        // Belt AND braces, deliberately. `config::external_verify_enabled`
6232        // consults `DEVFLOW_EXTERNAL_VERIFY_ENABLED` BEFORE `devflow.toml`, and
6233        // `config::tests::env_overrides_file_external_verification` sets that
6234        // variable to "true" process-globally under a mutex private to its own
6235        // module — which cannot serialize against this one. A PLAN declaring
6236        // `external_verify` would therefore let a parallel run of that test
6237        // re-enable Layer 0 here and flake this control into a green.
6238        // Declaring no probe closes that window: with no declared commands and
6239        // no approval vector, `evaluate_layer0` abstains whatever the env says,
6240        // so this test is deterministic under every value of the variable.
6241        std::fs::write(phase_dir.join("16-01-PLAN.md"), "---\nplan: 01\n---\n").unwrap();
6242        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6243        let mut state = state_in(dir.path(), PhaseId::new(16));
6244        state.stage = Stage::Validate;
6245
6246        std::fs::write(
6247            stdout_path(dir.path(), PhaseId::new(16)),
6248            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
6249        )
6250        .unwrap();
6251        // No approval vector — Layer 0 is disabled, so there is nothing to
6252        // approve, and supplying one would re-arm the very arm being removed.
6253        let result =
6254            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6255                .unwrap();
6256
6257        assert_eq!(
6258            result.status,
6259            AgentStatus::Failed,
6260            "with Layer 0 disabled, Layer 1's self-reported failure stands \
6261             verbatim — there is no affirmative probe success to graft onto"
6262        );
6263        assert_eq!(result.decided_by_layer, Some(1));
6264        assert_eq!(
6265            crate::outcome_policy::decide_action(Stage::Validate, result.status),
6266            crate::outcome_policy::Action::GateReview,
6267            "a self-reported failure must gate for review, never advance"
6268        );
6269    }
6270
6271    /// 18e's reconciliation is scoped to `Stage::Validate` only (flagged
6272    /// assumption in 18-05-PLAN.md): at every other stage an affirmative
6273    /// Layer 0 success must keep `verdict: None`, even when Layer 1's marker
6274    /// carries an explicit verdict.
6275    #[test]
6276    fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
6277        let dir = tempfile::tempdir().unwrap();
6278        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6279        std::fs::create_dir_all(&phase_dir).unwrap();
6280        std::fs::write(
6281            phase_dir.join("16-01-PLAN.md"),
6282            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6283        )
6284        .unwrap();
6285        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6286        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6287        std::fs::write(
6288            stdout_path(dir.path(), PhaseId::new(16)),
6289            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
6290        )
6291        .unwrap();
6292        let state = state_in(dir.path(), PhaseId::new(16)); // Stage::Code by default
6293        let approval = vec!["test -f shipped".to_string()];
6294
6295        let result = evaluate_agent_result_inner(
6296            dir.path(),
6297            &state,
6298            &GitFlowConfig::default(),
6299            Some(&approval),
6300        )
6301        .unwrap();
6302
6303        assert_eq!(result.status, AgentStatus::Success);
6304        assert_eq!(result.decided_by_layer, Some(0));
6305        assert_eq!(result.verdict, None);
6306    }
6307
6308    /// Ordering edge (17a): with multiple declared probes, ALL must pass for
6309    /// affirmative Success — the first failing probe vetoes the outcome
6310    /// regardless of which position it occupies among the declarations.
6311    #[test]
6312    fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
6313        let dir = tempfile::tempdir().unwrap();
6314        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6315        std::fs::create_dir_all(&phase_dir).unwrap();
6316        // 16-01 comes first alphabetically and passes; 16-02 comes second and fails.
6317        std::fs::write(
6318            phase_dir.join("16-01-PLAN.md"),
6319            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
6320        )
6321        .unwrap();
6322        std::fs::write(
6323            phase_dir.join("16-02-PLAN.md"),
6324            "---\nexternal_verify: \"test -f never-created\"\n---\n",
6325        )
6326        .unwrap();
6327        std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
6328        let mut state = state_in(dir.path(), PhaseId::new(16));
6329        state.stage = Stage::Define;
6330
6331        let approval = vec![
6332            "test -f passing-artifact".to_string(),
6333            "test -f never-created".to_string(),
6334        ];
6335        let result_a = evaluate_agent_result_inner(
6336            dir.path(),
6337            &state,
6338            &GitFlowConfig::default(),
6339            Some(&approval),
6340        )
6341        .unwrap();
6342        assert_eq!(result_a.status, AgentStatus::Failed);
6343        assert!(
6344            result_a
6345                .reason
6346                .as_deref()
6347                .is_some_and(|reason| reason.contains("never-created")),
6348            "unexpected reason: {:?}",
6349            result_a.reason
6350        );
6351
6352        // Swap which position fails: 16-01 now fails, 16-02 passes. The
6353        // overall outcome must still veto — order of declaration must not
6354        // matter.
6355        std::fs::write(
6356            phase_dir.join("16-01-PLAN.md"),
6357            "---\nexternal_verify: \"test -f still-missing\"\n---\n",
6358        )
6359        .unwrap();
6360        std::fs::write(
6361            phase_dir.join("16-02-PLAN.md"),
6362            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
6363        )
6364        .unwrap();
6365        let approval_swapped = vec![
6366            "test -f still-missing".to_string(),
6367            "test -f passing-artifact".to_string(),
6368        ];
6369        let result_b = evaluate_agent_result_inner(
6370            dir.path(),
6371            &state,
6372            &GitFlowConfig::default(),
6373            Some(&approval_swapped),
6374        )
6375        .unwrap();
6376        assert_eq!(result_b.status, AgentStatus::Failed);
6377
6378        // Now make BOTH pass: only then is the outcome Success.
6379        std::fs::write(dir.path().join("still-missing"), "done").unwrap();
6380        let result_c = evaluate_agent_result_inner(
6381            dir.path(),
6382            &state,
6383            &GitFlowConfig::default(),
6384            Some(&approval_swapped),
6385        )
6386        .unwrap();
6387        assert_eq!(result_c.status, AgentStatus::Success);
6388        assert_eq!(result_c.decided_by_layer, Some(0));
6389    }
6390
6391    /// A quota denial in the capture must be visible to the monitor BEFORE it
6392    /// records a verdict about the silence that denial caused.
6393    ///
6394    /// The positive arm of the 2026-08-08 misclassification: a real `seven_day`
6395    /// / `out_of_credits` denial silenced the agent, the idle timer fired, and
6396    /// the resulting record shadowed the classifier that had the right answer.
6397    #[test]
6398    fn a_quota_denial_in_the_capture_is_visible_to_the_monitor() {
6399        let dir = tempfile::tempdir().unwrap();
6400        let root = dir.path();
6401        let phase = PhaseId::new(3);
6402        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6403
6404        std::fs::write(
6405            stdout_path(root, phase),
6406            format!("{V3_INIT_EVENT}\n{}\n", v3_rate_limit_event("rejected")),
6407        )
6408        .unwrap();
6409
6410        assert!(
6411            capture_shows_rate_limit_denial(root, phase),
6412            "an explicit `rejected` quota denial must be detectable, or the monitor \
6413             will record a hang for a pause that is resumable"
6414        );
6415    }
6416
6417    /// Negative control, and the more important half: this must NOT fire on an
6418    /// ordinary capture, or every genuine hang stops being recorded as one.
6419    ///
6420    /// The `allowed` arm is the specific trap — the CLI emits `rate_limit_event`
6421    /// routinely while healthy, and `overageStatus: "rejected"` sits one level
6422    /// below `status: "allowed"`, so any loose nested search matches it.
6423    #[test]
6424    fn an_ordinary_capture_is_not_mistaken_for_a_quota_denial() {
6425        let dir = tempfile::tempdir().unwrap();
6426        let root = dir.path();
6427        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6428
6429        let quiet = PhaseId::new(4);
6430        std::fs::write(stdout_path(root, quiet), format!("{V3_INIT_EVENT}\n")).unwrap();
6431        assert!(
6432            !capture_shows_rate_limit_denial(root, quiet),
6433            "a capture with no rate-limit event at all must read as no denial"
6434        );
6435
6436        let healthy = PhaseId::new(5);
6437        std::fs::write(
6438            stdout_path(root, healthy),
6439            format!("{V3_INIT_EVENT}\n{V3_RATE_LIMIT_EVENT_ALLOWED}\n"),
6440        )
6441        .unwrap();
6442        assert!(
6443            !capture_shows_rate_limit_denial(root, healthy),
6444            "a healthy `status: allowed` event carries `overageStatus: rejected` one \
6445             level down — matching it would suppress the idle timeout on every run"
6446        );
6447
6448        let absent = PhaseId::new(6);
6449        assert!(
6450            !capture_shows_rate_limit_denial(root, absent),
6451            "a missing capture must read as no denial, never as one"
6452        );
6453    }
6454
6455    /// A stage attempt's idle-timeout verdict must not survive into the next
6456    /// attempt.
6457    ///
6458    /// Reproduces the 2026-08-08 observation directly: a record written by a
6459    /// killed Plan stage was still authoritative when the next stage launched,
6460    /// and because `evaluate_layer1` consults it FIRST and returns
6461    /// unconditionally, it overrode a stage that had genuinely succeeded.
6462    #[test]
6463    fn archive_clears_a_previous_attempts_idle_timeout_verdict() {
6464        let dir = tempfile::tempdir().unwrap();
6465        let root = dir.path();
6466        let phase = PhaseId::new(1);
6467        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6468
6469        // A verdict left behind by an earlier, killed attempt.
6470        std::fs::write(
6471            idle_timeout_path(root, phase),
6472            r#"{"status":"idle_timeout","idle_secs":120,"agent_pid":501757,"written_at":1786157328,"commits":[]}"#,
6473        )
6474        .unwrap();
6475        // Precondition, asserted rather than assumed: while it exists it is
6476        // authoritative, which is precisely why it must not persist.
6477        assert!(
6478            evaluate_layer1(root, phase).is_some(),
6479            "fixture precondition: the stale record must be readable as a verdict"
6480        );
6481
6482        archive_phase_files(root, root, phase, 5).unwrap();
6483
6484        assert!(
6485            !idle_timeout_path(root, phase).exists(),
6486            "a previous attempt's timeout verdict survived a stage launch — it \
6487             will now outrank the next stage's real result, for this phase, forever"
6488        );
6489    }
6490
6491    /// Negative control for the test above. The clearing happens at stage
6492    /// LAUNCH, and it must not be reachable in a way that discards a verdict
6493    /// before it has been read: a record with no capture beside it is the
6494    /// stale case, but a record is only ever written mid-attempt, after the
6495    /// launch that would have cleared it.
6496    ///
6497    /// This pins the other half — that clearing the file did not neuter the
6498    /// mechanism, only its lifetime.
6499    #[test]
6500    fn a_current_attempts_idle_timeout_verdict_is_still_authoritative() {
6501        let dir = tempfile::tempdir().unwrap();
6502        let root = dir.path();
6503        let phase = PhaseId::new(2);
6504        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6505
6506        // Stage launched (archive ran), THEN the monitor recorded a timeout —
6507        // the real ordering within one attempt.
6508        archive_phase_files(root, root, phase, 5).unwrap();
6509        std::fs::write(
6510            idle_timeout_path(root, phase),
6511            r#"{"status":"idle_timeout","idle_secs":120,"agent_pid":4242,"written_at":1786159637,"commits":[]}"#,
6512        )
6513        .unwrap();
6514
6515        let verdict = evaluate_layer1(root, phase)
6516            .expect("a verdict recorded during this attempt must still be honoured");
6517        assert_eq!(
6518            verdict.status,
6519            AgentStatus::IdleTimeout,
6520            "the timeout mechanism itself must survive the lifetime fix"
6521        );
6522    }
6523
6524    #[test]
6525    fn archive_moves_captures_into_history_and_removes_pid_file() {
6526        // 16b: prior-stage captures must survive a simulated next-launch by
6527        // appearing under .devflow/history/phase-NN/, not be wiped outright.
6528        let dir = tempfile::tempdir().unwrap();
6529        let root = dir.path();
6530        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6531        std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
6532        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
6533        std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
6534
6535        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
6536
6537        // The live capture paths are gone (moved, not merely deleted).
6538        assert!(!root.join(".devflow/phase-01-stdout").exists());
6539        assert!(!root.join(".devflow/phase-01-exit").exists());
6540        // Agent-pid is bookkeeping, not diagnostic — still removed outright.
6541        assert!(!root.join(".devflow/phase-01-agent-pid").exists());
6542
6543        let history = history_dir(root, PhaseId::new(1));
6544        let archived: Vec<_> = std::fs::read_dir(&history)
6545            .unwrap()
6546            .flatten()
6547            .map(|e| e.file_name().to_string_lossy().into_owned())
6548            .collect();
6549        let archived_stdout = archived
6550            .iter()
6551            .find(|name| name.ends_with("-stdout"))
6552            .expect("stdout capture should be archived into history");
6553        assert!(archived.iter().any(|name| name.ends_with("-exit")));
6554        let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
6555        assert_eq!(contents, "prior stdout");
6556    }
6557
6558    #[test]
6559    fn archive_is_noop_when_nothing_to_archive() {
6560        let dir = tempfile::tempdir().unwrap();
6561        let root = dir.path();
6562        // Should not panic when there is nothing to archive (first launch).
6563        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
6564        assert!(!history_dir(root, PhaseId::new(1)).exists());
6565    }
6566
6567    #[test]
6568    fn archive_handles_missing_devflow_dir() {
6569        let dir = tempfile::tempdir().unwrap();
6570        let root = dir.path();
6571        // No .devflow dir at all — should not panic.
6572        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
6573    }
6574
6575    #[test]
6576    fn archive_failure_preserves_live_capture_for_retry() {
6577        let dir = tempfile::tempdir().unwrap();
6578        let root = dir.path();
6579        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6580        std::fs::write(stdout_path(root, PhaseId::new(1)), "evidence").unwrap();
6581        // A file where the history directory must be forces create_dir_all
6582        // to fail before the live capture is moved or a monitor can truncate it.
6583        std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
6584
6585        assert!(archive_phase_files(root, root, PhaseId::new(1), 5).is_err());
6586        assert_eq!(
6587            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
6588            "evidence"
6589        );
6590    }
6591
6592    #[test]
6593    fn archive_second_publish_failure_rolls_back_complete_live_pair() {
6594        let dir = tempfile::tempdir().unwrap();
6595        let root = dir.path();
6596        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6597        std::fs::write(stdout_path(root, PhaseId::new(1)), "stdout evidence").unwrap();
6598        std::fs::write(exit_code_path(root, PhaseId::new(1)), "17").unwrap();
6599        let history = history_dir(root, PhaseId::new(1));
6600        std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
6601
6602        assert!(archive_phase_files_with_stamp(root, root, PhaseId::new(1), 5, "fixed").is_err());
6603
6604        assert_eq!(
6605            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
6606            "stdout evidence"
6607        );
6608        assert_eq!(
6609            std::fs::read_to_string(exit_code_path(root, PhaseId::new(1))).unwrap(),
6610            "17"
6611        );
6612        assert!(!history.join("fixed-stdout").exists());
6613        assert!(!history.join(".pending-fixed").exists());
6614    }
6615
6616    #[test]
6617    fn archive_review_copy_failure_rolls_back_complete_live_pair() {
6618        let dir = tempfile::tempdir().unwrap();
6619        let root = dir.path();
6620        let evidence_root = root.join("phase-worktree");
6621        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6622        std::fs::write(stdout_path(root, PhaseId::new(1)), "stdout evidence").unwrap();
6623        std::fs::write(exit_code_path(root, PhaseId::new(1)), "23").unwrap();
6624        let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
6625        std::fs::create_dir_all(&review).unwrap();
6626
6627        assert!(
6628            archive_phase_files_with_stamp(root, &evidence_root, PhaseId::new(1), 5, "review-copy")
6629                .is_err()
6630        );
6631
6632        assert_eq!(
6633            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
6634            "stdout evidence"
6635        );
6636        assert_eq!(
6637            std::fs::read_to_string(exit_code_path(root, PhaseId::new(1))).unwrap(),
6638            "23"
6639        );
6640        let history = history_dir(root, PhaseId::new(1));
6641        assert!(!history.join("review-copy-stdout").exists());
6642        assert!(!history.join("review-copy-exit").exists());
6643        assert!(!history.join(".pending-review-copy").exists());
6644    }
6645
6646    #[test]
6647    fn archive_snapshots_current_review_into_same_generation() {
6648        let dir = tempfile::tempdir().unwrap();
6649        let root = dir.path();
6650        let evidence_root = root.join("phase-worktree");
6651        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6652        std::fs::write(stdout_path(root, PhaseId::new(1)), "attempt").unwrap();
6653        let phase_dir = evidence_root.join(".planning/phases/01-example");
6654        std::fs::create_dir_all(&phase_dir).unwrap();
6655        std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
6656
6657        let stamp = archive_phase_files(root, &evidence_root, PhaseId::new(1), 5)
6658            .unwrap()
6659            .unwrap();
6660
6661        assert_eq!(
6662            std::fs::read_to_string(
6663                history_dir(root, PhaseId::new(1)).join(format!("{stamp}-REVIEW.md"))
6664            )
6665            .unwrap(),
6666            "review one"
6667        );
6668    }
6669
6670    #[test]
6671    fn archive_prunes_history_to_retain_count() {
6672        let dir = tempfile::tempdir().unwrap();
6673        let root = dir.path();
6674        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6675
6676        for i in 0..7 {
6677            std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
6678            std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
6679            archive_phase_files(root, root, PhaseId::new(1), 3).unwrap();
6680        }
6681
6682        let history = history_dir(root, PhaseId::new(1));
6683        let stdout_count = std::fs::read_dir(&history)
6684            .unwrap()
6685            .flatten()
6686            .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
6687            .count();
6688        let exit_count = std::fs::read_dir(&history)
6689            .unwrap()
6690            .flatten()
6691            .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
6692            .count();
6693        assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
6694        assert_eq!(exit_count, 3, "expected at most 3 retained generations");
6695    }
6696
6697    /// The set of stamp groups currently surviving in a history directory,
6698    /// derived the same way `prune_history` derives them (`rsplit_once('-')`,
6699    /// keep the left part) so the assertion measures grouping rather than a
6700    /// listing length.
6701    fn surviving_stamps(history: &Path) -> std::collections::BTreeSet<String> {
6702        std::fs::read_dir(history)
6703            .unwrap()
6704            .flatten()
6705            .filter_map(|entry| {
6706                let name = entry.file_name().to_str()?.to_string();
6707                name.rsplit_once('-')
6708                    .map(|(stamp, _suffix)| stamp.to_string())
6709            })
6710            .collect()
6711    }
6712
6713    /// ROADMAP criterion 7's retention half. `DEFAULT_CAPTURE_RETENTION` was
6714    /// `5`, and `archive_phase_files` runs once per launch: a clean five-stage
6715    /// Define→Plan→Code→Validate→Ship run produces 4 archive events and each
6716    /// Validate→Code loop-back adds 2. At `5`, the first loop-back's sixth
6717    /// event evicted Define's capture — silently, with no error and no log.
6718    ///
6719    /// What a regression here costs: a stage capture destroyed before the
6720    /// phase that requested it has read it, which is unrecoverable after the
6721    /// fact because `.devflow/` is the only copy until it is deliberately
6722    /// copied out.
6723    #[test]
6724    fn prune_history_retains_a_full_five_stage_run_with_loop_backs() {
6725        let dir = tempfile::tempdir().unwrap();
6726        let root = dir.path();
6727        let history = history_dir(root, PhaseId::new(1));
6728        std::fs::create_dir_all(&history).unwrap();
6729
6730        let retain = crate::config::DEFAULT_CAPTURE_RETENTION;
6731
6732        // Twelve generations, strictly increasing. The suffix is load-bearing:
6733        // `prune_history` derives a stamp with `rsplit_once('-')` and keeps the
6734        // LEFT part, so a bare `{nanos}-{seq}` name would yield the stamp
6735        // `{nanos}` and then delete `{nanos}-stdout`, which never exists — the
6736        // retain half would false-pass via the `stamps.len() <= retain` early
6737        // return while the evict half could never pass at all.
6738        let stamps: Vec<String> = (0..12)
6739            .map(|i| format!("{}-0", 1_700_000_000_000_000_000u128 + i))
6740            .collect();
6741        for stamp in &stamps {
6742            std::fs::write(history.join(format!("{stamp}-stdout")), "capture").unwrap();
6743        }
6744        // The oldest generation gets a second suffix so eviction-by-stamp-group
6745        // is actually exercised rather than assumed: one evicted stamp must
6746        // take BOTH its files.
6747        std::fs::write(history.join(format!("{}-exit", stamps[0])), "0").unwrap();
6748
6749        prune_history(&history, retain);
6750
6751        let survivors = surviving_stamps(&history);
6752        assert_eq!(
6753            survivors.len(),
6754            12,
6755            "a five-stage run with loop-backs must not lose a capture at the default \
6756             retention; found {survivors:?}"
6757        );
6758        for stamp in &stamps {
6759            assert!(
6760                survivors.contains(stamp),
6761                "generation {stamp} was evicted at exactly the retention boundary"
6762            );
6763        }
6764
6765        // Opposite-result control. Without this half the test would be
6766        // measuring a directory listing, not pruning: `prune_history` returns
6767        // early whenever `stamps.len() <= retain`, so a fixture that never
6768        // crosses the boundary passes identically against a `prune_history`
6769        // that does nothing at all.
6770        let thirteenth = format!("{}-0", 1_700_000_000_000_000_000u128 + 12);
6771        std::fs::write(history.join(format!("{thirteenth}-stdout")), "capture").unwrap();
6772
6773        prune_history(&history, retain);
6774
6775        let after = surviving_stamps(&history);
6776        assert_eq!(
6777            after.len(),
6778            12,
6779            "crossing the boundary by one must evict exactly one stamp group, not zero \
6780             and not several; found {after:?}"
6781        );
6782        assert!(
6783            !after.contains(&stamps[0]),
6784            "the evicted generation must be the OLDEST by stamp order"
6785        );
6786        assert!(
6787            !history.join(format!("{}-exit", stamps[0])).exists(),
6788            "eviction operates on the stamp GROUP: the oldest generation's -exit file must \
6789             go with its -stdout, or pruning is leaking partial generations"
6790        );
6791        assert!(
6792            after.contains(&thirteenth),
6793            "the newest generation must survive its own arrival"
6794        );
6795    }
6796
6797    #[test]
6798    fn evaluate_agent_result_reads_files_end_to_end() {
6799        let dir = tempfile::tempdir().unwrap();
6800        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6801        std::fs::write(
6802            stdout_path(dir.path(), PhaseId::new(6)),
6803            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
6804        )
6805        .unwrap();
6806        std::fs::write(exit_code_path(dir.path(), PhaseId::new(6)), "0").unwrap();
6807        let state = state_in(dir.path(), PhaseId::new(6));
6808
6809        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
6810
6811        assert_eq!(result.status, AgentStatus::Success);
6812        assert_eq!(result.commits, Some(2));
6813        assert_eq!(result.summary.as_deref(), Some("ok"));
6814    }
6815
6816    // ---- exit-code arbitration on a claimed success (31-04, T-31-15) -----
6817    //
6818    // Every test below drives the FULL cascade through
6819    // `evaluate_agent_result_inner`, never the parser's own return value.
6820    // 31-RESEARCH.md § Pitfall 4 records why: a truncation-boundary test that
6821    // checks only `parse_claude_event_result` exercises constraint 9's items 1
6822    // and 2, which the `a557805` root-cause refactor already closed. The
6823    // residual this arbitration exists for lives in the WIRING — Layer 1
6824    // returning before Layer 2 is ever consulted — and only the cascade
6825    // exercises it.
6826
6827    /// A success marker that also claims `verdict: pass` — the shape a naive
6828    /// "carry every other field over" downgrade would have preserved. Used to
6829    /// prove `verdict` is dropped.
6830    ///
6831    /// Correction (34-01, D-15): an earlier version of this comment asserted
6832    /// that keeping the field would classify Validate as Passed because
6833    /// `classify_validate_outcome` matches `Some(Verdict::Pass)` first with the
6834    /// status discarded. That overstated the reachability — `decide_action`
6835    /// intercepts a non-`Success` status before the classifier runs. The
6836    /// corrected record of how the inversion is actually reached lives on
6837    /// [`super::reconcile_layer0_verdict`].
6838    const MARKER_SUCCESS_CLAIMING_PASS: &str =
6839        r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}"#;
6840
6841    /// The residual of constraint 9 that no parser assertion can reach.
6842    ///
6843    /// A capture cut at an exact line boundary is byte-identical to a healthy
6844    /// shorter run, so the stream itself carries no evidence of the tear. The
6845    /// writer that died between flushing turn N and turn N+1 also died
6846    /// non-zero, and that exit code is the only signal left.
6847    #[test]
6848    fn stream_success_cannot_stand_against_nonzero_exit_code() {
6849        let dir = tempfile::tempdir().unwrap();
6850        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6851        std::fs::write(
6852            stdout_path(dir.path(), PhaseId::new(31)),
6853            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS_CLAIMING_PASS),
6854        )
6855        .unwrap();
6856
6857        // NEGATIVE CONTROL, encoded in the test rather than described in prose:
6858        // Layer 1 on its own decides Success here AND reports `verdict: Pass`.
6859        // Without this the assertions below cannot distinguish "the arbitration
6860        // downgraded a success" from "nothing ever claimed success", nor
6861        // "`verdict` was dropped" from "`verdict` was never set".
6862        let layer1 = evaluate_layer1(dir.path(), PhaseId::new(31)).unwrap();
6863        assert_eq!(layer1.status, AgentStatus::Success);
6864        assert_eq!(layer1.verdict, Some(Verdict::Pass));
6865
6866        std::fs::write(exit_code_path(dir.path(), PhaseId::new(31)), "1\n").unwrap();
6867        let state = state_in(dir.path(), PhaseId::new(31));
6868
6869        let result =
6870            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6871                .unwrap();
6872
6873        assert_eq!(result.status, AgentStatus::Failed);
6874        assert_eq!(result.exit_code, Some(1));
6875        assert!(
6876            result.reason.as_deref().is_some_and(|r| r.contains("1")),
6877            "the reason must name the exit code: {:?}",
6878            result.reason
6879        );
6880        // Layer 1 still decided this — the arbitration corrects its verdict, it
6881        // does not hand the decision to Layer 2.
6882        assert_eq!(result.decided_by_layer, Some(1));
6883        // Load-bearing: a downgraded result has no verdict to offer. Carrying
6884        // `Some(Verdict::Pass)` over would leave Validate classified Passed and
6885        // make this whole test's premise false at the stage that matters most
6886        // (999.74 / DEN-95).
6887        assert_eq!(result.verdict, None);
6888    }
6889
6890    /// The matched negative control for the test above. Without it, that test
6891    /// cannot tell "the arbitration works" from "the arbitration fires on
6892    /// everything".
6893    #[test]
6894    fn stream_success_stands_when_the_exit_code_is_zero() {
6895        let dir = tempfile::tempdir().unwrap();
6896        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6897        std::fs::write(
6898            stdout_path(dir.path(), PhaseId::new(32)),
6899            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS_CLAIMING_PASS),
6900        )
6901        .unwrap();
6902        std::fs::write(exit_code_path(dir.path(), PhaseId::new(32)), "0\n").unwrap();
6903        let state = state_in(dir.path(), PhaseId::new(32));
6904
6905        let result =
6906            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6907                .unwrap();
6908
6909        assert_eq!(result.status, AgentStatus::Success);
6910        assert_eq!(result.decided_by_layer, Some(1));
6911        // The verdict survives an untouched result — proof that the `None`
6912        // asserted in the downgrade test is the arbitration's doing and not a
6913        // property of the fixture.
6914        assert_eq!(result.verdict, Some(Verdict::Pass));
6915    }
6916
6917    /// A missing exit file is not evidence of failure. This matches
6918    /// `evaluate_layer2`'s own tolerance (`Err(_) => return Ok(None)`); a
6919    /// stricter reading here would fail every stage whose monitor had not yet
6920    /// written the file.
6921    #[test]
6922    fn stream_success_stands_when_no_exit_file_exists() {
6923        let dir = tempfile::tempdir().unwrap();
6924        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6925        std::fs::write(
6926            stdout_path(dir.path(), PhaseId::new(33)),
6927            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
6928        )
6929        .unwrap();
6930        assert!(
6931            !exit_code_path(dir.path(), PhaseId::new(33)).exists(),
6932            "fixture precondition: there must be no exit file"
6933        );
6934        let state = state_in(dir.path(), PhaseId::new(33));
6935
6936        let result =
6937            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6938                .unwrap();
6939
6940        assert_eq!(result.status, AgentStatus::Success);
6941        assert_eq!(result.decided_by_layer, Some(1));
6942    }
6943
6944    /// Only a *claimed success* is arbitrated. Downgrading a rate limit to a
6945    /// generic failure would route the run to a human gate instead of the
6946    /// auto-resume cron it needs — the exact harm `rate_limited_result`'s
6947    /// precedence over `detect_claude_envelope_failure` exists to prevent.
6948    #[test]
6949    fn rate_limited_verdict_is_not_arbitrated_by_exit_code() {
6950        let dir = tempfile::tempdir().unwrap();
6951        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6952        std::fs::write(
6953            stdout_path(dir.path(), PhaseId::new(34)),
6954            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
6955        )
6956        .unwrap();
6957        std::fs::write(exit_code_path(dir.path(), PhaseId::new(34)), "1\n").unwrap();
6958        let state = state_in(dir.path(), PhaseId::new(34));
6959
6960        let result =
6961            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6962                .unwrap();
6963
6964        assert_eq!(result.status, AgentStatus::RateLimited);
6965        assert_eq!(
6966            result.reason.as_deref(),
6967            Some("rate limited until 2026-06-18T15:45:30Z"),
6968            "the rate-limit reason must survive verbatim — the resume cron reads it"
6969        );
6970    }
6971
6972    /// Plan 31-02's side-channel verdict survives arbitration unchanged. An
6973    /// `IdleTimeout` collapsed into `Failed` would lose exactly the distinction
6974    /// 31-02 exists to create, and the monitor writes a NON-zero exit for a
6975    /// child it killed, so this is not a hypothetical pairing.
6976    #[test]
6977    fn idle_timeout_verdict_is_not_arbitrated_by_exit_code() {
6978        let dir = tempfile::tempdir().unwrap();
6979        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6980        std::fs::write(
6981            stdout_path(dir.path(), PhaseId::new(35)),
6982            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
6983        )
6984        .unwrap();
6985        write_idle_timeout_record(
6986            dir.path(),
6987            PhaseId::new(35),
6988            &[("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "feat: partial")],
6989        );
6990        std::fs::write(exit_code_path(dir.path(), PhaseId::new(35)), "143\n").unwrap();
6991        let state = state_in(dir.path(), PhaseId::new(35));
6992
6993        let result =
6994            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6995                .unwrap();
6996
6997        assert_eq!(result.status, AgentStatus::IdleTimeout);
6998        assert_eq!(
6999            result.exit_code, None,
7000            "the arbitration must not graft an exit code onto a timeout verdict"
7001        );
7002    }
7003
7004    /// Exit-code fidelity (adversarial review of 31-04, W1). A blanket `Failed`
7005    /// would flatten the two codes `evaluate_layer2` classifies specially, and
7006    /// `outcome_policy::decide_action` routes those to `GateInfra` rather than
7007    /// `GateReview`. The same exit code must not reach two different operator
7008    /// gates depending on whether a stale Layer 1 success happened to be there.
7009    #[test]
7010    fn arbitration_preserves_layer2s_resource_and_unavailable_codes() {
7011        for (code, expected) in [
7012            (137, AgentStatus::ResourceKilled),
7013            (127, AgentStatus::AgentUnavailable),
7014            (2, AgentStatus::Failed),
7015        ] {
7016            let dir = tempfile::tempdir().unwrap();
7017            std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7018            std::fs::write(
7019                stdout_path(dir.path(), PhaseId::new(36)),
7020                v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
7021            )
7022            .unwrap();
7023            std::fs::write(
7024                exit_code_path(dir.path(), PhaseId::new(36)),
7025                format!("{code}\n"),
7026            )
7027            .unwrap();
7028            let state = state_in(dir.path(), PhaseId::new(36));
7029
7030            let arbitrated =
7031                evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7032                    .unwrap();
7033
7034            assert_eq!(
7035                arbitrated.status, expected,
7036                "exit {code} must arbitrate to {expected:?}, matching evaluate_layer2"
7037            );
7038            assert_eq!(arbitrated.exit_code, Some(code));
7039        }
7040    }
7041
7042    /// D-12's inverse assertion, and the mirror of
7043    /// [`single_doc_envelope_not_consumed_by_claude_stream_parser`].
7044    ///
7045    /// That test pins one direction: today's shipped `--output-format json`
7046    /// envelope must NOT be consumed by the stream parser. This pins the other:
7047    /// a capture produced by plan 31-01's new `stream-json` argv classifies as
7048    /// [`CaptureKind::ClaudeStream`] and is NOT consumed by the
7049    /// single-document envelope path. Without both directions, widening either
7050    /// gate is only half-guarded.
7051    ///
7052    /// Cites `classify()` / `CaptureKind::ClaudeStream` deliberately: the gate
7053    /// predicate `31-CONTEXT.md` and `30-VERIFICATION.md` W-02 still name is no
7054    /// longer a live function — the `a557805` refactor replaced it.
7055    #[test]
7056    fn stream_json_capture_is_not_consumed_by_the_single_document_path() {
7057        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
7058
7059        // The classifier owns it.
7060        assert!(capture_is_claude_stream(&capture));
7061
7062        // Every single-document reader declines it...
7063        assert!(claude_session_id(&capture).is_none());
7064        assert!(detect_claude_envelope_failure(&capture).is_none());
7065        assert!(detect_claude_rate_limit(&capture).is_none());
7066
7067        // ...and the stream parser still owns it, so declining costs no verdict.
7068        assert_eq!(
7069            parse_claude_event_result(&capture).unwrap().status,
7070            AgentStatus::Success
7071        );
7072
7073        // Non-vacuity: the single-document readers are not simply broken — the
7074        // same three answer a real envelope. Without this, the `is_none()`
7075        // assertions above would pass against a reader that returned `None` for
7076        // everything.
7077        let envelope = r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z","session_id":"abc"}"#;
7078        assert_eq!(claude_session_id(envelope).as_deref(), Some("abc"));
7079        assert!(detect_claude_envelope_failure(envelope).is_some());
7080        assert!(detect_claude_rate_limit(envelope).is_some());
7081        assert!(!capture_is_claude_stream(envelope));
7082    }
7083
7084    #[test]
7085    fn evaluate_layer1_finds_devflow_result_in_file() {
7086        let dir = tempfile::tempdir().unwrap();
7087        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7088        std::fs::write(
7089            stdout_path(dir.path(), PhaseId::new(3)),
7090            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
7091        )
7092        .unwrap();
7093
7094        let result = evaluate_layer1(dir.path(), PhaseId::new(3)).unwrap();
7095
7096        assert_eq!(result.status, AgentStatus::Failed);
7097        assert_eq!(result.reason.as_deref(), Some("bad output"));
7098    }
7099
7100    /// The case `consecutive_failures_reaches_ceiling_across_cycles`
7101    /// (`pipeline_outcomes.rs`) silently depends on: a repository with no
7102    /// `feature/phase-NN` branch at all must report 0, not error or panic.
7103    #[test]
7104    fn phase_commit_count_reports_zero_without_a_branch() {
7105        let dir = tempfile::tempdir().unwrap();
7106        git(dir.path(), &["init"]);
7107        git(dir.path(), &["config", "user.email", "devflow@example.com"]);
7108        git(dir.path(), &["config", "user.name", "DevFlow Tests"]);
7109        git(dir.path(), &["config", "commit.gpgsign", "false"]);
7110        git(dir.path(), &["config", "tag.gpgsign", "false"]);
7111        git(dir.path(), &["config", "core.hooksPath", "/dev/null"]);
7112        git(dir.path(), &["checkout", "-b", "develop"]);
7113        std::fs::write(dir.path().join("README.md"), "base\n").unwrap();
7114        git(dir.path(), &["add", "README.md"]);
7115        git(dir.path(), &["commit", "-m", "base"]);
7116
7117        let count = phase_commit_count(dir.path(), &GitFlowConfig::default(), PhaseId::new(999));
7118
7119        assert_eq!(
7120            count,
7121            Some(0),
7122            "git RAN and reported the branch absent — a real observation of zero, \
7123             not a failure to measure"
7124        );
7125    }
7126
7127    /// The paired opposite-result case for
7128    /// `phase_commit_count_reports_zero_without_a_branch` directly above, and
7129    /// the pair is what makes either one mean anything (NC-4).
7130    ///
7131    /// The two differ in exactly one respect: whether the `git` child could be
7132    /// executed at all. The repository is identical in both — no
7133    /// `feature/phase-NN` branch — so a `Some(0)` here would prove the split
7134    /// was made on "was the answer zero" rather than on "did the command run",
7135    /// which is the distinction the whole `Option` exists to carry.
7136    ///
7137    /// Deliberately NOT built on a `git` shim that runs and exits non-zero:
7138    /// that path returns `Ok(status)` from `.output()` and is a real
7139    /// observation, so it would exercise the case above while appearing to
7140    /// cover this one (F-1).
7141    ///
7142    /// **Why an unspawnable working directory rather than `NoGitPath` here —
7143    /// F-1b's recorded fallback, taken on measured evidence.** `NoGitPath`
7144    /// makes `git` unresolvable *process-wide*, and `devflow-core`'s tests
7145    /// shell out to `git` from eight modules that all compile into ONE
7146    /// parallel test binary. Installing it here failed 1-5 unrelated sibling
7147    /// tests per run, nondeterministically, depending on which of them
7148    /// happened to invoke `git` inside the guarded window. Serializing them
7149    /// would mean every present and future `git`-touching test in the crate
7150    /// opting into the same mutex — discipline, not structure, and silently
7151    /// reopened by the next test that forgets.
7152    ///
7153    /// `hermetic_command` sets `cmd.current_dir(dir)`, so a directory that
7154    /// does not exist makes the spawn itself fail and `.output()` return
7155    /// `Err` — the identical arm, reached with no environment mutation at all
7156    /// and therefore no effect on any other test. `phase_commit_count` cannot
7157    /// tell the two causes apart: it sees only `Err`.
7158    ///
7159    /// This route is also independent of the PATH-resolution property C5
7160    /// flags as a latent fragility of `NoGitPath` (a future refactor to an
7161    /// absolute `git` path would disarm that guard silently; it would not
7162    /// disarm this).
7163    #[test]
7164    fn phase_commit_count_reports_none_when_git_cannot_run() {
7165        let dir = tempfile::tempdir().unwrap();
7166        let unspawnable_root = dir.path().join("this-directory-does-not-exist");
7167        assert!(
7168            !unspawnable_root.exists(),
7169            "the fixture depends on this path being absent"
7170        );
7171
7172        let count = phase_commit_count(
7173            &unspawnable_root,
7174            &GitFlowConfig::default(),
7175            PhaseId::new(999),
7176        );
7177
7178        assert_eq!(
7179            count, None,
7180            "a git child that could not be executed is a measurement FAILURE and must \
7181             never be reported as a measured zero"
7182        );
7183    }
7184
7185    /// CR-01 (35-REVIEW), the `rev-list` half. The branch EXISTS, so the
7186    /// `rev-parse` step succeeds and the function reaches its second git call
7187    /// — but `develop` is absent from the checkout, so `A..B` is an invalid
7188    /// range and `rev-list` runs, exits non-zero, and writes nothing to
7189    /// stdout. That used to fall out of `.parse().ok()` as `None`, splitting
7190    /// on whether the output PARSED rather than on whether the command RAN,
7191    /// which contradicts this function's own A-06 rule and the `rev-parse`
7192    /// step directly above it.
7193    ///
7194    /// It is the *permanence* that makes this worth a test: unlike a fork
7195    /// failure, a misconfigured or absent `develop` does not clear on retry,
7196    /// so before the fix every stage of every phase in such a checkout
7197    /// measured as unmeasurable, forever.
7198    ///
7199    /// `phase_commit_count_reports_none_when_git_cannot_run` is the NC-4
7200    /// control — the one case that must still be `None`.
7201    #[test]
7202    fn phase_commit_count_reports_zero_when_the_range_is_invalid() {
7203        let dir = tempfile::tempdir().unwrap();
7204        git(dir.path(), &["init"]);
7205        git(dir.path(), &["config", "user.email", "devflow@example.com"]);
7206        git(dir.path(), &["config", "user.name", "DevFlow Tests"]);
7207        git(dir.path(), &["config", "commit.gpgsign", "false"]);
7208        git(dir.path(), &["config", "tag.gpgsign", "false"]);
7209        git(dir.path(), &["config", "core.hooksPath", "/dev/null"]);
7210        // The feature branch exists; `develop` deliberately does not.
7211        git(dir.path(), &["checkout", "-b", "feature/phase-999"]);
7212        std::fs::write(dir.path().join("README.md"), "base\n").unwrap();
7213        git(dir.path(), &["add", "README.md"]);
7214        git(dir.path(), &["commit", "-m", "base"]);
7215
7216        // The fixture is only meaningful if the second git call really is the
7217        // one that fails, so assert the first one would have succeeded.
7218        assert!(
7219            git_command(dir.path())
7220                .args(["rev-parse", "--verify", "feature/phase-999"])
7221                .output()
7222                .expect("git must be runnable for this fixture")
7223                .status
7224                .success(),
7225            "the branch must verify, or this test exercises the rev-parse arm instead"
7226        );
7227
7228        let count = phase_commit_count(dir.path(), &GitFlowConfig::default(), PhaseId::new(999));
7229
7230        assert_eq!(
7231            count,
7232            Some(0),
7233            "git RAN and reported the range unusable — a measurement, not a failure to \
7234             measure; `None` here is permanent for the whole checkout"
7235        );
7236    }
7237
7238    #[test]
7239    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
7240        let dir = tempfile::tempdir().unwrap();
7241        init_repo_with_feature_commit(dir.path(), PhaseId::new(4));
7242        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7243        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "0").unwrap();
7244        let state = state_in(dir.path(), PhaseId::new(4));
7245
7246        let result = evaluate_layer2(
7247            dir.path(),
7248            PhaseId::new(4),
7249            &GitFlowConfig::default(),
7250            state.stage,
7251        )
7252        .unwrap()
7253        .unwrap();
7254
7255        assert_eq!(result.status, AgentStatus::Success);
7256        assert_eq!(result.exit_code, Some(0));
7257        assert_eq!(result.commits, Some(1));
7258        assert!(result.reason.unwrap().contains("1 commits"));
7259    }
7260
7261    #[test]
7262    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
7263        // exit=0 but the feature branch has 0 commits ahead of develop →
7264        // "no work done" failure (the Layer 2 middle branch).
7265        let dir = tempfile::tempdir().unwrap();
7266        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(4));
7267        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7268        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "0").unwrap();
7269        let state = state_in(dir.path(), PhaseId::new(4));
7270
7271        let result = evaluate_layer2(
7272            dir.path(),
7273            PhaseId::new(4),
7274            &GitFlowConfig::default(),
7275            state.stage,
7276        )
7277        .unwrap()
7278        .unwrap();
7279
7280        assert_eq!(result.status, AgentStatus::Failed);
7281        assert_eq!(result.exit_code, Some(0));
7282        assert_eq!(result.commits, Some(0));
7283        assert!(result.reason.unwrap().contains("no commits"));
7284    }
7285
7286    // HARDEN-07 / criterion 6's two discriminating tests — the layer-level one
7287    // on `evaluate_layer2` and the cascade-level one on
7288    // `evaluate_agent_result` — do NOT live here. They need `git` to be
7289    // unresolvable while `project_root` still EXISTS (Layer 2 reads its exit
7290    // file from that root, so an unspawnable working directory would make the
7291    // exit read fail and return `Ok(None)` for the wrong reason), and only a
7292    // `PATH` guard delivers that combination.
7293    //
7294    // A process-global `PATH` guard is not viable in THIS test binary:
7295    // `devflow-core` shells out to `git` from eight modules that run in
7296    // parallel, and tests call production code that spawns `git` directly, so
7297    // no fixture-helper lock can cover them. Measured twice — 1-5 unrelated
7298    // failures per run before any serialization, and still 1 failure in 8 runs
7299    // after this module's own `git()` helper took the lock
7300    // (`evaluate_layer2_exit_zero_no_commits_is_failed`, whose `git` call
7301    // happens inside `evaluate_layer2` itself).
7302    //
7303    // Both tests therefore live in `devflow-cli`'s `pipeline_outcomes.rs`,
7304    // whose test binary routes every `PATH` mutation through one `ENV_MUTEX`
7305    // its `git`-touching tests already hold. They call these same `pub`
7306    // functions directly, so the assertion is unchanged — only the binary it
7307    // runs in differs. `evaluate_layer2_exit_zero_no_commits_is_failed` below
7308    // remains their NC-11 opposite-result control and is unedited.
7309
7310    #[test]
7311    fn evaluate_layer2_nonzero_exit_is_failed() {
7312        // Non-zero exit code → failure regardless of commit count.
7313        let dir = tempfile::tempdir().unwrap();
7314        init_repo_with_feature_commit(dir.path(), PhaseId::new(4));
7315        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7316        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "1").unwrap();
7317        let state = state_in(dir.path(), PhaseId::new(4));
7318
7319        let result = evaluate_layer2(
7320            dir.path(),
7321            PhaseId::new(4),
7322            &GitFlowConfig::default(),
7323            state.stage,
7324        )
7325        .unwrap()
7326        .unwrap();
7327
7328        assert_eq!(result.status, AgentStatus::Failed);
7329        assert_eq!(result.exit_code, Some(1));
7330        assert!(result.reason.unwrap().contains("exited with code 1"));
7331    }
7332
7333    #[test]
7334    fn layer2_nonzero_exit_is_failed_all_stages() {
7335        // Non-zero exit is Failed regardless of stage — including Define and
7336        // Validate, which are exempt from the zero-commit gate but NOT from
7337        // the exit-code check.
7338        let dir = tempfile::tempdir().unwrap();
7339        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(10));
7340        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7341        std::fs::write(exit_code_path(dir.path(), PhaseId::new(10)), "1").unwrap();
7342
7343        for stage in [
7344            Stage::Define,
7345            Stage::Plan,
7346            Stage::Code,
7347            Stage::Validate,
7348            Stage::Ship,
7349        ] {
7350            let result = evaluate_layer2(
7351                dir.path(),
7352                PhaseId::new(10),
7353                &GitFlowConfig::default(),
7354                stage,
7355            )
7356            .unwrap()
7357            .unwrap();
7358            assert_eq!(
7359                result.status,
7360                AgentStatus::Failed,
7361                "stage {stage:?} should be Failed on nonzero exit"
7362            );
7363        }
7364    }
7365
7366    #[test]
7367    fn layer2_skips_commit_gate_for_define_and_validate() {
7368        let dir = tempfile::tempdir().unwrap();
7369        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(11));
7370        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7371        std::fs::write(exit_code_path(dir.path(), PhaseId::new(11)), "0").unwrap();
7372
7373        for stage in [Stage::Define, Stage::Validate] {
7374            let result = evaluate_layer2(
7375                dir.path(),
7376                PhaseId::new(11),
7377                &GitFlowConfig::default(),
7378                stage,
7379            )
7380            .unwrap()
7381            .unwrap();
7382            assert_ne!(
7383                result.status,
7384                AgentStatus::Failed,
7385                "stage {stage:?} should not be Failed for zero commits"
7386            );
7387        }
7388
7389        // Code stage with the same zero-commit inputs is still Failed
7390        // (existing behavior preserved).
7391        let result = evaluate_layer2(
7392            dir.path(),
7393            PhaseId::new(11),
7394            &GitFlowConfig::default(),
7395            Stage::Code,
7396        )
7397        .unwrap()
7398        .unwrap();
7399        assert_eq!(result.status, AgentStatus::Failed);
7400    }
7401
7402    #[test]
7403    fn evaluate_layer3_falls_back_to_commit_count() {
7404        let dir = tempfile::tempdir().unwrap();
7405        init_repo_with_feature_commit(dir.path(), PhaseId::new(5));
7406
7407        let result =
7408            evaluate_layer3(dir.path(), PhaseId::new(5), &GitFlowConfig::default()).unwrap();
7409
7410        assert_eq!(result.status, AgentStatus::Unknown);
7411        assert_eq!(result.exit_code, None);
7412        assert_eq!(result.commits, Some(1));
7413        assert!(result.reason.unwrap().contains("1 commits"));
7414        assert_eq!(result.decided_by_layer, Some(3));
7415    }
7416
7417    /// D-02/D-03 case 3 (17-03): "process gone, nothing accounted for" — zero
7418    /// commits and no declared external post-condition — is a fail-closed
7419    /// `Failed` outcome that flags human review, not a blanket advanceable
7420    /// `Unknown`. The commits-present case above stays `Unknown` (gated
7421    /// downstream by Plan 04's never-advance dispatch, D-04) — only the
7422    /// zero-commit sub-case is reclassified here.
7423    #[test]
7424    fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
7425        let dir = tempfile::tempdir().unwrap();
7426        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(5));
7427
7428        let result =
7429            evaluate_layer3(dir.path(), PhaseId::new(5), &GitFlowConfig::default()).unwrap();
7430
7431        assert_eq!(result.status, AgentStatus::Failed);
7432        assert_eq!(result.exit_code, None);
7433        assert_eq!(result.commits, Some(0));
7434        assert_eq!(result.decided_by_layer, Some(3));
7435        let reason = result.reason.unwrap();
7436        assert!(reason.contains("no work"), "reason was: {reason}");
7437        assert!(
7438            reason.to_ascii_lowercase().contains("human review"),
7439            "reason was: {reason}"
7440        );
7441    }
7442
7443    /// F-4 (35-01) / HARDEN-07: Layer 3 used to carry its OWN inline commit
7444    /// count with the same lossy `.unwrap_or(0)` collapse `phase_commit_count`
7445    /// had, and classified the resulting zero as `Failed`. Since every path
7446    /// that reaches Layer 2 also reaches Layer 3, fixing only Layer 2 would
7447    /// have relocated the misclassification one layer down rather than
7448    /// removing it.
7449    ///
7450    /// A count that could not be measured is not evidence of absent work. It
7451    /// is strictly less certain than the `commits > 0` case Layer 3 already
7452    /// calls `Unknown`, so `Unknown` is the consistent classification and
7453    /// `Failed` — which asserts a negative — is not.
7454    ///
7455    /// The two tests directly above are this one's required opposite-result
7456    /// controls, and they run in the same suite with their bodies unedited: a
7457    /// branch with one commit still gives `Unknown`/`Some(1)`, and a branch
7458    /// with no commits still gives `Failed`/`Some(0)`. Without them, an
7459    /// implementation that returned `Unknown` unconditionally would pass this
7460    /// test.
7461    ///
7462    /// **No assertion here touches `Action` or anything downstream of
7463    /// `outcome_policy::decide_action` (F-5).** `Failed` and `Unknown` map
7464    /// identically to `Action::GateReview` today, so a dispatch-level
7465    /// assertion would pass against the buggy code too. The observable
7466    /// difference is entirely in the `AgentResult`.
7467    ///
7468    /// Uses the same unspawnable-working-directory route as
7469    /// `phase_commit_count_reports_none_when_git_cannot_run`, for the reason
7470    /// recorded there (F-1b): a process-wide `PATH` guard broke unrelated
7471    /// sibling tests in this crate nondeterministically, and this route
7472    /// reaches the identical `Err` arm with no environment mutation.
7473    #[test]
7474    fn evaluate_layer3_unmeasurable_count_is_unknown_not_failed() {
7475        let dir = tempfile::tempdir().unwrap();
7476        let unspawnable_root = dir.path().join("this-directory-does-not-exist");
7477        assert!(
7478            !unspawnable_root.exists(),
7479            "the fixture depends on this path being absent"
7480        );
7481
7482        let result = evaluate_layer3(
7483            &unspawnable_root,
7484            PhaseId::new(5),
7485            &GitFlowConfig::default(),
7486        )
7487        .unwrap();
7488
7489        assert_ne!(
7490            result.status,
7491            AgentStatus::Failed,
7492            "an unmeasurable commit count must never be classified as absent work — \
7493             this is the outcome criterion 6 exists to remove"
7494        );
7495        assert_eq!(
7496            result.status,
7497            AgentStatus::Unknown,
7498            "asserted positively as well as negatively, so a future change to some \
7499             other non-Failed value still has to confront this test"
7500        );
7501        assert_eq!(
7502            result.commits, None,
7503            "the commit figure must be absent, not a forged Some(0) — the difference \
7504             between 'no work' and 'could not tell' is the whole point"
7505        );
7506        assert_eq!(result.decided_by_layer, Some(3));
7507        let reason = result.reason.unwrap();
7508        assert!(
7509            reason.contains("could not be measured"),
7510            "the reason must name the measurement failure rather than absent work, \
7511             reason was: {reason}"
7512        );
7513    }
7514
7515    #[test]
7516    fn parse_devflow_result_reads_verdict() {
7517        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
7518        let result = parse_devflow_result(stdout).unwrap();
7519        assert_eq!(result.status, AgentStatus::Success);
7520        assert_eq!(result.verdict, Some(Verdict::Gaps));
7521    }
7522
7523    #[test]
7524    fn parse_devflow_result_reads_verdict_pass() {
7525        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
7526        let result = parse_devflow_result(stdout).unwrap();
7527        assert_eq!(result.status, AgentStatus::Success);
7528        assert_eq!(result.verdict, Some(Verdict::Pass));
7529    }
7530
7531    #[test]
7532    fn parse_devflow_result_verdict_absent_is_none() {
7533        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
7534        let result = parse_devflow_result(stdout).unwrap();
7535        assert_eq!(result.status, AgentStatus::Success);
7536        assert_eq!(result.verdict, None);
7537    }
7538
7539    #[test]
7540    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
7541        // An unknown verdict string must not fail the whole marker parse —
7542        // status must still come through as Success with verdict None (T-13-14).
7543        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
7544        let result = parse_devflow_result(unknown).unwrap();
7545        assert_eq!(result.status, AgentStatus::Success);
7546        assert_eq!(result.verdict, None);
7547
7548        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
7549        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
7550        let result = parse_devflow_result(miscased).unwrap();
7551        assert_eq!(result.status, AgentStatus::Success);
7552        assert_eq!(result.verdict, None);
7553    }
7554
7555    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
7556    /// JSON *type* (bool, number, object) must be just as lenient as a
7557    /// malformed string value — before the fix, deserializing straight to
7558    /// `Option<String>` errored out the entire `AgentResult` parse for a
7559    /// type mismatch, defeating the doc comment's "a malformed verdict must
7560    /// never silently drop a valid status" guarantee for this specific case.
7561    #[test]
7562    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
7563        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
7564        let result = parse_devflow_result(bool_verdict).unwrap();
7565        assert_eq!(result.status, AgentStatus::Success);
7566        assert_eq!(result.verdict, None);
7567
7568        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
7569        let result = parse_devflow_result(numeric_verdict).unwrap();
7570        assert_eq!(result.status, AgentStatus::Success);
7571        assert_eq!(result.verdict, None);
7572
7573        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
7574        let result = parse_devflow_result(object_verdict).unwrap();
7575        assert_eq!(result.status, AgentStatus::Success);
7576        assert_eq!(result.verdict, None);
7577    }
7578
7579    /// D-07 (17-01): the two new multi-word variants must serialize with
7580    /// their word boundary preserved — `#[serde(rename_all = "lowercase")]`
7581    /// alone would collapse `ResourceKilled` to `"resourcekilled"` (Pitfall 1).
7582    #[test]
7583    fn multi_word_variants_serialize_with_word_boundary() {
7584        assert_eq!(
7585            serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
7586            "\"resource_killed\""
7587        );
7588        assert_eq!(
7589            serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
7590            "\"agent_unavailable\""
7591        );
7592        assert_eq!(
7593            serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
7594            AgentStatus::ResourceKilled
7595        );
7596        assert_eq!(
7597            serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
7598            AgentStatus::AgentUnavailable
7599        );
7600    }
7601
7602    /// Existing variants must keep their pre-existing lowercase wire form
7603    /// unchanged by the two new variants' additions.
7604    #[test]
7605    fn existing_variants_keep_wire_form() {
7606        assert_eq!(
7607            serde_json::to_string(&AgentStatus::Success).unwrap(),
7608            "\"success\""
7609        );
7610        assert_eq!(
7611            serde_json::to_string(&AgentStatus::Failed).unwrap(),
7612            "\"failed\""
7613        );
7614        assert_eq!(
7615            serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
7616            "\"ratelimited\""
7617        );
7618        assert_eq!(
7619            serde_json::to_string(&AgentStatus::Unknown).unwrap(),
7620            "\"unknown\""
7621        );
7622    }
7623
7624    /// review consensus #1: `as_wire_str()` must never diverge from the serde
7625    /// form for ANY variant — pin it for all seven via a single round-trip
7626    /// assertion (quotes stripped).
7627    ///
7628    /// 31-02: `IdleTimeout` is enumerated here explicitly rather than left to
7629    /// the compiler. `as_wire_str`'s wildcard-free match makes a MISSING arm a
7630    /// compile error, but it cannot catch a WRONG one — an arm returning
7631    /// `"idletimeout"` compiles happily and diverges from the serde form the
7632    /// `#[serde(rename)]` produces. Only enumerating the variant here pins that.
7633    #[test]
7634    fn as_wire_str_matches_serde_form_for_every_variant() {
7635        for variant in [
7636            AgentStatus::Success,
7637            AgentStatus::Failed,
7638            AgentStatus::RateLimited,
7639            AgentStatus::Unknown,
7640            AgentStatus::ResourceKilled,
7641            AgentStatus::AgentUnavailable,
7642            AgentStatus::IdleTimeout,
7643        ] {
7644            let serde_form = serde_json::to_string(&variant).unwrap();
7645            let stripped = serde_form.trim_matches('"');
7646            assert_eq!(
7647                variant.as_wire_str(),
7648                stripped,
7649                "as_wire_str() diverged from serde form for {variant:?}"
7650            );
7651        }
7652    }
7653
7654    #[test]
7655    fn evaluate_layer2_exit_137_is_resource_killed() {
7656        let dir = tempfile::tempdir().unwrap();
7657        init_repo_with_feature_commit(dir.path(), PhaseId::new(20));
7658        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7659        std::fs::write(exit_code_path(dir.path(), PhaseId::new(20)), "137").unwrap();
7660        let state = state_in(dir.path(), PhaseId::new(20));
7661
7662        let result = evaluate_layer2(
7663            dir.path(),
7664            PhaseId::new(20),
7665            &GitFlowConfig::default(),
7666            state.stage,
7667        )
7668        .unwrap()
7669        .unwrap();
7670
7671        assert_eq!(result.status, AgentStatus::ResourceKilled);
7672        assert_eq!(result.exit_code, Some(137));
7673    }
7674
7675    #[test]
7676    fn evaluate_layer2_exit_127_is_agent_unavailable() {
7677        let dir = tempfile::tempdir().unwrap();
7678        init_repo_with_feature_commit(dir.path(), PhaseId::new(21));
7679        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7680        std::fs::write(exit_code_path(dir.path(), PhaseId::new(21)), "127").unwrap();
7681        let state = state_in(dir.path(), PhaseId::new(21));
7682
7683        let result = evaluate_layer2(
7684            dir.path(),
7685            PhaseId::new(21),
7686            &GitFlowConfig::default(),
7687            state.stage,
7688        )
7689        .unwrap()
7690        .unwrap();
7691
7692        assert_eq!(result.status, AgentStatus::AgentUnavailable);
7693        assert_eq!(result.exit_code, Some(127));
7694    }
7695
7696    // -----------------------------------------------------------------
7697    // 27-03 (D-01/D-03): branch-exists + commit-count evidence resolves
7698    // the caller's own repository under a hostile GIT_DIR, not an
7699    // unrelated one.
7700    // -----------------------------------------------------------------
7701
7702    /// D-03/T-27-08: `evaluate_layer2`'s branch-exists and commit-count
7703    /// evidence (the two production sites at what were base-commit lines
7704    /// 574/583) resolves `project_root`'s own repository even when the
7705    /// process inherited a hostile `GIT_DIR` pointed at an unrelated
7706    /// repository — proven with a real spawned `git` process, not by
7707    /// inspecting a `Command` object alone. Mirrors
7708    /// `version::tests::tag_reads_resolve_caller_root_under_a_hostile_git_dir`
7709    /// (27-03) and `origin_main_ancestor_status_holds_under_a_hostile_git_dir`
7710    /// (`git.rs`, 27-01): the hostile `GIT_DIR` this test's own `<verify>`
7711    /// entries exercise (`GIT_DIR=<hostile>/.git cargo test ... this test`)
7712    /// is injected the same way any inherited-env attack reaches
7713    /// `evaluate_layer2` in production — via the whole process's
7714    /// environment, then down into the spawned child unless the
7715    /// constructor scrubs it.
7716    ///
7717    /// Deliberately tests the mirror direction from the plan's literal
7718    /// framing (real repo HAS the feature branch with a real commit;
7719    /// the standard hostile-`GIT_DIR` harness's throwaway repository does
7720    /// NOT), because the standard harness (`git init -q "$HOSTILE"`, no
7721    /// `feature/phase-NN` branch) cannot itself manufacture a false
7722    /// *positive* — an empty repository has no branch to spuriously
7723    /// report as present. It can, however, still prove the scrub's
7724    /// necessity by manufacturing a false *negative*: before this plan's
7725    /// migration, the two unmigrated `Command::new("git")` sites inherit
7726    /// the poisoned `GIT_DIR` and silently read the hostile repository
7727    /// instead of `project_root` — `rev-parse --verify` reports the real
7728    /// branch absent, the commit count is undercounted to zero, and a
7729    /// real agent's completed work is wrongly classified `Failed`. This
7730    /// is the same trust-boundary violation T-27-08 names (a foreign
7731    /// repository's state substituting for the real one), reached from
7732    /// the opposite direction; the scrub this plan adds removes `GIT_DIR`'s
7733    /// ability to redirect the spawned child at all, closing both
7734    /// directions identically.
7735    /// 27-REVIEW WR-01: this test previously set no hostile environment at
7736    /// all — it asserted ordinary-path behavior and claimed a hostile-
7737    /// `GIT_DIR` proof, so it passed identically with or without the scrub
7738    /// and could never have caught a regression back to a bare
7739    /// `Command::new("git")`. It now uses the spawned-child shape this
7740    /// phase established in `staleness.rs`
7741    /// (`embedded_commit_is_stale_resolves_execution_root_under_a_hostile_git_dir`):
7742    /// `GIT_DIR` is never set on this process (Rust 2024 `unsafe`, unsound
7743    /// under threaded tests — Phase 25 D-14), only on one freshly spawned
7744    /// child that re-invokes this same binary filtered to this one test.
7745    #[test]
7746    fn branch_evidence_resolves_caller_root_under_a_hostile_git_dir() {
7747        const INNER_ROOT: &str = "DEVFLOW_27_03_BRANCH_EVIDENCE_INNER_ROOT";
7748
7749        if let Ok(root) = std::env::var(INNER_ROOT) {
7750            // Inner mode: spawned by the outer half below with GIT_DIR
7751            // pointed at an unrelated foreign repository, scoped to this
7752            // child process only.
7753            let root = std::path::PathBuf::from(root);
7754            let phase = PhaseId::new(27);
7755            let state = state_in(&root, phase);
7756
7757            let result = evaluate_layer2(&root, phase, &GitFlowConfig::default(), state.stage)
7758                .unwrap()
7759                .unwrap();
7760
7761            assert_eq!(
7762                result.status,
7763                AgentStatus::Success,
7764                "evaluate_layer2 must see project_root's own branch/commits, \
7765                 not a hostile GIT_DIR's repository: {result:?}"
7766            );
7767            assert_eq!(result.commits, Some(1));
7768            return;
7769        }
7770
7771        // Outer mode: build the real repository (which HAS the feature
7772        // branch and its commit) plus a second, unrelated foreign
7773        // repository that has neither. Unscrubbed, the child would read the
7774        // foreign repo, find no branch, count zero commits, and misreport a
7775        // real agent's completed work as Failed.
7776        let dir = tempfile::tempdir().unwrap();
7777        let phase = PhaseId::new(27);
7778        init_repo_with_feature_commit(dir.path(), phase);
7779        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7780        std::fs::write(exit_code_path(dir.path(), phase), "0").unwrap();
7781
7782        let foreign = tempfile::tempdir().unwrap();
7783        git(foreign.path(), &["init", "-q"]);
7784
7785        let exe = std::env::current_exe().expect("current_exe for child re-invocation");
7786        let out = std::process::Command::new(&exe)
7787            // Substring filter, NOT `--exact`: the binary's real test name is
7788            // module-qualified (`agent_result::tests::branch_evidence_...`),
7789            // so `--exact` against the bare name matches nothing, runs zero
7790            // tests, and still exits 0 — a false green.
7791            .arg("branch_evidence_resolves_caller_root_under_a_hostile_git_dir")
7792            .arg("--test-threads=1")
7793            .env(INNER_ROOT, dir.path().to_str().unwrap())
7794            .env("GIT_DIR", foreign.path().join(".git"))
7795            .output()
7796            .expect("spawn hostile child test process");
7797
7798        let stdout = String::from_utf8_lossy(&out.stdout);
7799        // Assert the child actually RAN the test, not merely that it exited
7800        // 0. A filter matching nothing exits 0 with "0 passed".
7801        assert!(
7802            stdout.contains("1 passed"),
7803            "child test process must have run exactly the inner test; \
7804             stdout:\n{stdout}"
7805        );
7806        assert!(
7807            out.status.success(),
7808            "child test process (hostile GIT_DIR pointed at an unrelated \
7809             foreign repository) must still resolve project_root's own \
7810             branch and commits; child exit status {:?}\nstdout:\n{stdout}",
7811            out.status
7812        );
7813    }
7814
7815    /// D-01 (33-CONTEXT.md): `phase_verification_exists` is the sole signal
7816    /// a Validate→Code loop-back consults to tell a mid-arc phase apart from
7817    /// a genuinely gap-flagged one. Covers all three states: no
7818    /// `.planning/phases` directory at all, a phase directory with no
7819    /// verification artifact, and a phase directory that has one — mirroring
7820    /// `phase_review_path`'s directory-prefix-scan idiom.
7821    #[test]
7822    fn phase_verification_exists_finds_the_artifact_by_prefix() {
7823        let dir = tempfile::tempdir().unwrap();
7824        let root = dir.path();
7825
7826        assert!(
7827            !phase_verification_exists(root, PhaseId::new(82)),
7828            "no .planning/phases directory at all must return false, not panic"
7829        );
7830
7831        let phase_dir = root.join(".planning/phases/82-loop-back-fix");
7832        std::fs::create_dir_all(&phase_dir).unwrap();
7833        assert!(
7834            !phase_verification_exists(root, PhaseId::new(82)),
7835            "a phase directory with no {{N}}-VERIFICATION.md must return false"
7836        );
7837
7838        std::fs::write(phase_dir.join("82-VERIFICATION.md"), "verified\n").unwrap();
7839        assert!(
7840            phase_verification_exists(root, PhaseId::new(82)),
7841            "a phase directory holding {{N}}-VERIFICATION.md must return true"
7842        );
7843    }
7844
7845    /// 999.79 (35-05): the fingerprint must be a function of the artifact's
7846    /// BYTES, not of its existence.
7847    ///
7848    /// Both halves are required and neither is redundant. The first half
7849    /// (different bytes → different values) is satisfied by any hash. The
7850    /// second half (identical bytes → identical values) is what rules out a
7851    /// value derived from something incidental — a timestamp, an inode, a
7852    /// counter — which would make every check read "changed" and permanently
7853    /// disable the gaps-only path. A constant-returning implementation fails
7854    /// the first half; a nondeterministic one fails the second.
7855    #[test]
7856    fn phase_verification_fingerprint_differs_when_content_differs() {
7857        let dir = tempfile::tempdir().unwrap();
7858        let root = dir.path();
7859        let phase_dir = root.join(".planning/phases/84-fingerprint");
7860        std::fs::create_dir_all(&phase_dir).unwrap();
7861        let artifact = phase_dir.join("84-VERIFICATION.md");
7862
7863        std::fs::write(&artifact, "verdict: gaps\n").unwrap();
7864        let first = phase_verification_fingerprint(root, PhaseId::new(84))
7865            .expect("an artifact that exists must produce a fingerprint");
7866
7867        let first_again = phase_verification_fingerprint(root, PhaseId::new(84))
7868            .expect("an artifact that exists must produce a fingerprint");
7869        assert_eq!(
7870            first, first_again,
7871            "identical bytes must produce identical fingerprints — a value that changes on \
7872             its own would mark every artifact fresh forever and disable the stale check"
7873        );
7874
7875        std::fs::write(&artifact, "verdict: pass\n").unwrap();
7876        let second = phase_verification_fingerprint(root, PhaseId::new(84))
7877            .expect("an artifact that exists must produce a fingerprint");
7878        assert_ne!(
7879            first, second,
7880            "different bytes must produce different fingerprints — a constant implementation \
7881             would report every re-authored artifact as unchanged"
7882        );
7883    }
7884
7885    /// 999.79 (35-05): an absent artifact yields no fingerprint, which is
7886    /// distinguishable from an artifact whose content happens to hash to zero.
7887    /// Both are asserted here so "absent" can never be conflated with "hashed
7888    /// to the zero value".
7889    #[test]
7890    fn phase_verification_fingerprint_is_none_when_the_artifact_is_absent() {
7891        let dir = tempfile::tempdir().unwrap();
7892        let root = dir.path();
7893
7894        assert_eq!(
7895            phase_verification_fingerprint(root, PhaseId::new(85)),
7896            None,
7897            "no .planning/phases directory at all must yield None, not panic"
7898        );
7899
7900        let phase_dir = root.join(".planning/phases/85-fingerprint");
7901        std::fs::create_dir_all(&phase_dir).unwrap();
7902        assert_eq!(
7903            phase_verification_fingerprint(root, PhaseId::new(85)),
7904            None,
7905            "a phase directory with no {{N}}-VERIFICATION.md must yield None"
7906        );
7907
7908        std::fs::write(phase_dir.join("85-VERIFICATION.md"), "").unwrap();
7909        let empty = phase_verification_fingerprint(root, PhaseId::new(85));
7910        assert!(
7911            empty.is_some(),
7912            "an EMPTY artifact still exists and must yield Some — the control against an \
7913             implementation that conflates 'absent' with 'no bytes'"
7914        );
7915    }
7916}