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::{AgentKind, 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    /// The agent's own final message self-reported success, but the CLI's
83    /// result envelope reports a transport-level cancellation ("context
84    /// canceled" / "context deadline exceeded"). The outcome is AMBIGUOUS:
85    /// the work completed — the success marker is present in
86    /// `result.response` — but the transport was torn down before the result
87    /// could be finalized (A2, 41-antigravity UAT).
88    ///
89    /// Deliberately NOT `Success` (advance): a torn envelope is not proof the
90    /// stage finished cleanly, and silently advancing would be the exact
91    /// stale-marker class the Antigravity "ERROR envelope first" rule
92    /// (round-3 notice (c)) exists to prevent. Retryable rather than gated:
93    /// the agent's own final word was success, so the stage is re-driven
94    /// instead of asking an operator to review a stage that already reported
95    /// success.
96    Ambiguous,
97}
98
99impl AgentStatus {
100    /// The wire-format name for this variant, pinned equal to
101    /// `serde_json::to_string(&self)` with the surrounding quotes stripped
102    /// (see the `as_wire_str_matches_serde_form` test). Exhaustive match with
103    /// NO wildcard arm — adding a variant without updating this is a compile
104    /// error. This is the sanctioned replacement for
105    /// `format!("{:?}", status).to_ascii_lowercase()`, which collapses word
106    /// boundaries on multi-word variants (review consensus #1).
107    pub fn as_wire_str(&self) -> &'static str {
108        match self {
109            AgentStatus::Success => "success",
110            AgentStatus::Failed => "failed",
111            AgentStatus::RateLimited => "ratelimited",
112            AgentStatus::Unknown => "unknown",
113            AgentStatus::ResourceKilled => "resource_killed",
114            AgentStatus::AgentUnavailable => "agent_unavailable",
115            AgentStatus::IdleTimeout => "idle_timeout",
116            AgentStatus::Ambiguous => "ambiguous",
117        }
118    }
119}
120
121/// The Validate stage's self-reported verdict (13b verdict-vs-ran split).
122#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum Verdict {
125    /// Validation found no gaps — ready to advance to Ship.
126    Pass,
127    /// Validation found gaps that still need fixing — must loop back to Code
128    /// (or gate, depending on the consecutive-failure threshold).
129    Gaps,
130}
131
132/// Deserialize `verdict` leniently: an absent, unknown, or mis-cased value
133/// (e.g. `"wat"`, `"Pass"`) becomes `Ok(None)` rather than an error, so a
134/// malformed verdict never fails the whole `from_str::<AgentResult>` parse
135/// and silently drops a valid `status` to Layer 2 (T-13-14, consensus #5).
136///
137/// Matching is intentionally exact-case (only the wire-format lowercase
138/// strings `"pass"`/`"gaps"` are accepted) — a mis-cased value like `"Pass"`
139/// is NOT case-folded into a match; it is treated the same as an unknown
140/// value and maps to `None`, so a subtly wrong-case verdict fails safe
141/// (gate/loop) instead of silently passing.
142///
143/// WR-09 (13-REVIEW.md): decodes as `serde_json::Value` first, then only
144/// pattern-matches the string case — a non-string JSON type (`true`, `123`,
145/// an object) is a wrong *type*, not a malformed string value, and must
146/// still fall through to `None` rather than erroring out the entire
147/// `AgentResult` parse (the same guarantee this deserializer already gives
148/// mis-cased/unknown string values).
149fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
150where
151    D: serde::Deserializer<'de>,
152{
153    let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
154    Ok(raw.and_then(|v| {
155        v.as_str().and_then(|s| match s {
156            "pass" => Some(Verdict::Pass),
157            "gaps" => Some(Verdict::Gaps),
158            _ => None,
159        })
160    }))
161}
162
163/// Errors produced by agent result evaluation.
164#[derive(Debug, thiserror::Error)]
165pub enum ResultError {
166    #[error("I/O error reading agent output: {0}")]
167    Io(#[from] std::io::Error),
168    #[error("phase directory not found")]
169    NoPhaseDir,
170}
171
172/// Search stdout for a DEVFLOW_RESULT marker.
173///
174/// The marker is a single line starting with `DEVFLOW_RESULT:` followed by
175/// a JSON object with at minimum a `status` field. Matching is case-insensitive.
176///
177/// When an agent is run with `--output-format json` (e.g. Claude), its final
178/// message is wrapped in a JSON result envelope with the text — and its
179/// embedded newlines — escaped inside a `result` field. In that case the
180/// marker never appears at the start of a line, so we first unwrap the
181/// envelope and search the inner text.
182pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
183    // normalise_stream_marker_provenance on BOTH arms: parse_marker_lines
184    // deserializes the agent's own JSON, so without the overwrite an agent
185    // writing `"decided_by_layer":0` into its marker forges Layer-0
186    // external-verification provenance, which `classify_validate_outcome`
187    // (pipeline_outcomes.rs) trusts when classifying a Validate stage. The
188    // stream path has normalised since 30-01; this generic path — the one
189    // production hits today — did not (fourth adversarial pass, Medium 1;
190    // the class 999.67 tracks).
191    if let Some(inner) = extract_json_result_text(stdout)
192        && let Some(result) = parse_marker_lines(&inner)
193    {
194        return Some(normalise_stream_marker_provenance(result));
195    }
196    parse_marker_lines(stdout).map(normalise_stream_marker_provenance)
197}
198
199/// Detect agent-specific rate-limit output and return the retry description.
200///
201/// Claude can emit a JSON result envelope when run with `--output-format json`;
202/// Codex commonly emits plain text such as "Try again at ...". This function is
203/// intentionally conservative so ordinary progress text does not become a
204/// false positive.
205pub fn detect_rate_limit(stdout: &str) -> Option<String> {
206    detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
207}
208
209fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
210    // strip_corruption_padding, not trim(): this detector OUTRANKS the generic
211    // envelope-failure detector, and rate-limit envelopes carry `is_error:
212    // true`. When only the lower-precedence detector stripped edge corruption,
213    // one stray byte inverted the precedence — a RateLimited envelope (routes
214    // to auto-resume) decayed into a generic Failed (routes to review/gating).
215    // Fifth adversarial pass, Medium 1.
216    let value: serde_json::Value = serde_json::from_str(strip_corruption_padding(stdout)).ok()?;
217    let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
218        || json_has_i64(&value, "api_error_status", 429)
219        || json_has_i64(&value, "status", 429)
220        || json_has_i64(&value, "status_code", 429);
221    if !rate_limited {
222        return None;
223    }
224    json_find_key(&value, "retry_after")
225        .and_then(json_scalar_to_string)
226        .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
227        .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
228        .or_else(|| Some("usage limit".to_string()))
229}
230
231fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
232    // This heuristic exists for Codex's PLAIN-TEXT output. JSONL event lines
233    // are authoritative and handled by parse_codex_event_result — scanning
234    // them here false-positives on document content echoed into events
235    // (13-06 dogfood finding: GSD reference tables mentioning "rate limiting"
236    // were read by the agent, echoed into an `item.completed` payload, and
237    // this scan returned that entire multi-KB line as the "retry time").
238    // The JSON-line exclusion applies the SAME edge-strip policy as
239    // ParsedCapture::parse (sixth-pass Medium 4): an event line whose leading
240    // byte was corrupted to U+FFFD failed the bare parse here and was treated
241    // as prose — re-admitting the exact multi-KB echoed-document false
242    // positive this filter exists to exclude, after ParsedCapture had already
243    // correctly recovered the line as an event.
244    let stdout: String = stdout
245        .lines()
246        .filter(|line| {
247            serde_json::from_str::<serde_json::Value>(strip_corruption_padding(line))
248                .map(|v| !v.is_object())
249                .unwrap_or(true)
250        })
251        .collect::<Vec<_>>()
252        .join("\n");
253    let stdout = stdout.as_str();
254    let lower = stdout.to_ascii_lowercase();
255    if let Some(idx) = lower.find("try again at ") {
256        let start = idx + "try again at ".len();
257        let retry = stdout[start..]
258            .lines()
259            .next()
260            .unwrap_or_default()
261            .trim()
262            .trim_end_matches(['.', ',', ';'])
263            .trim();
264        if !retry.is_empty() {
265            return Some(retry.to_string());
266        }
267    }
268
269    // "429" counts as rate-limit evidence only as a STANDALONE token
270    // (sixth-pass Medium 5): a bare substring check fired on "processed issue
271    // #429 successfully" and any number containing 429, routing a healthy run
272    // into auto-resume. A neighbor that is alphanumeric or '#' means the
273    // digits belong to something else.
274    fn standalone_429(line: &str) -> bool {
275        let bytes = line.as_bytes();
276        line.match_indices("429").any(|(i, _)| {
277            let before_ok = i == 0 || {
278                let b = bytes[i - 1];
279                !b.is_ascii_alphanumeric() && b != b'#'
280            };
281            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_alphanumeric();
282            before_ok && after_ok
283        })
284    }
285
286    if lower.contains("usage limit") || lower.contains("rate limit") || standalone_429(&lower) {
287        stdout
288            .lines()
289            .find(|line| {
290                let line = line.to_ascii_lowercase();
291                line.contains("usage limit") || line.contains("rate limit") || standalone_429(&line)
292            })
293            .map(str::trim)
294            .filter(|line| !line.is_empty())
295            .map(str::to_string)
296            .or_else(|| Some("usage limit".to_string()))
297    } else {
298        None
299    }
300}
301
302/// If `stdout` is a JSON result envelope, return the decoded `result` text
303/// field (with escapes such as `\n` resolved). Returns `None` for plain text.
304fn extract_json_result_text(stdout: &str) -> Option<String> {
305    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
306    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
307    // (third-pass High). Interior corruption still fails the parse, by design.
308    let trimmed = strip_corruption_padding(stdout);
309    if !trimmed.starts_with('{') {
310        return None;
311    }
312    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
313    value.get("result")?.as_str().map(str::to_string)
314}
315
316/// Read the top-level `session_id` string from a Claude JSON result envelope
317/// (`--output-format json`). Returns `None` for plain-text stdout, a
318/// non-JSON-object envelope, an envelope with no `session_id` key, or a
319/// `session_id` of a non-string JSON type — never panics.
320///
321/// D-04 / T-28-04 (this plan's `<threat_model>`): deliberately reads ONLY the
322/// envelope's TOP-LEVEL `session_id` key via a direct [`serde_json::Value::get`],
323/// never the module's [`json_find_key`]/[`json_scan`] traversal helpers. Those
324/// helpers descend into nested objects, and the agent-authored `DEVFLOW_RESULT`
325/// marker payload — embedded inside this same envelope's `result` text and
326/// deserialized by [`parse_marker_lines`] directly into [`AgentResult`] — is
327/// reachable that way. A top-level `get` makes it true BY CONSTRUCTION that an
328/// agent cannot redirect the session DevFlow later resumes into by planting a
329/// different `session_id` key inside its own self-authored marker JSON.
330/// Regression test: `session_id_in_devflow_result_marker_is_not_returned`.
331///
332/// Deliberate deviation from RESEARCH.md § "Discretion Resolutions" item 5,
333/// which suggested adding a `session_id` field directly to [`AgentResult`].
334/// NOT done: `parse_marker_lines` deserializes the agent's own
335/// `DEVFLOW_RESULT` JSON straight into `AgentResult` via `serde_json::from_str`,
336/// so a `#[serde(default)]` field there would be agent-settable — the agent
337/// could name the session DevFlow resumes into (T-28-04). A standalone reader
338/// over the top-level envelope key carries no such surface and is equally
339/// available to every caller; D-04's persistence target (`State::session_id`)
340/// is unchanged, only the carrier differs.
341pub fn claude_session_id(stdout: &str) -> Option<String> {
342    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
343    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
344    // (third-pass High). Interior corruption still fails the parse, by design.
345    let trimmed = strip_corruption_padding(stdout);
346    if !trimmed.starts_with('{') {
347        return None;
348    }
349    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
350    value.get("session_id")?.as_str().map(str::to_string)
351}
352
353/// Read the CLI-emitted `session_id` from a Claude `--output-format
354/// stream-json` JSONL capture: the top-level `session_id` of the LAST
355/// `system`/`init` event. `None` for any other capture shape.
356///
357/// The stream sibling of [`claude_session_id`], and it carries that function's
358/// D-04 / T-28-04 discipline **for the same reason** — read its doc comment
359/// before changing anything here. Only the event's TOP-LEVEL `session_id` is
360/// read, via a direct [`serde_json::Value::get`]; the
361/// [`json_find_key`]/[`json_scan`] traversal helpers are NOT to be used. They
362/// descend into nested objects, and a stream carries agent-authored text in
363/// every `result` event — including the `DEVFLOW_RESULT` marker JSON that
364/// [`parse_marker_lines`] deserializes. A traversal would make a `session_id`
365/// the agent planted in its own marker reachable, handing it the ability to
366/// name the session DevFlow later resumes into (T-30-11). Regression test:
367/// `claude_stream_session_id_ignores_agent_planted_value`.
368///
369/// The LAST `init` event wins, consistent with the last-`result`-wins
370/// convention. Verified against the archived capture: its three `init` events
371/// (lines 5, 32 and 47) all carry the same `session_id`, so last-wins and
372/// first-wins agree on today's evidence — but only last-wins stays correct if a
373/// future capture rotates the value mid-stream. Three `init` events do NOT mean
374/// three sessions: session continuity must never be keyed off "have I seen an
375/// `init` event".
376///
377/// No `session_id` field is added to [`AgentResult`] — see
378/// [`claude_session_id`]'s doc comment for why that design stays rejected.
379pub fn claude_stream_session_id(stdout: &str) -> Option<String> {
380    let capture = ParsedCapture::parse(stdout);
381    if classify(&capture) != CaptureKind::ClaudeStream {
382        return None;
383    }
384
385    // A session can rotate mid-capture: each turn opens with its own `init`, and
386    // the LAST one carries the id a resume must target. A torn later `init` is
387    // invisible to the scan below, which would silently return an EARLIER
388    // session's id — resuming the wrong session with a token that looks
389    // perfectly valid. Fail closed on any TORN JSON line: it could have been a
390    // newer `init`. `None` costs a resume; the wrong id corrupts one. (Third
391    // adversarial pass, 2026-08-02.)
392    //
393    // Prose noise lines do NOT block recovery — an `init` is a JSON line, so a
394    // non-`{` line can never be a torn one. The first version of this guard
395    // failed closed on ANY unparsed line and rejected captures with benign
396    // interleaved progress output (fourth adversarial pass, Medium 3).
397    if capture.torn_json_line_present() {
398        return None;
399    }
400
401    capture
402        .events
403        .iter()
404        .rev()
405        .find(|v| {
406            v.get("type").and_then(serde_json::Value::as_str) == Some("system")
407                && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init")
408        })?
409        .get("session_id")?
410        .as_str()
411        .map(str::to_string)
412}
413
414/// Thin file-reading wrapper over the two session-id readers: reads the phase's
415/// captured stdout file (via [`stdout_path`]) and delegates. `None` for a
416/// missing capture file, never an `Err` — mirrors [`evaluate_layer1`]'s
417/// lossy-read convention (CR-01: one invalid UTF-8 byte from raw `sh`
418/// redirection must not silently disable this reader).
419///
420/// [`claude_stream_session_id`] is tried FIRST, then [`claude_session_id`].
421/// Stream-first is safe and behavior-preserving: the stream gate
422/// ([`is_claude_event_stream`]) declines a single-document envelope, so every
423/// capture shape that ships today still resolves through `claude_session_id`
424/// bit-for-bit. Without this chain the Phase 28 checkpoint-resume path — whose
425/// whole delivery is reconstructing a session via `claude --resume` — returns
426/// `None` for every `stream-json` capture.
427pub fn session_id_from_capture(project_root: &Path, phase: PhaseId) -> Option<String> {
428    let stdout = read_capture(&stdout_path(project_root, phase))?;
429    claude_stream_session_id(&stdout).or_else(|| claude_session_id(&stdout))
430}
431
432/// The ONE decode policy for capture files: read the bytes and replace invalid
433/// UTF-8 with U+FFFD. Every capture-file consumer (`evaluate_layer1`,
434/// `checkpoint_reported_in_capture`, `session_id_from_capture`) reads through
435/// here, so the policy cannot silently diverge per call site again.
436///
437/// REPLACE, never drop. A drop-based decode was tried (third adversarial pass
438/// remediation) and refuted by the fourth pass: deleting invalid bytes JOINS
439/// the tokens on either side, and `DEVFLOW_RESULT: {"status":"suc<FF>cess"}`
440/// decoded to a fabricated, VALID success marker that short-circuited a
441/// nonzero exit code. Replacement keeps corruption visible: the marker parser
442/// sees `suc\u{FFFD}cess`, which is not a recognized status, and correctly
443/// refuses to trust it. Consumers that need to tolerate corruption at the
444/// EDGES of a single-document capture strip it explicitly via
445/// [`strip_corruption_padding`] — bounded, and incapable of joining tokens.
446fn read_capture(path: &Path) -> Option<String> {
447    let bytes = std::fs::read(path).ok()?;
448    Some(String::from_utf8_lossy(&bytes).into_owned())
449}
450
451/// Trim whitespace and U+FFFD replacement characters from both ends of a
452/// single-document capture.
453///
454/// U+FFFD is what [`read_capture`] substitutes for invalid bytes, and it is a
455/// printing, non-whitespace character — so a stray byte written before or after
456/// the JSON envelope survives `trim()` and defeats every `starts_with('{')`
457/// guard. That was the third pass's High: Layer 1 abstained on an authoritative
458/// `is_error: true` and the exit-code fallback turned a reported failure into a
459/// Ship-gate success. Stripping only the EDGES is deliberate: corruption inside
460/// the envelope must stay visible and fail the parse, because "repairing" it is
461/// how the fourth pass's marker-fabrication High happened.
462fn strip_corruption_padding(s: &str) -> &str {
463    s.trim_matches(|c: char| c.is_whitespace() || c == '\u{FFFD}')
464}
465
466// WR-12 (13-REVIEW.md), revised: these traversal helpers run on the coding
467// agent's raw stdout (via detect_claude_rate_limit, which every `devflow
468// advance` invocation runs through evaluate_layer1), so deeply nested JSON —
469// accidental or adversarial — must not stack-overflow the process. The
470// traversal is iterative (an explicit worklist), so nesting depth never
471// consumes call stack and no depth cap is needed. The first WR-12 fix capped
472// recursion at 64, which silently missed keys at depths 64–128 — nesting
473// serde_json's default 128-level parse recursion limit (the only producer of
474// these `Value`s) accepts just fine.
475
476/// Depth-first pre-order scan over every JSON object in `value`, returning
477/// the first `Some` produced by `visit` on an object's map.
478fn json_scan<'a, T>(
479    value: &'a serde_json::Value,
480    visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
481) -> Option<T> {
482    let mut stack = vec![value];
483    while let Some(current) = stack.pop() {
484        match current {
485            serde_json::Value::Object(map) => {
486                if let Some(found) = visit(map) {
487                    return Some(found);
488                }
489                // Push in reverse so pop order preserves document order.
490                for child in map.values().rev() {
491                    stack.push(child);
492                }
493            }
494            serde_json::Value::Array(values) => {
495                for child in values.iter().rev() {
496                    stack.push(child);
497                }
498            }
499            _ => {}
500        }
501    }
502    None
503}
504
505fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
506    json_scan(value, |map| {
507        (map.get(key)?.as_str()? == expected).then_some(())
508    })
509    .is_some()
510}
511
512fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
513    json_scan(value, |map| {
514        (map.get(key)?.as_i64()? == expected).then_some(())
515    })
516    .is_some()
517}
518
519fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
520    json_scan(value, |map| map.get(key))
521}
522
523fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
524    match value {
525        serde_json::Value::String(s) => Some(s.clone()),
526        serde_json::Value::Number(n) => Some(n.to_string()),
527        _ => None,
528    }
529}
530
531/// Read the top-level `is_error` boolean (and, if present, `num_turns`) from
532/// a Claude JSON result envelope (`--output-format json`) and treat
533/// `is_error: true` as an authoritative Layer-1 failure.
534///
535/// This is checked BEFORE the `DEVFLOW_RESULT` marker path in
536/// [`evaluate_layer1`], so `is_error: true` OVERRIDES a stale/echoed success
537/// marker embedded in the same envelope's `result` text — the envelope is
538/// authoritative for errors. `is_error` absent or `false` returns `None`,
539/// deferring to the marker path and, ultimately, Layer 2. It runs AFTER
540/// `detect_claude_rate_limit`, though: rate-limit envelopes also carry
541/// `is_error: true`, and the specific `RateLimited` classification (which
542/// drives the primary rate-limit resume cron) must win over this
543/// generic `Failed`.
544///
545/// Per RESEARCH Pitfall 5, `is_error` (not specific `subtype` strings) is
546/// the documented, stable signal — this does not special-case non-success
547/// subtype values beyond what already exists in `detect_claude_rate_limit`.
548fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
549    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
550    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
551    // (third-pass High). Interior corruption still fails the parse, by design.
552    let trimmed = strip_corruption_padding(stdout);
553    if !trimmed.starts_with('{') {
554        return None;
555    }
556    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
557    let is_error = value.get("is_error")?.as_bool()?;
558    if !is_error {
559        return None;
560    }
561
562    let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
563    let base_reason = value
564        .get("result")
565        .and_then(serde_json::Value::as_str)
566        .map(str::to_string)
567        .or_else(|| {
568            value
569                .get("subtype")
570                .and_then(serde_json::Value::as_str)
571                .map(str::to_string)
572        })
573        .unwrap_or_else(|| "agent reported is_error".to_string());
574    let reason = match num_turns {
575        Some(n) => format!("{base_reason} (num_turns: {n})"),
576        None => base_reason,
577    };
578
579    Some(AgentResult {
580        status: AgentStatus::Failed,
581        exit_code: None,
582        reason: Some(reason),
583        commits: None,
584        summary: None,
585        verdict: None,
586        decided_by_layer: Some(1),
587    })
588}
589
590/// The rendered VALUE of a human-blocking checkpoint's `**Gate:**` line.
591///
592/// **CONFIRMED against a live end-to-end run (2026-07-31).** Assumption A1 is
593/// closed. A real `devflow start` run drove a synthetic phase declaring a
594/// `gate="blocking-human"` task through DevFlow's own monitor process (not a
595/// Claude Code agent session, which is what blocked `28-PROBE.md`'s original
596/// attempt at the Bash-tool permission classifier). The checkpoint fired and
597/// `.devflow/phase-NN-stdout` captured it inside the JSON envelope's `result`
598/// text as:
599///
600/// ```text
601/// **Gate:** `blocking-human`
602/// ```
603///
604/// The VALUE is what this constant holds. The surrounding markdown — bold
605/// label, and a **code span around the value** — is handled by
606/// [`text_reports_human_gate`]'s trim set, not by this constant.
607///
608/// The code span is the part RESEARCH.md did not predict. Its § "Architecture
609/// Patterns / Pattern 2" derived the literal by reading the *emitting* source
610/// (`gsd-executor.md:356`, `execute-phase.md:1053`) and predicted a bare
611/// `**Gate:** blocking-human`. The real relay renders the value as a code
612/// span, which defeated the original matcher entirely — see
613/// [`text_reports_human_gate`] for that failure and its fix. Lesson worth
614/// keeping: the emitting source told us the value, not the rendering.
615const HUMAN_GATE_VALUE: &str = "blocking-human";
616
617/// Confirm whether captured stdout reports a human-blocking checkpoint, by
618/// searching for a `**Gate:**`-labeled line whose VALUE is exactly
619/// [`HUMAN_GATE_VALUE`] — see that constant's doc comment for the live
620/// observation (2026-07-31) the matched rendering is built from.
621///
622/// This is the CONFIRMATION half of D-01: it is only ever consulted AFTER
623/// [`crate::verify::phase_has_blocking_human_checkpoint`] has already
624/// returned `true` for the stage's plan(s) (D-01's static half, plan 28-01).
625/// A false negative here is the SAFE direction — it falls back to today's
626/// never-silent generic gate, losing nothing. A false positive is bounded by
627/// the resume ceiling (`mode::MAX_CHECKPOINT_RESUMES`, plan 28-03) and
628/// unconditionally recorded by the `checkpoint_auto_decided` audit event
629/// (plan 28-03) — it can never silently authorize anything.
630///
631/// Searches BOTH the raw stdout text and — when the stdout is a Claude JSON
632/// result envelope — the unescaped inner `result` text obtained via
633/// [`extract_json_result_text`], because the `Gate:` line typically crosses
634/// into the capture escaped inside that envelope (RESEARCH § "Common
635/// Pitfalls / Pitfall 2": two indirections, subagent emission → orchestrator
636/// relay → DevFlow's captured top-level stdout). Matching is
637/// case-insensitive on the `Gate` LABEL and tolerates surrounding markdown
638/// emphasis (`*`) and whitespace, but the VALUE comparison is exact — this
639/// deliberately does NOT widen into a general "does this look like a
640/// checkpoint" heuristic (D-02 rejected that class of predicate); the scope
641/// is one declared field label with one enumerated value.
642///
643/// **A Claude `stream-json` capture takes a separate branch** and is answered
644/// by [`claude_stream_reports_human_gate`] ALONE — it never consults raw stdout.
645/// That is not an oversight to be "completed" later: under a stream capture the
646/// raw stdout contains the operator's prompt echoed back as a `user` event, so
647/// also scanning it would reinstate the exact false positive the branch exists
648/// to remove (review constraint 3 — the unbounded raw scan is the reader that
649/// "survives by accident" once the single-document invariant is gone). See that
650/// function for which events are eligible and why.
651///
652/// The branch is taken when [`classify`] says [`CaptureKind::ClaudeStream`], so
653/// a single-document envelope, plain text and a Codex stream all fall through to
654/// the two-target logic below, unchanged (T-30-25). Classification is
655/// deliberately weaker than [`is_claude_event_stream`]: requiring a parsed
656/// `system`/`init` here made a single torn line fail OPEN back to the raw scan,
657/// reinstating the echoed-prompt false positive this branch exists to remove.
658/// See [`classify`] for the full rule set and the defects each rule encodes;
659/// see [`is_claude_event_stream`] for why the verdict path keeps its stricter
660/// init-only gate.
661pub fn blocking_human_checkpoint_reported(stdout: &str) -> bool {
662    let capture = ParsedCapture::parse(stdout);
663    if classify(&capture) == CaptureKind::ClaudeStream {
664        return claude_stream_reports_human_gate(&capture.events);
665    }
666    if text_reports_human_gate(stdout) {
667        return true;
668    }
669    extract_json_result_text(stdout)
670        .as_deref()
671        .is_some_and(text_reports_human_gate)
672}
673
674/// Core matcher shared by both search targets (raw stdout and the unescaped
675/// inner envelope text) in [`blocking_human_checkpoint_reported`]. Scans for
676/// a case-insensitive `gate` label, tolerating surrounding markdown emphasis
677/// (`*`), code-span backticks (`` ` ``), and whitespace up to the following
678/// `:`, then compares the VALUE token immediately after the colon exactly
679/// against [`HUMAN_GATE_VALUE`].
680///
681/// The backtick tolerance is not speculative — it is the single reason this
682/// matcher failed against the first real checkpoint ever observed. The live
683/// A1 run (2026-07-31) captured the value as a markdown code span,
684/// ``**Gate:** `blocking-human` ``, and the original trim set (`*` and space
685/// only) left the leading backtick in place, so the `take_while` below
686/// terminated immediately and produced an EMPTY value token. The reader
687/// returned `false` and a genuine checkpoint fell through to the generic
688/// gate. Trimming the backtick is what makes the observed rendering match;
689/// do not narrow this set back without re-running that live probe.
690///
691/// Note the closing backtick needs no handling: `take_while` already stops
692/// at it, since a backtick is neither alphanumeric nor `-`.
693fn text_reports_human_gate(text: &str) -> bool {
694    let lower = text.to_ascii_lowercase();
695    let mut search_from = 0;
696    while let Some(rel_idx) = lower[search_from..].find("gate") {
697        let idx = search_from + rel_idx;
698        let after_label = &lower[idx + "gate".len()..];
699        let after_label = after_label.trim_start_matches(['*', ' ', '`']);
700        if let Some(rest) = after_label.strip_prefix(':') {
701            let value_region = rest.trim_start_matches(['*', ' ', '`']);
702            let value_token: String = value_region
703                .chars()
704                .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
705                .collect();
706            if value_token == HUMAN_GATE_VALUE {
707                return true;
708            }
709        }
710        search_from = idx + "gate".len();
711    }
712    false
713}
714
715/// Thin file-reading wrapper over [`blocking_human_checkpoint_reported`]:
716/// reads the phase's captured stdout file (via [`stdout_path`]) and
717/// delegates. `false` for a missing capture file, never an error.
718pub fn checkpoint_reported_in_capture(project_root: &Path, phase: PhaseId) -> bool {
719    let Some(stdout) = read_capture(&stdout_path(project_root, phase)) else {
720        return false;
721    };
722    blocking_human_checkpoint_reported(&stdout)
723}
724
725/// Determine whether a set of parsed JSONL lines look like a Codex `--json`
726/// event stream (as opposed to a single-document Claude envelope or plain
727/// text) — i.e. at least one line is a `thread.started` or `turn.*` event.
728pub(crate) fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
729    events.iter().any(|v| {
730        v.get("type")
731            .and_then(serde_json::Value::as_str)
732            .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
733    })
734}
735
736/// Parse a Codex `--json` JSONL event stream (one JSON object per line) and
737/// look at the LAST terminal event (`turn.completed` / `turn.failed`).
738///
739/// Only decisive when the captured stdout is actually a Codex event stream
740/// (per [`is_codex_event_stream`]) — a single-document Claude envelope
741/// (`type: "result"`, no `turn.*` lines) is not consumed here and returns
742/// `None`, so the Claude envelope/marker paths handle it instead.
743///
744/// `turn.failed` is decisive: returns `AgentStatus::Failed` with `reason`
745/// from `error.message`. A final `turn.completed` with no `DEVFLOW_RESULT`
746/// marker returns `None` (defers to Layer 2) rather than an unconditional
747/// Success — a marker-less turn must not silently advance a stage (this is
748/// the composition fix that keeps a marker-less Validate run from
749/// false-passing to Ship).
750///
751/// NOTE: written against the documented `--json` event schema (thread.started
752/// / turn.started / item.* / turn.completed with usage / turn.failed with
753/// error.message) but not yet verified against the installed Codex CLI
754/// version — the 13-06 dogfood run captures real output and reconciles any
755/// delta, the same empirical practice 12-12-SUMMARY.md used for Claude.
756pub(crate) fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
757    let capture = ParsedCapture::parse(stdout);
758    let events = &capture.events;
759
760    if !is_codex_event_stream(events) {
761        return None;
762    }
763
764    // Same trailing-torn rule as the Claude stream parser, same R1 root cause:
765    // a torn JSON line after the last parsed event means the capture's tail —
766    // where `turn.failed` would be — may be among the casualties. An earlier
767    // `agent_message` success marker must not decide the stage over a tail we
768    // provably failed to read. The Codex adapter is live in production, so
769    // this is not a Phase-31 deferral.
770    if capture.torn_json_after_last_matching(|_| true) {
771        return Some(indeterminate_capture_failure());
772    }
773
774    // 999.107 #1: a terminal `turn.failed` must not be overridden by an
775    // earlier `agent_message` success marker. Resolve BOTH the terminal event
776    // and the marker once, then apply precedence: `turn.failed` is decisive
777    // regardless of any success marker that preceded it (the pre-fix order
778    // returned the marker before ever reading the terminal, so a stream ending
779    // `success marker → turn.failed` was misread as Success).
780    let terminal = events.iter().rev().find(|v| {
781        matches!(
782            v.get("type").and_then(serde_json::Value::as_str),
783            Some("turn.completed") | Some("turn.failed")
784        )
785    });
786
787    // Codex delivers the agent's DEVFLOW_RESULT self-report inside an
788    // `agent_message` item's `text` — never as a raw stdout line — so the
789    // top-level marker scan cannot see it (13-06 dogfood finding). The decoded
790    // `text` is a plain marker line; reuse the marker parser on it. Last
791    // marker wins, matching parse_marker_lines.
792    let marker = events.iter().rev().find_map(|v| {
793        if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
794            return None;
795        }
796        let item = v.get("item")?;
797        if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
798            return None;
799        }
800        let text = item.get("text").and_then(serde_json::Value::as_str)?;
801        parse_marker_lines(text)
802    });
803
804    if let Some(terminal) = terminal
805        && terminal.get("type").and_then(serde_json::Value::as_str) == Some("turn.failed")
806    {
807        let reason = terminal
808            .get("error")
809            .and_then(|e| e.get("message"))
810            .and_then(serde_json::Value::as_str)
811            .map(str::to_string)
812            .unwrap_or_else(|| "codex turn failed".to_string());
813
814        // The failure direction is safe, but keep whatever the agent did
815        // self-report (commits/summary/verdict/exit_code) so the gate context
816        // isn't silently discarded (999.107 #1 review). `decided_by_layer` is
817        // deliberately NOT copied — it is the forgeable provenance field, and
818        // Layer 1 owns this verdict.
819        let mut result = AgentResult {
820            status: AgentStatus::Failed,
821            exit_code: None,
822            reason: Some(reason),
823            commits: None,
824            summary: None,
825            verdict: None,
826            decided_by_layer: Some(1),
827        };
828        if let Some(m) = marker.as_ref() {
829            result.exit_code = m.exit_code;
830            result.commits = m.commits;
831            result.summary = m.summary.clone();
832            result.verdict = m.verdict;
833        }
834        return Some(result);
835    }
836
837    if let Some(result) = marker {
838        // Same provenance overwrite as parse_devflow_result and the Claude
839        // stream path (T-30-26): this AgentResult was deserialized from the
840        // agent's own marker JSON, so a planted `"decided_by_layer":0` would
841        // otherwise forge Layer-0 external-verification provenance. Found by
842        // reading, while closing the identical hole one function over.
843        return Some(normalise_stream_marker_provenance(result));
844    }
845
846    // turn.completed (or no terminal event at all) with no marker defers to
847    // Layer 2 rather than an unconditional Success.
848    None
849}
850
851/// Parse a captured stdout as JSONL: one `serde_json::Value` per non-blank,
852/// parseable line. Lines that are not valid JSON are dropped, so a stream
853/// interleaved with plain-text progress noise still yields its events.
854///
855/// Shared by [`is_claude_event_stream`] and [`last_top_level_result`], which
856/// both need the same parsed vector. Deliberately NOT retrofitted into
857/// [`parse_codex_event_result`], which open-codes the identical idiom: that
858/// parser is correct and shipping, and rewriting it would put an unrelated
859/// adapter's behavior at risk for a cosmetic dedupe.
860/// Determine whether parsed JSONL lines are a Claude `--output-format
861/// stream-json` event stream, as opposed to a single-document Claude envelope,
862/// a Codex `--json` stream, or plain text.
863///
864/// **Gates on `type: "system"` + `subtype: "init"` and NOTHING ELSE.**
865/// 30-RESEARCH.md offered an alternative — also gate on `type: "result"`
866/// carrying a `session_id` — and that alternative is WRONG; do not "restore"
867/// it. The single-document envelope that ships today is literally
868/// `{"type":"result",...,"session_id":"abc"}`, so a `result`-keyed gate would
869/// swallow every production capture in use and silently displace
870/// [`parse_devflow_result`] in the [`evaluate_layer1`] cascade — a change to
871/// the shipped Layer-1 verdict path, disguised as adding stream support
872/// (T-30-02). The `init` event is both stronger and earlier: it opens the
873/// stream and is present in all three archived captures
874/// (`30a-evidence/raw_output_v3.jsonl` lines 5, 32 and 47).
875///
876/// `single_doc_envelope_not_consumed_by_claude_stream_parser` is the test that
877/// fails if this gate is widened.
878fn is_claude_event_stream(events: &[serde_json::Value]) -> bool {
879    events.iter().any(|v| {
880        v.get("type").and_then(serde_json::Value::as_str) == Some("system")
881            && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init")
882    })
883}
884
885/// The shape of one non-empty capture line after a parse attempt.
886///
887/// `TornJson` vs `Noise` is the load-bearing distinction everywhere below: a
888/// line that failed to parse but still opens with `{` could be a torn event —
889/// a truncated write, or a read of a capture still being appended to — while a
890/// prose line cannot be (every stream event line opens with `{`). Conflating
891/// the two produced both prior misclassification defects: requiring ALL lines
892/// to parse sent torn streams back to the raw scan (second-pass fail-open),
893/// and counting any malformed line as suspicious rejected benign interleaved
894/// progress noise (fourth-pass Medium 3).
895#[derive(Clone, Copy, PartialEq, Eq)]
896enum LineShape {
897    /// Parsed as JSON; the value lives at the same index in
898    /// [`ParsedCapture::events`]' insertion order.
899    Event,
900    /// Failed to parse but opens with `{` — potentially a torn event.
901    TornJson,
902    /// Failed to parse and does not open with `{` — cannot be a torn event.
903    Noise,
904}
905
906/// A capture parsed ONCE, keeping both the surviving events and the shape of
907/// every non-empty line — including the ones that did not parse.
908///
909/// This is the R1 root-cause fix from the phase-30 adversarial series: the old
910/// `claude_stream_events` returned a bare `Vec<Value>`, so "I dropped
911/// something" was unrepresentable and every consumer silently assumed the
912/// survivors were complete. Four separate defects came from that assumption
913/// (torn-init gate fail-open, stale-success verdict resurrection, stale
914/// session-id resurrection, torn-user gate reopening). Consumers now see the
915/// full line record and must decide explicitly what a torn line means for them.
916struct ParsedCapture {
917    events: Vec<serde_json::Value>,
918    line_shapes: Vec<LineShape>,
919}
920
921impl ParsedCapture {
922    fn parse(stdout: &str) -> Self {
923        let mut events = Vec::new();
924        let mut line_shapes = Vec::new();
925        for line in stdout.lines() {
926            let trimmed = line.trim();
927            if trimmed.is_empty() {
928                continue;
929            }
930            match serde_json::from_str::<serde_json::Value>(trimmed) {
931                Ok(v) => {
932                    events.push(v);
933                    line_shapes.push(LineShape::Event);
934                }
935                Err(_) => {
936                    // Apply the SAME edge-corruption policy per line that
937                    // strip_corruption_padding applies per capture. Without
938                    // this, `read_capture`'s U+FFFD replacement in front of an
939                    // otherwise-intact line made it classify as Noise — not
940                    // `{`-prefixed — so the torn-tail guard could not see a
941                    // corrupt superseding event and an earlier success marker
942                    // decided the stage (fifth adversarial pass, High 1).
943                    //
944                    // Retry the parse on the stripped line first: edge
945                    // corruption around an intact event RECOVERS the event and
946                    // its true verdict. Stripping edges cannot join tokens —
947                    // the fabrication hazard was DROPPING bytes inside content
948                    // (fourth pass) — and interior corruption still fails the
949                    // parse. A line that strips to empty was pure corruption:
950                    // torn, fail closed.
951                    let stripped = strip_corruption_padding(trimmed);
952                    if stripped != trimmed
953                        && let Ok(v) = serde_json::from_str::<serde_json::Value>(stripped)
954                    {
955                        events.push(v);
956                        line_shapes.push(LineShape::Event);
957                    } else {
958                        line_shapes.push(if stripped.starts_with('{') || stripped.is_empty() {
959                            LineShape::TornJson
960                        } else {
961                            LineShape::Noise
962                        });
963                    }
964                }
965            }
966        }
967        Self {
968            events,
969            line_shapes,
970        }
971    }
972
973    fn torn_json_line_present(&self) -> bool {
974        self.line_shapes.contains(&LineShape::TornJson)
975    }
976
977    /// Whether a torn JSON line sits AFTER the last parsed event matching
978    /// `pred` — or anywhere at all, when no event matches.
979    ///
980    /// This is the question behind constraint 9 item 1: the capture's REAL
981    /// final verdict may be among the casualties, so nothing that survives
982    /// before the tear is allowed to stand in for it. Prose noise lines are
983    /// not counted — they cannot be a torn event (events open with `{`).
984    fn torn_json_after_last_matching(&self, pred: impl Fn(&serde_json::Value) -> bool) -> bool {
985        let mut last_match_line = None;
986        let mut event_idx = 0usize;
987        for (line_idx, shape) in self.line_shapes.iter().enumerate() {
988            if *shape == LineShape::Event {
989                if pred(&self.events[event_idx]) {
990                    last_match_line = Some(line_idx);
991                }
992                event_idx += 1;
993            }
994        }
995        self.line_shapes
996            .iter()
997            .enumerate()
998            .any(|(line_idx, shape)| {
999                *shape == LineShape::TornJson && last_match_line.is_none_or(|last| line_idx > last)
1000            })
1001    }
1002}
1003
1004/// What kind of capture this is — decided ONCE, here, instead of re-derived by
1005/// per-call-site heuristics.
1006///
1007/// This is the R2 root-cause fix from the phase-30 adversarial series. Four
1008/// generations of ad-hoc shape checks (`starts_with('{')` guards, "any event of
1009/// type X", all-lines-JSON, line counts) each got one case wrong: a torn `init`
1010/// un-recognised a stream (fail-open), one stray JSON line hijacked plain text
1011/// (V-01, fail-closed), a torn gate-bearing `user` event un-recognised a stream
1012/// again, and an interleaved prose line was treated as tearing. One classifier
1013/// carries all of those lessons in one place.
1014#[derive(Clone, Copy, PartialEq, Eq)]
1015enum CaptureKind {
1016    /// Not JSONL-shaped in the majority — the raw-scan paths own it.
1017    PlainText,
1018    /// Exactly one parsed `{"type":"result",…}` line: the envelope the shipped
1019    /// `--output-format json` adapter emits (T-30-25). Raw-scan paths own it.
1020    SingleDocEnvelope,
1021    /// A Claude `stream-json` capture — possibly torn, possibly noisy.
1022    ClaudeStream,
1023    /// A Codex `--json` capture: dotted top-level types (`thread.started`,
1024    /// `item.completed`, `turn.*`). Raw-scan paths own it, as before.
1025    CodexStream,
1026}
1027
1028/// Classification rules, in order — each carries the defect that forced it:
1029///
1030/// 1. **Majority of non-empty lines must be JSON-shaped** (parsed OR torn-`{`),
1031///    else `PlainText`. Counting only PARSED lines fails: truncating a real
1032///    stream drops its parsed count below any threshold while every surviving
1033///    line is still `{`-shaped (the truncation sweep caught exactly that). One
1034///    stray JSON line in prose stays under the majority (V-01).
1035/// 2. **Any parsed `system`/`user`/`assistant` event → `ClaudeStream`.** Claude
1036///    types win over dotted deterministically — the old event loop returned
1037///    whichever it happened to iterate first. Real Codex captures never carry
1038///    these types, and on a corrupt mixed capture the scoped path is the
1039///    fail-closed direction for the gate.
1040/// 3. **Any parsed dotted type → `CodexStream`.**
1041/// 4. **A single parsed `result` line → `SingleDocEnvelope`** — today's shipped
1042///    format, which must keep the raw-scan path (T-30-02 / T-30-25).
1043/// 5. **Multi-line with a `result` event or a torn JSON line → `ClaudeStream`.**
1044///    A stream whose gate-bearing `user` event tore, leaving only a later
1045///    `result`, is still a stream (fourth-pass Low / third-pass Medium shape).
1046/// 6. Everything else → `PlainText`.
1047///
1048/// A LONE torn JSON line is deliberately `PlainText`, not `ClaudeStream`: under
1049/// today's format that shape is a torn single-document envelope, and raw-scanning
1050/// it preserves detection of a REAL gate declaration inside (dropping one is the
1051/// T-30-24 harm — worse than the echo false positive). The residual — a stream
1052/// that died with only its echoed-prompt line, torn, and nothing else — requires
1053/// the `init` line to have never flushed while the echo line partially did.
1054/// Accepted and recorded rather than silently traded away.
1055fn classify(capture: &ParsedCapture) -> CaptureKind {
1056    let total = capture.line_shapes.len();
1057    if total == 0 {
1058        return CaptureKind::PlainText;
1059    }
1060    let noise = capture
1061        .line_shapes
1062        .iter()
1063        .filter(|s| **s == LineShape::Noise)
1064        .count();
1065    if (total - noise) * 2 <= total {
1066        return CaptureKind::PlainText;
1067    }
1068
1069    if capture.events.iter().any(|v| {
1070        matches!(
1071            v.get("type").and_then(serde_json::Value::as_str),
1072            Some("system" | "user" | "assistant")
1073        )
1074    }) {
1075        return CaptureKind::ClaudeStream;
1076    }
1077    if capture.events.iter().any(|v| {
1078        v.get("type")
1079            .and_then(serde_json::Value::as_str)
1080            .is_some_and(|t| t.contains('.'))
1081    }) {
1082        return CaptureKind::CodexStream;
1083    }
1084
1085    let result_events = capture
1086        .events
1087        .iter()
1088        .filter(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
1089        .count();
1090    if total == 1 {
1091        return if result_events == 1 {
1092            CaptureKind::SingleDocEnvelope
1093        } else {
1094            CaptureKind::PlainText
1095        };
1096    }
1097    if result_events > 0 || capture.torn_json_line_present() {
1098        CaptureKind::ClaudeStream
1099    } else {
1100        CaptureKind::PlainText
1101    }
1102}
1103
1104/// Test-only accessor: does [`classify`] call this capture text a Claude
1105/// `stream-json` capture?
1106///
1107/// Exists so `monitor.rs`'s end-to-end tracer test can assert on the REAL
1108/// classifier rather than re-deriving "looks like a stream" with its own
1109/// heuristic — which is precisely the per-call-site divergence [`classify`]
1110/// was introduced to end. `classify`/`CaptureKind`/`ParsedCapture` stay
1111/// private; only this yes/no question crosses the module boundary, and only
1112/// under `cfg(test)`.
1113#[cfg(test)]
1114pub(crate) fn capture_is_claude_stream(capture: &str) -> bool {
1115    classify(&ParsedCapture::parse(capture)) == CaptureKind::ClaudeStream
1116}
1117
1118/// Whether an event is TOP-LEVEL — authored by the orchestrator session, not
1119/// forwarded from a subagent. `parent_tool_use_id` JSON-null or absent.
1120///
1121/// The ONE provenance predicate, shared by gate scanning and verdict selection
1122/// (constraint 9 item 2 / code-review M2: the two paths previously held
1123/// different notions — gate scanning enforced provenance while
1124/// [`last_top_level_result`] silently did not, despite its name and doc).
1125///
1126/// The absent case must stay top-level: `result` events carry no such key at
1127/// all in any archived capture. Treating absence as positive provenance remains
1128/// NECESSARY for today's captures and UNPROVEN safe — no archived capture
1129/// contains a subagent-origin `result`, so if one can omit the key it would be
1130/// admitted. Recorded, not solved; the type filter is the second, independent
1131/// guard on the gate path.
1132fn is_top_level(event: &serde_json::Value) -> bool {
1133    matches!(
1134        event.get("parent_tool_use_id"),
1135        None | Some(serde_json::Value::Null)
1136    )
1137}
1138
1139/// The LAST top-level `type: "result"` event in a Claude stream capture.
1140///
1141/// One capture can hold several: a session kept alive across turns emits one
1142/// terminal `result` per turn (the archived v3 stream carries three, at lines
1143/// 19, 37 and 54, produced across task-notification wake-ups). The last is the
1144/// session's final verdict, so an earlier turn must never decide the stage.
1145///
1146/// T-30-01: selection runs over TOP-LEVEL objects only — each value here is one
1147/// whole JSONL line. A `result`-shaped structure the agent writes inside its own
1148/// message text is inert string content and structurally unreachable from this
1149/// scan. Never route this through [`json_scan`]/[`json_find_key`], which descend
1150/// into nested objects; that is the same protection class as D-04/T-28-04's
1151/// top-level-only `session_id` read.
1152///
1153/// Provenance is ENFORCED via [`is_top_level`], not merely documented — the
1154/// first version of this function selected on `type == "result"` alone, so a
1155/// subagent-origin `result` event would have decided the stage (code-review
1156/// M2, constraint 9 item 2).
1157fn last_top_level_result(events: &[serde_json::Value]) -> Option<&serde_json::Value> {
1158    events.iter().rev().find(|v| {
1159        v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1160    })
1161}
1162
1163/// Whether a declared canary `token` came back inside a TOP-LEVEL `result`
1164/// event of this capture (D-13).
1165///
1166/// **Why this takes capture TEXT rather than a project root and phase**, unlike
1167/// its siblings [`checkpoint_reported_in_capture`] and
1168/// [`session_id_from_capture`]: the delivery canary runs against its own
1169/// throwaway capture file, not the phase capture. A canary that read (and
1170/// therefore implied writing) `stdout_path(project_root, phase)` would clobber
1171/// the stage's own capture — the one artifact the entire Layer 1 cascade
1172/// decides on.
1173///
1174/// **D-13 trap 1 — this may not be a NEW trust path.** The CLI echoes the
1175/// operator's prompt back into the same stdout as a `user` event, so the
1176/// planted token *will* appear in the stream regardless of whether anything was
1177/// delivered. That echo is exactly what produced the checkpoint false positive
1178/// 30-05 fixed. Matching is therefore confined to events that are both
1179/// `type: "result"` and [`is_top_level`] — the same provenance predicate
1180/// [`last_top_level_result`] enforces, reused rather than reinvented.
1181///
1182/// **D-13 trap 2 — a match proves DELIVERY, never WORK.** The agent can see the
1183/// token in its own prompt and emit it without doing anything (999.67's shape).
1184/// A hit means "the task-notification path is alive"; it never means the
1185/// dispatched work happened. Summaries and merges remain the evidence of work
1186/// (D-16/D-18).
1187///
1188/// Scans EVERY top-level `result`, not just the last one, which is the one
1189/// place this deliberately differs from [`last_top_level_result`]. That
1190/// function selects the session's final *verdict*, so later turns must
1191/// supersede earlier ones. The canary asks a different question — "did the
1192/// token ever come back?" — and a token returned on an earlier
1193/// task-notification turn is a complete answer to it.
1194pub fn token_reported_in_capture(capture: &str, token: &str) -> bool {
1195    token_reported_in_capture_for(AgentKind::Claude, capture, token)
1196}
1197
1198/// The AGENT-AWARE token-trust predicate: did the planted canary token come
1199/// back inside an event the AGENT — not the CLI's prompt echo — authored?
1200///
1201/// The 30-05 discipline, extended to the Antigravity schema (round-3 D-07/B2).
1202/// The raw `contains` on the whole capture is exactly the false-positive shape
1203/// 30-05 fixed: the CLI echoes the operator's prompt back into the same stdout
1204/// as a user event, so the planted token appears there whether or not anything
1205/// was delivered. The trustworthy locations are schema-specific:
1206///
1207/// - Claude: a top-level `type: "result"` event whose STRING `result` field
1208///   contains the token ([`token_reported_in_capture`]).
1209/// - Antigravity: a top-level `event: "result"` object whose `result.response`
1210///   STRING contains the token — the `result` value is an OBJECT under the
1211///   event-key schema, so the Claude filter would never match an
1212///   Antigravity-shaped capture and the canary would report `Absent` against a
1213///   healthy CLI, refusing every Antigravity launch.
1214pub fn token_reported_in_capture_for(agent: AgentKind, capture: &str, token: &str) -> bool {
1215    match agent {
1216        AgentKind::Antigravity => ParsedCapture::parse(capture)
1217            .events
1218            .iter()
1219            .filter(|v| {
1220                v.get("event").and_then(serde_json::Value::as_str) == Some("result")
1221                    && is_top_level(v)
1222            })
1223            .any(|v| {
1224                v.get("result")
1225                    .and_then(|r| r.get("response"))
1226                    .and_then(serde_json::Value::as_str)
1227                    .is_some_and(|text| text.contains(token))
1228            }),
1229        _ => ParsedCapture::parse(capture)
1230            .events
1231            .iter()
1232            .filter(|v| {
1233                v.get("type").and_then(serde_json::Value::as_str) == Some("result")
1234                    && is_top_level(v)
1235            })
1236            .any(|v| {
1237                v.get("result")
1238                    .and_then(serde_json::Value::as_str)
1239                    .is_some_and(|text| text.contains(token))
1240            }),
1241    }
1242}
1243
1244/// Whether ONE parsed stream event is a top-level `result` carrying a
1245/// `DEVFLOW_RESULT` marker in its `result` text.
1246///
1247/// Exposed for the pipe-owning monitor's close rule (Phase 31, constraint 4),
1248/// which must decide line-by-line and in real time whether the marker arm is
1249/// satisfied — it cannot wait for a whole capture and re-parse it.
1250///
1251/// This is a COMPOSITION of the two existing predicates, deliberately not a
1252/// second implementation of either. T-31-01: the CLI echoes the operator's
1253/// prompt back into the same stdout as a `user` event — that echo is what
1254/// produced the checkpoint false positive 30-05 fixed — so a marker seen
1255/// anywhere but inside an event that is BOTH `type: "result"` AND
1256/// [`is_top_level`] must not close the stream. Reusing [`parse_marker_lines`]
1257/// keeps the marker grammar (case-insensitive prefix, edge-corruption
1258/// stripping, JSON body) in one place rather than letting the monitor grow a
1259/// looser `contains("DEVFLOW_RESULT")` of its own.
1260pub(crate) fn event_is_top_level_result_marker(event: &serde_json::Value) -> bool {
1261    event.get("type").and_then(serde_json::Value::as_str) == Some("result")
1262        && is_top_level(event)
1263        && event
1264            .get("result")
1265            .and_then(serde_json::Value::as_str)
1266            .and_then(parse_marker_lines)
1267            .is_some()
1268}
1269
1270/// Whether any AGENT-AUTHORED text in a Claude stream capture declares a
1271/// human-blocking gate. The stream-capture half of
1272/// [`blocking_human_checkpoint_reported`]; the pure matcher it delegates to,
1273/// [`text_reports_human_gate`], is unchanged.
1274///
1275/// **Why this exists (review constraint 3).** Scanning raw stdout is safe under
1276/// the single-document envelope, because the only place gate text can appear
1277/// there is the one `result` field the agent authored. A stream capture breaks
1278/// that invariant: the operator's prompt is echoed back into the same stdout as
1279/// a `user` event, so a prompt that merely DOCUMENTS a checkpoint gate
1280/// rendering becomes textually indistinguishable from a live declaration. The
1281/// failure is silent — a checkpoint auto-decide fires, or the resume ceiling is
1282/// consumed, on a stage whose prompt only discussed checkpoints. DevFlow's own
1283/// planning documents are exactly that kind of prompt content.
1284///
1285/// Two independent filters, both required, neither a substitute for the other:
1286///
1287/// 1. **Type — keep ONLY `result` events.** `user` events are always either the
1288///    echoed prompt or a `task_notification` summary re-injected as user-role
1289///    content; neither is the agent declaring anything. `system` events carry
1290///    the `init` tool and agent inventory, inert text with no business in a gate
1291///    scan. `assistant` events are excluded too, and that exclusion is
1292///    deliberate — do NOT "restore" it for completeness. Turn-FINAL assistant
1293///    text is duplicated verbatim into the `result` event that follows it
1294///    (`30a-evidence/raw_output_v3.jsonl` lines 17→19, 36→37, 53→54), so
1295///    admitting the class buys no detection the `result` events do not already
1296///    give. What it buys is a new false-positive surface: v3 line 6's top-level
1297///    assistant narration ("I'll spawn both subagents in the background now.")
1298///    reaches no `result` event at all, so an agent narrating "next I'll handle
1299///    the task whose gate the plan declares" would recreate the prompt-echo
1300///    false positive one layer inward.
1301/// 2. **Provenance — keep only top-level events.** An event is top-level when
1302///    `parent_tool_use_id` is JSON null OR the key is absent entirely. The
1303///    absent case is load-bearing: `result` events carry no such key at all
1304///    (confirmed across all three archived captures), so a naive presence check
1305///    would drop exactly the events that matter most. Mistaking
1306///    subagent-forwarded narration for orchestrator output is the error that
1307///    invalidated the v1 experiment outright. Kept even though filter 1 already
1308///    makes it redundant for today's captures — the two guards are meant to
1309///    fail independently, so a future widening of the type filter cannot
1310///    silently inherit subagent content.
1311///
1312/// **ALL eligible `result` events are scanned, not only the last.** This
1313/// deliberately diverges from [`last_top_level_result`]'s last-result-wins
1314/// verdict semantics, and the two conventions must not be "harmonised": a
1315/// verdict is a single final answer, whereas this asks whether a gate was
1316/// reported ANYWHERE in the stage's output. A gate declared in turn N followed
1317/// by task-notification wake-up turns N+1/N+2 — the exact turn shape the v3
1318/// capture archives — would be silently dropped by last-result-only, losing a
1319/// human authorization request to the generic gate. That is the
1320/// opposite-direction harm, and the worse of the two.
1321///
1322/// Text is read with a direct [`serde_json::Value::get`] chain. Never route
1323/// this through [`json_scan`]/[`json_find_key`]: a recursive traversal descends
1324/// straight back into the nested message content both filters just excluded,
1325/// silently undoing the fix while the tests on the outer shape still pass
1326/// (T-30-23).
1327///
1328/// Returns `bool` and short-circuits on the first match rather than collecting
1329/// the eligible text: this runs on every `devflow advance` over a capture that
1330/// grows for the whole stage, and there is no reason to allocate a copy of it.
1331fn claude_stream_reports_human_gate(events: &[serde_json::Value]) -> bool {
1332    events
1333        .iter()
1334        .filter(|event| event.get("type").and_then(serde_json::Value::as_str) == Some("result"))
1335        .filter(|event| is_top_level(event))
1336        .filter_map(|event| event.get("result").and_then(serde_json::Value::as_str))
1337        .any(text_reports_human_gate)
1338}
1339
1340/// The `rate_limit_info.status` values that mean the CLI DENIED the request.
1341///
1342/// Provenance, per entry — required reading before adding one:
1343///
1344/// - `rejected` — drawn from the observed vocabulary of this schema: it is the
1345///   value the CLI writes for `overageStatus` in the only archived
1346///   `rate_limit_event`
1347///   (`.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`
1348///   line 15), so it is the denial token this schema actually speaks. It has
1349///   NOT been observed as a `status` value — no archived capture is of a
1350///   blocked stream, and every capture DevFlow has taken carries
1351///   `status: "allowed"`.
1352///
1353/// Nothing else is listed, deliberately. Speculatively adding tokens is how the
1354/// false positive this list exists to prevent comes back: an unrecognised
1355/// status must DEFER (see [`detect_claude_stream_rate_limit`]), never classify.
1356/// Correct this list the first time a real blocked capture is archived — that
1357/// is the only evidence that settles the vocabulary.
1358const CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES: &[&str] = &["rejected"];
1359
1360/// Detect an explicit quota DENIAL in a Claude `stream-json` capture and return
1361/// the retry description, mirroring what [`detect_claude_rate_limit`] returns
1362/// for the single-document envelope.
1363///
1364/// **A `rate_limit_event` is not a rate limit.** The CLI emits these routinely
1365/// as quota telemetry on healthy streams: the only archived one
1366/// (`raw_output_v3.jsonl` line 15) says `rate_limit_info.status: "allowed"` and
1367/// sits in a stream that then completed three turns successfully. Classifying
1368/// on the event's PRESENCE would mark every healthy Claude stream stage
1369/// `RateLimited`, and `outcome_policy.rs` maps that to `Action::AutoResume` —
1370/// so every stage would be auto-resumed against a fabricated retry time
1371/// instead of advancing (T-30-26). Note the second trap in the same object:
1372/// `overageStatus` is `rejected` one level below `status: "allowed"`, so any
1373/// nested search for the token also false-positives. Hence every field here is
1374/// read with a direct [`serde_json::Value::get`] on the top-level event and its
1375/// `rate_limit_info` child — never [`json_find_key`]/[`json_scan`], which
1376/// descend into nested (and, elsewhere in the stream, agent-authored) content
1377/// and would let the agent supply the retry hint that drives the resume cron's
1378/// scheduling (T-30-12).
1379///
1380/// Two independent guards, both required, neither a substitute for the other:
1381///
1382/// 1. **Positional** — only events after the SECOND-TO-LAST `result` event are
1383///    eligible, i.e. the final turn. A session kept alive across turns emits one
1384///    `result` per turn, and rate-limit chatter from an earlier turn must never
1385///    outrank the outcome of a turn that finished later. (In the archived
1386///    capture the rate event is at line 15 and the results at 19/37/54, so it is
1387///    excluded on position alone.) With fewer than two `result` events the whole
1388///    stream IS the final turn.
1389/// 2. **Semantic** — only a `status` in
1390///    [`CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES`] classifies. A missing
1391///    `rate_limit_info`, a missing or non-string `status`, or any unrecognised
1392///    value returns `None`.
1393///
1394/// **Deferring is the deliberately safe direction, not an oversight.**
1395/// Under-classifying means an unknown denial status falls through to the
1396/// envelope-failure path and is reported `Failed` — a real degradation (the
1397/// operator loses automatic resume) but a never-silent one that still gates.
1398/// Over-classifying means a healthy stream is auto-resumed against a retry time
1399/// the parser invented. The asymmetry is the whole reason this function reads
1400/// one field instead of matching a shape.
1401fn detect_claude_stream_rate_limit(events: &[serde_json::Value]) -> Option<String> {
1402    // Index of the second-to-last `result` event: everything at or before it is
1403    // previous-turn history. `None` (fewer than two results) means the whole
1404    // stream is the final turn.
1405    let boundary = events
1406        .iter()
1407        .enumerate()
1408        .filter(|(_, v)| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
1409        .map(|(idx, _)| idx)
1410        .rev()
1411        .nth(1);
1412    let eligible = match boundary {
1413        Some(idx) => &events[idx + 1..],
1414        None => events,
1415    };
1416
1417    // Last eligible event wins, matching the last-`result`-wins convention.
1418    let event = eligible
1419        .iter()
1420        .rev()
1421        .find(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("rate_limit_event"))?;
1422
1423    let info = event.get("rate_limit_info")?;
1424    let status = info.get("status")?.as_str()?;
1425    if !CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES.contains(&status) {
1426        return None;
1427    }
1428
1429    // `resetsAt` is epoch seconds, rendered from the JSON number as-is: nothing
1430    // parses this string. `outcome_policy.rs` routes on the
1431    // `AgentStatus::RateLimited` variant alone and the `reason` text is
1432    // operator-facing. Mirrors `detect_claude_rate_limit`'s `retry_after` →
1433    // `message` → `error` chain; its final `"usage limit"` default has no
1434    // counterpart here because a matched `status` is by construction one of the
1435    // non-empty enumerated strings above, so a third rung would be unreachable.
1436    Some(
1437        info.get("resetsAt")
1438            .and_then(json_scalar_to_string)
1439            .unwrap_or_else(|| status.to_string()),
1440    )
1441}
1442
1443/// The stream-path counterpart of [`detect_claude_envelope_failure`]: treat
1444/// `is_error: true` on a stream's last `result` event as an authoritative
1445/// Layer-1 failure.
1446///
1447/// The `reason` shape is reproduced deliberately rather than shared — `result`
1448/// text, else `subtype`, else `agent reported is_error`, with a
1449/// ` (num_turns: {n})` suffix when present. This phase's scope fence keeps the
1450/// four shipped single-document parsers unmodified, so factoring the common
1451/// body out of `detect_claude_envelope_failure` is out of bounds here; the two
1452/// must be kept in step by hand. `is_error` absent, non-bool, or `false`
1453/// returns `None`, deferring exactly as the single-document path does.
1454fn claude_stream_envelope_failure(result_event: &serde_json::Value) -> Option<AgentResult> {
1455    if !result_event.get("is_error")?.as_bool()? {
1456        return None;
1457    }
1458
1459    let num_turns = result_event
1460        .get("num_turns")
1461        .and_then(serde_json::Value::as_u64);
1462    let base_reason = result_event
1463        .get("result")
1464        .and_then(serde_json::Value::as_str)
1465        .map(str::to_string)
1466        .or_else(|| {
1467            result_event
1468                .get("subtype")
1469                .and_then(serde_json::Value::as_str)
1470                .map(str::to_string)
1471        })
1472        .unwrap_or_else(|| "agent reported is_error".to_string());
1473    let reason = match num_turns {
1474        Some(n) => format!("{base_reason} (num_turns: {n})"),
1475        None => base_reason,
1476    };
1477
1478    Some(AgentResult {
1479        status: AgentStatus::Failed,
1480        exit_code: None,
1481        reason: Some(reason),
1482        commits: None,
1483        summary: None,
1484        verdict: None,
1485        decided_by_layer: Some(1),
1486    })
1487}
1488
1489/// Parse a Claude `--output-format stream-json` JSONL capture and read the
1490/// `DEVFLOW_RESULT` marker out of its LAST `result` event.
1491///
1492/// The new sibling of [`parse_codex_event_result`], mirroring its shape. Only
1493/// decisive when the capture is actually a Claude event stream (per
1494/// [`is_claude_event_stream`]); every other shape returns `None` and falls
1495/// through to the parser that owns it. Before this existed, a JSONL capture
1496/// returned `None` from all four single-document parsers —
1497/// `serde_json::from_str` on the whole multi-line document is a hard "trailing
1498/// characters" error — so every Claude-driven stage fell through to Layer 2's
1499/// coarse exit-code+commit heuristic.
1500///
1501/// **Precedence, mirroring [`evaluate_layer1`]'s single-document ordering
1502/// rather than inventing a new one** — do not reshuffle without reading the
1503/// reasons:
1504///
1505/// 1. Format gate ([`is_claude_event_stream`]); every other shape declines here.
1506/// 2. [`detect_claude_stream_rate_limit`] — a final-turn explicit quota denial
1507///    wins over EVERYTHING below it, for the same reason `evaluate_layer1`
1508///    already puts `detect_claude_rate_limit` ahead of the generic failure
1509///    check: a rate-limited run classified as plain `Failed` kills the primary
1510///    rate-limit resume cron, the one automated path that exists to recover
1511///    from it (T-30-13). The precedence is narrow, not broad — the detector
1512///    only fires on an explicit denial inside the final turn, so it cannot
1513///    shadow the outcome of a stream that completed.
1514/// 3. The `DEVFLOW_RESULT` marker in the last `result` event. A non-success
1515///    marker is decisive and returns immediately; a success marker is HELD, not
1516///    returned, because step 4 may override it.
1517/// 4. [`claude_stream_envelope_failure`] — `is_error: true` on that same event
1518///    overrides a held success marker, matching the single-document rule that
1519///    the envelope is authoritative for errors and a stale or echoed success
1520///    marker must not win (T-30-15).
1521/// 5. The held success marker, else `None`.
1522///
1523/// A last `result` event with no marker and no `is_error` returns `None`
1524/// (defer to Layer 2) rather than an unconditional Success, matching the
1525/// `turn.completed` convention: a marker-less turn must never silently advance
1526/// a stage.
1527///
1528/// Passing the isolated `result` text to [`parse_marker_lines`] is the correct
1529/// scoping, not a workaround. The marker is JSON-escaped inside a
1530/// `"result":"..."` string value, so it can never appear as a line starting
1531/// with `DEVFLOW_RESULT:` in the raw capture, and that parser's 4000-character
1532/// tail window is smaller than a single stream `result` line. Once serde
1533/// decodes the field the escaped newlines become real newlines and the existing
1534/// tail scan works on it as designed.
1535fn parse_claude_event_result(stdout: &str) -> Option<AgentResult> {
1536    let capture = ParsedCapture::parse(stdout);
1537    if !is_claude_event_stream(&capture.events) {
1538        return None;
1539    }
1540
1541    // Constraint 9 item 1 (code-review H1): a torn JSON line at or after the
1542    // last surviving top-level result means the session's REAL final verdict
1543    // may be among the casualties — a capture read while the CLI was still
1544    // appending, or a truncated write. Nothing that survives before the tear
1545    // is allowed to stand in for it; in particular an earlier turn's SUCCESS
1546    // must never advance the stage. Returning a Failed verdict rather than
1547    // None is deliberate: None would fall through to `parse_devflow_result`'s
1548    // raw tail scan, which can find the stale marker TEXT inside the surviving
1549    // JSON lines and resurrect it through the back door. The cost is a false
1550    // failure when the torn trailing line was a quiet task-notification turn;
1551    // that reads as loop-back noise, not a silent wrong advance.
1552    if capture.torn_json_after_last_matching(|v| {
1553        v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1554    }) {
1555        return Some(indeterminate_capture_failure());
1556    }
1557
1558    if let Some(retry) = detect_claude_stream_rate_limit(&capture.events) {
1559        return Some(rate_limited_result(retry));
1560    }
1561
1562    let last_result = last_top_level_result(&capture.events)?;
1563
1564    let marker = last_result
1565        .get("result")
1566        .and_then(serde_json::Value::as_str)
1567        .and_then(parse_marker_lines)
1568        .map(normalise_stream_marker_provenance);
1569
1570    let held_success = match marker {
1571        // A non-success marker is the agent's own final word and nothing below
1572        // can improve on it.
1573        //
1574        // 31-02 audit (non-exhaustive equality site 1 of 3). This `!= Success`
1575        // is CORRECT AS-IS for `AgentStatus::IdleTimeout` and is deliberately
1576        // left unchanged. The compiler cannot flag this site — an equality test
1577        // compiles fine against a new variant — so it is audited by hand here
1578        // rather than left to the wildcard-free-match mechanism, which does not
1579        // reach it.
1580        //
1581        // The only way `IdleTimeout` arrives here is an agent writing
1582        // `DEVFLOW_RESULT: {"status":"idle_timeout"}` into its own output,
1583        // claiming a verdict only DevFlow's monitor is supposed to produce.
1584        // The predicate handles that in the fail-safe direction: it is not
1585        // `Success`, so it returns immediately as decisive non-success and
1586        // `decide_action` gates it for review. A forged idle timeout can
1587        // therefore only make a run gate, never advance. The REAL
1588        // monitor-produced verdict does not travel this path at all — it is
1589        // read from its own side-channel file at the top of `evaluate_layer1`,
1590        // before this parser ever runs.
1591        Some(result) if result.status != AgentStatus::Success => return Some(result),
1592        other => other,
1593    };
1594
1595    if let Some(failure) = claude_stream_envelope_failure(last_result) {
1596        return Some(failure);
1597    }
1598
1599    held_success
1600}
1601
1602/// Whether parsed JSONL lines are an Antigravity `stream-json` event stream.
1603///
1604/// Gates on `event: "init"` — the Antigravity event-key schema — and nothing
1605/// else. The Antigravity CLI emits one JSON object per line under an `event`
1606/// key (`init`, `step_update`, `user`, `result`, ...); the live shape is
1607/// `{"event":"init",...}` -> `{"event":"step_update",...}` ->
1608/// `{"event":"result","result":{"status":"SUCCESS","response":"..."}}`.
1609///
1610/// **Why this cannot collide with the other adapters' gates:** Claude's gate
1611/// ([`is_claude_event_stream`]) is `type: "system"` + `subtype: "init"`,
1612/// Codex's ([`is_codex_event_stream`]) is `type: "thread.started"` /
1613/// `turn.*`, and the single-document envelope is a bare `type: "result"`
1614/// line. Antigravity events carry `event`, not `type`/`subtype` — the two key
1615/// namespaces are disjoint, so an Antigravity capture can never satisfy a
1616/// Claude or Codex gate and vice versa (41-CONTEXT D-03, round 3).
1617fn is_antigravity_event_stream(events: &[serde_json::Value]) -> bool {
1618    events
1619        .iter()
1620        .any(|v| v.get("event").and_then(serde_json::Value::as_str) == Some("init"))
1621}
1622
1623/// The LAST top-level `event: "result"` object in an Antigravity stream
1624/// capture.
1625///
1626/// The Antigravity counterpart of [`last_top_level_result`], keying on the
1627/// event-key schema and the OBJECT-shaped `result` field instead of Claude's
1628/// `type: "result"` + string `result`. Same provenance discipline: only
1629/// top-level objects are eligible — each value here is one whole JSONL line,
1630/// so a `result`-shaped structure the agent writes inside its own message
1631/// text is structurally unreachable from this scan.
1632fn last_top_level_antigravity_result(events: &[serde_json::Value]) -> Option<&serde_json::Value> {
1633    events.iter().rev().find(|v| {
1634        v.get("event").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1635    })
1636}
1637
1638/// The Antigravity counterpart of [`claude_stream_envelope_failure`]: the
1639/// CLI's explicit failure report is a Layer-1-decisive verdict carrying the
1640/// CLI's own reason.
1641///
1642/// The CLI writes `result.status: "ERROR"` (often with a non-empty
1643/// `result.error` string, e.g. `stream input message is missing the "event"
1644/// field` when the first turn's schema is wrong). Without this arm, Layer 1
1645/// returns `None` and the CLI's explicit reason is lost to Layer 2's coarse
1646/// exit-code heuristic (antigravity reviewer notice (c)).
1647///
1648/// A2 (41-antigravity UAT): the ONE exception to the decisive-`Failed` rule
1649/// is a transport-level cancellation (`context canceled` / `context deadline
1650/// exceeded`) whose SAME envelope still carries a `DEVFLOW_RESULT` SUCCESS
1651/// marker in `result.response` — the agent succeeded but the CLI's context was
1652/// torn down before the result could be finalized. That resolves to
1653/// [`AgentStatus::Ambiguous`] (re-driven, never advanced, never gated), not
1654/// `Failed`.
1655fn antigravity_stream_envelope_failure(result_event: &serde_json::Value) -> Option<AgentResult> {
1656    let result = result_event.get("result")?;
1657    let status = result.get("status").and_then(serde_json::Value::as_str);
1658    let error = result.get("error").and_then(serde_json::Value::as_str);
1659    if status != Some("ERROR") && error.is_none_or(str::is_empty) {
1660        return None;
1661    }
1662
1663    let reason = error
1664        .filter(|e| !e.is_empty())
1665        .map(str::to_string)
1666        .unwrap_or_else(|| "antigravity reported an error envelope".to_string());
1667
1668    // A2 (41-antigravity UAT): a transport-level cancellation whose SAME
1669    // envelope still carries a SUCCESS marker in `result.response` is an
1670    // AMBIGUOUS outcome, not a failure. The agent's own final message
1671    // self-reported success, but the CLI's context was torn down before the
1672    // result could be finalized. `Failed` would gate a stage whose agent
1673    // already succeeded; `Success` would silently advance on a torn envelope
1674    // — the exact stale-marker class round-3's "ERROR envelope first" rule
1675    // exists to prevent. `Ambiguous` routes to a bounded re-drive (never
1676    // advance).
1677    if is_antigravity_transport_cancel(error) {
1678        let marker = result
1679            .get("response")
1680            .and_then(serde_json::Value::as_str)
1681            .and_then(parse_marker_lines);
1682        if matches!(
1683            marker.as_ref().map(|m| m.status),
1684            Some(AgentStatus::Success)
1685        ) {
1686            return Some(AgentResult {
1687                status: AgentStatus::Ambiguous,
1688                exit_code: None,
1689                reason: Some(reason),
1690                commits: None,
1691                summary: None,
1692                verdict: None,
1693                decided_by_layer: Some(1),
1694            });
1695        }
1696    }
1697
1698    Some(AgentResult {
1699        status: AgentStatus::Failed,
1700        exit_code: None,
1701        reason: Some(reason),
1702        commits: None,
1703        summary: None,
1704        verdict: None,
1705        decided_by_layer: Some(1),
1706    })
1707}
1708
1709/// Whether an Antigravity CLI error string is a transport-level cancellation
1710/// (Go's `context.Canceled` / `context.DeadlineExceeded`) rather than an
1711/// agent-reported or model failure (A2, 41-antigravity UAT).
1712fn is_antigravity_transport_cancel(error: Option<&str>) -> bool {
1713    matches!(
1714        error,
1715        Some("context canceled") | Some("context deadline exceeded")
1716    )
1717}
1718
1719/// Parse an Antigravity `--input-format stream-json --output-format
1720/// stream-json` JSONL capture and read the `DEVFLOW_RESULT` marker out of its
1721/// LAST `event: "result"` object.
1722///
1723/// The Antigravity counterpart of [`parse_claude_event_result`] — same
1724/// contract, agent-specific schema (41-CONTEXT D-03, round-3 re-derivation).
1725/// The CLI's live terminal shape is
1726/// `{"event":"result","result":{"status":"SUCCESS","response":"DEVFLOW_RESULT: ..."}}`
1727/// — the `result` value is an OBJECT whose `response` STRING holds the
1728/// agent's final message, unlike Claude's string `result` field.
1729///
1730/// Precedence, per the round-3 plan rather than a new invention:
1731///
1732/// 1. Format gate ([`is_antigravity_event_stream`]); every other shape
1733///    declines here.
1734/// 2. Torn-tail guard, identical to the Claude path: a torn JSON line after
1735///    the last surviving `result` means the session's REAL final verdict may
1736///    be among the casualties — nothing that survives before the tear is
1737///    allowed to stand in for it (constraint 9 item 1).
1738/// 3. **ERROR envelope first** — `result.status == "ERROR"` or a non-empty
1739///    `result.error` string is the CLI's explicit failure report and is
1740///    decisive immediately ([`antigravity_stream_envelope_failure`], notice
1741///    (c)).
1742/// 4. The `DEVFLOW_RESULT` marker in `result.response`. A non-success marker
1743///    is the agent's own final word and returns immediately; a success marker
1744///    is HELD for the same reason the Claude parser holds it — nothing below
1745///    can override it here, so the hold is what survives.
1746///
1747/// A last `result` with no marker and no ERROR envelope returns `None`
1748/// (defer to Layer 2) rather than an unconditional Success, matching the
1749/// `turn.completed` convention: a marker-less turn must never silently
1750/// advance a stage (ANTG-03).
1751pub(crate) fn parse_antigravity_event_result(stdout: &str) -> Option<AgentResult> {
1752    let capture = ParsedCapture::parse(stdout);
1753    if !is_antigravity_event_stream(&capture.events) {
1754        return None;
1755    }
1756
1757    if capture.torn_json_after_last_matching(|v| {
1758        v.get("event").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
1759    }) {
1760        return Some(indeterminate_capture_failure());
1761    }
1762
1763    let last_result = last_top_level_antigravity_result(&capture.events)?;
1764
1765    // ERROR envelope first (antigravity notice (c)): the CLI's explicit
1766    // failure report is decisive at Layer 1 — without it the reason is lost
1767    // to Layer 2.
1768    if let Some(failure) = antigravity_stream_envelope_failure(last_result) {
1769        return Some(failure);
1770    }
1771
1772    let marker = last_result
1773        .get("result")
1774        .and_then(|r| r.get("response"))
1775        .and_then(serde_json::Value::as_str)
1776        .and_then(parse_marker_lines)
1777        .map(normalise_stream_marker_provenance);
1778
1779    // The marker IS the answer here: a non-success marker is the agent's own
1780    // final word (decisive), and a success marker is held — but unlike the
1781    // Claude parser, there is nothing AFTER this point that could override a
1782    // hold, because the ERROR envelope already ran above. So the function's
1783    // value is simply `marker`, and the hold/return split the Claude parser
1784    // needs does not exist here.
1785    marker
1786}
1787
1788/// Whether ONE parsed Antigravity stream event is a top-level
1789/// `event: "result"` carrying a `DEVFLOW_RESULT` marker in its
1790/// `result.response` STRING.
1791///
1792/// The agent-aware CLOSE predicate for the pipe-owning monitor's `CloseRule`
1793/// (41-CONTEXT round-3 B1). The Claude close predicate
1794/// ([`event_is_top_level_result_marker`]) requires `type: "result"` AND the
1795/// `result` field to be a STRING that parses as a marker — Antigravity emits
1796/// `event: "result"` with `result` as an OBJECT, so `marker_seen` would never
1797/// become true, stdin would never be released, and every real stage would
1798/// idle-timeout before its capture was ever read. This predicate keys on the
1799/// Antigravity schema instead: `event == "result"`, top-level, and
1800/// `result.response` a string that [`parse_marker_lines`] accepts. The Claude
1801/// predicate is deliberately unchanged.
1802pub(crate) fn event_is_top_level_antigravity_result_marker(event: &serde_json::Value) -> bool {
1803    event.get("event").and_then(serde_json::Value::as_str) == Some("result")
1804        && is_top_level(event)
1805        && event
1806            .get("result")
1807            .and_then(|r| r.get("response"))
1808            .and_then(serde_json::Value::as_str)
1809            .and_then(parse_marker_lines)
1810            .is_some()
1811}
1812
1813/// The Layer-1 verdict for a stream capture whose TAIL is provably unreadable:
1814/// a torn JSON line after the last surviving result (constraint 9 item 1).
1815///
1816/// Failed, not `None`, and not the pre-tear result. `None` hands the same
1817/// stdout to `parse_devflow_result`'s raw tail scan, which can resurrect the
1818/// stale marker text out of the surviving JSON lines; the pre-tear result is
1819/// exactly the stale-success defect this exists to close. A false failure on a
1820/// torn-but-benign tail surfaces as a retried stage, never as a silent wrong
1821/// advance — the asymmetry this whole module is built around.
1822fn indeterminate_capture_failure() -> AgentResult {
1823    AgentResult {
1824        status: AgentStatus::Failed,
1825        exit_code: None,
1826        reason: Some(
1827            "stream capture ends in an unparseable line; the final verdict is indeterminate"
1828                .to_string(),
1829        ),
1830        commits: None,
1831        summary: None,
1832        verdict: None,
1833        decided_by_layer: Some(1),
1834    }
1835}
1836
1837/// T-30-26: overwrite the agent-supplied `decided_by_layer` unconditionally.
1838///
1839/// [`parse_marker_lines`] deserializes the agent's own marker JSON straight
1840/// into [`AgentResult`], and the field is `#[serde(default)]`, so an ordinary
1841/// `{"status":"success"}` marker leaves it `None` while a hostile
1842/// `{"status":"success","decided_by_layer":0}` leaves it `Some(0)`. Neither is
1843/// acceptable: every other Layer-1 constructor in this module sets `Some(1)`
1844/// explicitly, and `Some(0)` is a Layer-0 external-probe provenance that
1845/// `classify_validate_outcome` (devflow-cli's `pipeline_outcomes.rs`) reads as
1846/// `external` when classifying a Validate stage. An agent must not be able to
1847/// claim a probe verdict it did not earn, so the value is derived here rather
1848/// than trusted.
1849fn normalise_stream_marker_provenance(mut result: AgentResult) -> AgentResult {
1850    result.decided_by_layer = Some(1);
1851    result
1852}
1853
1854/// Scan a bounded tail of `stdout` in reverse line order for the last
1855/// `DEVFLOW_RESULT` marker.
1856///
1857/// `DEVFLOW_RESULT` markers are ASCII. Searching the bounded tail and returning
1858/// the last valid marker ensures the agent's final status wins over an earlier
1859/// prompt echo without requiring the surrounding output to be ASCII.
1860///
1861/// Three sixth-pass corrections, each with a paired regression:
1862/// - The tail budget counts WHOLE LINES, never bisecting one (High 2): the old
1863///   fixed 4000-char window could cut through the final marker line itself
1864///   when it carried a long `reason`, silently dropping the authoritative
1865///   failure and handing the verdict to the exit code.
1866/// - Each line is edge-stripped before prefix matching (High 1): the capture
1867///   is read lossily, so one stray byte became U+FFFD glued to the prefix or
1868///   the JSON and the marker vanished. Same policy as every other reader:
1869///   edges stripped, interior corruption stays visible and untrusted.
1870/// - The prefix match is genuinely case-insensitive (High 3), as this
1871///   parser's contract has promised all along — the old strip_prefix chain
1872///   accepted only ALL-upper or ALL-lower.
1873fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
1874    const TAIL_BUDGET_CHARS: usize = 4000;
1875    const PREFIX: &str = "DEVFLOW_RESULT:";
1876
1877    let mut budget_used = 0usize;
1878    for line in stdout.lines().rev() {
1879        // The line that crosses the budget is still scanned whole; only the
1880        // NEXT one stops the walk. The last line is always scanned, however
1881        // long — that is the line the fixed window used to bisect.
1882        if budget_used > TAIL_BUDGET_CHARS {
1883            break;
1884        }
1885        budget_used += line.chars().count() + 1;
1886
1887        let line = strip_corruption_padding(line);
1888        let Some(head) = line.get(..PREFIX.len()) else {
1889            continue;
1890        };
1891        if !head.eq_ignore_ascii_case(PREFIX) {
1892            continue;
1893        }
1894
1895        let json_str = line[PREFIX.len()..].trim();
1896        if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
1897            return Some(result);
1898        }
1899    }
1900    None
1901}
1902
1903/// One commit the agent made before its stream went silent (D-07, 31-02).
1904///
1905/// The subject is carried alongside the sha because a bare sha list is not
1906/// operator-actionable — D-07's requirement is that the commits be *named*, so
1907/// that a silent miscount becomes something a human can act on.
1908#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1909pub struct IdleTimeoutCommit {
1910    /// Full commit sha, as `git log --format=%H` emits it.
1911    pub sha: String,
1912    /// Commit subject line (`%s`).
1913    pub subject: String,
1914}
1915
1916/// The pipe-owning monitor's authoritative idle-timeout verdict, as written to
1917/// [`idle_timeout_path`] BEFORE the child is terminated (D-05, 31-02).
1918///
1919/// This is a SIDE CHANNEL, deliberately not the stdout capture. See
1920/// [`parse_idle_timeout_side_channel`] for why that distinction is a
1921/// correctness requirement rather than a filing preference.
1922#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1923pub struct IdleTimeoutRecord {
1924    /// Always [`AgentStatus::IdleTimeout`]'s wire string. Recorded so the file
1925    /// is self-describing to a human reading `.devflow/` by hand.
1926    pub status: String,
1927    /// The idle window that elapsed with no line on the child's stdout.
1928    pub idle_secs: u64,
1929    /// The supervised child's pid, from the in-memory `Child` handle — never
1930    /// re-read from the on-disk pid file, which is exposed to pid reuse
1931    /// (T-31-07).
1932    pub agent_pid: u32,
1933    /// Unix seconds at which the monitor wrote this record.
1934    pub written_at: u64,
1935    /// Every commit on the phase branch when the timeout fired. NONE of these
1936    /// is rolled back — see [`parse_idle_timeout_side_channel`].
1937    pub commits: Vec<IdleTimeoutCommit>,
1938}
1939
1940/// Read the monitor's own idle-timeout verdict, if it wrote one.
1941///
1942/// **This is consulted as the FIRST statement of [`evaluate_layer1`], before
1943/// `read_capture` and before every marker parser. That placement is
1944/// load-bearing and must not be "tidied" into the `.or_else` chain below it.**
1945///
1946/// The obvious-looking alternative — appending the verdict to the stdout
1947/// capture — is a real correctness bug, not a style choice.
1948/// `evaluate_layer1`'s chain reaches `parse_devflow_result`'s tail scan only
1949/// when `parse_claude_event_result` returns `None`, and that parser resolves to
1950/// the LAST top-level `result` event regardless of what text follows it. On any
1951/// stream that already completed one successful turn — the normal shape of a
1952/// run long enough to idle out at all — an appended verdict is therefore never
1953/// reached, and a stale success stands as the recorded outcome of a run DevFlow
1954/// itself killed (T-31-06, 31-RESEARCH Pitfall 3).
1955///
1956/// Reading before `read_capture` matters for a second reason: that call is an
1957/// early `return None` when the capture is missing, so a timeout that fired
1958/// before the child emitted anything at all would otherwise be discarded
1959/// entirely.
1960///
1961/// `decided_by_layer` stays `1`. This is a Layer-1-CLASS authoritative verdict
1962/// — it just comes from the monitor that supervised the run rather than from
1963/// parsing what the agent said about itself. It is emphatically not `0`, which
1964/// is reserved for operator-authored external probe provenance that
1965/// `classify_validate_outcome` reads as `external`.
1966///
1967/// **The file's PRESENCE is the signal; its contents are enrichment.** A record
1968/// that exists but cannot be read still returns an `IdleTimeout` verdict,
1969/// carrying a reason that says the details were lost. Returning `None` there
1970/// would drop the verdict back into the cascade and let precisely the stale
1971/// success above win — turning a corrupt file into a silent wrong advance,
1972/// which is the exact failure this function exists to prevent. The asymmetry is
1973/// the one this whole module is built around: a false failure surfaces as a
1974/// gate, never as a wrong advance.
1975///
1976/// **Nothing here rolls anything back** (D-07, T-31-09). The commits are read
1977/// and named, never reverted: an idle timeout may be a false positive, and
1978/// destroying real work on a false positive is unrecoverable.
1979/// Whether the phase's live capture already carries an explicit quota DENIAL.
1980///
1981/// Exists for exactly one caller: [`crate::monitor`]'s idle-timeout path, which
1982/// must know *why* the stream went quiet before it records a verdict about the
1983/// silence.
1984///
1985/// **The problem this solves.** A quota denial makes the agent go silent — it
1986/// has nothing left to say. The monitor's idle timer then fires, writes an
1987/// idle-timeout record, and kills the child. Because
1988/// [`parse_idle_timeout_side_channel`] is `evaluate_layer1`'s first statement
1989/// and returns unconditionally (T-31-06), that record shadows
1990/// [`detect_claude_stream_rate_limit`] — which was sitting in the same capture
1991/// with the answer. The run is then reported as an idle timeout, "TERMINAL and
1992/// not retried automatically", when the truth is `RateLimited`, which
1993/// `outcome_policy` routes to auto-resume.
1994///
1995/// Observed 2026-08-08 on a real Code stage: `rate_limit_event` with
1996/// `status: "rejected"`, `rateLimitType: "seven_day"`,
1997/// `overageDisabledReason: "out_of_credits"`. Replaying the classifier over that
1998/// capture returns the denial; the operator was instead told the stream had been
1999/// silent for 120s. Running out of quota is the likeliest way a long unattended
2000/// run stops, so it is the failure this phase can least afford to misreport.
2001///
2002/// Deliberately delegates to the SAME detector the read path uses rather than
2003/// re-implementing the check. Two independent notions of "is this a rate limit"
2004/// would be free to disagree, and the disagreement would be invisible.
2005#[must_use]
2006pub fn capture_shows_rate_limit_denial(project_root: &Path, phase: PhaseId) -> bool {
2007    let Some(raw) = read_capture(&stdout_path(project_root, phase)) else {
2008        return false;
2009    };
2010    detect_claude_stream_rate_limit(&ParsedCapture::parse(&raw).events).is_some()
2011}
2012
2013fn parse_idle_timeout_side_channel(project_root: &Path, phase: PhaseId) -> Option<AgentResult> {
2014    let path = idle_timeout_path(project_root, phase);
2015    let raw = read_capture(&path)?;
2016
2017    let Ok(record) = serde_json::from_str::<IdleTimeoutRecord>(&raw) else {
2018        return Some(idle_timeout_result(
2019            format!(
2020                "idle timeout: DevFlow's monitor recorded a timeout verdict at {} but the \
2021                 record itself is unreadable, so the commit list and idle duration are lost. \
2022                 The timeout stands regardless — the file's presence is the authoritative \
2023                 signal. Inspect the phase branch by hand; nothing was rolled back.",
2024                path.display()
2025            ),
2026            None,
2027        ));
2028    };
2029
2030    let named: Vec<String> = record
2031        .commits
2032        .iter()
2033        .map(|commit| {
2034            let short: String = commit.sha.chars().take(7).collect();
2035            format!("{short} {}", commit.subject)
2036        })
2037        .collect();
2038
2039    let commit_phrase = if named.is_empty() {
2040        "No commits were found on the phase branch.".to_string()
2041    } else {
2042        format!(
2043            "The agent made {} commit(s) before going quiet and NONE of them were rolled \
2044             back: {}.",
2045            named.len(),
2046            named.join("; ")
2047        )
2048    };
2049
2050    Some(idle_timeout_result(
2051        format!(
2052            "idle timeout: the agent's output stream was silent for {}s, so DevFlow \
2053             terminated it (agent pid {}). {commit_phrase} Review the branch before deciding \
2054             what to keep — this run is TERMINAL and is not retried automatically.",
2055            record.idle_secs, record.agent_pid
2056        ),
2057        Some(record.commits.len() as u32),
2058    ))
2059}
2060
2061/// Build the `IdleTimeout` verdict Layer 1 reports for a monitor-recorded
2062/// timeout.
2063///
2064/// `verdict` stays `None` deliberately: a timeout has no verdict to offer, and
2065/// inventing one here would advance a run that never reported. The invariant is
2066/// now carried by two structural defences, not by this convention alone
2067/// (999.85 / F-34-01):
2068///
2069/// 1. The classifier's enumerated status position — `classify_validate_outcome`
2070///    (`pipeline_outcomes.rs`) matches `(_, AgentStatus::Success,
2071///    Some(Verdict::Pass))`, so a non-`Success` status such as `IdleTimeout`
2072///    can never reach `Passed` on the strength of the verdict field alone.
2073/// 2. The graft's status filter — `reconcile_layer0_verdict` transplants a
2074///    Layer 1 verdict only when `layer1.status == AgentStatus::Success`. This
2075///    result's `IdleTimeout` status is filtered out, so its (already `None`)
2076///    verdict can never be grafted onto a Layer 0 Validate result.
2077fn idle_timeout_result(reason: String, commits: Option<u32>) -> AgentResult {
2078    AgentResult {
2079        status: AgentStatus::IdleTimeout,
2080        exit_code: None,
2081        reason: Some(reason),
2082        commits,
2083        summary: None,
2084        verdict: None,
2085        decided_by_layer: Some(1),
2086    }
2087}
2088
2089/// Layer 1: Try to detect agent result from the native per-adapter envelope
2090/// or the DEVFLOW_RESULT marker in stdout.
2091///
2092/// The monitor's own idle-timeout side channel is consulted FIRST, ahead of
2093/// everything below including `read_capture` itself — see
2094/// [`parse_idle_timeout_side_channel`], where that ordering is a correctness
2095/// requirement rather than a preference.
2096///
2097/// Precedence: Claude rate-limit envelope (a SPECIFIC failure that must
2098/// outrank the generic `is_error` check — rate-limit envelopes carry
2099/// `is_error: true`, and classifying them `Failed` would kill the primary
2100/// rate-limit resume cron path) → Claude envelope `is_error: true` (authoritative,
2101/// overrides a success marker) → Claude `stream-json` JSONL event stream (the
2102/// last `result` event's marker decides; a marker-less last turn defers) →
2103/// DEVFLOW_RESULT marker (portable; works for plain text and a Claude
2104/// envelope's unwrapped `result` text) → Codex JSONL event stream
2105/// (`turn.failed` decisive; `turn.completed` defers) → Codex plain-text
2106/// rate-limit heuristic (least authoritative, stays last).
2107///
2108/// The Claude stream parser's position is load-bearing in BOTH directions
2109/// (T-30-03). The two single-document detectors stay ahead of it because they
2110/// remain authoritative for the `--output-format json` envelope that ships
2111/// today. It goes ahead of `parse_devflow_result` so that an adapter-specific
2112/// stream capture is owned whole by the parser that understands its framing,
2113/// rather than letting the generic 4000-character tail scan take a bite of a
2114/// mid-line window of JSONL first.
2115pub fn evaluate_layer1(project_root: &Path, phase: PhaseId) -> Option<AgentResult> {
2116    // FIRST STATEMENT, before `read_capture` and before every parser below.
2117    // Do not move this into the `.or_else` chain: `parse_claude_event_result`
2118    // resolves the LAST top-level `result` event and would shadow it on any
2119    // stream that already had one successful turn. See
2120    // `parse_idle_timeout_side_channel`'s doc comment (T-31-06).
2121    if let Some(timed_out) = parse_idle_timeout_side_channel(project_root, phase) {
2122        return Some(timed_out);
2123    }
2124
2125    let stdout = read_capture(&stdout_path(project_root, phase))?;
2126    detect_claude_rate_limit(&stdout)
2127        .map(rate_limited_result)
2128        .or_else(|| detect_claude_envelope_failure(&stdout))
2129        .or_else(|| parse_claude_event_result(&stdout))
2130        .or_else(|| parse_antigravity_event_result(&stdout))
2131        .or_else(|| parse_devflow_result(&stdout))
2132        .or_else(|| parse_codex_event_result(&stdout))
2133        .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
2134}
2135
2136/// Build the `RateLimited` result Layer 1 reports for a detected retry hint.
2137fn rate_limited_result(retry: String) -> AgentResult {
2138    AgentResult {
2139        status: AgentStatus::RateLimited,
2140        exit_code: None,
2141        reason: Some(format!("rate limited until {retry}")),
2142        commits: None,
2143        summary: None,
2144        verdict: None,
2145        decided_by_layer: Some(1),
2146    }
2147}
2148
2149/// Commits on the phase's feature branch that are not on `develop`.
2150///
2151/// Derives the branch name from `git_flow.feature_prefix` and the zero-padded
2152/// `phase`, verifies the branch exists with `rev-parse --verify`, and on
2153/// success counts `{git_flow.develop}..{branch}` with `rev-list --count`.
2154/// This is the single implementation of that count — [`evaluate_layer2`],
2155/// [`evaluate_layer3`] and `pipeline_outcomes::handle_validate_outcome`'s
2156/// forward-progress check all call it rather than each re-deriving the branch
2157/// name and re-running the same two git commands, which is what made the
2158/// counts able to silently diverge before this extraction. That claim was
2159/// aspirational until 35-01: [`evaluate_layer3`] carried its own inline
2160/// `rev-list --count` with an independent copy of the lossy zero collapse, and
2161/// deleting it is what makes "single implementation" true.
2162///
2163/// Must be called with the main `project_root`, never a worktree path — git
2164/// worktrees share refs and the object database, so a commit made inside a
2165/// linked worktree is immediately visible to a count run from the main
2166/// checkout, which is the property every caller already relies on.
2167///
2168/// The return distinguishes a MEASUREMENT from a measurement FAILURE, which
2169/// is the whole point of the `Option` (999.77 / D-08, A-06):
2170///
2171/// - `Some(n)` — git ran and reported a real number. This includes
2172///   `Some(0)` for a branch that genuinely does not exist yet, which is
2173///   normal on a phase's first Validate and is a real observation, not a
2174///   failure to observe.
2175/// - `None` — the count could not be established: either the `git` child
2176///   could not be executed at all (`.output()` returned `Err`), or it ran but
2177///   produced stdout that does not parse as a `u32`. A-06 splits only the
2178///   ran/did-not-run axis; the unparseable case is mapped to `None` here
2179///   because the child produced no usable count, and reporting a forged zero
2180///   for it would recreate exactly the hazard this signature removes.
2181///
2182/// **The two consumers now handle `None` distinctly, and neither collapses it
2183/// to zero.** `pipeline_outcomes::handle_validate_outcome` treats an
2184/// unmeasurable cycle as not-progress and leaves its persisted baseline
2185/// untouched, so the next real measurement still compares against the last
2186/// real observation. [`evaluate_layer2`] returns `Ok(None)` and falls through
2187/// to [`evaluate_layer3`], which classifies an unmeasurable count as
2188/// [`AgentStatus::Unknown`] rather than asserting the negative that no work
2189/// was done.
2190///
2191/// # Changed in v2.5.0 — breaking
2192///
2193/// The return type was `u32` before this release; it is now `Option<u32>`
2194/// (999.77 / 999.87). A call site updating from the old form must decide which
2195/// of the two states it means, because the old type conflated them:
2196///
2197/// - `Some(0)` — git RAN and the branch genuinely has no commits. This is the
2198///   old `0` in its legitimate sense, and is normal on a phase's first Validate.
2199/// - `None` — no count was established at all. This is the case the old
2200///   signature could not express, and `.unwrap_or(0)` is precisely the wrong
2201///   way to restore it: collapsing it back to zero is the defect this change
2202///   exists to remove. A transient `git` failure then reads as "no work done",
2203///   which forged a `consecutive_failures` baseline reset (999.77) and made the
2204///   result cascade classify a successful agent as `Failed` (999.87).
2205///
2206/// The enumeration of this and every other public-surface change in the release
2207/// is in `CHANGELOG.md` under 2.5.0.
2208pub fn phase_commit_count(
2209    project_root: &Path,
2210    git_flow: &GitFlowConfig,
2211    phase: PhaseId,
2212) -> Option<u32> {
2213    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
2214
2215    // A-06: split on whether the command RAN, not on what it answered. An
2216    // `Err` means the child could not be executed — a measurement failure. An
2217    // `Ok` with an unsuccessful status means git ran and reported the branch
2218    // absent, which is a real observation of zero commits.
2219    match git_command(project_root)
2220        .args(["rev-parse", "--verify", &branch])
2221        .output()
2222    {
2223        Err(_) => return None,
2224        Ok(output) if !output.status.success() => return Some(0),
2225        Ok(_) => {}
2226    }
2227
2228    let range = format!("{}..{branch}", git_flow.develop);
2229    // A-06 again, applied to the second step (CR-01, 35-REVIEW). This arm used
2230    // to be `.output().ok()?` followed by `.parse().ok()`, which split on
2231    // whether the output PARSED rather than on whether the command RAN — the
2232    // opposite of the rule the `rev-parse` step above states and follows. A
2233    // `rev-list` that runs and exits non-zero writes an empty stdout, so any
2234    // condition making the range invalid (the configured `develop` absent from
2235    // the checkout, a shallow clone) parsed to nothing and returned `None`
2236    // *permanently*, not transiently. That is a measurement the command DID
2237    // make; it belongs with the branch-absent case above as a real zero.
2238    let output = match git_command(project_root)
2239        .args(["rev-list", "--count", &range])
2240        .output()
2241    {
2242        Err(_) => return None,
2243        Ok(output) => output,
2244    };
2245    if !output.status.success() {
2246        return Some(0);
2247    }
2248    // A success whose stdout does not parse is a different animal: git ran,
2249    // succeeded, and said something this function cannot read. Nothing was
2250    // established, so it stays `None` rather than being asserted as zero.
2251    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
2252}
2253
2254/// Layer 2: Use exit code + commit count to determine result.
2255///
2256/// Reads exit code from `.devflow/phase-NN-exit` file.
2257/// Counts commits in `feature/phase-NN` branch (if it exists), via
2258/// [`phase_commit_count`].
2259///
2260/// The commit-count gate ("no commits → failed") is scoped to `stage` — it
2261/// only applies to `Stage::Plan`/`Stage::Code` (checked via an explicit
2262/// `matches!`, NOT `Stage::is_agent_stage()`, since that also includes
2263/// `Define`, which legitimately produces zero commits). `exit≠0` is ALWAYS
2264/// `Failed`, for every stage — only the `exit=0`/zero-commits branch is
2265/// stage-scoped.
2266///
2267/// Decision matrix:
2268///   exit=137                                             → ResourceKilled (ALL stages, D-07)
2269///   exit=127                                             → AgentUnavailable (ALL stages, D-07)
2270///   exit≠0 (excluding 137/127)                           → Failed (ALL stages)
2271///   exit=0, stage in {Plan, Code}, commits=0             → Failed ("no work done")
2272///   exit=0, stage in {Plan, Code}, commits>0             → Success
2273///   exit=0, stage NOT in {Plan, Code} (Define/Validate/Ship), commits=0 → Success
2274///           (not commit-gated; Validate's real pass signal is its verdict,
2275///           not a bare zero-commit — see Task 2's turn.completed deferral)
2276///   exit unknown                                         → fall to Layer 3 (return None)
2277///   exit=0, stage in {Plan, Code}, commits UNMEASURABLE  → fall to Layer 3 (return None)
2278///           (CR-01: the ONLY row an unmeasurable count changes. Every other
2279///           row above is decided by the exit code alone and keeps its verdict
2280///           with the count rendered as "unknown" in the reason string.)
2281///
2282/// WR-06 (13-REVIEW.md): takes only the explicit `project_root` parameter
2283/// for both the `.devflow/` file paths and the git subprocess `current_dir`
2284/// — previously it also accepted `state: &State` and used `state.project_root`
2285/// for the git calls, which every caller happened to pass consistently with
2286/// `project_root` but which the function itself had no way to enforce.
2287pub fn evaluate_layer2(
2288    project_root: &Path,
2289    phase: PhaseId,
2290    git_flow: &GitFlowConfig,
2291    stage: Stage,
2292) -> Result<Option<AgentResult>, ResultError> {
2293    let exit_path = devflow_dir(project_root).join(format!("phase-{}-exit", phase.padded()));
2294    let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
2295        Ok(s) => s.trim().parse().unwrap_or(-1),
2296        Err(_) => return Ok(None), // fall to Layer 3
2297    };
2298
2299    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
2300    let commits = phase_commit_count(project_root, git_flow, phase);
2301    let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
2302
2303    // D-09 (999.87): an unmeasurable commit count is NOT evidence that no work
2304    // was done, and the commit gate below would classify it as
2305    // `Failed — no work done` if it were collapsed to zero.
2306    //
2307    // CR-01 (35-REVIEW): the guard belongs HERE, not above the exit-code
2308    // classification. `commits` is load-bearing for exactly one term —
2309    // `no_work_done`, which only exists when `commit_gated` holds. Returning
2310    // early on any `None` also discarded the 137 / 127 / `exit != 0` verdicts
2311    // and the non-commit-gated `Success`, none of which read the count at all.
2312    // That mattered because Layer 2 is the SOLE classifier for 137 and 127
2313    // (Layer 1 sees no marker from a SIGKILLed or never-launched agent, and
2314    // Layer 3 has no ResourceKilled/AgentUnavailable arm), and the same host
2315    // fault that OOM-kills an agent also makes the `fork` for `git` fail — so
2316    // the two observations arrive together, and an infra fault was routed into
2317    // the Validate loop it is explicitly forbidden from entering
2318    // (`pipeline_launch.rs`, review consensus #4 / D-08).
2319    //
2320    // Fall through ONLY when the missing count is what would have decided.
2321    if commit_gated && exit_code == 0 && commits.is_none() {
2322        return Ok(None); // fall to Layer 3
2323    }
2324
2325    let no_work_done = commit_gated && commits == Some(0);
2326    // Reason strings must not invent a number they do not have. Every
2327    // surviving arm below interpolates the count for context only.
2328    let commits_desc = match commits {
2329        Some(n) => format!("{n} commits"),
2330        None => "an unmeasurable number of commits".to_string(),
2331    };
2332
2333    // 137 (SIGKILL, typically OOM) and 127 (command not found) are classified
2334    // BEFORE the generic `exit_code != 0 -> Failed` catch-all, using the same
2335    // trusted plain-i32 already parsed above from the monitor-written exit
2336    // file (D-07, 17b — no ExitStatusExt/signal API per Pitfall 1a).
2337    let status = if exit_code == 137 {
2338        AgentStatus::ResourceKilled
2339    } else if exit_code == 127 {
2340        AgentStatus::AgentUnavailable
2341    } else if exit_code != 0 || no_work_done {
2342        AgentStatus::Failed
2343    } else {
2344        AgentStatus::Success
2345    };
2346
2347    Ok(Some(AgentResult {
2348        status,
2349        exit_code: Some(exit_code),
2350        reason: if exit_code == 137 {
2351            Some(format!(
2352                "agent process was killed (exit code 137, likely OOM) ({commits_desc} on {branch})"
2353            ))
2354        } else if exit_code == 127 {
2355            Some(format!(
2356                "agent command was unavailable (exit code 127, command not found) \
2357                 ({commits_desc} on {branch})"
2358            ))
2359        } else if exit_code != 0 {
2360            Some(format!(
2361                "agent exited with code {exit_code} ({commits_desc} on {branch})"
2362            ))
2363        } else if no_work_done {
2364            Some(format!(
2365                "no commits found on {branch} (agent exit code was {exit_code})"
2366            ))
2367        } else {
2368            Some(format!(
2369                "{commits_desc} on {branch} (agent exit code was {exit_code})"
2370            ))
2371        },
2372        commits,
2373        summary: None,
2374        verdict: None,
2375        decided_by_layer: Some(2),
2376    }))
2377}
2378
2379/// Layer 3: Last resort — agent process is gone.
2380///
2381/// Split per D-02/D-03 case 3 (17-03): "process gone, commits exist" stays
2382/// `Unknown` — unverified but there is SOMETHING to account for, and Plan
2383/// 04's never-advance dispatch gates it downstream (D-04) rather than
2384/// reclassifying it here. "Process gone, zero commits, nothing declared" is
2385/// no longer a blanket advanceable `Unknown` — it is reclassified to
2386/// `Failed` so a vanished agent that produced and declared nothing cannot
2387/// masquerade as ambiguous-but-fine; the reason flags that human review is
2388/// needed. This only fires when neither Layer 1 nor Layer 2 produced a
2389/// definitive result.
2390///
2391/// **The split is three-way, not two-way (35-01/F-4).** The two cases above
2392/// both assume the commit count was actually established. A third case —
2393/// the count could not be MEASURED at all — is classified `Unknown` with
2394/// `commits` left absent and a reason naming the measurement failure. It is
2395/// not `Failed`: that asserts a negative the evidence does not support, and
2396/// on a transient `git` fault it is the exact misclassification this layer
2397/// used to produce. An unmeasurable count is strictly less certain than the
2398/// `commits > 0` case already called `Unknown`, so `Unknown` is the
2399/// consistent answer.
2400///
2401/// The count now comes from [`phase_commit_count`] rather than a second
2402/// inline derivation. This layer previously ran its own `rev-list --count`
2403/// that fell soft to a zero default, an independent copy of the same lossy
2404/// collapse — so fixing only [`evaluate_layer2`] relocated the
2405/// misclassification here instead of removing it. The two measurable arms'
2406/// behaviour and reason strings are unchanged.
2407pub fn evaluate_layer3(
2408    project_root: &Path,
2409    phase: PhaseId,
2410    git_flow: &GitFlowConfig,
2411) -> Result<AgentResult, ResultError> {
2412    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
2413    // F-4 (35-01): this layer used to run its OWN inline `rev-list --count`
2414    // that fell soft to a zero default, an independent copy of the same lossy
2415    // collapse `phase_commit_count` carried. Because every path that reaches
2416    // Layer 2 also reaches Layer 3, fixing only Layer 2 relocated the
2417    // misclassification here instead of removing it. Routed through the shared
2418    // counter so the cascade's last layer measures the same way every other
2419    // consumer does.
2420    let commits = phase_commit_count(project_root, git_flow, phase);
2421
2422    let (status, reason) = match commits {
2423        Some(n) if n > 0 => (
2424            AgentStatus::Unknown,
2425            format!(
2426                "unverified — agent process is gone but {} commits exist on {}",
2427                n, branch
2428            ),
2429        ),
2430        Some(_) => (
2431            AgentStatus::Failed,
2432            "no work accounted for — agent process is gone with no commits and no declared \
2433             external post-condition; human review needed"
2434                .to_string(),
2435        ),
2436        // F-4: an unmeasurable count is not evidence of absent work here
2437        // either. `Failed` asserts a negative the evidence does not support,
2438        // and it is the classification this phase exists to stop producing on
2439        // a transient fault. Layer 3 already reserves `Unknown` for "there is
2440        // something here I cannot verify"; a count that could not be taken at
2441        // all is strictly less certain than that, so `Unknown` is the
2442        // consistent answer. `commits` is left absent rather than forged to
2443        // zero — "no work" and "could not tell" are different facts.
2444        None => (
2445            AgentStatus::Unknown,
2446            format!(
2447                "unverified — agent process is gone and the work could not be accounted for: \
2448                 the commit count on {} could not be measured; human review needed",
2449                branch
2450            ),
2451        ),
2452    };
2453
2454    Ok(AgentResult {
2455        status,
2456        exit_code: None,
2457        reason: Some(reason),
2458        commits,
2459        summary: None,
2460        verdict: None,
2461        decided_by_layer: Some(3),
2462    })
2463}
2464
2465/// Layer 0: run explicitly operator-approved external post-condition probes.
2466///
2467/// A failed probe outranks every agent-controlled signal. An approved,
2468/// all-passing set of declared probes is itself affirmative completion
2469/// evidence — `Success` — so a legitimately external-only stage with zero
2470/// commits can still complete cleanly (D-05 gap 2). Evaluated for EVERY
2471/// stage, not only Code (D-05 gap 1 / D-06). With no declarations (or when
2472/// disabled), behavior is byte-for-byte the pre-Phase-16 cascade.
2473///
2474/// Both DISCOVERY and probe EXECUTION read `execution_root` — the worktree
2475/// when one is set, `project_root` otherwise (999.76, ROADMAP criterion 6).
2476///
2477/// This knowingly OVERTURNS a recorded prior peer-review decision
2478/// (review Plan 03 MEDIUM, OpenCode). That decision held the two roots must
2479/// stay distinct, discovery reading `project_root` because
2480/// `.planning/phases/` "lives there, not in a worktree checkout". **The
2481/// premise has the direction backwards.** `.planning/` is TRACKED content,
2482/// so an in-flight phase's `{N}-PLAN.md` is committed on `feature/phase-{N}`
2483/// and therefore exists INSIDE the worktree while absent from the main checkout for
2484/// the phase's whole duration. Discovering from `project_root` meant a
2485/// correctly-declared probe set silently never ran in worktree mode —
2486/// DevFlow's default operating shape — with no error and no log, and the
2487/// "PLAN removed" veto below fired in its place. Recorded as an overturn
2488/// rather than patched quietly, so a later reader can see the direction was
2489/// reconsidered on evidence rather than overlooked.
2490///
2491/// Three sibling reads deliberately KEEP `project_root` and must not be
2492/// "corrected" to match: [`phase_commit_count`] (git worktrees share refs and
2493/// the object database, so counting from the main checkout is right), and
2494/// [`checkpoint_reported_in_capture`] and [`evaluate_layer1`] (both read the
2495/// stdout capture under `.devflow/`, which lives in the project root).
2496fn evaluate_layer0(
2497    project_root: &Path,
2498    state: &State,
2499    approved_commands: Option<&[String]>,
2500) -> Option<AgentResult> {
2501    if !crate::config::external_verify_enabled(project_root) {
2502        return None;
2503    }
2504
2505    let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
2506    let commands = crate::verify::external_verify_commands(execution_root, state.phase);
2507    if commands.is_empty() {
2508        return approved_commands.map(|_| AgentResult {
2509            status: AgentStatus::Failed,
2510            exit_code: None,
2511            reason: Some(
2512                "external verification approval mismatch; PLAN declaration was removed".into(),
2513            ),
2514            commits: None,
2515            summary: None,
2516            verdict: None,
2517            decided_by_layer: Some(0),
2518        });
2519    }
2520    let Some(approved_commands) = approved_commands else {
2521        return Some(AgentResult {
2522            status: AgentStatus::Failed,
2523            exit_code: None,
2524            reason: Some(format!(
2525                "external verification is not approved; set {} to the reviewed JSON command array",
2526                crate::verify::TRUST_EXTERNAL_VERIFY_ENV
2527            )),
2528            commits: None,
2529            summary: None,
2530            verdict: None,
2531            decided_by_layer: Some(0),
2532        });
2533    };
2534    if commands != approved_commands {
2535        return Some(AgentResult {
2536            status: AgentStatus::Failed,
2537            exit_code: None,
2538            reason: Some("external verification approval mismatch; PLAN commands changed".into()),
2539            commits: None,
2540            summary: None,
2541            verdict: None,
2542            decided_by_layer: Some(0),
2543        });
2544    }
2545    match commands
2546        .into_iter()
2547        .find(|command| !crate::verify::run_external_verification(command, execution_root))
2548    {
2549        Some(command) => Some(AgentResult {
2550            status: AgentStatus::Failed,
2551            exit_code: None,
2552            reason: Some(format!("external verification failed: {command}")),
2553            commits: None,
2554            summary: None,
2555            verdict: None,
2556            decided_by_layer: Some(0),
2557        }),
2558        // Every declared, approved probe passed — affirmative completion
2559        // evidence on its own (D-05 gap 2), even with zero commits.
2560        None => Some(AgentResult {
2561            status: AgentStatus::Success,
2562            exit_code: None,
2563            reason: Some(
2564                "external verification passed — all declared, approved probes succeeded".into(),
2565            ),
2566            commits: None,
2567            summary: None,
2568            verdict: None,
2569            decided_by_layer: Some(0),
2570        }),
2571    }
2572}
2573
2574/// Reconciles Layer 0's affirmative-success result with Layer 1's
2575/// self-reported verdict at `Stage::Validate` (18e).
2576///
2577/// Layer 0's affirmative-success arm above short-circuits the cascade before
2578/// Layer 1 ever runs (`evaluate_agent_result_inner` returns immediately on
2579/// any `Some(..)` from Layer 0), but Layer 1 is the ONLY carrier of a
2580/// `verdict` — `status` reports whether the stage's task ran; `verdict`
2581/// reports whether validation itself passed (see `AgentResult::verdict`'s
2582/// doc comment). At `Stage::Validate` that meant an agent's explicit
2583/// `verdict: pass` was silently discarded and `advance()` computed a failure
2584/// from it — a regression introduced by this project's own 17-03, fixed
2585/// here.
2586///
2587/// `decided_by_layer` deliberately stays `Some(0)` — Layer 0 still DECIDED
2588/// the `status`; Layer 1 only supplies the `verdict`. The CLI relies on that
2589/// value to tell an `external_verify` Validate apart from an ordinary one
2590/// (`classify_validate_outcome`, 18e).
2591///
2592/// Scoped to `Stage::Validate` only (flagged assumption in 18-05-PLAN.md): at
2593/// every other stage an affirmative Layer 0 success keeps `verdict: None`,
2594/// unchanged from current behavior. A Layer 0 FAILURE is never passed here —
2595/// only its affirmative-success arm is, so a failed probe still outranks
2596/// every agent-controlled signal.
2597///
2598/// 31-02 audit (non-exhaustive equality site 2 of 3). The `!= Success` guard
2599/// below is CORRECT AS-IS for `AgentStatus::IdleTimeout` and is left unchanged.
2600/// The compiler cannot flag an equality test against a new variant, so this is
2601/// audited by hand. An idle-timeout result is rejected here by BOTH independent
2602/// guards, not just one: its status is not `Success`, and its
2603/// `decided_by_layer` is `Some(1)` (the monitor's side-channel verdict is a
2604/// Layer-1-class fact), never `Some(0)`. It returns unchanged, which is right —
2605/// this function exists only to graft Layer 1's `verdict` onto an affirmative
2606/// Layer 0 probe success, and a timeout is neither.
2607///
2608/// # This function is 999.74's real defect site (D-15, ROADMAP criterion 4)
2609///
2610/// Until 34-01 the graft read Layer 1's `verdict` and nothing else. A marker of
2611/// `{"status":"failed","verdict":"pass"}` therefore produced `(Success,
2612/// Some(Pass), Some(0))`: an agent's self-reported FAILURE laundered into an
2613/// affirmative pair, which `outcome_policy::decide_action` advances and
2614/// `classify_validate_outcome` reads as `Passed` — Ship, in `Mode::Auto`, on a
2615/// run whose agent said it had failed. The status was never inspected, so
2616/// nothing downstream could see the contradiction; by the time the classifier
2617/// ran, the status genuinely WAS `Success`.
2618///
2619/// The fix consults Layer 1's own `AgentStatus` before transplanting its
2620/// verdict, because **a verdict attached to a self-reported failure is not a
2621/// pass**. Only `AgentStatus::Success` from Layer 1 may contribute a verdict;
2622/// everything else leaves `verdict: None` and the stage classifies `Ambiguous`,
2623/// which gates.
2624///
2625/// The classifier fix (plan 34-03, ROADMAP criterion 3) does **not** close this
2626/// and never could: gating `classify_validate_outcome`'s `Passed` arm on the
2627/// derived status passes cleanly here, because the derived status is `Success`.
2628/// Criterion 3 and criterion 4 are separate deliverables. Regression-pinned by
2629/// `layer0_verdict_graft_declines_when_layer1_status_is_not_success`, with
2630/// `layer0_verdict_graft_still_transplants_a_passing_layer1_verdict` as its
2631/// mandatory opposite-result control.
2632///
2633/// `evaluate_layer1` is called on `project_root`, NOT on the execution root,
2634/// and that asymmetry is deliberate rather than an oversight: Layer 1 reads the
2635/// stdout capture under `.devflow/`, which lives in the project root, while
2636/// Layer 0 above DISCOVERS declarations in `.planning/phases/` (project root)
2637/// and RUNS probes in the worktree. Plan 34-04 moves Layer 0's *discovery* to
2638/// the execution root; this call stays on `project_root` and is still correct
2639/// afterwards. Recorded here so a later reader does not "fix" the asymmetry.
2640fn reconcile_layer0_verdict(
2641    project_root: &Path,
2642    state: &State,
2643    result: AgentResult,
2644) -> AgentResult {
2645    if state.stage != Stage::Validate
2646        || result.status != AgentStatus::Success
2647        || result.decided_by_layer != Some(0)
2648    {
2649        return result;
2650    }
2651    let verdict = evaluate_layer1(project_root, state.phase)
2652        .filter(|layer1| layer1.status == AgentStatus::Success)
2653        .and_then(|layer1| layer1.verdict);
2654    AgentResult { verdict, ..result }
2655}
2656
2657/// Refuse to let a stream-derived `Success` outrank a contradicting exit code
2658/// (constraint 9's residual, T-31-15, 31-04).
2659///
2660/// # Why this cannot be a parser assertion
2661///
2662/// Constraint 9's items 1 and 2 — a torn line at or after the last surviving
2663/// top-level `result`, and provenance on verdict selection — were closed at the
2664/// root by the `a557805` refactor that made lossiness and capture kind
2665/// first-class ([`ParsedCapture`], [`classify`]). What survives is precisely
2666/// the case no parser can detect: **a capture cut at an exact line boundary is
2667/// byte-identical to a healthy shorter run.** There is nothing in the bytes to
2668/// assert on. The writer that died between flushing turn N and turn N+1 also
2669/// died non-zero, so the exit code is the only remaining signal — and it lives
2670/// one layer up, in the wiring, which is where this defence had to go.
2671///
2672/// # Why the fix is narrow rather than a cascade reordering
2673///
2674/// [`evaluate_agent_result_inner`] consults Layer 2 only when Layer 1 abstains,
2675/// which is why a Layer 1 `Success` wins over a contradicting exit code today.
2676/// That ordering is correct in the ordinary case: Layer 1 is authoritative
2677/// precisely so it does not need Layer 2's slower `git rev-list` fallback.
2678/// Making Layer 2 run first would trade a rare wrong answer for a slow one on
2679/// every stage. So this arbitrates one verdict rather than reordering anything.
2680///
2681/// # Scope
2682///
2683/// Fires ONLY on `AgentStatus::Success`. `RateLimited`, `IdleTimeout`,
2684/// `ResourceKilled`, `AgentUnavailable`, `Failed` and `Unknown` all return
2685/// untouched, each with a named test. Two of those exclusions are load-bearing
2686/// rather than tidy: a `RateLimited` downgraded to `Failed` would route the run
2687/// to a human gate instead of the auto-resume cron it needs, and an
2688/// `IdleTimeout` downgraded to `Failed` would erase the distinction plan 31-02
2689/// exists to create — 999.64 reborn inside its own fix.
2690///
2691/// 31-02 audit convention (non-exhaustive equality site): the `!= Success`
2692/// guard below is correct as-is for every current and future variant. Anything
2693/// that is not an affirmative claimed success has nothing to arbitrate, so
2694/// passing it through unchanged is the right default for a variant added later.
2695///
2696/// # `verdict: None` is load-bearing — do not carry it over for symmetry
2697///
2698/// `classify_validate_outcome` (`devflow-cli/src/pipeline_outcomes.rs`) matches
2699/// `(_, Some(Verdict::Pass)) => ValidateOutcome::Passed` FIRST, with `_`
2700/// discarding the status entirely. A downgraded result has no verdict to offer
2701/// and must not invent one. [`idle_timeout_result`] dodges the same trap the
2702/// same way, and says so. That instruction is unchanged and still binding.
2703///
2704/// **Correction (34-01, D-15).** An earlier version of this note went further
2705/// and claimed a kept `verdict: Pass` on a `status: Failed` "would still
2706/// classify Validate as **Passed**", making this function a no-op at Validate.
2707/// That overstated the reachability. `outcome_policy::decide_action` intercepts
2708/// every non-`Success` status and routes it to a gate BEFORE
2709/// `classify_validate_outcome` is ever reached, so THIS path is protected and
2710/// this function is not a no-op. The `verdict: None` above is defence in depth,
2711/// which is why it stays.
2712///
2713/// The route into the inversion that IS reachable is
2714/// [`reconcile_layer0_verdict`]'s graft — it produced `status: Success` with a
2715/// self-reported failure's verdict attached, so `decide_action` had nothing to
2716/// intercept. See that function's own doc comment for the full record. It is
2717/// closed in plan 34-01; the classifier's own structural fix (gating the
2718/// `Passed` arm on the derived status) lands in plan 34-03.
2719///
2720/// **999.74 / DEN-95** is therefore being CLOSED in Phase 34 rather than
2721/// deliberately deferred. The caution that motivated the earlier deferral still
2722/// applies to the classifier half and is discharged there, not here: changing
2723/// that match arm re-routes `Failed`, `Unknown` and `ResourceKilled`, so 34-03
2724/// audits all of them explicitly.
2725///
2726/// # Exit-code fidelity
2727///
2728/// 137 → `ResourceKilled` and 127 → `AgentUnavailable` are preserved rather
2729/// than collapsed into `Failed`, mirroring [`evaluate_layer2`] exactly:
2730/// `outcome_policy::decide_action` routes those two to `GateInfra` rather than
2731/// `GateReview`, and the same exit code must not reach two different operator
2732/// gates depending on whether a stale Layer 1 success happened to be present.
2733///
2734/// Note the `ResourceKilled` arm is currently **unreachable via the
2735/// `MonitorLaunch::PipeOwning` path**: `run_pipe_owning_monitor` records
2736/// `status.code().unwrap_or(-1)`, so a SIGKILLed child writes `-1`, not `137`.
2737/// Recorded rather than silently relabelling a real OOM as `Failed` — the arm
2738/// is still reachable from the `Legacy` arm's `sh` monitor, whose `$?` does
2739/// carry `128 + signal`.
2740///
2741/// Unreadable or unparseable exit-file content is tolerated exactly as
2742/// [`evaluate_layer2`] tolerates it — a missing file returns the result
2743/// unchanged (an absent file is not evidence of failure), and garbage parses to
2744/// `-1`. Neither is invented behaviour; both match the sibling reader.
2745fn reconcile_stream_success_against_exit_code(
2746    project_root: &Path,
2747    phase: PhaseId,
2748    result: AgentResult,
2749) -> AgentResult {
2750    if result.status != AgentStatus::Success {
2751        return result;
2752    }
2753
2754    let Ok(raw) = std::fs::read_to_string(exit_code_path(project_root, phase)) else {
2755        return result;
2756    };
2757    let exit_code: i32 = raw.trim().parse().unwrap_or(-1);
2758    if exit_code == 0 {
2759        return result;
2760    }
2761
2762    let (status, lead) = if exit_code == 137 {
2763        (
2764            AgentStatus::ResourceKilled,
2765            format!(
2766                "the agent's output stream reported SUCCESS but the process was killed \
2767                 (exit code {exit_code}, likely OOM)"
2768            ),
2769        )
2770    } else if exit_code == 127 {
2771        (
2772            AgentStatus::AgentUnavailable,
2773            format!(
2774                "the agent's output stream reported SUCCESS but the agent command was \
2775                 unavailable (exit code {exit_code}, command not found)"
2776            ),
2777        )
2778    } else {
2779        (
2780            AgentStatus::Failed,
2781            format!(
2782                "the agent's output stream reported SUCCESS but the agent exited with \
2783                 code {exit_code}"
2784            ),
2785        )
2786    };
2787
2788    AgentResult {
2789        status,
2790        exit_code: Some(exit_code),
2791        reason: Some(format!(
2792            "{lead}. A capture cut at an exact line boundary is byte-identical to a healthy \
2793             shorter run, so no parser assertion can tell the two apart — the exit code is the \
2794             only remaining signal, and it contradicts the claim. Review the phase branch before \
2795             deciding what to keep; nothing was rolled back."
2796        )),
2797        verdict: None,
2798        ..result
2799    }
2800}
2801
2802/// Full four-layer evaluation: returns the best available AgentResult.
2803pub fn evaluate_agent_result(
2804    project_root: &Path,
2805    state: &State,
2806    git_flow: &GitFlowConfig,
2807) -> Result<AgentResult, ResultError> {
2808    let approval = crate::verify::external_verification_approval();
2809    evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
2810}
2811
2812fn evaluate_agent_result_inner(
2813    project_root: &Path,
2814    state: &State,
2815    git_flow: &GitFlowConfig,
2816    approved_commands: Option<&[String]>,
2817) -> Result<AgentResult, ResultError> {
2818    // Layer 0: operator-authored external post-condition (authoritative failure)
2819    if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
2820        return Ok(reconcile_layer0_verdict(project_root, state, result));
2821    }
2822
2823    // Layer 1: DEVFLOW_RESULT marker (authoritative)
2824    //
2825    // Authoritative, but not unconditionally: a CLAIMED success is arbitrated
2826    // against the recorded exit code before it is returned (31-04, T-31-15).
2827    // The cascade below is deliberately NOT reordered — see
2828    // `reconcile_stream_success_against_exit_code` for why Layer 2 running
2829    // first would be the wrong trade.
2830    if let Some(result) = evaluate_layer1(project_root, state.phase) {
2831        return Ok(reconcile_stream_success_against_exit_code(
2832            project_root,
2833            state.phase,
2834            result,
2835        ));
2836    }
2837
2838    // Layer 2: Exit code + commit gate
2839    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
2840        return Ok(result);
2841    }
2842
2843    // Layer 3: Process existence + commits
2844    evaluate_layer3(project_root, state.phase, git_flow)
2845}
2846
2847/// Path to the .devflow directory for a project root.
2848fn devflow_dir(project_root: &Path) -> PathBuf {
2849    project_root.join(".devflow")
2850}
2851
2852/// Path to the stdout file for a given phase.
2853pub fn stdout_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2854    devflow_dir(project_root).join(format!("phase-{}-stdout", phase.padded()))
2855}
2856
2857/// Path where the agent's stderr is captured for a given phase.
2858/// Lives alongside `stdout_path` under `.devflow/`.
2859pub fn stderr_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2860    devflow_dir(project_root).join(format!(
2861        "phase-{padded}-stderr.log",
2862        padded = phase.padded()
2863    ))
2864}
2865
2866/// Path to the exit code file for a given phase.
2867pub fn exit_code_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2868    devflow_dir(project_root).join(format!("phase-{}-exit", phase.padded()))
2869}
2870
2871/// Path to the file where the monitor records the launched agent's PID.
2872pub fn agent_pid_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2873    devflow_dir(project_root).join(format!("phase-{}-agent-pid", phase.padded()))
2874}
2875
2876/// Path to the file holding the stage prompt handed to the pipe-owning
2877/// monitor (Phase 31).
2878///
2879/// The prompt travels `spawn_monitor` → detached monitor process as a FILE,
2880/// never as argv: DevFlow stage prompts are large and argv has a hard length
2881/// ceiling, so a prompt passed positionally would fail on exactly the
2882/// context-heavy stages that matter most.
2883pub fn prompt_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2884    devflow_dir(project_root).join(format!("phase-{}-prompt", phase.padded()))
2885}
2886
2887/// Path to the pipe-owning monitor's own log for a phase (Phase 31).
2888///
2889/// The monitor is a detached process whose stdio is not the operator's
2890/// terminal — anything it prints to its own stdout goes nowhere. Every "log
2891/// loudly" obligation in this phase (the D-04 idle-timeout clamp, the D-11
2892/// opt-out notice) writes here instead, so a loud message is actually
2893/// readable after the fact.
2894pub fn monitor_log_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2895    devflow_dir(project_root).join(format!("phase-{}-monitor.log", phase.padded()))
2896}
2897
2898/// Path to the pipe-owning monitor's idle-timeout verdict for a phase
2899/// (D-05/D-06, 31-02).
2900///
2901/// A SIDE CHANNEL, deliberately separate from the stdout capture: the capture
2902/// is the agent's own narration, and a verdict appended to it is shadowed by
2903/// any earlier genuine `result` event the stream already contained. See
2904/// [`parse_idle_timeout_side_channel`] — that separation is a correctness
2905/// requirement (T-31-06), not a filing convention.
2906///
2907/// Holds a JSON [`IdleTimeoutRecord`]. Written and fsynced by the monitor
2908/// BEFORE the child is signalled, so nothing can race the verdict.
2909pub fn idle_timeout_path(project_root: &Path, phase: PhaseId) -> PathBuf {
2910    devflow_dir(project_root).join(format!("phase-{}-idle-timeout", phase.padded()))
2911}
2912
2913/// Path to the archived-capture-history directory for a phase (16b).
2914///
2915/// `.devflow/history/phase-NN/` holds retained per-stage capture generations
2916/// so a false-positive self-report can be diagnosed after the fact. Exposed
2917/// as a constructor (rather than inlined at each call site) so downstream
2918/// tooling (16h in 16-07's correlation, 16i in 16-05's enumeration) always
2919/// derives the path from here instead of hardcoding it.
2920pub fn history_dir(project_root: &Path, phase: PhaseId) -> PathBuf {
2921    devflow_dir(project_root)
2922        .join("history")
2923        .join(format!("phase-{}", phase.padded()))
2924}
2925
2926/// Monotonically increasing tie-breaker appended to the nanosecond timestamp
2927/// used to stamp archived generations, so two archives issued within the
2928/// same nanosecond (possible in a tight test loop) never collide.
2929static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2930
2931/// A stamp unique within this process, used to name an archived generation.
2932/// The outgoing stage's name is not available at the `archive_phase_files`
2933/// call site (see `launch_stage` in main.rs), so a monotonic timestamp is
2934/// used instead — sufficient to order and identify generations.
2935fn archive_stamp() -> String {
2936    let nanos = std::time::SystemTime::now()
2937        .duration_since(std::time::UNIX_EPOCH)
2938        .map(|d| d.as_nanos())
2939        .unwrap_or(0);
2940    let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2941    format!("{nanos}-{seq}")
2942}
2943
2944/// Archive the prior stage's stdout/exit captures into bounded per-phase
2945/// history instead of wiping them outright, so a false-positive self-report
2946/// can be diagnosed after the fact (16b). Replaces the old
2947/// `cleanup_phase_files`, which deleted these files unconditionally.
2948///
2949/// At most `retain` capture generations are kept per phase; older ones are
2950/// pruned (see [`prune_history`]). The agent-pid file is still removed
2951/// outright — it is process bookkeeping, not diagnostic output. When there
2952/// is nothing to archive (first launch), this is a no-op success.
2953pub fn archive_phase_files(
2954    project_root: &Path,
2955    evidence_root: &Path,
2956    phase: PhaseId,
2957    retain: usize,
2958) -> Result<Option<String>, std::io::Error> {
2959    archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
2960}
2961
2962fn archive_phase_files_with_stamp(
2963    project_root: &Path,
2964    evidence_root: &Path,
2965    phase: PhaseId,
2966    retain: usize,
2967    stamp: &str,
2968) -> Result<Option<String>, std::io::Error> {
2969    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
2970    // The idle-timeout record has the SAME lifetime as the pid file above: it
2971    // describes one stage attempt and must not outlive it. Clearing it here was
2972    // simply missed when the side channel was introduced (31-02), and the
2973    // omission is not benign — [`parse_idle_timeout_side_channel`] is
2974    // `evaluate_layer1`'s FIRST statement and returns unconditionally
2975    // (T-31-06), by design, so that nothing can shadow a real timeout. A record
2976    // that survives its attempt therefore outranks every later stage's real
2977    // result, forever, for that phase.
2978    //
2979    // Observed 2026-08-08: a record written at 22:48 by a killed Plan stage
2980    // condemned a Define stage that had succeeded 15s earlier, in a 22-second
2981    // stage, with a message quoting a 120s silence and a pid dead for 14
2982    // minutes. It survived both `gate reject --note abort` and
2983    // `devflow start --force`, because nothing anywhere unlinked it.
2984    //
2985    // Deleting rather than archiving loses nothing: the verdict is already
2986    // durable in `advance_evaluated`'s `reason` in `events.jsonl` and in the
2987    // gate context that quotes it.
2988    //
2989    // This must stay ABOVE the "nothing to archive" early return below — the
2990    // case with a stale record and no capture beside it is exactly the one that
2991    // needs clearing.
2992    let _ = std::fs::remove_file(idle_timeout_path(project_root, phase));
2993
2994    let stdout_src = stdout_path(project_root, phase);
2995    let exit_src = exit_code_path(project_root, phase);
2996    let stdout_exists = stdout_src.exists();
2997    let exit_exists = exit_src.exists();
2998    if !stdout_exists && !exit_exists {
2999        return Ok(None); // Nothing to archive — first launch.
3000    }
3001
3002    let history_dir = history_dir(project_root, phase);
3003    crate::workflow::ensure_devflow_dir(&history_dir)?;
3004
3005    let staging_dir = history_dir.join(format!(".pending-{stamp}"));
3006    std::fs::create_dir(&staging_dir)?;
3007    let stdout_stage = staging_dir.join("stdout");
3008    let exit_stage = staging_dir.join("exit");
3009    let review_stage = staging_dir.join("REVIEW.md");
3010    let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
3011    let exit_dest = history_dir.join(format!("{stamp}-exit"));
3012    let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
3013    let review_src = phase_review_path(evidence_root, phase);
3014
3015    let mut stdout_staged = false;
3016    let mut exit_staged = false;
3017    let mut stdout_published = false;
3018    let mut exit_published = false;
3019    let mut review_published = false;
3020
3021    let archive_result = (|| -> Result<(), std::io::Error> {
3022        if stdout_exists {
3023            std::fs::rename(&stdout_src, &stdout_stage)?;
3024            stdout_staged = true;
3025        }
3026        if exit_exists {
3027            std::fs::rename(&exit_src, &exit_stage)?;
3028            exit_staged = true;
3029        }
3030        if let Some(review) = &review_src {
3031            std::fs::copy(review, &review_stage)?;
3032        }
3033
3034        if stdout_exists {
3035            std::fs::rename(&stdout_stage, &stdout_dest)?;
3036            stdout_staged = false;
3037            stdout_published = true;
3038        }
3039        if exit_exists {
3040            std::fs::rename(&exit_stage, &exit_dest)?;
3041            exit_staged = false;
3042            exit_published = true;
3043        }
3044        if review_src.is_some() {
3045            std::fs::rename(&review_stage, &review_dest)?;
3046            review_published = true;
3047        }
3048        Ok(())
3049    })();
3050
3051    if let Err(error) = archive_result {
3052        let mut rollback_error = None;
3053        let mut restore = |from: &Path, to: &Path| {
3054            if let Err(error) = std::fs::rename(from, to)
3055                && rollback_error.is_none()
3056            {
3057                rollback_error = Some(error);
3058            }
3059        };
3060        if stdout_published {
3061            restore(&stdout_dest, &stdout_src);
3062        } else if stdout_staged {
3063            restore(&stdout_stage, &stdout_src);
3064        }
3065        if exit_published {
3066            restore(&exit_dest, &exit_src);
3067        } else if exit_staged {
3068            restore(&exit_stage, &exit_src);
3069        }
3070        if review_published {
3071            let _ = std::fs::remove_file(&review_dest);
3072        }
3073        let _ = std::fs::remove_dir_all(&staging_dir);
3074
3075        if let Some(rollback_error) = rollback_error {
3076            return Err(std::io::Error::new(
3077                error.kind(),
3078                format!("{error}; archive rollback failed: {rollback_error}"),
3079            ));
3080        }
3081        return Err(error);
3082    }
3083
3084    let _ = std::fs::remove_dir(&staging_dir);
3085
3086    prune_history(&history_dir, retain);
3087    Ok(Some(stamp.to_string()))
3088}
3089
3090fn phase_review_path(evidence_root: &Path, phase: PhaseId) -> Option<PathBuf> {
3091    let phases = std::fs::read_dir(evidence_root.join(".planning/phases")).ok()?;
3092    let prefix = format!("{padded}-", padded = phase.padded());
3093    for entry in phases.flatten() {
3094        if entry
3095            .file_name()
3096            .to_str()
3097            .is_some_and(|name| name.starts_with(&prefix))
3098        {
3099            let review = entry
3100                .path()
3101                .join(format!("{padded}-REVIEW.md", padded = phase.padded()));
3102            if review.exists() {
3103                return Some(review);
3104            }
3105        }
3106    }
3107    None
3108}
3109
3110/// Whether `/gsd-verify-work` has produced a `{phase:02}-VERIFICATION.md`
3111/// artifact for `phase` yet.
3112///
3113/// Per D-01 (33-CONTEXT.md), this is the sole mid-arc-vs-genuine-gaps signal
3114/// a Validate→Code loop-back consults: a phase with no verification artifact
3115/// is still mid-arc (its remaining plans have not been judged at all), so a
3116/// loop-back must re-run the phase in full rather than dispatch `--gaps-only`,
3117/// which matches zero plans and gates unresolvably. Mirrors
3118/// [`phase_review_path`]'s directory-prefix-scan idiom exactly, but returns a
3119/// `bool` — no caller needs the artifact's path, only whether it exists. A
3120/// missing `.planning/phases` directory returns `false` rather than panicking.
3121///
3122/// `evidence_root` is the root the Validate agent actually wrote to — the
3123/// phase's worktree when `state.worktree_path` is set, else the project root.
3124/// `.planning/` is tracked, so in worktree mode the artifact lands on
3125/// `feature/phase-N` and is invisible from the main checkout for the phase's
3126/// entire in-flight duration. Passing the project root in worktree mode is
3127/// exactly the defect this parameter name exists to prevent (33-CONTEXT.md
3128/// CR-01); it is NOT interchangeable with the root used for git reads such as
3129/// [`phase_commit_count`], whose refs and object database are shared across
3130/// worktrees and which therefore correctly takes the project root.
3131pub fn phase_verification_exists(evidence_root: &Path, phase: PhaseId) -> bool {
3132    phase_verification_path(evidence_root, phase).is_some()
3133}
3134
3135/// The `{phase:02}-VERIFICATION.md` artifact's path under `evidence_root`, or
3136/// `None` when no phase directory carries one.
3137///
3138/// Extracted from [`phase_verification_exists`] (999.79, 35-05) so the
3139/// existence probe and the content fingerprint below scan for the artifact in
3140/// exactly ONE place. Duplicating the prefix scan would let the two answer
3141/// about different files after any future change to the directory layout — and
3142/// the freshness rule is only sound while "does it exist" and "what are its
3143/// bytes" are questions about the same path.
3144///
3145/// `evidence_root` carries the same meaning and the same prohibition as it does
3146/// for [`phase_verification_exists`]: it is the root the Validate agent
3147/// actually wrote to, never the main checkout in worktree mode.
3148fn phase_verification_path(evidence_root: &Path, phase: PhaseId) -> Option<PathBuf> {
3149    let phases = std::fs::read_dir(evidence_root.join(".planning/phases")).ok()?;
3150    let prefix = format!("{padded}-", padded = phase.padded());
3151    for entry in phases.flatten() {
3152        if entry
3153            .file_name()
3154            .to_str()
3155            .is_some_and(|name| name.starts_with(&prefix))
3156        {
3157            let verification = entry
3158                .path()
3159                .join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
3160            if verification.exists() {
3161                return Some(verification);
3162            }
3163        }
3164    }
3165    None
3166}
3167
3168/// A content fingerprint of the phase's `{phase:02}-VERIFICATION.md`, or `None`
3169/// when no such artifact exists under `evidence_root` (999.79, 35-05).
3170///
3171/// **What it is for.** Nothing deletes, dates or invalidates the artifact, and
3172/// `devflow start --phase N --force` checks out a branch that still carries the
3173/// PREVIOUS run's committed copy. That re-run is mid-arc by construction, so its
3174/// first Validate failure would find the stale artifact, read it as a verdict,
3175/// and dispatch a `--gaps-only` pass against zero matching plans — gating
3176/// unresolvably. Comparing this value against the one recorded at the start of
3177/// the run distinguishes "the Validate agent authored this during this run"
3178/// from "this was inherited".
3179///
3180/// **Why the algorithm is written out rather than borrowed from `std`.** This
3181/// value is persisted by one process (`devflow start`) and compared by a later
3182/// one (`devflow advance`), so it must mean the same thing in both.
3183/// `std::collections::hash_map::DefaultHasher` explicitly does NOT guarantee a
3184/// stable output across toolchain versions, so an operator who upgraded Rust
3185/// mid-phase would see every artifact read as "changed" — which is the
3186/// fail-OPEN direction, dispatching gaps-only exactly where a full execute was
3187/// correct. This is FNV-1a/64, fixed by these two constants and nothing else.
3188///
3189/// **No security property is claimed.** This is change detection over a
3190/// planning document that is already committed to the repository. It is not
3191/// collision-resistant and must never be used to authenticate anything; an
3192/// adversary who can write the artifact can already write whatever verdict they
3193/// like into it.
3194///
3195/// # Companion: [`phase_verification_mtime_nanos`]
3196///
3197/// Content alone cannot see an IDEMPOTENT rewrite (WR-06, 35-REVIEW): a
3198/// Validate agent that re-authors byte-identical content on a later cycle
3199/// produces the same fingerprint as an artifact nobody touched, and the
3200/// consumer then classifies its own agent's work as inherited. The mtime is
3201/// the second input that separates "unchanged because inherited" from
3202/// "unchanged because idempotent"; it is read from the same resolved path and
3203/// returns `None` on exactly the same "no artifact" condition, so the two are
3204/// always consistent about whether an artifact exists.
3205pub fn phase_verification_fingerprint(evidence_root: &Path, phase: PhaseId) -> Option<u64> {
3206    let path = phase_verification_path(evidence_root, phase)?;
3207    let bytes = std::fs::read(path).ok()?;
3208    // FNV-1a, 64-bit: offset basis and prime are the published constants.
3209    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
3210    for byte in bytes {
3211        hash ^= u64::from(byte);
3212        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
3213    }
3214    Some(hash)
3215}
3216
3217/// The mtime of the same `{phase:02}-VERIFICATION.md`
3218/// [`phase_verification_fingerprint`] hashes, in nanoseconds since the Unix
3219/// epoch, or `None` when no such artifact exists under `evidence_root`.
3220///
3221/// WR-06 (35-REVIEW): the second input the freshness rule needs. A content
3222/// fingerprint cannot see an IDEMPOTENT rewrite — a Validate agent that
3223/// re-authors byte-identical content on a later cycle produces the same hash as
3224/// an artifact nobody touched — so a hash-only rule classifies its own agent's
3225/// work as inherited and re-runs every plan in the phase from then on. An
3226/// inherited file's mtime does not advance during a run; a rewritten one's
3227/// does, whatever the bytes say.
3228///
3229/// Resolved through the same [`phase_verification_path`] and returning `None`
3230/// on the same "no artifact" condition, so the two readings can never disagree
3231/// about whether the artifact exists.
3232///
3233/// **This is not provenance either.** Any writer advances an mtime, so the
3234/// limitation the fingerprint's doc comment records — a mid-run branch switch
3235/// or an operator edit reading as authored-this-run — is not closed by this and
3236/// is marginally widened by it: a checkout restoring byte-identical content
3237/// used to read as inherited and now reads as authored. That is accepted
3238/// deliberately, because the case it fixes (a deterministic verification writer
3239/// on cycle 2 of an unresolved gap) is ordinary rather than exotic.
3240pub fn phase_verification_mtime_nanos(evidence_root: &Path, phase: PhaseId) -> Option<u64> {
3241    let path = phase_verification_path(evidence_root, phase)?;
3242    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
3243    let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
3244    // `as` would silently wrap past year 2554; a `None` here degrades to the
3245    // content-only comparison, which is the pre-WR-06 behaviour.
3246    u64::try_from(since_epoch.as_nanos()).ok()
3247}
3248
3249/// Keep only the newest `retain` capture generations under `history_dir`,
3250/// deleting older ones. Generations are grouped by their stamp (the shared
3251/// prefix of a `{stamp}-stdout`/`{stamp}-exit` pair, split off the trailing
3252/// `-stdout`/`-exit` suffix via `rsplit_once`) and ordered lexicographically,
3253/// which matches numeric/chronological order for the fixed-width nanosecond
3254/// stamps `archive_stamp` produces. Ordering parses both numeric components;
3255/// the process-local sequence is intentionally not fixed-width.
3256fn prune_history(history_dir: &Path, retain: usize) {
3257    let Ok(entries) = std::fs::read_dir(history_dir) else {
3258        return;
3259    };
3260
3261    let mut stamps: Vec<String> = entries
3262        .flatten()
3263        .filter_map(|entry| {
3264            let name = entry.file_name().to_str()?.to_string();
3265            name.rsplit_once('-')
3266                .map(|(stamp, _suffix)| stamp.to_string())
3267        })
3268        .collect();
3269    stamps.sort_by_key(|stamp| {
3270        let mut parts = stamp.split('-');
3271        let nanos = parts
3272            .next()
3273            .and_then(|part| part.parse::<u128>().ok())
3274            .unwrap_or(0);
3275        let sequence = parts
3276            .next()
3277            .and_then(|part| part.parse::<u64>().ok())
3278            .unwrap_or(0);
3279        (nanos, sequence)
3280    });
3281    stamps.dedup();
3282
3283    if stamps.len() <= retain {
3284        return;
3285    }
3286
3287    let to_remove = stamps.len() - retain;
3288    for stamp in &stamps[..to_remove] {
3289        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
3290        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
3291        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
3292    }
3293}
3294
3295#[cfg(test)]
3296mod tests {
3297    use super::*;
3298    use crate::config::GitFlowConfig;
3299    use crate::mode::Mode;
3300    use crate::stage::Stage;
3301    use crate::state::{AgentKind, State};
3302
3303    fn state_in(root: &Path, phase: PhaseId) -> State {
3304        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
3305        state.stage = Stage::Code;
3306        state
3307    }
3308
3309    fn git(root: &Path, args: &[&str]) {
3310        let output = crate::test_support::git_command(root)
3311            .args(args)
3312            .output()
3313            .unwrap();
3314        assert!(
3315            output.status.success(),
3316            "git {:?} failed\nstdout: {}\nstderr: {}",
3317            args,
3318            String::from_utf8_lossy(&output.stdout),
3319            String::from_utf8_lossy(&output.stderr)
3320        );
3321    }
3322
3323    fn init_repo_with_feature_commit(root: &Path, phase: PhaseId) {
3324        git(root, &["init"]);
3325        git(root, &["config", "user.email", "devflow@example.com"]);
3326        git(root, &["config", "user.name", "DevFlow Tests"]);
3327        git(root, &["config", "commit.gpgsign", "false"]);
3328        git(root, &["config", "tag.gpgsign", "false"]);
3329        git(root, &["config", "core.hooksPath", "/dev/null"]);
3330        git(root, &["checkout", "-b", "develop"]);
3331        std::fs::write(root.join("README.md"), "base\n").unwrap();
3332        git(root, &["add", "README.md"]);
3333        git(root, &["commit", "-m", "base"]);
3334
3335        let branch = format!("feature/phase-{padded}", padded = phase.padded());
3336        git(root, &["checkout", "-b", &branch]);
3337        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
3338        git(root, &["add", "phase.txt"]);
3339        git(root, &["commit", "-m", "feature work"]);
3340    }
3341
3342    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
3343    /// develop's tip with **no** extra commit (0 commits ahead).
3344    fn init_repo_with_feature_no_commit(root: &Path, phase: PhaseId) {
3345        git(root, &["init"]);
3346        git(root, &["config", "user.email", "devflow@example.com"]);
3347        git(root, &["config", "user.name", "DevFlow Tests"]);
3348        git(root, &["config", "commit.gpgsign", "false"]);
3349        git(root, &["config", "tag.gpgsign", "false"]);
3350        git(root, &["config", "core.hooksPath", "/dev/null"]);
3351        git(root, &["checkout", "-b", "develop"]);
3352        std::fs::write(root.join("README.md"), "base\n").unwrap();
3353        git(root, &["add", "README.md"]);
3354        git(root, &["commit", "-m", "base"]);
3355
3356        let branch = format!("feature/phase-{padded}", padded = phase.padded());
3357        git(root, &["checkout", "-b", &branch]);
3358    }
3359
3360    #[test]
3361    fn parse_success_marker() {
3362        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
3363        let result = parse_devflow_result(stdout).unwrap();
3364        assert_eq!(result.status, AgentStatus::Success);
3365    }
3366
3367    #[test]
3368    fn parse_failed_marker_with_reason() {
3369        let stdout =
3370            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
3371        let result = parse_devflow_result(stdout).unwrap();
3372        assert_eq!(result.status, AgentStatus::Failed);
3373        assert_eq!(result.reason.unwrap(), "clippy errors");
3374    }
3375
3376    #[test]
3377    fn parse_missing_marker_returns_none() {
3378        let stdout = "just some output\nno marker here\n";
3379        assert!(parse_devflow_result(stdout).is_none());
3380    }
3381
3382    #[test]
3383    fn parse_malformed_json_returns_none() {
3384        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
3385        assert!(parse_devflow_result(stdout).is_none());
3386    }
3387
3388    #[test]
3389    fn parse_lowercase_marker() {
3390        let stdout = "devflow_result: {\"status\":\"success\"}\n";
3391        let result = parse_devflow_result(stdout).unwrap();
3392        assert_eq!(result.status, AgentStatus::Success);
3393    }
3394
3395    #[test]
3396    fn parse_marker_without_space_after_colon() {
3397        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
3398        let result = parse_devflow_result(stdout).unwrap();
3399        assert_eq!(result.status, AgentStatus::Success);
3400    }
3401
3402    #[test]
3403    fn parse_lowercase_no_space_marker() {
3404        // Lowercase prefix AND no space after the colon — the combination that
3405        // the Phase 6 review flagged as uncovered.
3406        let stdout = "devflow_result:{\"status\":\"success\"}\n";
3407        let result = parse_devflow_result(stdout).unwrap();
3408        assert_eq!(result.status, AgentStatus::Success);
3409    }
3410
3411    #[test]
3412    fn parse_finds_last_marker_in_tail() {
3413        // Multiple markers — should find the last one.
3414        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
3415        let result = parse_devflow_result(stdout).unwrap();
3416        assert_eq!(result.status, AgentStatus::Success);
3417    }
3418
3419    #[test]
3420    fn parse_marker_lines_returns_last_marker_in_long_output() {
3421        let stdout = format!(
3422            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
3423             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
3424            "prefix".repeat(900),
3425            "tail output".repeat(100)
3426        );
3427
3428        let result = parse_marker_lines(&stdout).unwrap();
3429
3430        assert_eq!(result.status, AgentStatus::Success);
3431    }
3432
3433    #[test]
3434    fn parse_marker_only_in_last_4000_chars() {
3435        // Marker beyond 4000 chars from end should not be found.
3436        let prefix = "a".repeat(5000);
3437        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
3438        assert!(parse_devflow_result(&stdout).is_none());
3439    }
3440
3441    #[test]
3442    fn parse_marker_with_commits_and_summary() {
3443        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
3444        let result = parse_devflow_result(stdout).unwrap();
3445        assert_eq!(result.status, AgentStatus::Success);
3446        assert_eq!(result.commits, Some(3));
3447        assert_eq!(result.summary.unwrap(), "added tests");
3448    }
3449
3450    #[test]
3451    fn parse_marker_inside_json_result_envelope() {
3452        // Claude --output-format json wraps the final text in a `result` field
3453        // with embedded newlines escaped.
3454        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
3455        let result = parse_devflow_result(stdout).unwrap();
3456        assert_eq!(result.status, AgentStatus::Success);
3457        assert_eq!(result.commits, Some(2));
3458    }
3459
3460    #[test]
3461    fn parse_failed_marker_inside_json_envelope() {
3462        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
3463        let result = parse_devflow_result(stdout).unwrap();
3464        assert_eq!(result.status, AgentStatus::Failed);
3465        assert_eq!(result.reason.unwrap(), "tests failed");
3466    }
3467
3468    #[test]
3469    fn parse_json_envelope_without_marker_returns_none() {
3470        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
3471        assert!(parse_devflow_result(stdout).is_none());
3472    }
3473
3474    #[test]
3475    fn detect_claude_json_rate_limit_by_subtype() {
3476        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
3477        assert_eq!(
3478            detect_rate_limit(stdout).as_deref(),
3479            Some("2026-06-18T15:45:30Z")
3480        );
3481    }
3482
3483    #[test]
3484    fn detect_claude_json_rate_limit_by_429() {
3485        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
3486        assert_eq!(
3487            detect_rate_limit(stdout).as_deref(),
3488            Some("Too many requests. Try later.")
3489        );
3490    }
3491
3492    #[test]
3493    fn detect_codex_try_again_rate_limit() {
3494        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
3495        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
3496    }
3497
3498    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
3499    /// `json_find_key` run on the coding agent's raw stdout via
3500    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
3501    /// goes through. Deeply nested JSON — accidental or adversarial — must
3502    /// not stack-overflow the process, and a real marker at any depth
3503    /// serde_json will parse (its default recursion limit is exactly 128)
3504    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
3505    /// silently misclassified rate-limit markers at depths 64–128.
3506    #[test]
3507    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
3508        // 100 levels: parseable by serde_json (limit 128), deeper than the
3509        // removed 64-level traversal cap that used to hide the marker.
3510        const DEPTH: usize = 100;
3511        let mut stdout = String::new();
3512        for _ in 0..DEPTH {
3513            stdout.push_str(r#"{"nested":"#);
3514        }
3515        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
3516        for _ in 0..DEPTH {
3517            stdout.push('}');
3518        }
3519
3520        // Must return promptly without crashing AND find the buried marker —
3521        // the iterative worklist traversal has no silent-miss window.
3522        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
3523    }
3524
3525    #[test]
3526    fn detect_rate_limit_ignores_normal_stdout() {
3527        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
3528        assert!(detect_rate_limit(stdout).is_none());
3529    }
3530
3531    #[test]
3532    fn claude_envelope_is_error_detected() {
3533        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
3534        let result = detect_claude_envelope_failure(stdout).unwrap();
3535        assert_eq!(result.status, AgentStatus::Failed);
3536    }
3537
3538    #[test]
3539    fn claude_is_error_overrides_success_marker() {
3540        let dir = tempfile::tempdir().unwrap();
3541        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3542        std::fs::write(
3543            stdout_path(dir.path(), PhaseId::new(9)),
3544            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
3545        )
3546        .unwrap();
3547
3548        let result = evaluate_layer1(dir.path(), PhaseId::new(9)).unwrap();
3549
3550        assert_eq!(result.status, AgentStatus::Failed);
3551    }
3552
3553    #[test]
3554    fn claude_envelope_is_error_false_defers() {
3555        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
3556        assert!(detect_claude_envelope_failure(stdout).is_none());
3557    }
3558
3559    #[test]
3560    fn claude_envelope_marker_still_wins() {
3561        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
3562        assert!(detect_claude_envelope_failure(stdout).is_none());
3563        let result = parse_devflow_result(stdout).unwrap();
3564        assert_eq!(result.status, AgentStatus::Success);
3565        assert_eq!(result.commits, Some(2));
3566    }
3567
3568    #[test]
3569    fn session_id_reads_top_level_string() {
3570        let stdout = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
3571        assert_eq!(
3572            claude_session_id(stdout).as_deref(),
3573            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
3574        );
3575    }
3576
3577    /// T-28-04 forgery guard: the embedded `DEVFLOW_RESULT` marker carries a
3578    /// DIFFERENT session id than the envelope's own top-level key. The
3579    /// top-level id must win — an agent must not be able to redirect which
3580    /// session DevFlow resumes into by planting its own `session_id` inside
3581    /// its self-authored marker JSON.
3582    #[test]
3583    fn session_id_in_devflow_result_marker_is_not_returned() {
3584        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"}"#;
3585        assert_eq!(
3586            claude_session_id(stdout).as_deref(),
3587            Some("real-top-level-id")
3588        );
3589    }
3590
3591    #[test]
3592    fn session_id_plain_text_stdout_returns_none() {
3593        let stdout = "just some plain text output, not JSON\n";
3594        assert!(claude_session_id(stdout).is_none());
3595    }
3596
3597    #[test]
3598    fn session_id_missing_key_returns_none() {
3599        let stdout = r#"{"type":"result","result":"done, no session key"}"#;
3600        assert!(claude_session_id(stdout).is_none());
3601    }
3602
3603    #[test]
3604    fn session_id_non_string_type_returns_none_not_panic() {
3605        let stdout = r#"{"type":"result","result":"done","session_id":12345}"#;
3606        assert!(claude_session_id(stdout).is_none());
3607    }
3608
3609    #[test]
3610    fn session_id_from_capture_missing_file_returns_none() {
3611        let dir = tempfile::tempdir().unwrap();
3612        assert!(session_id_from_capture(dir.path(), PhaseId::new(42)).is_none());
3613    }
3614
3615    #[test]
3616    fn session_id_from_capture_lossy_reads_invalid_utf8() {
3617        let dir = tempfile::tempdir().unwrap();
3618        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3619        let mut bytes = br#"{"type":"result","result":"done "#.to_vec();
3620        bytes.push(0xFF); // invalid UTF-8 byte
3621        bytes.extend_from_slice(br#"","session_id":"lossy-ok"}"#);
3622        std::fs::write(stdout_path(dir.path(), PhaseId::new(5)), bytes).unwrap();
3623
3624        assert_eq!(
3625            session_id_from_capture(dir.path(), PhaseId::new(5)).as_deref(),
3626            Some("lossy-ok")
3627        );
3628    }
3629
3630    /// Positive fixture built from RESEARCH's *predicted* `**Gate:**`
3631    /// rendering (a bare, un-spanned value). Kept as a tolerated shape, but
3632    /// note this is NOT what a real run emits — see
3633    /// `blocking_human_checkpoint_reported_matches_live_observed_rendering`
3634    /// for the rendering actually captured on 2026-07-31, which this
3635    /// prediction missed.
3636    #[test]
3637    fn blocking_human_checkpoint_reported_detects_human_gate_line() {
3638        let stdout = format!(
3639            "## 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"
3640        );
3641        assert!(blocking_human_checkpoint_reported(&stdout));
3642    }
3643
3644    /// The Phase 26 near-miss distinction: a plain `blocking` gate must NOT
3645    /// be classified as a human-blocking checkpoint. `PLAIN_GATE_VALUE` is
3646    /// local to this test (not a module-level const) — it has no production
3647    /// use, only this negative fixture's.
3648    #[test]
3649    fn blocking_human_checkpoint_reported_false_for_plain_blocking() {
3650        const PLAIN_GATE_VALUE: &str = "blocking";
3651        let stdout = format!(
3652            "## 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"
3653        );
3654        assert!(!blocking_human_checkpoint_reported(&stdout));
3655    }
3656
3657    #[test]
3658    fn blocking_human_checkpoint_reported_false_when_no_gate_field() {
3659        let stdout = "some ordinary agent failure output, no checkpoint at all\n";
3660        assert!(!blocking_human_checkpoint_reported(stdout));
3661    }
3662
3663    /// The `Gate:` line arrives inside an escaped Claude JSON result
3664    /// envelope's `result` field — must be found via the unescaped inner
3665    /// text, not the raw (escaped) JSON string.
3666    #[test]
3667    fn blocking_human_checkpoint_reported_true_inside_escaped_envelope() {
3668        let inner = format!(
3669            "## CHECKPOINT REACHED\\n\\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\\n"
3670        );
3671        let stdout = format!(
3672            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"abc"}}"#
3673        );
3674        assert!(blocking_human_checkpoint_reported(&stdout));
3675    }
3676
3677    #[test]
3678    fn blocking_human_checkpoint_reported_tolerates_whitespace_and_emphasis() {
3679        let stdout = format!("  **Gate:**   {HUMAN_GATE_VALUE}   \n");
3680        assert!(blocking_human_checkpoint_reported(&stdout));
3681    }
3682
3683    /// REGRESSION — the rendering a real headless run actually produces.
3684    ///
3685    /// Transcribed verbatim from `.devflow/phase-91-stdout` of the live A1
3686    /// run on 2026-07-31 (a genuine `gate="blocking-human"` task driven
3687    /// through DevFlow's own monitor). The value arrives as a markdown CODE
3688    /// SPAN, not the bare token RESEARCH.md predicted.
3689    ///
3690    /// Before the backtick was added to `text_reports_human_gate`'s trim set
3691    /// this returned `false`: the leading backtick survived the trim, so the
3692    /// value `take_while` terminated at once and yielded an empty token. A
3693    /// real checkpoint was therefore never recognized, and the run fell
3694    /// through to the generic gate. If this test ever goes red, DevFlow has
3695    /// stopped recognizing real checkpoints — do not "fix" it by relaxing
3696    /// the assertion.
3697    #[test]
3698    fn blocking_human_checkpoint_reported_matches_live_observed_rendering() {
3699        let stdout = format!(
3700            "---\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"
3701        );
3702        assert!(
3703            blocking_human_checkpoint_reported(&stdout),
3704            "the live-observed code-span rendering must be recognized; \
3705             a false negative here means real checkpoints fall through to \
3706             the generic gate (the 2026-07-31 A1 defect)"
3707        );
3708    }
3709
3710    /// The same live rendering as it actually crosses into DevFlow's capture:
3711    /// escaped inside the Claude JSON result envelope. This is the exact
3712    /// path `checkpoint_reported_in_capture` reads in production.
3713    #[test]
3714    fn blocking_human_checkpoint_reported_matches_live_rendering_in_envelope() {
3715        let inner = format!(
3716            "## Checkpoint: Decision\\n\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Progress:** 0/1 tasks complete\\n"
3717        );
3718        let stdout = format!(
3719            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"live-a1"}}"#
3720        );
3721        assert!(
3722            blocking_human_checkpoint_reported(&stdout),
3723            "the code-span rendering must also be found inside the escaped envelope"
3724        );
3725    }
3726
3727    /// The backtick tolerance must not erode the Phase 26 near-miss
3728    /// distinction: a code-spanned PLAIN `blocking` gate is still not a
3729    /// human-blocking checkpoint.
3730    #[test]
3731    fn blocking_human_checkpoint_reported_false_for_code_spanned_plain_blocking() {
3732        let stdout = "## Checkpoint: Decision\n\n**Gate:** `blocking`\n";
3733        assert!(!blocking_human_checkpoint_reported(stdout));
3734    }
3735
3736    #[test]
3737    fn checkpoint_reported_in_capture_missing_file_returns_false() {
3738        let dir = tempfile::tempdir().unwrap();
3739        assert!(!checkpoint_reported_in_capture(
3740            dir.path(),
3741            PhaseId::new(42)
3742        ));
3743    }
3744
3745    #[test]
3746    fn checkpoint_reported_in_capture_reads_true_from_file() {
3747        let dir = tempfile::tempdir().unwrap();
3748        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3749        std::fs::write(
3750            stdout_path(dir.path(), PhaseId::new(11)),
3751            format!("**Gate:** {HUMAN_GATE_VALUE}\n"),
3752        )
3753        .unwrap();
3754        assert!(checkpoint_reported_in_capture(dir.path(), PhaseId::new(11)));
3755    }
3756
3757    // ---- stream-capture gate scoping (plan 30-05) --------------------------
3758    //
3759    // Fixtures for this cluster live with the other v3 envelopes further down:
3760    // `V3_USER_EVENT`, `V3_ASSISTANT_TOP_LEVEL_EVENT`,
3761    // `V3_ASSISTANT_SUBAGENT_EVENT`, `gate_declaration_text` and
3762    // `gate_documenting_text`. Read their doc comments before adding a case —
3763    // they record which capture line each envelope came from and that every
3764    // gate payload is synthetic.
3765    //
3766    // Each negative asserts a NEGATIVE CONTROL first: `text_reports_human_gate`
3767    // must still match the raw capture. Without it a negative would also pass
3768    // against a fixture that simply contains no gate text, and would keep
3769    // passing if someone deleted the gate line from the fixture.
3770
3771    /// **REGRESSION — review constraint 3, the prompt-echo false positive.**
3772    ///
3773    /// Under a single-document envelope the only place gate text can appear is
3774    /// the one `result` field the agent authored, so scanning raw stdout is
3775    /// safe. A stream capture breaks that invariant: text DevFlow never
3776    /// authored is echoed back into the same stdout, and a substring scan
3777    /// cannot tell which event it is inside.
3778    ///
3779    /// A failure here means a checkpoint auto-decide can fire, or the resume
3780    /// ceiling be consumed, on a stage whose prompt merely DISCUSSED
3781    /// checkpoints — and DevFlow's own planning documents are exactly that kind
3782    /// of prompt content.
3783    #[test]
3784    fn blocking_human_checkpoint_reported_false_for_gate_text_in_user_event() {
3785        let capture = stream_capture_of(&[
3786            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3787            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3788        ]);
3789        assert!(
3790            text_reports_human_gate(&capture),
3791            "negative control: the raw capture must still contain matchable \
3792             gate text, or this test asserts nothing"
3793        );
3794        assert!(
3795            !blocking_human_checkpoint_reported(&capture),
3796            "gate text inside a `user` event is echoed input, not an agent \
3797             declaration (review constraint 3)"
3798        );
3799    }
3800
3801    /// **REGRESSION — T-30-22.** Gate text forwarded from a subagent is not the
3802    /// orchestrator declaring a gate. Mistaking subagent-forwarded content for
3803    /// orchestrator output is the error that invalidated the v1 experiment
3804    /// outright and got its whole capture discarded.
3805    ///
3806    /// Two independent guards reject this event — the type filter (it is an
3807    /// `assistant` event) and the provenance filter (its `parent_tool_use_id`
3808    /// is non-null). The case is kept even though either alone suffices: they
3809    /// are meant to fail independently, so a future widening of the type filter
3810    /// cannot silently inherit subagent content.
3811    #[test]
3812    fn blocking_human_checkpoint_reported_false_for_subagent_forwarded_gate_text() {
3813        let capture = stream_capture_of(&[
3814            &v3_message_event(V3_ASSISTANT_SUBAGENT_EVENT, &gate_documenting_text()),
3815            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3816        ]);
3817        assert!(
3818            text_reports_human_gate(&capture),
3819            "negative control: the raw capture must still contain matchable \
3820             gate text, or this test asserts nothing"
3821        );
3822        assert!(
3823            !blocking_human_checkpoint_reported(&capture),
3824            "a subagent's forwarded text cannot present as the orchestrator's \
3825             own gate declaration"
3826        );
3827    }
3828
3829    /// **REGRESSION — T-30-26, the surface cross-AI review found.**
3830    ///
3831    /// Narration is not declaration. The envelope is v3 line 6, whose captured
3832    /// payload is mid-turn narration appearing in NO `result` event of the
3833    /// capture — so admitting top-level `assistant` events would add a
3834    /// genuinely new trusted surface, not merely duplicate the result text.
3835    /// An agent narrating the checkpoint task it is about to work on would then
3836    /// trip the gate: the prompt-echo false positive, recreated one layer
3837    /// inward.
3838    ///
3839    /// Nothing observed is lost by excluding the class: turn-FINAL assistant
3840    /// text is duplicated verbatim into the `result` event that follows it
3841    /// (v3 lines 17→19, 36→37, 53→54).
3842    #[test]
3843    fn blocking_human_checkpoint_reported_false_for_top_level_assistant_narration() {
3844        let capture = stream_capture_of(&[
3845            &v3_message_event(V3_ASSISTANT_TOP_LEVEL_EVENT, &gate_documenting_text()),
3846            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3847        ]);
3848        assert!(
3849            text_reports_human_gate(&capture),
3850            "negative control: the raw capture must still contain matchable \
3851             gate text, or this test asserts nothing"
3852        );
3853        assert!(
3854            !blocking_human_checkpoint_reported(&capture),
3855            "intermediate assistant narration discussing a gate is not a live \
3856             gate declaration"
3857        );
3858    }
3859
3860    /// The positive that stops the scoping from degenerating into always-false
3861    /// — which would pass every negative above while silently dropping every
3862    /// real human authorization request (T-30-24).
3863    #[test]
3864    fn blocking_human_checkpoint_reported_true_for_top_level_result_declaration() {
3865        let capture = stream_capture_of(&[
3866            &v3_message_event(V3_USER_EVENT, "Execute the plan."),
3867            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3868        ]);
3869        assert!(
3870            blocking_human_checkpoint_reported(&capture),
3871            "a gate declared in a top-level `result` event's own result text \
3872             must still be detected under a stream capture"
3873        );
3874    }
3875
3876    /// **T-30-27.** Detection asks whether a gate fired ANYWHERE in the stage,
3877    /// so it deliberately does NOT inherit plan 30-01's last-result-wins
3878    /// verdict semantics. A gate declared in turn 1 followed by
3879    /// task-notification wake-up turns — the exact turn shape the v3 capture
3880    /// archives — must not be dropped in favour of the later, silent results.
3881    ///
3882    /// Losing a checkpoint report is the opposite-direction harm from the false
3883    /// positive this plan closes, and the worse of the two: it silently drops a
3884    /// request for human authorization to the generic gate.
3885    #[test]
3886    fn blocking_human_checkpoint_reported_true_when_only_first_result_declares_gate() {
3887        let capture = v3_stream_capture(&gate_declaration_text(), NO_MARKER, NO_MARKER);
3888        assert!(
3889            blocking_human_checkpoint_reported(&capture),
3890            "detection must scan every top-level `result` event, not only the \
3891             last one"
3892        );
3893    }
3894
3895    /// The overcorrection guard: an echo and a genuine declaration can coexist
3896    /// in one capture, and the scoping must resolve per event rather than
3897    /// suppressing any capture that contains an echo.
3898    #[test]
3899    fn blocking_human_checkpoint_reported_true_when_echo_co_occurs_with_declaration() {
3900        let capture = stream_capture_of(&[
3901            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3902            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3903        ]);
3904        assert!(
3905            blocking_human_checkpoint_reported(&capture),
3906            "an echoed prompt in the same capture must not suppress a genuine \
3907             declaration"
3908        );
3909    }
3910
3911    /// The same scoping, proven on the path production actually consumes —
3912    /// `checkpoint_reported_in_capture` reading `.devflow/phase-NN-stdout` from
3913    /// disk. Both directions are asserted in one test on purpose: the negative
3914    /// alone cannot distinguish correct scoping from a wrapper that stopped
3915    /// reading the file at all.
3916    #[test]
3917    fn checkpoint_reported_in_capture_scopes_stream_gate_text_to_result_events() {
3918        let dir = tempfile::tempdir().unwrap();
3919        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
3920
3921        let echo_only = stream_capture_of(&[
3922            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3923            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3924        ]);
3925        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), &echo_only).unwrap();
3926        assert!(
3927            !checkpoint_reported_in_capture(dir.path(), PhaseId::new(30)),
3928            "an echoed gate mention read from the capture file must not report \
3929             a checkpoint"
3930        );
3931
3932        let declared = stream_capture_of(&[
3933            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3934            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3935        ]);
3936        std::fs::write(stdout_path(dir.path(), PhaseId::new(31)), &declared).unwrap();
3937        assert!(
3938            checkpoint_reported_in_capture(dir.path(), PhaseId::new(31)),
3939            "a genuine declaration read from the capture file must still \
3940             report a checkpoint"
3941        );
3942    }
3943
3944    /// **The fail-open regression.** A torn `system`/`init` line must not send
3945    /// gate scanning back to raw stdout.
3946    ///
3947    /// `claude_stream_events` silently drops any line that fails to parse, and
3948    /// recognition used to require a successfully parsed `init`. So one
3949    /// truncated first line — a partial write, or a read of a capture still
3950    /// being appended to — made the whole capture unrecognised, and
3951    /// `blocking_human_checkpoint_reported` fell back to scanning raw stdout,
3952    /// which under a stream capture contains the echoed prompt. The constraint-3
3953    /// scoping failed OPEN, into the exact false positive it exists to close.
3954    /// Found by cross-AI code review (gpt-5.6-sol, 2026-08-02, High finding 2).
3955    ///
3956    /// Envelopes are real (v3 `user` + `result`); the `init` line is a real one
3957    /// truncated mid-token, and the gate text payload is synthetic — no archived
3958    /// capture contains gate text or a prompt echo.
3959    #[test]
3960    fn blocking_human_checkpoint_reported_false_when_init_is_torn() {
3961        let torn_init = &V3_INIT_EVENT[..40];
3962        assert!(
3963            serde_json::from_str::<serde_json::Value>(torn_init).is_err(),
3964            "fixture precondition: the truncated init must actually fail to parse"
3965        );
3966
3967        let capture = format!(
3968            "{}\n{}\n{}\n",
3969            torn_init,
3970            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3971            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3972        );
3973        assert!(
3974            !blocking_human_checkpoint_reported(&capture),
3975            "a torn init must not re-enable the raw-stdout scan and let the \
3976             echoed prompt read as a gate declaration"
3977        );
3978
3979        // Same capture, init intact — proves the negative above is the torn-init
3980        // path being handled, not the fixture simply lacking gate text.
3981        let intact = stream_capture_of(&[
3982            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3983            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
3984        ]);
3985        assert!(
3986            !blocking_human_checkpoint_reported(&intact),
3987            "control: the same capture with a valid init is also false"
3988        );
3989
3990        // And a real declaration is still detected with the init torn, so the
3991        // fix did not degenerate into always-false (T-30-24).
3992        let declared = format!(
3993            "{}\n{}\n{}\n",
3994            torn_init,
3995            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
3996            v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
3997        );
3998        assert!(
3999            blocking_human_checkpoint_reported(&declared),
4000            "a genuine declaration must still be detected when init is torn"
4001        );
4002    }
4003
4004    /// A stream with NO `init` at all is likewise scoped rather than raw-scanned.
4005    /// Same fail-open class as the torn-init case; reported by the same review.
4006    #[test]
4007    fn blocking_human_checkpoint_reported_false_when_init_is_absent() {
4008        let capture = format!(
4009            "{}\n{}\n",
4010            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
4011            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4012        );
4013        assert!(
4014            !blocking_human_checkpoint_reported(&capture),
4015            "an init-less stream must still scope the gate scan to result events"
4016        );
4017    }
4018
4019    /// **The mandatory over-correction controls.** Widening stream recognition
4020    /// must not divert the three non-stream inputs off the raw-scan path they
4021    /// have always used (T-30-25). Each carries genuine gate text and must
4022    /// still report `true`; if any flips to `false`, the widening has started
4023    /// suppressing real gates.
4024    #[test]
4025    fn non_stream_captures_still_use_the_raw_scan_after_widening() {
4026        let plain = format!("Some narration.\n{}\n", gate_declaration_text());
4027        assert!(
4028            blocking_human_checkpoint_reported(&plain),
4029            "plain text must still be raw-scanned"
4030        );
4031
4032        let single_doc = v3_result_event(V3_RESULT_TURN1, &gate_declaration_text());
4033        assert!(
4034            blocking_human_checkpoint_reported(&single_doc),
4035            "a single-document envelope must still be raw-scanned — it is \
4036             `{{\"type\":\"result\"}}`, which claude_stream_gate_shape excludes"
4037        );
4038
4039        let codex = format!(
4040            "{{\"type\":\"thread.started\",\"thread_id\":\"t1\"}}\n\
4041             {{\"type\":\"item.completed\",\"item\":{{\"type\":\"agent_message\",\
4042             \"text\":\"{}\"}}}}\n",
4043            gate_declaration_text().replace('"', "\\\"")
4044        );
4045        assert!(
4046            blocking_human_checkpoint_reported(&codex),
4047            "a Codex stream must still be raw-scanned — its top-level types are \
4048             dotted, so claude_stream_gate_shape excludes it"
4049        );
4050    }
4051
4052    /// **Fourth-pass High.** Decoding must never JOIN tokens across corrupt
4053    /// bytes. The third pass's remediation dropped invalid bytes, and
4054    /// `DEVFLOW_RESULT: {"status":"suc<FF>cess"}` with exit 1 decoded to a
4055    /// fabricated, VALID success marker — Layer 1 then short-circuited the
4056    /// nonzero exit. Replacement (U+FFFD) keeps the corruption visible: the
4057    /// status reads `suc\u{FFFD}cess`, no parser trusts it, and the exit code
4058    /// decides. Edge corruption stays covered by [`strip_corruption_padding`]
4059    /// — see the sibling third-pass test, which must pass alongside this one.
4060    #[test]
4061    fn corrupt_byte_inside_a_marker_is_never_repaired_into_success() {
4062        let dir = tempfile::tempdir().unwrap();
4063        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
4064
4065        let mut poisoned = b"DEVFLOW_RESULT: {\"status\":\"suc".to_vec();
4066        poisoned.push(0xff);
4067        poisoned.extend_from_slice(b"cess\"}");
4068        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), &poisoned).unwrap();
4069        assert_ne!(
4070            evaluate_layer1(dir.path(), PhaseId::new(30)).map(|r| r.status),
4071            Some(AgentStatus::Success),
4072            "a corrupt capture with no valid success marker must not be \
4073             repaired into an authoritative one"
4074        );
4075
4076        // Control: the same marker with the byte absent IS a real success.
4077        std::fs::write(
4078            stdout_path(dir.path(), PhaseId::new(31)),
4079            br#"DEVFLOW_RESULT: {"status":"success"}"#,
4080        )
4081        .unwrap();
4082        assert_eq!(
4083            evaluate_layer1(dir.path(), PhaseId::new(31)).map(|r| r.status),
4084            Some(AgentStatus::Success),
4085            "control: the intact marker must still parse as success"
4086        );
4087    }
4088
4089    /// **Third-pass High.** A stray invalid byte outside the JSON envelope must
4090    /// not convert an authoritative failure into a Layer-2 success.
4091    ///
4092    /// `from_utf8_lossy` substitutes U+FFFD, which survives `trim()`, so
4093    /// `detect_claude_envelope_failure`'s `starts_with('{')` guard went false and
4094    /// Layer 1 abstained on `is_error: true`. The cascade then fell through to
4095    /// the exit-code check — Ship proceeding on a reported failure. Reachable on
4096    /// the shipped `--output-format json` envelope; nothing to do with
4097    /// stream-json.
4098    #[test]
4099    fn stray_invalid_byte_does_not_hide_an_envelope_failure() {
4100        let envelope = br#"{"type":"result","subtype":"error","is_error":true,"result":"boom","session_id":"s"}"#;
4101        let dir = tempfile::tempdir().unwrap();
4102        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
4103
4104        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), envelope).unwrap();
4105        assert_eq!(
4106            evaluate_layer1(dir.path(), PhaseId::new(30)).map(|r| r.status),
4107            Some(AgentStatus::Failed),
4108            "control: the intact envelope is an authoritative Layer-1 failure"
4109        );
4110
4111        let mut poisoned = vec![0xffu8];
4112        poisoned.extend_from_slice(envelope);
4113        std::fs::write(stdout_path(dir.path(), PhaseId::new(31)), &poisoned).unwrap();
4114        assert_eq!(
4115            evaluate_layer1(dir.path(), PhaseId::new(31)).map(|r| r.status),
4116            Some(AgentStatus::Failed),
4117            "one invalid byte before the envelope must not make Layer 1 abstain \
4118             and hand a FAILURE to the exit-code fallback"
4119        );
4120    }
4121
4122    /// **Third-pass Medium.** A torn gate-bearing `user` event must not reopen
4123    /// raw-stdout scanning.
4124    ///
4125    /// `claude_stream_gate_shape` keyed stream recognition on system/user/
4126    /// assistant events. If the echoed `user` event tore *after* carrying the
4127    /// full gate text and only a later `result` parsed, none of those types
4128    /// survived, the capture stopped looking like a stream, and the raw scan
4129    /// read the echoed prompt as a declaration. Every line is still `{`-shaped,
4130    /// so this is neither the torn-`init` case nor V-01.
4131    #[test]
4132    fn torn_gate_bearing_user_event_does_not_reopen_raw_scanning() {
4133        let echo = v3_message_event(V3_USER_EVENT, &gate_documenting_text());
4134        let quiet_result = v3_result_event(V3_RESULT_TURN1, NO_MARKER);
4135
4136        let closed = format!("{}\n{}\n{}\n", V3_INIT_EVENT, echo, quiet_result);
4137        assert!(
4138            !blocking_human_checkpoint_reported(&closed),
4139            "control: with the echo intact the gate mention is correctly scoped out"
4140        );
4141
4142        let torn = format!("{}\n{}\n", &echo[..echo.len() - 12], quiet_result);
4143        assert!(
4144            !blocking_human_checkpoint_reported(&torn),
4145            "a torn echo leaving only a result must stay scoped, not fall back to \
4146             the raw scan that reads the echoed prompt as a declaration"
4147        );
4148
4149        // The shipped single-document envelope is ONE result line and must keep
4150        // taking the raw path (T-30-25).
4151        let single_doc = v3_result_event(V3_RESULT_TURN1, &gate_declaration_text());
4152        assert!(
4153            blocking_human_checkpoint_reported(&single_doc),
4154            "control: the single-document envelope still uses the raw scan"
4155        );
4156    }
4157
4158    /// **Fourth-pass Medium 3.** Benign prose noise must not block session
4159    /// recovery — only a torn JSON line can conceal a newer `init`.
4160    ///
4161    /// The first fail-closed guard rejected the capture when ANY non-empty line
4162    /// failed to parse, so one interleaved progress line disabled checkpoint
4163    /// auto-resume while the verdict parser accepted the same capture. An
4164    /// `init` is a JSON line; a non-`{` line can never be a torn one.
4165    #[test]
4166    fn prose_noise_does_not_block_session_recovery() {
4167        let stream = format!(
4168            "{}\nprogress: still working…\n{}\n",
4169            V3_INIT_EVENT,
4170            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4171        );
4172        assert!(
4173            claude_stream_session_id(&stream).is_some(),
4174            "a prose progress line must not fail session recovery closed"
4175        );
4176
4177        // Control: the same capture with the noise line made JSON-shaped-but-torn
4178        // MUST fail closed — that shape could be a torn newer init.
4179        let torn = format!(
4180            "{}\n{{\"type\":\"system\",\"subty\n{}\n",
4181            V3_INIT_EVENT,
4182            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4183        );
4184        assert!(
4185            claude_stream_session_id(&torn).is_none(),
4186            "a torn JSON line could be a newer init and must fail closed"
4187        );
4188    }
4189
4190    /// **Third-pass High.** A torn *later* `init` must not resurrect an earlier
4191    /// session's id.
4192    ///
4193    /// Each turn opens its own `init`; the last carries the id a resume must
4194    /// target. Dropped lines are invisible, so the scan returned the last
4195    /// PARSEABLE init — a stale token that looks entirely valid. Fails closed
4196    /// now: `None` costs a resume, the wrong id corrupts one.
4197    #[test]
4198    fn torn_later_init_does_not_resurrect_a_stale_session_id() {
4199        let init =
4200            |id: &str| format!(r#"{{"type":"system","subtype":"init","session_id":"{id}"}}"#);
4201
4202        let rotated = format!("{}\n{}\n", init("session-a"), init("session-b"));
4203        assert_eq!(
4204            claude_stream_session_id(&rotated).as_deref(),
4205            Some("session-b"),
4206            "control: with both init events intact the LAST id wins"
4207        );
4208
4209        let init_c = init("session-c");
4210        let torn = format!(
4211            "{}\n{}\n{}\n",
4212            init("session-a"),
4213            init("session-b"),
4214            &init_c[..init_c.len() - 10],
4215        );
4216        assert_ne!(
4217            claude_stream_session_id(&torn).as_deref(),
4218            Some("session-b"),
4219            "a torn newer init must not hand back the previous session's id"
4220        );
4221    }
4222
4223    /// **V-01 regression.** One stray JSONL-shaped line must not divert a
4224    /// plain-text capture onto the stream branch and suppress a real gate.
4225    ///
4226    /// The first `claude_stream_gate_shape` asked only whether ANY event carried
4227    /// a stream type. Since the stream branch never consults raw stdout, a single
4228    /// `{"type":"assistant",…}` line was enough to hide a genuine declaration
4229    /// sitting in the surrounding plain text — turning the fail-OPEN this
4230    /// predicate was written to close into a fail-CLOSED that drops a human
4231    /// authorization request. Found by phase-30 verification after the fix
4232    /// shipped in `06675da`.
4233    #[test]
4234    fn one_stray_json_line_does_not_suppress_a_plain_text_gate() {
4235        let gate = gate_declaration_text();
4236
4237        assert!(
4238            blocking_human_checkpoint_reported(&gate),
4239            "positive control: the gate text alone must be detected"
4240        );
4241
4242        let poisoned =
4243            format!("{gate}\n{{\"type\":\"assistant\",\"message\":{{\"content\":[]}}}}\n");
4244        assert!(
4245            blocking_human_checkpoint_reported(&poisoned),
4246            "one stray JSONL line must not suppress a real plain-text gate (V-01)"
4247        );
4248
4249        // The torn-init capture is still recognised as a stream — the majority
4250        // rule must not undo the fail-open fix it was added to preserve.
4251        let torn_init = &V3_INIT_EVENT[..40];
4252        let torn = format!(
4253            "{}\n{}\n{}\n",
4254            torn_init,
4255            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
4256            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4257        );
4258        assert!(
4259            !blocking_human_checkpoint_reported(&torn),
4260            "control: a torn-init stream must still be scoped, not raw-scanned"
4261        );
4262    }
4263
4264    /// Every byte-prefix of a capture, fed to the gate scanner.
4265    ///
4266    /// **Why a sweep and not more hand-written cases.** Phase 30 shipped 116
4267    /// green tests, seven of them written specifically to prove the prompt-echo
4268    /// false positive was closed — and a cross-AI review then found that ONE
4269    /// torn line reverted the whole protection to the raw-stdout path. Every
4270    /// test fed the parser well-formed input; none fed it a broken one. Hand
4271    /// -picking more malformed cases would repeat that bias. Truncating at every
4272    /// offset removes the judgment call: the inputs are generated, not chosen.
4273    ///
4274    /// The invariant is one-directional — a prefix may lose detection (it has
4275    /// strictly less information), but it must never *gain* permissiveness.
4276    #[test]
4277    fn truncation_sweep_never_widens_gate_detection() {
4278        let intact = stream_capture_of(&[
4279            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
4280            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4281        ]);
4282        assert!(
4283            !blocking_human_checkpoint_reported(&intact),
4284            "precondition: the intact capture must report no gate, or the sweep \
4285             below proves nothing"
4286        );
4287
4288        let mut checked = 0usize;
4289        for n in 0..=intact.len() {
4290            if !intact.is_char_boundary(n) {
4291                continue;
4292            }
4293            checked += 1;
4294            assert!(
4295                !blocking_human_checkpoint_reported(&intact[..n]),
4296                "truncating to {n} bytes made an echoed gate MENTION read as a \
4297                 live declaration — the fail-open class (constraint 9)"
4298            );
4299        }
4300        assert!(
4301            checked > 500,
4302            "sweep degenerated to {checked} offsets; it is no longer exercising \
4303             the capture"
4304        );
4305    }
4306
4307    /// Same sweep against the session-id reader. Truncation may degrade it to
4308    /// `None` (a failed resume — fail-closed, acceptable); it must never yield a
4309    /// DIFFERENT id, which would resume the wrong session.
4310    #[test]
4311    fn truncation_sweep_never_forges_session_id() {
4312        let intact = stream_capture_of(&[
4313            &v3_message_event(V3_USER_EVENT, "session_id: forged-by-agent-text"),
4314            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4315        ]);
4316        let real = claude_stream_session_id(&intact);
4317        assert!(
4318            real.is_some(),
4319            "precondition: the intact capture yields an id"
4320        );
4321
4322        for n in 0..=intact.len() {
4323            if !intact.is_char_boundary(n) {
4324                continue;
4325            }
4326            let got = claude_stream_session_id(&intact[..n]);
4327            assert!(
4328                got.is_none() || got == real,
4329                "truncating to {n} bytes produced session id {got:?}, which is \
4330                 neither None nor the CLI-emitted {real:?}"
4331            );
4332        }
4333    }
4334
4335    /// **Constraint 9 item 2, closed.** A subagent-origin `result` event must
4336    /// never decide the stage verdict — `last_top_level_result`'s name and doc
4337    /// always claimed top-level selection, but the first implementation
4338    /// selected on `type == "result"` alone (code-review M2). Envelope real
4339    /// (v3 result turn), planted `parent_tool_use_id` synthetic: no archived
4340    /// capture contains a subagent-origin result, so this pins deterministic
4341    /// behavior for an unobserved-but-legal shape.
4342    #[test]
4343    fn subagent_result_event_never_decides_the_verdict() {
4344        let subagent_success = v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS).replacen(
4345            "{",
4346            "{\"parent_tool_use_id\":\"toolu_child\",",
4347            1,
4348        );
4349        let capture = format!(
4350            "{}\n{}\n{}\n",
4351            V3_INIT_EVENT,
4352            v3_result_event_is_error(V3_RESULT_TURN1, MARKER_FAILED),
4353            subagent_success,
4354        );
4355        assert_eq!(
4356            parse_claude_event_result(&capture).map(|r| r.status),
4357            Some(AgentStatus::Failed),
4358            "a subagent-origin success result must not override the last \
4359             top-level failure"
4360        );
4361
4362        // Control: the same final event WITHOUT the planted parent id is
4363        // top-level and legitimately wins.
4364        let top_level = format!(
4365            "{}\n{}\n{}\n",
4366            V3_INIT_EVENT,
4367            v3_result_event_is_error(V3_RESULT_TURN1, MARKER_FAILED),
4368            v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS),
4369        );
4370        assert_eq!(
4371            parse_claude_event_result(&top_level).map(|r| r.status),
4372            Some(AgentStatus::Success),
4373            "control: the same event without a parent id is the final verdict"
4374        );
4375    }
4376
4377    /// D-13 trap 1, pinned: the delivery canary's declared token appears in the
4378    /// stream as a PROMPT ECHO before it can ever appear as an answer, so a
4379    /// naive text scan reports delivery on every run — including runs where the
4380    /// notification path is dead. That echo is what produced the checkpoint
4381    /// false positive 30-05 fixed.
4382    ///
4383    /// Three cases, and the first two are the negative controls that give the
4384    /// third its meaning: the same token, in the same capture shape, must read
4385    /// `false` from an echo and from a subagent-origin result, and `true` only
4386    /// from a top-level `result`.
4387    #[test]
4388    fn token_matches_only_inside_top_level_result() {
4389        const TOKEN: &str = "DEVFLOW-CANARY-7f3a";
4390
4391        // 1. Echo only: the token is in the operator's own turn, forwarded back
4392        //    into stdout, and in no result at all.
4393        let echoed = format!(
4394            "{}\n{}\n{}\n",
4395            V3_INIT_EVENT,
4396            V3_USER_EVENT.replace("__MARKER__", &format!("please return {TOKEN} when done")),
4397            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
4398        );
4399        assert!(
4400            !token_reported_in_capture(&echoed, TOKEN),
4401            "a token echoed back in the prompt is not delivery evidence — \
4402             the CLI forwards the operator's own turn into the same stdout"
4403        );
4404
4405        // 2. Subagent-origin result: right event type, wrong provenance.
4406        let subagent = format!(
4407            "{}\n{}\n",
4408            V3_INIT_EVENT,
4409            v3_result_event(V3_RESULT_TURN2, TOKEN).replacen(
4410                "{",
4411                "{\"parent_tool_use_id\":\"toolu_child\",",
4412                1,
4413            ),
4414        );
4415        assert!(
4416            !token_reported_in_capture(&subagent, TOKEN),
4417            "a subagent-origin result must not satisfy the canary — it is the \
4418             same provenance hole constraint 9 item 2 closed for the verdict"
4419        );
4420
4421        // 3. Authoritative: a top-level `result` carrying the token.
4422        let authoritative = format!(
4423            "{}\n{}\n",
4424            V3_INIT_EVENT,
4425            v3_result_event(V3_RESULT_TURN1, TOKEN),
4426        );
4427        assert!(
4428            token_reported_in_capture(&authoritative, TOKEN),
4429            "a token inside a top-level result IS the canary's answer"
4430        );
4431    }
4432
4433    /// The Codex arm of the trailing-torn rule — same R1 root cause, and the
4434    /// Codex adapter is live in production.
4435    ///
4436    /// A torn tail must not resurrect an earlier success marker: the torn-tail
4437    /// check runs before both the terminal and marker scans. (The
4438    /// terminal-vs-marker precedence is now `turn.failed`-over-marker —
4439    /// 999.107 #1 — superseding the pre-fix marker-first order.)
4440    #[test]
4441    fn codex_torn_tail_does_not_resurrect_earlier_success_marker() {
4442        let intact = concat!(
4443            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4444            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4445            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
4446            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4447            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"failed\\\"}\"}}\n",
4448        );
4449        assert_eq!(
4450            parse_codex_event_result(intact).map(|r| r.status),
4451            Some(AgentStatus::Failed),
4452            "control: intact capture — the LAST marker wins and it is a failure"
4453        );
4454
4455        let torn = &intact[..intact.len() - 20];
4456        assert_ne!(
4457            parse_codex_event_result(torn).map(|r| r.status),
4458            Some(AgentStatus::Success),
4459            "a torn superseding marker must not let the earlier success marker \
4460             decide the stage"
4461        );
4462    }
4463
4464    /// **Sixth-pass Highs 1–3.** The marker tail scanner — the reader that
4465    /// decides most production stages today — must survive edge corruption, a
4466    /// marker line longer than the tail budget, and mixed-case prefixes (its
4467    /// contract has always said case-insensitive).
4468    #[test]
4469    fn marker_tail_scan_survives_corruption_length_and_case() {
4470        let m = "DEVFLOW_RESULT: {\"status\":\"failed\"}";
4471        assert_eq!(
4472            parse_devflow_result(m).map(|r| r.status),
4473            Some(AgentStatus::Failed),
4474            "control: the plain marker parses"
4475        );
4476
4477        // High 1 — edge corruption on either side must not hide the marker.
4478        for poisoned in [format!("\u{FFFD}{m}"), format!("{m}\u{FFFD}")] {
4479            assert_eq!(
4480                parse_devflow_result(&poisoned).map(|r| r.status),
4481                Some(AgentStatus::Failed),
4482                "one stray byte at a line edge must not hide a failure marker"
4483            );
4484        }
4485        // …while interior corruption stays untrusted (fourth-pass hazard).
4486        assert!(
4487            parse_devflow_result("DEVFLOW_RESULT: {\"status\":\"fai\u{FFFD}led\"}").is_none(),
4488            "interior corruption must not parse as a valid status"
4489        );
4490
4491        // High 2 — a marker line longer than the tail budget is scanned whole.
4492        let long_reason = "x".repeat(5000);
4493        let long =
4494            format!("DEVFLOW_RESULT: {{\"status\":\"failed\",\"reason\":\"{long_reason}\"}}");
4495        assert_eq!(
4496            parse_devflow_result(&long).map(|r| r.status),
4497            Some(AgentStatus::Failed),
4498            "the tail budget must never bisect the final marker line"
4499        );
4500        // …and the budget still bounds the walk: a marker buried beyond the
4501        // budget with newer non-marker output after it stays out of reach.
4502        let buried = format!("{m}\n{}\n", "y\n".repeat(4100));
4503        assert!(
4504            parse_devflow_result(&buried).is_none(),
4505            "control: the budget still cuts off markers deep in old output"
4506        );
4507
4508        // High 3 — mixed case matches, per the documented contract.
4509        assert_eq!(
4510            parse_devflow_result("DevFlow_Result: {\"status\":\"failed\"}").map(|r| r.status),
4511            Some(AgentStatus::Failed),
4512            "mixed-case prefix must match — the contract says case-insensitive"
4513        );
4514    }
4515
4516    /// **Sixth-pass Mediums 4–5.** The codex plain-text rate-limit heuristic:
4517    /// an edge-corrupt JSON event line must stay excluded from prose scanning,
4518    /// and "429" only counts as a standalone token.
4519    #[test]
4520    fn codex_rate_limit_heuristic_excludes_recovered_json_and_embedded_429() {
4521        // M4 — a corrupt-prefixed event line is still a JSON line, not prose.
4522        let doc_line = concat!(
4523            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4524            "\"text\":\"docs mention rate limiting policies\"}}",
4525        );
4526        let poisoned =
4527            format!("{{\"type\":\"thread.started\",\"thread_id\":\"t\"}}\n\u{FFFD}{doc_line}\n");
4528        assert!(
4529            detect_codex_rate_limit(&poisoned).is_none(),
4530            "an edge-corrupt event line must not be prose-scanned for \
4531             rate-limit vocabulary"
4532        );
4533        // Control: genuine plain-text rate-limit output is still detected.
4534        assert!(
4535            detect_codex_rate_limit("Rate limit exceeded. Try again at 17:00.").is_some(),
4536            "control: real plain-text rate-limit output must still be detected"
4537        );
4538
4539        // M5 — embedded digits are not rate-limit evidence…
4540        assert!(
4541            detect_codex_rate_limit("processed issue #429 successfully").is_none(),
4542            "'#429' is an issue number, not a rate limit"
4543        );
4544        assert!(
4545            detect_codex_rate_limit("transferred 14290 bytes").is_none(),
4546            "digits containing 429 are not a rate limit"
4547        );
4548        // …while a genuine standalone 429 still is.
4549        assert!(
4550            detect_codex_rate_limit("HTTP 429 Too Many Requests").is_some(),
4551            "control: a standalone 429 status is still detected"
4552        );
4553    }
4554
4555    /// **Fifth-pass High 1.** A replacement-character-prefixed event line must
4556    /// not classify as prose Noise and slip past the torn-tail guard.
4557    ///
4558    /// `read_capture` turns an invalid byte into U+FFFD; a line reading
4559    /// `\u{FFFD}{"type":…}` fails to parse and does not start with `{`, so it
4560    /// became Noise — invisible to `torn_json_after_last_matching`. A corrupt
4561    /// byte in front of a superseding failed marker let the earlier success
4562    /// marker decide the stage, with the contradicting exit code never
4563    /// consulted. Live today on the Codex `--json` adapter. The fix recovers
4564    /// an edge-corrupt-but-intact event by re-parsing the stripped line, so
4565    /// the TRUE verdict decides — better than merely failing indeterminate.
4566    #[test]
4567    fn corruption_prefixed_event_line_is_not_prose_noise() {
4568        let good = concat!(
4569            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4570            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4571            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
4572        );
4573        let failed_line = concat!(
4574            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4575            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"failed\\\"}\"}}\n",
4576        );
4577
4578        let intact = format!("{good}{failed_line}");
4579        assert_eq!(
4580            parse_codex_event_result(&intact).map(|r| r.status),
4581            Some(AgentStatus::Failed),
4582            "control: intact capture — the last (failed) marker decides"
4583        );
4584
4585        let poisoned = format!("{good}\u{FFFD}{failed_line}");
4586        assert_eq!(
4587            parse_codex_event_result(&poisoned).map(|r| r.status),
4588            Some(AgentStatus::Failed),
4589            "an edge-corrupt superseding marker must be recovered (or at worst \
4590             fail indeterminate) — never let the earlier success decide"
4591        );
4592
4593        // Interior corruption stays visible and untrusted: a FFFD INSIDE the
4594        // marker's status string must not parse as a valid status (the
4595        // fourth-pass fabrication hazard, still guarded).
4596        let interior = concat!(
4597            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4598            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4599            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"suc\u{FFFD}cess\\\"}\"}}\n",
4600        );
4601        assert_ne!(
4602            parse_codex_event_result(interior).map(|r| r.status),
4603            Some(AgentStatus::Success),
4604            "interior corruption must never be repaired into a success"
4605        );
4606    }
4607
4608    /// **Fifth-pass Medium 1.** An edge-corrupt rate-limit envelope must stay
4609    /// `RateLimited`, not decay into a generic `Failed`.
4610    ///
4611    /// The rate-limit detector outranks the generic envelope-failure detector
4612    /// precisely because rate-limit envelopes carry `is_error: true`. It was
4613    /// the one single-document reader without `strip_corruption_padding`, so a
4614    /// stray byte inverted the precedence — auto-resume became review/gating.
4615    #[test]
4616    fn edge_corrupt_rate_limit_envelope_stays_rate_limited() {
4617        let envelope = r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"result":"rate limited","retry_after":"17:00"}"#;
4618        assert!(
4619            detect_claude_rate_limit(envelope).is_some(),
4620            "control: the intact envelope is detected as a rate limit"
4621        );
4622        assert!(
4623            detect_claude_rate_limit(&format!("\u{FFFD}{envelope}")).is_some(),
4624            "one stray byte must not demote RateLimited to generic Failed"
4625        );
4626    }
4627
4628    /// **Fourth-pass Medium 1.** The generic marker path — the one production
4629    /// hits today — must overwrite a planted `decided_by_layer`, exactly as the
4630    /// stream path has since 30-01. `Some(0)` is Layer-0 external-verification
4631    /// provenance, which `classify_validate_outcome` trusts when classifying a
4632    /// Validate stage: an agent could force the ambiguous-validation gate with
4633    /// a verdict it never earned (999.67's class, live instance).
4634    #[test]
4635    fn generic_marker_cannot_forge_layer0_provenance() {
4636        let stdout = r#"DEVFLOW_RESULT: {"status":"success","decided_by_layer":0}"#;
4637        let result = parse_devflow_result(stdout).unwrap();
4638        assert_eq!(
4639            result.decided_by_layer,
4640            Some(1),
4641            "a planted decided_by_layer:0 must be overwritten to Layer 1"
4642        );
4643
4644        // Control: an honest marker without the field also normalises to
4645        // Some(1) — provenance is DERIVED here, never deserialized.
4646        let honest = r#"DEVFLOW_RESULT: {"status":"success"}"#;
4647        assert_eq!(
4648            parse_devflow_result(honest).unwrap().decided_by_layer,
4649            Some(1)
4650        );
4651    }
4652
4653    /// Codex arm of the T-30-26 provenance overwrite (fourth-pass Medium 1's
4654    /// class): a `decided_by_layer` planted in the codex marker JSON must be
4655    /// overwritten, exactly as on the generic and Claude-stream marker paths.
4656    #[test]
4657    fn codex_marker_cannot_forge_layer0_provenance() {
4658        let capture = concat!(
4659            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4660            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
4661            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\",\\\"decided_by_layer\\\":0}\"}}\n",
4662        );
4663        let result = parse_codex_event_result(capture).unwrap();
4664        assert_eq!(
4665            result.decided_by_layer,
4666            Some(1),
4667            "a planted decided_by_layer:0 must be overwritten to Layer 1"
4668        );
4669    }
4670
4671    /// **Constraint 9 item 1, closed for every DETECTABLE truncation**
4672    /// (originally committed `#[ignore]`d as a known-red deferral to Phase 31;
4673    /// the operator's "fix root causes before proceeding" decision pulled it
4674    /// back into phase 30).
4675    ///
4676    /// A truncated terminal `result` used to vanish from the parsed events, so
4677    /// `last_top_level_result` returned an EARLIER turn's result — a stale
4678    /// SUCCESS advancing a stage whose real terminal turn failed. Now every
4679    /// prefix with a torn trailing line yields an indeterminate FAILURE.
4680    ///
4681    /// **The named residual — line-boundary truncation is UNDETECTABLE from
4682    /// content.** A prefix cut exactly at the newline after the success turn is
4683    /// a well-formed capture: two parsed events, no torn line, byte-identical
4684    /// to a healthy one-turn-success capture plus nothing. The evidence of loss
4685    /// is in the bytes that never arrived, so no parser assertion can exist for
4686    /// it. The remaining defense belongs to the layer that HAS the missing
4687    /// information: Phase 31's wiring must not let a stream-derived Success
4688    /// short-circuit a contradicting exit code (a writer that died between
4689    /// flushing turn N and turn N+1 also died with a non-zero exit). Recorded
4690    /// in ROADMAP constraint 9.
4691    #[test]
4692    fn truncation_sweep_never_upgrades_verdict_to_success() {
4693        let intact = format!(
4694            "{}\n{}\n{}\n",
4695            V3_INIT_EVENT,
4696            v3_result_event(V3_RESULT_TURN1, MARKER_SUCCESS),
4697            v3_result_event_is_error(V3_RESULT_TURN2, MARKER_FAILED),
4698        );
4699        assert_eq!(
4700            parse_claude_event_result(&intact).map(|r| r.status),
4701            Some(AgentStatus::Failed),
4702            "precondition: intact capture ends in a failure verdict"
4703        );
4704
4705        let mut torn_prefixes = 0usize;
4706        let mut clean_prefixes = 0usize;
4707        for n in 0..=intact.len() {
4708            if !intact.is_char_boundary(n) {
4709                continue;
4710            }
4711            let prefix = &intact[..n];
4712            let got = parse_claude_event_result(prefix).map(|r| r.status);
4713            if ParsedCapture::parse(prefix).torn_json_line_present() {
4714                torn_prefixes += 1;
4715                assert_ne!(
4716                    got,
4717                    Some(AgentStatus::Success),
4718                    "truncating to {n} bytes left a torn tail yet resurrected \
4719                     an earlier turn's SUCCESS over a failed terminal turn"
4720                );
4721            } else {
4722                clean_prefixes += 1;
4723            }
4724        }
4725        // Negative controls on the sweep itself: both branches must have been
4726        // exercised, or the loop is asserting over nothing.
4727        assert!(
4728            torn_prefixes > 500,
4729            "sweep degenerated: only {torn_prefixes} torn prefixes"
4730        );
4731        assert!(
4732            clean_prefixes > 2,
4733            "sweep never produced a well-formed prefix; the residual case \
4734             documented above is not being exercised"
4735        );
4736    }
4737
4738    #[test]
4739    fn codex_event_stream_parses_turn_failed() {
4740        let stdout = concat!(
4741            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4742            "{\"type\":\"turn.started\"}\n",
4743            "{\"type\":\"item.started\",\"item\":{}}\n",
4744            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
4745        );
4746        let result = parse_codex_event_result(stdout).unwrap();
4747        assert_eq!(result.status, AgentStatus::Failed);
4748        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
4749    }
4750
4751    #[test]
4752    fn codex_turn_completed_no_marker_defers() {
4753        let stdout = concat!(
4754            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4755            "{\"type\":\"turn.started\"}\n",
4756            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4757        );
4758        assert!(parse_codex_event_result(stdout).is_none());
4759    }
4760
4761    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
4762    /// inside an `agent_message` item's text, never as a raw stdout line. A
4763    /// self-reported failure followed by a bare `turn.completed` must parse
4764    /// as Failed with the agent's reason — not defer to Layer 2 (which would
4765    /// see exit 0 and call it a success).
4766    #[test]
4767    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
4768        let stdout = concat!(
4769            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4770            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
4771            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4772        );
4773        let result = parse_codex_event_result(stdout).unwrap();
4774        assert_eq!(result.status, AgentStatus::Failed);
4775        assert_eq!(
4776            result.reason.as_deref(),
4777            Some("interactive input unavailable")
4778        );
4779    }
4780
4781    #[test]
4782    fn codex_agent_message_marker_success_short_circuits() {
4783        let stdout = concat!(
4784            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4785            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
4786            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4787        );
4788        let result = parse_codex_event_result(stdout).unwrap();
4789        assert_eq!(result.status, AgentStatus::Success);
4790    }
4791
4792    /// 999.107 #1: the pre-fix parser returned the `agent_message` success
4793    /// marker before examining the terminal event, so a stream that ended
4794    /// `success marker → turn.failed` was misread as Success and the stage
4795    /// could advance despite the terminal failure. A terminal `turn.failed`
4796    /// must win over any earlier success marker.
4797    #[test]
4798    fn codex_turn_failed_beats_an_earlier_success_marker() {
4799        let stdout = concat!(
4800            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4801            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
4802            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
4803        );
4804        let result = parse_codex_event_result(stdout).unwrap();
4805        assert_eq!(result.status, AgentStatus::Failed);
4806        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
4807    }
4808
4809    /// 13-06 dogfood regression: document content echoed into a JSONL event
4810    /// (GSD reference tables mentioning "rate limiting") must not trip the
4811    /// plain-text rate-limit heuristic — it returned the entire multi-KB
4812    /// event line as the "retry time" and that reached the desktop
4813    /// notification verbatim.
4814    #[test]
4815    fn detect_rate_limit_ignores_json_event_lines() {
4816        let stdout = concat!(
4817            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4818            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
4819            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4820        );
4821        assert_eq!(detect_rate_limit(stdout), None);
4822    }
4823
4824    #[test]
4825    fn detect_rate_limit_still_reads_codex_plain_text() {
4826        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
4827        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
4828    }
4829
4830    #[test]
4831    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
4832        let stdout = concat!(
4833            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4834            "not json at all\n",
4835            "{\"type\":\"item.started\",\"item\":{}}\n",
4836            "{\"type\":\"item.updated\",\"item\":{}}\n",
4837            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
4838        );
4839        let result = parse_codex_event_result(stdout).unwrap();
4840        assert_eq!(result.status, AgentStatus::Failed);
4841        assert_eq!(result.reason.as_deref(), Some("boom"));
4842    }
4843
4844    #[test]
4845    fn claude_envelope_not_consumed_by_codex_parser() {
4846        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
4847        assert!(parse_codex_event_result(stdout).is_none());
4848    }
4849
4850    /// The highest-value isolation test in plan 30-01 (T-30-02).
4851    ///
4852    /// The single-document `--output-format json` envelope that ships TODAY
4853    /// carries `type: "result"` AND a `session_id` — precisely the gate shape
4854    /// 30-RESEARCH.md offered as an alternative to `system`/`init`. If anyone
4855    /// widens [`is_claude_event_stream`] to accept it, the stream parser starts
4856    /// consuming every production capture in use and silently displaces
4857    /// `parse_devflow_result` in the Layer-1 cascade. This test fails first.
4858    ///
4859    /// The first literal is reused verbatim from
4860    /// `claude_envelope_not_consumed_by_codex_parser` above so the two read as
4861    /// a matched pair.
4862    #[test]
4863    fn single_doc_envelope_not_consumed_by_claude_stream_parser() {
4864        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
4865        assert!(parse_claude_event_result(stdout).is_none());
4866
4867        // Non-vacuity: the literal above carries no marker, so it would return
4868        // None even from a WRONGLY-widened gate — on its own it proves little.
4869        // This envelope does carry one, so it can only return None because the
4870        // gate declined the document, not because the marker scan came up dry.
4871        let with_marker = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"Done.\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#;
4872        assert!(parse_claude_event_result(with_marker).is_none());
4873
4874        // ...and the shipped path still owns it, so declining costs no verdict.
4875        assert_eq!(
4876            parse_devflow_result(with_marker).unwrap().status,
4877            AgentStatus::Success
4878        );
4879    }
4880
4881    /// Cross-adapter isolation: a Codex `--json` event stream is not consumed
4882    /// by the Claude stream parser. The two gates are mutually exclusive by
4883    /// construction — Codex keys on `thread.started`/`turn.*`, Claude on
4884    /// `system`/`init` — and this pins that.
4885    #[test]
4886    fn codex_stream_not_consumed_by_claude_stream_parser() {
4887        let stdout = concat!(
4888            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
4889            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
4890            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
4891        );
4892        assert!(parse_claude_event_result(stdout).is_none());
4893
4894        // The Codex parser still decides it — isolation costs no verdict.
4895        assert_eq!(
4896            parse_codex_event_result(stdout).unwrap().status,
4897            AgentStatus::Success
4898        );
4899    }
4900
4901    /// The same isolation claim in the other direction: a Claude stream capture
4902    /// is not consumed by the Codex parser, so the two never collide.
4903    #[test]
4904    fn claude_stream_not_consumed_by_codex_parser() {
4905        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
4906        assert!(parse_codex_event_result(&capture).is_none());
4907    }
4908
4909    /// Plain-text stdout is not consumed by the Claude stream parser.
4910    ///
4911    /// Non-vacuous by construction: the text carries a real marker, so a gate
4912    /// that wrongly fired on non-JSON input would change the verdict rather
4913    /// than merely returning None. The second assertion pins that the marker
4914    /// path still decides it — the cascade must lose nothing.
4915    #[test]
4916    fn plain_text_not_consumed_by_claude_stream_parser() {
4917        let stdout = "Running the plan...\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
4918        assert!(parse_claude_event_result(stdout).is_none());
4919        assert_eq!(
4920            parse_devflow_result(stdout).unwrap().status,
4921            AgentStatus::Success
4922        );
4923    }
4924
4925    // ---- Claude `--output-format stream-json` fixtures (plan 30-01) --------
4926    //
4927    // Sourced from the archived capture
4928    // `.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`,
4929    // a real 54-line stream from a session that survived three orchestrator
4930    // turns via task-notification wake-ups. The `init` event is line 5; the
4931    // three `result` events are lines 19, 37 and 54.
4932    //
4933    // TWO documented modifications, both labelled where they occur:
4934    //   1. Each envelope's `result` string value is replaced with the sentinel
4935    //      `__MARKER__`, which each test fills in. NO archived capture contains
4936    //      a real `DEVFLOW_RESULT` marker — the v3 harness produced
4937    //      acknowledgment prose, not GSD stage output — so every marker payload
4938    //      below is SYNTHETIC. Envelope shape is real; marker text is not.
4939    //   2. The `init` event's three inert array payloads are truncated and its
4940    //      `cwd` is redacted (see `V3_INIT_EVENT`).
4941    // Everything else is byte-for-byte as captured, including field ORDER —
4942    // note that `"type":"result"` appears near the END of each result line,
4943    // long after `result` itself, which is exactly why the parser must key on
4944    // the parsed object rather than on textual position.
4945
4946    /// v3 line 5 — the `system`/`init` event that opens the stream and is the
4947    /// ONLY thing `is_claude_event_stream` gates on.
4948    ///
4949    /// Modification 2: verbatim except that `tools`, `mcp_servers` and
4950    /// `slash_commands` are truncated to a real prefix (verbatim they run to
4951    /// 5,523 characters of tool and slash-command names that no code path here
4952    /// reads) and `cwd` is redacted to a neutral path — the captured value
4953    /// embeds a developer's home directory, and `devflow-core` is published to
4954    /// crates.io. Both fields are inert for every function under test.
4955    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"}"#;
4956
4957    /// v3 line 19 — the FIRST turn's terminal `result` event.
4958    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"}"#;
4959
4960    /// v3 line 37 — the SECOND turn's terminal `result` event, produced after a
4961    /// task-notification wake-up. Carries the `origin` key the later turns have
4962    /// and the first does not.
4963    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"}"#;
4964
4965    /// v3 line 54 — the THIRD and LAST turn's terminal `result` event. This is
4966    /// the one whose marker must decide the stage.
4967    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"}"#;
4968
4969    // ---- prompt-echo regression fixtures (plan 30-05) ----------------------
4970    //
4971    // Message-event envelopes from the same archived capture. Same sentinel
4972    // discipline as the `result` envelopes above — the innermost text payload
4973    // is replaced with `__MARKER__` and each test fills it — plus a third
4974    // documented modification noted per constant where inert bulk is dropped.
4975    // The ENVELOPE is real: every `type`, `parent_tool_use_id`, `session_id`
4976    // and `uuid` value, and the nesting shape the extraction path walks, is
4977    // exactly as captured.
4978    //
4979    // NO archived capture contains checkpoint gate text at all — the 30a
4980    // harness prompt was about background tasks and never mentioned gates. So
4981    // every gate payload below is SYNTHETIC and must not be described as an
4982    // observed rendering. What IS observed is the gate VALUE's markdown
4983    // code-span rendering, transcribed from the live 2026-07-31 A1 run (see
4984    // `HUMAN_GATE_VALUE`), which every fixture here reproduces.
4985
4986    /// v3 line 10 — a TOP-LEVEL `user` event (`parent_tool_use_id` null).
4987    ///
4988    /// Modification 3: the trailing `tool_use_result` object is dropped. It is
4989    /// inert for every function under test and embeds both a developer home
4990    /// directory and the child agent's full prompt; `devflow-core` is published
4991    /// to crates.io.
4992    ///
4993    /// **The archived capture contains no echoed prompt.** Every `user` event
4994    /// in it is a `tool_result` relay, because the 30a harness ran a single
4995    /// prompt with no re-injection. This fixture's payload therefore STANDS IN
4996    /// for an echoed prompt rather than reproducing one. The substitution is
4997    /// sound for what is under test: the scan's first filter keys on the
4998    /// event's `type`, which is `user` in both cases, and
4999    /// `claude_stream_reports_human_gate` excludes that whole class — an echoed
5000    /// prompt and a re-injected notification summary are the two members of it.
5001    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"}"#;
5002
5003    /// v3 line 6 — a TOP-LEVEL `assistant` event (`parent_tool_use_id` null).
5004    ///
5005    /// Its captured payload is `I'll spawn both subagents in the background
5006    /// now.` — mid-turn narration that appears in NO `result` event of the
5007    /// capture, re-confirmed by re-parsing all 54 lines at execution time. That
5008    /// property is the entire reason this envelope was chosen: it proves
5009    /// top-level assistant text is not merely a preview of the result text, so
5010    /// admitting the class would add a genuinely new trusted surface.
5011    ///
5012    /// Modification 3: the `usage.cache_creation` sub-object is dropped (inert).
5013    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"}"#;
5014
5015    /// v3 line 11 — a SUBAGENT-forwarded `assistant` event. Its captured
5016    /// `parent_tool_use_id` (`toolu_01FVk15W8zxiazXutJYn8rsv`, the Task call
5017    /// that spawned child A) is preserved verbatim: it is the whole point of
5018    /// the fixture, and the discrimination whose absence invalidated the v1
5019    /// experiment outright.
5020    ///
5021    /// Modification 3: the `usage.cache_creation` sub-object is dropped (inert).
5022    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"}"#;
5023
5024    /// Fill a message envelope's innermost text payload. Mirrors
5025    /// [`v3_result_event`] and is kept separate from it so the assertion names
5026    /// the right fixture family when a sentinel is lost.
5027    fn v3_message_event(envelope: &str, text: &str) -> String {
5028        assert!(
5029            envelope.contains("__MARKER__"),
5030            "fixture envelope lost its message-text sentinel"
5031        );
5032        envelope.replace("__MARKER__", text)
5033    }
5034
5035    /// A checkpoint DECLARATION, as an agent's final message would render it,
5036    /// escaped for a JSON string field (literal `\n`, the way `claude` emits
5037    /// an agent's result text).
5038    ///
5039    /// The gate value carries the markdown CODE SPAN the live 2026-07-31 run
5040    /// captured — see [`HUMAN_GATE_VALUE`]. A bare unquoted value would test a
5041    /// rendering that has never been observed in production.
5042    fn gate_declaration_text() -> String {
5043        format!(
5044            "## CHECKPOINT REACHED\\n\\n**Type:** decision\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Plan:** 30-05\\n"
5045        )
5046    }
5047
5048    /// Text that merely DOCUMENTS a gate rendering — the shape a plan file, a
5049    /// GSD reference document, or an agent narrating its next task carries.
5050    /// Same code-span rendering as a real declaration, which is precisely why a
5051    /// substring scan cannot tell the two apart and the EVENT must decide.
5052    ///
5053    /// Single line, no double quotes, so it drops into a JSON string field
5054    /// without further escaping.
5055    fn gate_documenting_text() -> String {
5056        format!(
5057            "The next task is declared **Gate:** `{HUMAN_GATE_VALUE}` in the plan, so the executor must stop rather than auto-select."
5058        )
5059    }
5060
5061    // Synthetic `result`-text payloads (modification 1). Written exactly as
5062    // they appear INSIDE the envelope's `result` JSON string — escaped quotes
5063    // and an escaped newline — because that is how `claude` emits an agent's
5064    // final message. Once serde decodes the field the `\n` becomes a real
5065    // newline and `parse_marker_lines`' line scan works on it unmodified.
5066    const MARKER_SUCCESS: &str = r#"Plan complete.\nDEVFLOW_RESULT: {\"status\":\"success\"}"#;
5067    const MARKER_FAILED: &str =
5068        r#"Blocked.\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"earlier turn aborted\"}"#;
5069    const MARKER_PLANTED_LAYER: &str =
5070        r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"decided_by_layer\":0}"#;
5071    const NO_MARKER: &str = r#"Acknowledged; nothing to report."#;
5072
5073    /// Fill one real envelope's `result` field with a synthetic payload.
5074    fn v3_result_event(envelope: &str, escaped_result_text: &str) -> String {
5075        assert!(
5076            envelope.contains("__MARKER__"),
5077            "fixture envelope lost its result-text sentinel"
5078        );
5079        envelope.replace("__MARKER__", escaped_result_text)
5080    }
5081
5082    /// Assemble a three-turn Claude stream capture: the real `init` event
5083    /// followed by all three real `result` envelopes, each carrying the given
5084    /// payload. Three result events (not two) is load-bearing — a two-event
5085    /// fixture cannot tell "last wins" apart from "highest index of two".
5086    fn v3_stream_capture(turn1: &str, turn2: &str, turn3: &str) -> String {
5087        format!(
5088            "{}\n{}\n{}\n{}\n",
5089            V3_INIT_EVENT,
5090            v3_result_event(V3_RESULT_TURN1, turn1),
5091            v3_result_event(V3_RESULT_TURN2, turn2),
5092            v3_result_event(V3_RESULT_TURN3, turn3),
5093        )
5094    }
5095
5096    // ---- rate-limit / envelope-failure fixtures (plan 30-03) --------------
5097
5098    /// v3 line 15, **VERBATIM** — the only `rate_limit_event` in any archived
5099    /// capture, and the reason this plan exists in its current form.
5100    ///
5101    /// Read it before touching [`detect_claude_stream_rate_limit`]: its
5102    /// `rate_limit_info.status` is **`allowed`**. The CLI emits these events as
5103    /// routine quota telemetry on healthy streams — this one sits at line 15 of
5104    /// a capture that then completed three turns successfully (results at 19,
5105    /// 37 and 54). Presence of the event type carries NO information about
5106    /// whether the run was blocked.
5107    ///
5108    /// Note the second trap one level down: `overageStatus` is `rejected`. Any
5109    /// nested search for the token `rejected` (e.g. via [`json_find_key`]) also
5110    /// misclassifies this healthy event, which is why the classifier reads
5111    /// `rate_limit_info.status` and nothing else, by direct `.get()`.
5112    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"}"#;
5113
5114    /// A `rate_limit_event` with the given `rate_limit_info.status`, built by
5115    /// substituting one field of the real archived event above.
5116    ///
5117    /// **SYNTHETIC for every status except `allowed`.** No archived capture
5118    /// contains a blocked stream — the denial fixtures below are constructed,
5119    /// not observed, and are labelled as such at each use. Every other field
5120    /// (including `resetsAt`, which supplies the retry hint) is exactly as
5121    /// captured.
5122    fn v3_rate_limit_event(status: &str) -> String {
5123        assert!(
5124            V3_RATE_LIMIT_EVENT_ALLOWED.contains(r#""status":"allowed""#),
5125            "fixture lost its status field"
5126        );
5127        V3_RATE_LIMIT_EVENT_ALLOWED
5128            .replace(r#""status":"allowed""#, &format!(r#""status":"{status}""#))
5129    }
5130
5131    /// One real `result` envelope with its captured `is_error":false` flipped
5132    /// to `true`, every other field untouched. The assertion makes the
5133    /// substitution non-silent: if the fixture text ever changes, the test
5134    /// fails loudly rather than quietly testing an `is_error: false` envelope.
5135    fn v3_result_event_is_error(envelope: &str, escaped_result_text: &str) -> String {
5136        let filled = v3_result_event(envelope, escaped_result_text);
5137        assert!(
5138            filled.contains(r#""is_error":false"#),
5139            "fixture envelope lost its is_error field"
5140        );
5141        filled.replace(r#""is_error":false"#, r#""is_error":true"#)
5142    }
5143
5144    /// Assemble a capture from the real `init` event followed by the given
5145    /// lines in order. Unlike [`v3_stream_capture`] this lets a test position a
5146    /// `rate_limit_event` at an arbitrary index, which is the whole point of
5147    /// the final-turn scoping assertions.
5148    fn stream_capture_of(lines: &[&str]) -> String {
5149        let mut out = String::from(V3_INIT_EVENT);
5150        for line in lines {
5151            out.push('\n');
5152            out.push_str(line);
5153        }
5154        out.push('\n');
5155        out
5156    }
5157
5158    /// **The mandatory negative regression.** The real archived stream — whose
5159    /// `rate_limit_event` says `status: "allowed"` and which then completed
5160    /// three turns — must NOT classify as `RateLimited`.
5161    ///
5162    /// This event is routine quota telemetry, not a block. Classifying its mere
5163    /// presence as a rate limit would route EVERY healthy Claude stream stage
5164    /// into `Action::AutoResume` against a fabricated retry time, instead of
5165    /// advancing the pipeline. That mapping is
5166    /// `crates/devflow-core/src/outcome_policy.rs:41` — `AgentStatus::RateLimited
5167    /// => Action::AutoResume`, re-read in this crate at execution time; 30-03's
5168    /// plan and threat register cite it as `outcome_policy.rs:41` without a
5169    /// crate, and it is NOT in `devflow-cli`. This is a denial of service on
5170    /// the whole product, produced by a one-line "detect the event type"
5171    /// shortcut.
5172    ///
5173    /// Two independent guards must both hold here, and the second assertion
5174    /// pins the one the positioning guard alone would hide: the event is placed
5175    /// at its real position (before the first `result`, mirroring line 15 vs
5176    /// 19), AND its status is not a denial. `detect_claude_stream_rate_limit`
5177    /// is asserted directly on a final-turn placement of the same real event so
5178    /// the status guard cannot be dropped without this test failing.
5179    #[test]
5180    fn claude_stream_real_allowed_rate_limit_event_is_not_rate_limited() {
5181        let capture = stream_capture_of(&[
5182            V3_RATE_LIMIT_EVENT_ALLOWED,
5183            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5184            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5185            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5186        ]);
5187
5188        let result = parse_claude_event_result(&capture)
5189            .expect("the final turn's success marker still decides this stream");
5190        assert_eq!(result.status, AgentStatus::Success);
5191        assert_ne!(result.status, AgentStatus::RateLimited);
5192
5193        // The status guard on its own: the SAME real event moved into the final
5194        // turn (after the second-to-last `result`) is still not a rate limit.
5195        // Without this, deleting the status check would leave the test green.
5196        let final_turn = stream_capture_of(&[
5197            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5198            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5199            V3_RATE_LIMIT_EVENT_ALLOWED,
5200            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5201        ]);
5202        assert!(
5203            detect_claude_stream_rate_limit(&ParsedCapture::parse(&final_turn).events).is_none()
5204        );
5205    }
5206
5207    /// The positive: an explicit quota DENIAL inside the final turn classifies
5208    /// as `RateLimited`, so the rate-limit resume path stays reachable under
5209    /// `stream-json`.
5210    ///
5211    /// **The denial fixture is SYNTHETIC.** No archived capture contains a
5212    /// blocked stream, so the `rejected` status is constructed from the
5213    /// observed vocabulary of this schema rather than observed in the wild —
5214    /// the same honest-fixture rule this phase applies to marker payloads. The
5215    /// retry hint comes from the real `resetsAt` value.
5216    #[test]
5217    fn claude_stream_final_turn_denial_rate_limit_event_is_rate_limited() {
5218        let denial = v3_rate_limit_event("rejected");
5219        let capture = stream_capture_of(&[
5220            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5221            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5222            &denial,
5223            &v3_result_event(V3_RESULT_TURN3, NO_MARKER),
5224        ]);
5225
5226        let result = parse_claude_event_result(&capture)
5227            .expect("a final-turn quota denial must produce a Layer-1 verdict");
5228        assert_eq!(result.status, AgentStatus::RateLimited);
5229        assert_eq!(
5230            result.reason.as_deref(),
5231            Some("rate limited until 1785645600")
5232        );
5233        assert_eq!(result.decided_by_layer, Some(1));
5234
5235        // Fewer than two `result` events means the whole stream IS the final
5236        // turn — a run blocked before it ever completed a turn must still
5237        // classify, or the boundary logic silently swallows the common case.
5238        let single_turn =
5239            stream_capture_of(&[&denial, &v3_result_event(V3_RESULT_TURN1, NO_MARKER)]);
5240        assert_eq!(
5241            parse_claude_event_result(&single_turn).map(|r| r.status),
5242            Some(AgentStatus::RateLimited)
5243        );
5244    }
5245
5246    /// Scoping: a denial that predates the final turn cannot outrank the final
5247    /// turn's own outcome. Rate-limit chatter from an earlier turn must not
5248    /// decide a stream that later completed — in the real capture the rate
5249    /// event (line 15) precedes all three results, so an unscoped detector
5250    /// would let a first-turn event decide a stream that finished forty seconds
5251    /// later.
5252    ///
5253    /// The denial status here is the SAME one the positive test proves does
5254    /// classify, so this test can only pass because of the POSITION guard.
5255    #[test]
5256    fn claude_stream_denial_before_final_turn_does_not_outrank_final_result() {
5257        let capture = stream_capture_of(&[
5258            &v3_rate_limit_event("rejected"),
5259            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5260            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5261            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5262        ]);
5263
5264        let result = parse_claude_event_result(&capture)
5265            .expect("the final turn's success marker decides this stream");
5266        assert_eq!(result.status, AgentStatus::Success);
5267    }
5268
5269    /// An unrecognised `rate_limit_info.status` DEFERS rather than classifying.
5270    ///
5271    /// Deferring is the deliberately safe direction: an unknown denial status
5272    /// falls through to the envelope/marker paths and is reported `Failed` — a
5273    /// real degradation (the operator loses automatic resume) but a never-silent
5274    /// one that still gates. The opposite error auto-resumes a healthy stream
5275    /// against a retry time the parser invented.
5276    ///
5277    /// Positioned in the FINAL turn, so only the status check can decline it.
5278    #[test]
5279    fn claude_stream_unrecognised_rate_limit_status_defers() {
5280        let capture = stream_capture_of(&[
5281            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5282            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5283            &v3_rate_limit_event("some_future_status"),
5284            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5285        ]);
5286
5287        assert!(detect_claude_stream_rate_limit(&ParsedCapture::parse(&capture).events).is_none());
5288        let result = parse_claude_event_result(&capture)
5289            .expect("the parser must fall through to the marker path");
5290        assert_eq!(result.status, AgentStatus::Success);
5291    }
5292
5293    /// Precedence (T-30-13): when the detector fires, rate limit outranks the
5294    /// marker path. A rate-limited run classified as generic `Failed` kills the
5295    /// primary rate-limit resume cron — the one path that exists to recover
5296    /// from it — which is exactly why `evaluate_layer1` already orders
5297    /// `detect_claude_rate_limit` ahead of `detect_claude_envelope_failure` for
5298    /// the single-document path.
5299    ///
5300    /// Non-vacuous: the same capture WITHOUT the rate event yields `Failed`, so
5301    /// this test fails the moment the ordering is reshuffled.
5302    #[test]
5303    fn claude_stream_final_turn_denial_outranks_failed_marker() {
5304        let with_denial = stream_capture_of(&[
5305            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5306            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5307            &v3_rate_limit_event("rejected"),
5308            &v3_result_event(V3_RESULT_TURN3, MARKER_FAILED),
5309        ]);
5310        assert_eq!(
5311            parse_claude_event_result(&with_denial).map(|r| r.status),
5312            Some(AgentStatus::RateLimited)
5313        );
5314
5315        let without_denial = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_FAILED);
5316        assert_eq!(
5317            parse_claude_event_result(&without_denial).map(|r| r.status),
5318            Some(AgentStatus::Failed)
5319        );
5320    }
5321
5322    /// A last `result` event with `is_error: true` and NO marker is an
5323    /// authoritative Layer-1 failure, not a deferral to Layer 2's coarse
5324    /// exit-code heuristic — matching `detect_claude_envelope_failure` for the
5325    /// single-document envelope. The reason is drawn from the event's own
5326    /// `result` text with the `num_turns` suffix, the same shape that function
5327    /// produces.
5328    #[test]
5329    fn claude_stream_last_result_is_error_without_marker_is_failed() {
5330        let capture = stream_capture_of(&[
5331            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5332            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5333            &v3_result_event_is_error(V3_RESULT_TURN3, r#"Execution error: context exhausted"#),
5334        ]);
5335
5336        let result = parse_claude_event_result(&capture)
5337            .expect("is_error on the last result must not defer to Layer 2");
5338        assert_eq!(result.status, AgentStatus::Failed);
5339        assert_eq!(
5340            result.reason.as_deref(),
5341            Some("Execution error: context exhausted (num_turns: 2)")
5342        );
5343        assert_eq!(result.decided_by_layer, Some(1));
5344    }
5345
5346    /// Envelope-over-marker (T-30-15): `is_error: true` overrides a SUCCESS
5347    /// marker in the same event, matching `detect_claude_envelope_failure`'s
5348    /// documented precedence over a stale or echoed success marker.
5349    ///
5350    /// Non-vacuous: the identical capture with `is_error: false` yields
5351    /// `Success`, so the assertion below can only pass because the envelope
5352    /// check overrode the marker.
5353    #[test]
5354    fn claude_stream_is_error_overrides_success_marker() {
5355        let capture = stream_capture_of(&[
5356            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5357            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5358            &v3_result_event_is_error(V3_RESULT_TURN3, MARKER_SUCCESS),
5359        ]);
5360        let result = parse_claude_event_result(&capture)
5361            .expect("is_error must produce a verdict even with a success marker");
5362        assert_eq!(result.status, AgentStatus::Failed);
5363
5364        let healthy = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
5365        assert_eq!(
5366            parse_claude_event_result(&healthy).map(|r| r.status),
5367            Some(AgentStatus::Success)
5368        );
5369    }
5370
5371    // ---- session id from a stream capture (plan 30-03 Task 2) -------------
5372
5373    /// The single `session_id` every event in the archived v3 capture carries —
5374    /// all three `init` events (lines 5, 32 and 47) and all three `result`
5375    /// events agree on it, confirmed by reading the capture.
5376    const V3_SESSION_ID: &str = "559fef4d-2053-459e-b7a7-f3200c3b3790";
5377
5378    /// The real `init` event with its `session_id` substituted. Used only to
5379    /// build a SYNTHETIC mid-stream rotation — no archived capture rotates.
5380    fn v3_init_event_with_session(session_id: &str) -> String {
5381        assert!(
5382            V3_INIT_EVENT.contains(V3_SESSION_ID),
5383            "fixture lost its session_id"
5384        );
5385        V3_INIT_EVENT.replace(V3_SESSION_ID, session_id)
5386    }
5387
5388    /// `claude_stream_session_id` reads the CLI-emitted id out of a JSONL
5389    /// capture built from the archived `init` events (v3 lines 5, 32 and 47 —
5390    /// all three carry this same value).
5391    ///
5392    /// The second half pins LAST-init-wins with a synthetic rotation: the real
5393    /// capture's three `init` events are identical, so first-wins and last-wins
5394    /// agree on today's evidence and a fixture built only from it cannot tell
5395    /// the two apart. Three `init` events do NOT mean three sessions.
5396    #[test]
5397    fn claude_stream_session_id_reads_cli_emitted_init_value() {
5398        let capture = stream_capture_of(&[
5399            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5400            V3_INIT_EVENT,
5401            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
5402            V3_INIT_EVENT,
5403            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
5404        ]);
5405        assert_eq!(
5406            claude_stream_session_id(&capture).as_deref(),
5407            Some(V3_SESSION_ID)
5408        );
5409
5410        let rotated = stream_capture_of(&[
5411            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
5412            &v3_init_event_with_session("second-session-id"),
5413            &v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS),
5414        ]);
5415        assert_eq!(
5416            claude_stream_session_id(&rotated).as_deref(),
5417            Some("second-session-id")
5418        );
5419    }
5420
5421    /// D-04 / T-28-04 forgery guard for the stream path — the analog of
5422    /// `session_id_in_devflow_result_marker_is_not_returned`, which pins the
5423    /// same contract for the single-document envelope.
5424    ///
5425    /// The fixture defeats BOTH plausible wrong implementations at once: a
5426    /// nested traversal (`json_find_key`/`json_scan`) would reach the
5427    /// `session_id` the agent planted inside its own `DEVFLOW_RESULT` marker
5428    /// text, and a "last event carrying a `session_id`" scan would return the
5429    /// final `result` event's own key. Both are wrong; only the `init` event's
5430    /// top-level value is CLI-emitted. The divergence between the `result`
5431    /// event's id and the `init` event's is synthetic — no archived capture
5432    /// diverges — and exists purely so those two implementations cannot pass.
5433    #[test]
5434    fn claude_stream_session_id_ignores_agent_planted_value() {
5435        const PLANTED_MARKER: &str =
5436            r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"session_id\":\"forged-by-agent\"}"#;
5437
5438        let last_result = v3_result_event(V3_RESULT_TURN3, PLANTED_MARKER)
5439            .replace(V3_SESSION_ID, "result-event-session-id");
5440        let capture =
5441            stream_capture_of(&[&v3_result_event(V3_RESULT_TURN1, NO_MARKER), &last_result]);
5442
5443        // Non-vacuity: both decoys really are present in the capture text, so a
5444        // wrong implementation has something wrong to find.
5445        assert!(capture.contains("forged-by-agent"));
5446        assert!(capture.contains("result-event-session-id"));
5447
5448        assert_eq!(
5449            claude_stream_session_id(&capture).as_deref(),
5450            Some(V3_SESSION_ID)
5451        );
5452    }
5453
5454    /// The stream reader does not shadow or duplicate `claude_session_id`: it
5455    /// declines the single-document envelope (the exact literal
5456    /// `session_id_reads_top_level_string` asserts on) and plain text, so the
5457    /// wrapper's stream-first ordering cannot change today's behavior.
5458    #[test]
5459    fn claude_stream_session_id_declines_non_stream_shapes() {
5460        let envelope = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
5461        assert!(claude_stream_session_id(envelope).is_none());
5462        // ...and the shipped reader still owns it, so declining costs nothing.
5463        assert_eq!(
5464            claude_session_id(envelope).as_deref(),
5465            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
5466        );
5467
5468        assert!(claude_stream_session_id("just some plain text output\n").is_none());
5469    }
5470
5471    /// The wiring that matters: `session_id_from_capture` — the Phase 28
5472    /// checkpoint-resume reader (`claude --resume` needs an id DevFlow can
5473    /// read) — returns an id for a JSONL capture, where before this plan it
5474    /// returned `None` for every stream capture.
5475    #[test]
5476    fn claude_stream_session_id_from_capture_reads_jsonl() {
5477        let dir = tempfile::tempdir().unwrap();
5478        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5479        std::fs::write(
5480            stdout_path(dir.path(), PhaseId::new(30)),
5481            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
5482        )
5483        .unwrap();
5484
5485        assert_eq!(
5486            session_id_from_capture(dir.path(), PhaseId::new(30)).as_deref(),
5487            Some(V3_SESSION_ID)
5488        );
5489    }
5490
5491    /// The other half of the wiring claim: a single-document envelope capture
5492    /// still yields exactly what it did before the stream reader was inserted
5493    /// ahead of `claude_session_id` in the fallback chain.
5494    #[test]
5495    fn claude_stream_wiring_leaves_single_document_capture_unchanged() {
5496        let dir = tempfile::tempdir().unwrap();
5497        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5498        let envelope = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
5499        std::fs::write(stdout_path(dir.path(), PhaseId::new(8)), envelope).unwrap();
5500
5501        assert_eq!(
5502            session_id_from_capture(dir.path(), PhaseId::new(8)).as_deref(),
5503            claude_session_id(envelope).as_deref()
5504        );
5505        assert_eq!(
5506            session_id_from_capture(dir.path(), PhaseId::new(8)).as_deref(),
5507            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
5508        );
5509    }
5510
5511    /// The tracer: a real archived `stream-json` capture written to
5512    /// `.devflow/phase-NN-stdout` produces a Layer-1 verdict out of
5513    /// `evaluate_layer1`. Before plan 30-01 this returned `None` for every
5514    /// JSONL capture — `serde_json::from_str` on the whole multi-line document
5515    /// is a hard "trailing characters" error, so all four single-document
5516    /// parsers declined it and the stage fell through to Layer 2's coarse
5517    /// exit-code+commit heuristic.
5518    ///
5519    /// Fixture provenance and its two modifications are documented on
5520    /// `V3_INIT_EVENT` / `V3_RESULT_TURN1..3` above.
5521    #[test]
5522    fn evaluate_layer1_parses_claude_stream_capture() {
5523        let dir = tempfile::tempdir().unwrap();
5524        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5525        std::fs::write(
5526            stdout_path(dir.path(), PhaseId::new(30)),
5527            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
5528        )
5529        .unwrap();
5530
5531        let result = evaluate_layer1(dir.path(), PhaseId::new(30)).unwrap();
5532
5533        assert_eq!(result.status, AgentStatus::Success);
5534        assert_eq!(result.decided_by_layer, Some(1));
5535
5536        // Non-vacuity guard for the assertion above: this marker omits
5537        // `decided_by_layer`, and the field is `#[serde(default)]`, so
5538        // `parse_marker_lines` alone yields `None`. `Some(1)` can therefore
5539        // only have come from the parser's explicit overwrite.
5540        assert_eq!(
5541            parse_marker_lines(r#"DEVFLOW_RESULT: {"status":"success"}"#)
5542                .unwrap()
5543                .decided_by_layer,
5544            None
5545        );
5546    }
5547
5548    // ---- idle-timeout side channel (31-02, D-05/D-06/D-07) ---------------
5549
5550    /// Write a monitor-shaped idle-timeout record. Field names and types match
5551    /// `IdleTimeoutRecord` exactly; the monitor writes it via serde, so a drift
5552    /// between the two shows up as a failing deserialize here.
5553    fn write_idle_timeout_record(root: &Path, phase: PhaseId, commits: &[(&str, &str)]) {
5554        let record = IdleTimeoutRecord {
5555            status: AgentStatus::IdleTimeout.as_wire_str().to_string(),
5556            idle_secs: 30,
5557            agent_pid: 4242,
5558            written_at: 1_700_000_000,
5559            commits: commits
5560                .iter()
5561                .map(|(sha, subject)| IdleTimeoutCommit {
5562                    sha: (*sha).to_string(),
5563                    subject: (*subject).to_string(),
5564                })
5565                .collect(),
5566        };
5567        std::fs::write(
5568            idle_timeout_path(root, phase),
5569            serde_json::to_string(&record).unwrap(),
5570        )
5571        .unwrap();
5572    }
5573
5574    /// T-31-06, and the single most important test in plan 31-02.
5575    ///
5576    /// The fixture is a REAL archived three-turn capture in which every
5577    /// top-level `result` event carries a success marker — the normal shape of
5578    /// a run that got far enough to idle out. A fixture without a prior
5579    /// `result` event would pass vacuously while the same mechanism silently
5580    /// failed in production.
5581    ///
5582    /// The negative control is encoded INSIDE the test rather than described in
5583    /// prose: the same fixture is evaluated first WITHOUT the side channel and
5584    /// must return `Success`. If that ever stops holding, the `IdleTimeout`
5585    /// assertion below is proving a verdict nothing was competing with.
5586    #[test]
5587    fn idle_timeout_side_channel_wins_over_stale_stream_result() {
5588        let dir = tempfile::tempdir().unwrap();
5589        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5590        std::fs::write(
5591            stdout_path(dir.path(), PhaseId::new(40)),
5592            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
5593        )
5594        .unwrap();
5595
5596        // NEGATIVE CONTROL — must produce the OPPOSITE result.
5597        assert_eq!(
5598            evaluate_layer1(dir.path(), PhaseId::new(40))
5599                .unwrap()
5600                .status,
5601            AgentStatus::Success,
5602            "negative control: without the side channel this fixture must decide Success, \
5603             otherwise the assertion below is vacuous"
5604        );
5605
5606        write_idle_timeout_record(
5607            dir.path(),
5608            PhaseId::new(40),
5609            &[("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "feat: partial")],
5610        );
5611
5612        let result = evaluate_layer1(dir.path(), PhaseId::new(40)).unwrap();
5613        assert_eq!(
5614            result.status,
5615            AgentStatus::IdleTimeout,
5616            "a stale success already in the capture must not shadow the monitor's verdict"
5617        );
5618        assert_eq!(result.decided_by_layer, Some(1));
5619    }
5620
5621    /// The read must precede `read_capture`'s early `return None`, so a
5622    /// timeout that fired before the child emitted anything at all is still
5623    /// authoritative rather than discarded.
5624    #[test]
5625    fn idle_timeout_side_channel_is_read_even_when_the_capture_is_missing() {
5626        let dir = tempfile::tempdir().unwrap();
5627        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5628        assert!(
5629            !stdout_path(dir.path(), PhaseId::new(41)).exists(),
5630            "fixture precondition: there must be no capture at all"
5631        );
5632
5633        // NEGATIVE CONTROL: with neither file present Layer 1 abstains, so the
5634        // verdict below can only have come from the side channel.
5635        assert!(evaluate_layer1(dir.path(), PhaseId::new(41)).is_none());
5636
5637        write_idle_timeout_record(dir.path(), PhaseId::new(41), &[]);
5638
5639        let result = evaluate_layer1(dir.path(), PhaseId::new(41)).unwrap();
5640        assert_eq!(result.status, AgentStatus::IdleTimeout);
5641        assert_eq!(result.commits, Some(0));
5642    }
5643
5644    /// D-07: the verdict names the commits, and says they were not rolled back.
5645    #[test]
5646    fn idle_timeout_result_carries_the_commits_it_enumerated() {
5647        let dir = tempfile::tempdir().unwrap();
5648        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5649        write_idle_timeout_record(
5650            dir.path(),
5651            PhaseId::new(42),
5652            &[
5653                ("1111111abcdef0000000000000000000000000000", "feat: first"),
5654                ("2222222abcdef0000000000000000000000000000", "fix: second"),
5655            ],
5656        );
5657
5658        let result = evaluate_layer1(dir.path(), PhaseId::new(42)).unwrap();
5659
5660        assert_eq!(result.commits, Some(2));
5661        let reason = result.reason.expect("an idle timeout must explain itself");
5662        for fragment in [
5663            "1111111",     // short sha, first commit
5664            "feat: first", // its subject
5665            "2222222",
5666            "fix: second",
5667            "30s",                           // how long the stream was silent
5668            "NONE of them were rolled back", // D-07's non-destruction promise
5669        ] {
5670            assert!(
5671                reason.contains(fragment),
5672                "reason must name {fragment:?}; got: {reason}"
5673            );
5674        }
5675        // The full sha must not be what is printed — a 40-char sha in a gate
5676        // message is noise, and the short form is what an operator pastes.
5677        assert!(!reason.contains("1111111abcdef0000000000000000000000000000"));
5678    }
5679
5680    /// Nothing about the pre-existing cascade changes when no timeout fired.
5681    /// Three shapes, each asserted against the verdict it produced before this
5682    /// plan existed, with the side channel confirmed absent in every one.
5683    #[test]
5684    fn absent_side_channel_leaves_the_cascade_unchanged() {
5685        let dir = tempfile::tempdir().unwrap();
5686        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5687
5688        std::fs::write(
5689            stdout_path(dir.path(), PhaseId::new(43)),
5690            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
5691        )
5692        .unwrap();
5693        std::fs::write(
5694            stdout_path(dir.path(), PhaseId::new(44)),
5695            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_FAILED),
5696        )
5697        .unwrap();
5698
5699        for (phase, expected) in [
5700            (PhaseId::new(43), Some(AgentStatus::Success)),
5701            (PhaseId::new(44), Some(AgentStatus::Failed)),
5702            (PhaseId::new(45), None), // no capture, no side channel
5703        ] {
5704            assert!(
5705                !idle_timeout_path(dir.path(), phase).exists(),
5706                "fixture precondition: phase {phase} must have no side channel"
5707            );
5708            assert_eq!(
5709                evaluate_layer1(dir.path(), phase).map(|r| r.status),
5710                expected,
5711                "the cascade changed for phase {phase} with no timeout on disk"
5712            );
5713        }
5714    }
5715
5716    /// The file's PRESENCE is the signal; its contents are enrichment.
5717    ///
5718    /// A corrupt record must NOT fall back into the cascade — that would let
5719    /// the stale success in the capture win, converting a damaged file into a
5720    /// silent wrong advance. This is the same fixture as
5721    /// `idle_timeout_side_channel_wins_over_stale_stream_result`, so the
5722    /// Success it would otherwise decide is real and not hypothetical.
5723    #[test]
5724    fn an_unreadable_idle_timeout_record_still_produces_the_verdict() {
5725        let dir = tempfile::tempdir().unwrap();
5726        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5727        std::fs::write(
5728            stdout_path(dir.path(), PhaseId::new(46)),
5729            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
5730        )
5731        .unwrap();
5732
5733        // NEGATIVE CONTROL: this capture decides Success on its own.
5734        assert_eq!(
5735            evaluate_layer1(dir.path(), PhaseId::new(46))
5736                .unwrap()
5737                .status,
5738            AgentStatus::Success
5739        );
5740
5741        std::fs::write(
5742            idle_timeout_path(dir.path(), PhaseId::new(46)),
5743            "{ this is not json",
5744        )
5745        .unwrap();
5746
5747        let result = evaluate_layer1(dir.path(), PhaseId::new(46)).unwrap();
5748        assert_eq!(result.status, AgentStatus::IdleTimeout);
5749        assert_eq!(
5750            result.commits, None,
5751            "an unreadable record must not invent a commit count"
5752        );
5753        assert!(result.reason.unwrap().contains("unreadable"));
5754    }
5755
5756    /// Last-result-wins. A session kept alive across turns emits one `result`
5757    /// event per turn; only the final one is the session's verdict.
5758    ///
5759    /// Asserts BOTH directions so the test cannot pass by a parser that merely
5760    /// prefers `success`: failed-then-success yields Success, and
5761    /// success-then-failed yields Failed. The middle event carries the same
5762    /// payload as the first, so a parser that stopped at index 1 would also
5763    /// fail.
5764    #[test]
5765    fn claude_stream_last_result_event_wins_over_earlier_results() {
5766        let last_success = v3_stream_capture(MARKER_FAILED, MARKER_FAILED, MARKER_SUCCESS);
5767        let result = parse_claude_event_result(&last_success).unwrap();
5768        assert_eq!(result.status, AgentStatus::Success);
5769
5770        let last_failed = v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_FAILED);
5771        let result = parse_claude_event_result(&last_failed).unwrap();
5772        assert_eq!(result.status, AgentStatus::Failed);
5773        assert_eq!(result.reason.as_deref(), Some("earlier turn aborted"));
5774    }
5775
5776    /// T-30-26: `decided_by_layer` is provenance, not decoration.
5777    /// `crates/devflow-cli/src/pipeline_outcomes.rs` (`classify_validate_outcome`)
5778    /// computes `external = decided_by_layer == Some(0) && status == Success`
5779    /// and uses it to tell an externally-probe-verified Validate stage apart
5780    /// from an ordinary one. An agent that writes `"decided_by_layer": 0` into
5781    /// its own marker is claiming a Layer-0 probe provenance it did not earn,
5782    /// so the stream parser overwrites the field unconditionally.
5783    ///
5784    /// This is a runtime assertion on the returned struct, not a source grep —
5785    /// it fails the moment the overwrite is dropped.
5786    #[test]
5787    fn claude_stream_overwrites_agent_planted_decided_by_layer() {
5788        // Non-vacuity guard: prove the planted value really would survive
5789        // deserialization, so the `Some(1)` below is the overwrite at work and
5790        // not an artifact of a marker that failed to parse.
5791        assert_eq!(
5792            parse_marker_lines(r#"DEVFLOW_RESULT: {"status":"success","decided_by_layer":0}"#)
5793                .unwrap()
5794                .decided_by_layer,
5795            Some(0)
5796        );
5797
5798        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_PLANTED_LAYER);
5799        let result = parse_claude_event_result(&capture).unwrap();
5800
5801        assert_eq!(result.status, AgentStatus::Success);
5802        assert_eq!(result.decided_by_layer, Some(1));
5803    }
5804
5805    /// A marker-less final turn defers to Layer 2 rather than reporting an
5806    /// unconditional Success — the same convention `parse_codex_event_result`
5807    /// applies to a bare `turn.completed`. A marker-less turn must never
5808    /// silently advance a stage.
5809    ///
5810    /// The FIRST turn carries a success marker, so this also proves the parser
5811    /// does not fall back to an earlier turn's marker when the last one has
5812    /// none.
5813    ///
5814    /// Plan 30-03 addendum: the deferral must hold specifically for
5815    /// `is_error: false`, which is what the real captured envelope carries —
5816    /// asserted below so this reads as a deliberate is_error case rather than
5817    /// an incidental one. Only `is_error: true` may promote a marker-less turn
5818    /// to `Failed`.
5819    #[test]
5820    fn claude_stream_last_result_without_marker_defers() {
5821        let capture = v3_stream_capture(MARKER_SUCCESS, NO_MARKER, NO_MARKER);
5822        assert!(
5823            capture.contains(r#""is_error":false"#),
5824            "the archived envelopes carry is_error:false; this test is about that case"
5825        );
5826        assert!(parse_claude_event_result(&capture).is_none());
5827    }
5828
5829    #[test]
5830    fn evaluate_layer1_reports_rate_limited_without_marker() {
5831        let dir = tempfile::tempdir().unwrap();
5832        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5833        std::fs::write(
5834            stdout_path(dir.path(), PhaseId::new(7)),
5835            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
5836        )
5837        .unwrap();
5838
5839        let result = evaluate_layer1(dir.path(), PhaseId::new(7)).unwrap();
5840
5841        assert_eq!(result.status, AgentStatus::RateLimited);
5842        assert_eq!(
5843            result.reason.as_deref(),
5844            Some("rate limited until 2026-06-18T15:45:30Z")
5845        );
5846    }
5847
5848    /// A real Claude rate-limit envelope carries `is_error: true` alongside
5849    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
5850    /// must outrank the generic is_error → Failed path, or the primary
5851    /// rate-limit resume cron never triggers for the exact case it exists for.
5852    #[test]
5853    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
5854        let dir = tempfile::tempdir().unwrap();
5855        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5856        std::fs::write(
5857            stdout_path(dir.path(), PhaseId::new(7)),
5858            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
5859        )
5860        .unwrap();
5861
5862        let result = evaluate_layer1(dir.path(), PhaseId::new(7)).unwrap();
5863
5864        assert_eq!(result.status, AgentStatus::RateLimited);
5865        assert_eq!(
5866            result.reason.as_deref(),
5867            Some("rate limited until 2026-06-18T15:45:30Z")
5868        );
5869    }
5870
5871    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
5872    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
5873    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
5874    /// detection (the blocking-mode capture was fixed; the file read here is
5875    /// the other half of the same bug).
5876    #[test]
5877    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
5878        let dir = tempfile::tempdir().unwrap();
5879        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5880        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
5881        bytes.extend_from_slice(
5882            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
5883        );
5884        std::fs::write(stdout_path(dir.path(), PhaseId::new(5)), bytes).unwrap();
5885
5886        let result = evaluate_layer1(dir.path(), PhaseId::new(5)).unwrap();
5887
5888        assert_eq!(result.status, AgentStatus::Failed);
5889        assert_eq!(result.reason.as_deref(), Some("review: bad"));
5890    }
5891
5892    #[test]
5893    fn failing_external_probe_outranks_success_marker() {
5894        let dir = tempfile::tempdir().unwrap();
5895        let phase_dir = dir
5896            .path()
5897            .join(".planning/phases/16-pipeline-reliability-hardening");
5898        std::fs::create_dir_all(&phase_dir).unwrap();
5899        std::fs::write(
5900            phase_dir.join("16-03-PLAN.md"),
5901            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
5902        )
5903        .unwrap();
5904        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5905        std::fs::write(
5906            stdout_path(dir.path(), PhaseId::new(16)),
5907            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
5908        )
5909        .unwrap();
5910        let state = state_in(dir.path(), PhaseId::new(16));
5911
5912        let approval = vec!["test -f externally-shipped".to_string()];
5913        let result = evaluate_agent_result_inner(
5914            dir.path(),
5915            &state,
5916            &GitFlowConfig::default(),
5917            Some(&approval),
5918        )
5919        .unwrap();
5920
5921        assert_eq!(result.status, AgentStatus::Failed);
5922        assert!(
5923            result
5924                .reason
5925                .as_deref()
5926                .is_some_and(|reason| reason.contains("external verification failed"))
5927        );
5928    }
5929
5930    /// D-05 gap 1 / D-06 (17-03): Layer 0 now evaluates on every stage, not
5931    /// only Code.
5932    ///
5933    /// This is the MAIN-CHECKOUT MIRROR of
5934    /// `external_probe_discovers_from_the_worktree_when_the_main_checkout_lacks_the_plan`,
5935    /// and the two must be read together: with no worktree set, discovery and
5936    /// probe execution resolve to the SAME root, so 999.76's relocation of
5937    /// discovery to `execution_root` provably leaves this path untouched.
5938    /// Without this mirror the worktree fixture alone could not distinguish
5939    /// "discovery reads the execution root" from "discovery reads any root
5940    /// that happens to hold the PLAN".
5941    ///
5942    /// It previously set `state.worktree_path` and asserted the opposite
5943    /// direction — that discovery must read `project_root` while probes run in
5944    /// the worktree (review Plan 03 MEDIUM, OpenCode). 999.76 overturned that
5945    /// premise (see [`evaluate_layer0`]'s doc comment), so the fixture was
5946    /// converted rather than deleted: every assertion below is the original
5947    /// one, including the `"external verification failed"` reason text and the
5948    /// final `Success` assertion. Only the two roots' coincidence changed.
5949    #[test]
5950    fn external_probe_discovers_from_project_root_across_every_stage_without_a_worktree() {
5951        let dir = tempfile::tempdir().unwrap();
5952        let phase_dir = dir.path().join(".planning/phases/16-reliability");
5953        std::fs::create_dir_all(&phase_dir).unwrap();
5954        std::fs::write(
5955            phase_dir.join("16-01-PLAN.md"),
5956            "---\nexternal_verify: \"test -f implemented\"\n---\n",
5957        )
5958        .unwrap();
5959        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
5960        std::fs::write(
5961            stdout_path(dir.path(), PhaseId::new(16)),
5962            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
5963        )
5964        .unwrap();
5965        let mut state = state_in(dir.path(), PhaseId::new(16));
5966        // No worktree: `execution_root` falls back to `project_root`, so
5967        // discovery and probe execution read the same directory.
5968        state.worktree_path = None;
5969        state.stage = Stage::Plan;
5970
5971        let approval = vec!["test -f implemented".to_string()];
5972
5973        // Layer 0 now fires on Plan too — the probe file does not yet exist,
5974        // so this must fail on the probe itself (NOT a false PLAN-removed
5975        // veto, which would mean discovery silently returned zero commands).
5976        let plan_result = evaluate_agent_result_inner(
5977            dir.path(),
5978            &state,
5979            &GitFlowConfig::default(),
5980            Some(&approval),
5981        )
5982        .unwrap();
5983        assert_eq!(plan_result.status, AgentStatus::Failed);
5984        assert!(
5985            plan_result
5986                .reason
5987                .as_deref()
5988                .is_some_and(|reason| reason.contains("external verification failed")),
5989            "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
5990            plan_result.reason
5991        );
5992
5993        state.stage = Stage::Code;
5994        let code_result = evaluate_agent_result_inner(
5995            dir.path(),
5996            &state,
5997            &GitFlowConfig::default(),
5998            Some(&approval),
5999        )
6000        .unwrap();
6001        assert_eq!(code_result.status, AgentStatus::Failed);
6002
6003        // The probe executes against execution_root, which without a worktree
6004        // IS project_root — the coincidence this mirror exists to pin.
6005        std::fs::write(dir.path().join("implemented"), "done").unwrap();
6006        let passing = evaluate_agent_result_inner(
6007            dir.path(),
6008            &state,
6009            &GitFlowConfig::default(),
6010            Some(&approval),
6011        )
6012        .unwrap();
6013        assert_eq!(passing.status, AgentStatus::Success);
6014        assert_eq!(passing.decided_by_layer, Some(0));
6015    }
6016
6017    /// 999.76 (ROADMAP criterion 6): the INVERSE of the fixture above. The
6018    /// PLAN lives only under the worktree and `project_root`'s own
6019    /// `.planning/phases/` is absent entirely — which is what an in-flight
6020    /// phase actually looks like. `.planning/` is tracked content, so a phase's
6021    /// `{N}-PLAN.md` sits on `feature/phase-{N}` INSIDE the worktree and is
6022    /// absent from the main checkout for the phase's whole duration.
6023    ///
6024    /// The live provenance measurement for that layout claim is **NC-7**,
6025    /// recorded in this phase's `34-04-SUMMARY.md`: `git ls-tree -r develop`
6026    /// vs `git ls-tree -r HEAD` over `.planning/phases`, reported with both
6027    /// refs' counts. NC-7 is evidence that the layout manufactured here is the
6028    /// real one — it says nothing about whether this code is correct. That
6029    /// claim is carried by this fixture and by its main-checkout mirror
6030    /// `external_probe_discovers_from_project_root_across_every_stage_without_a_worktree`,
6031    /// which must be read together with it.
6032    #[test]
6033    fn external_probe_discovers_from_the_worktree_when_the_main_checkout_lacks_the_plan() {
6034        let dir = tempfile::tempdir().unwrap();
6035        let worktree = dir.path().join("phase-worktree");
6036        // The PLAN exists ONLY under the worktree — `dir.path()`'s own
6037        // `.planning/phases/` is deliberately never created.
6038        let phase_dir = worktree.join(".planning/phases/16-reliability");
6039        std::fs::create_dir_all(&phase_dir).unwrap();
6040        std::fs::write(
6041            phase_dir.join("16-01-PLAN.md"),
6042            "---\nexternal_verify: \"test -f implemented\"\n---\n",
6043        )
6044        .unwrap();
6045        // Captures live in the project root, not the worktree.
6046        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6047        std::fs::write(
6048            stdout_path(dir.path(), PhaseId::new(16)),
6049            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
6050        )
6051        .unwrap();
6052        let mut state = state_in(dir.path(), PhaseId::new(16));
6053        state.worktree_path = Some(worktree.clone());
6054
6055        let approval = vec!["test -f implemented".to_string()];
6056
6057        // The probe file does not exist yet, so this must fail ON THE PROBE.
6058        let failing = evaluate_agent_result_inner(
6059            dir.path(),
6060            &state,
6061            &GitFlowConfig::default(),
6062            Some(&approval),
6063        )
6064        .unwrap();
6065        assert_eq!(failing.status, AgentStatus::Failed);
6066        assert!(
6067            failing
6068                .reason
6069                .as_deref()
6070                .is_some_and(|reason| reason.contains("external verification failed")),
6071            "expected a failing-probe reason; a PLAN-removed reason means discovery \
6072             silently returned zero commands — i.e. discovery still reads project_root \
6073             and 999.76's fix did not land: {:?}",
6074            failing.reason
6075        );
6076
6077        std::fs::write(worktree.join("implemented"), "done").unwrap();
6078        let passing = evaluate_agent_result_inner(
6079            dir.path(),
6080            &state,
6081            &GitFlowConfig::default(),
6082            Some(&approval),
6083        )
6084        .unwrap();
6085        assert_eq!(passing.status, AgentStatus::Success);
6086        assert_eq!(passing.decided_by_layer, Some(0));
6087    }
6088
6089    #[test]
6090    fn changed_external_probe_never_inherits_prior_approval() {
6091        let dir = tempfile::tempdir().unwrap();
6092        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6093        std::fs::create_dir_all(&phase_dir).unwrap();
6094        std::fs::write(
6095            phase_dir.join("16-01-PLAN.md"),
6096            "---\nexternal_verify: \"touch escaped\"\n---\n",
6097        )
6098        .unwrap();
6099        let state = state_in(dir.path(), PhaseId::new(16));
6100        let approved = vec!["test -f reviewed-artifact".to_string()];
6101
6102        let result = evaluate_agent_result_inner(
6103            dir.path(),
6104            &state,
6105            &GitFlowConfig::default(),
6106            Some(&approved),
6107        )
6108        .unwrap();
6109
6110        assert_eq!(result.status, AgentStatus::Failed);
6111        assert!(result.reason.unwrap().contains("approval mismatch"));
6112        assert!(!dir.path().join("escaped").exists());
6113    }
6114
6115    #[test]
6116    fn removed_external_probe_fails_closed_against_prior_approval() {
6117        let dir = tempfile::tempdir().unwrap();
6118        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6119        std::fs::write(
6120            stdout_path(dir.path(), PhaseId::new(16)),
6121            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
6122        )
6123        .unwrap();
6124        let state = state_in(dir.path(), PhaseId::new(16));
6125        let approved = vec!["test -f shipped".to_string()];
6126
6127        let result = evaluate_agent_result_inner(
6128            dir.path(),
6129            &state,
6130            &GitFlowConfig::default(),
6131            Some(&approved),
6132        )
6133        .unwrap();
6134
6135        assert_eq!(result.status, AgentStatus::Failed);
6136        assert!(result.reason.unwrap().contains("declaration was removed"));
6137    }
6138
6139    #[test]
6140    fn no_external_declaration_preserves_layer1_result() {
6141        let dir = tempfile::tempdir().unwrap();
6142        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6143        std::fs::write(
6144            stdout_path(dir.path(), PhaseId::new(16)),
6145            "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
6146        )
6147        .unwrap();
6148        let state = state_in(dir.path(), PhaseId::new(16));
6149        let layer1 = evaluate_layer1(dir.path(), PhaseId::new(16)).unwrap();
6150
6151        let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
6152
6153        assert_eq!(
6154            serde_json::to_value(full).unwrap(),
6155            serde_json::to_value(layer1).unwrap()
6156        );
6157    }
6158
6159    /// D-05 gap 2 (17-03): a declared, operator-approved external
6160    /// post-condition whose probe passes is affirmative Success evidence on
6161    /// its own — even with zero commits and on a non-Code stage (Define
6162    /// here). No agent stdout is written at all, so if Layer 0 did not
6163    /// short-circuit, there would be nothing for Layer 1 to find and Layer 2
6164    /// would fall through for lack of an exit-code file.
6165    #[test]
6166    fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
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        let mut state = state_in(dir.path(), PhaseId::new(16));
6177        state.stage = Stage::Define;
6178
6179        let approval = vec!["test -f shipped".to_string()];
6180        let result = evaluate_agent_result_inner(
6181            dir.path(),
6182            &state,
6183            &GitFlowConfig::default(),
6184            Some(&approval),
6185        )
6186        .unwrap();
6187
6188        assert_eq!(result.status, AgentStatus::Success);
6189        assert_eq!(result.decided_by_layer, Some(0));
6190        assert_eq!(result.commits, None);
6191        // Off-Validate stage: verdict reconciliation does not apply (18e).
6192        assert_eq!(result.verdict, None);
6193    }
6194
6195    /// Review Plan 03 LOW (Codex+OpenCode), 16a: an approved all-passing
6196    /// Layer 0 probe intentionally outranks a Layer 1 self-reported failure
6197    /// marker — proven here at the cascade level (`evaluate_agent_result_inner`),
6198    /// not merely in isolation on `evaluate_layer0`.
6199    #[test]
6200    fn layer0_affirmative_success_outranks_layer1_failure_marker() {
6201        let dir = tempfile::tempdir().unwrap();
6202        let phase_dir = dir
6203            .path()
6204            .join(".planning/phases/16-pipeline-reliability-hardening");
6205        std::fs::create_dir_all(&phase_dir).unwrap();
6206        std::fs::write(
6207            phase_dir.join("16-03-PLAN.md"),
6208            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
6209        )
6210        .unwrap();
6211        std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
6212        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6213        std::fs::write(
6214            stdout_path(dir.path(), PhaseId::new(16)),
6215            "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
6216        )
6217        .unwrap();
6218        let state = state_in(dir.path(), PhaseId::new(16));
6219
6220        let approval = vec!["test -f externally-shipped".to_string()];
6221        let result = evaluate_agent_result_inner(
6222            dir.path(),
6223            &state,
6224            &GitFlowConfig::default(),
6225            Some(&approval),
6226        )
6227        .unwrap();
6228
6229        assert_eq!(result.status, AgentStatus::Success);
6230        assert_eq!(result.decided_by_layer, Some(0));
6231        // Off-Validate stage (Code): verdict reconciliation does not apply,
6232        // even though Layer 1's marker here reports a (failure) status (18e).
6233        assert_eq!(result.verdict, None);
6234    }
6235
6236    /// D-05/18e: Layer 0's affirmative-success arm at `Stage::Validate` must
6237    /// consult Layer 1's verdict rather than discard it — the two-signal
6238    /// reconciliation `reconcile_layer0_verdict` adds. Covers all three
6239    /// verdict states Layer 1 can produce: pass, gaps, and no marker at all.
6240    ///
6241    /// D-15 (34-01) adds a FOURTH case: the self-contradictory marker
6242    /// `{"status":"failed","verdict":"pass"}`. "Consult Layer 1's verdict" was
6243    /// implemented as "read Layer 1's verdict and nothing else", so an agent
6244    /// that reported its own failure while claiming a passing verdict had that
6245    /// verdict grafted onto Layer 0's `Success` — 999.74's real route. The
6246    /// fourth case pins `verdict: None` for it; before the fix it observed
6247    /// `Some(Pass)`.
6248    #[test]
6249    fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
6250        let dir = tempfile::tempdir().unwrap();
6251        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6252        std::fs::create_dir_all(&phase_dir).unwrap();
6253        std::fs::write(
6254            phase_dir.join("16-01-PLAN.md"),
6255            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6256        )
6257        .unwrap();
6258        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6259        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6260        let mut state = state_in(dir.path(), PhaseId::new(16));
6261        state.stage = Stage::Validate;
6262        let approval = vec!["test -f shipped".to_string()];
6263
6264        std::fs::write(
6265            stdout_path(dir.path(), PhaseId::new(16)),
6266            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
6267        )
6268        .unwrap();
6269        let result = evaluate_agent_result_inner(
6270            dir.path(),
6271            &state,
6272            &GitFlowConfig::default(),
6273            Some(&approval),
6274        )
6275        .unwrap();
6276        assert_eq!(result.status, AgentStatus::Success);
6277        assert_eq!(result.decided_by_layer, Some(0));
6278        assert_eq!(result.verdict, Some(Verdict::Pass));
6279
6280        std::fs::write(
6281            stdout_path(dir.path(), PhaseId::new(16)),
6282            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
6283        )
6284        .unwrap();
6285        let result = evaluate_agent_result_inner(
6286            dir.path(),
6287            &state,
6288            &GitFlowConfig::default(),
6289            Some(&approval),
6290        )
6291        .unwrap();
6292        assert_eq!(result.verdict, Some(Verdict::Gaps));
6293
6294        std::fs::remove_file(stdout_path(dir.path(), PhaseId::new(16))).unwrap();
6295        let result = evaluate_agent_result_inner(
6296            dir.path(),
6297            &state,
6298            &GitFlowConfig::default(),
6299            Some(&approval),
6300        )
6301        .unwrap();
6302        assert_eq!(result.verdict, None);
6303
6304        // D-15: the self-contradictory marker. Layer 1 reports its own run
6305        // FAILED and simultaneously claims a passing verdict. Pre-fix the graft
6306        // read only `.verdict` and produced `Some(Pass)`, i.e. an affirmative
6307        // pair `decide_action` advances and `classify_validate_outcome` reads
6308        // as Passed — Ship, unattended, on a run whose agent reported failure.
6309        std::fs::write(
6310            stdout_path(dir.path(), PhaseId::new(16)),
6311            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
6312        )
6313        .unwrap();
6314        let result = evaluate_agent_result_inner(
6315            dir.path(),
6316            &state,
6317            &GitFlowConfig::default(),
6318            Some(&approval),
6319        )
6320        .unwrap();
6321        assert_eq!(
6322            result.verdict, None,
6323            "a verdict attached to a self-reported failure must not be grafted (D-15)"
6324        );
6325        // The fix touches `.verdict` only — Layer 0 still decided the status.
6326        assert_eq!(result.status, AgentStatus::Success);
6327        assert_eq!(result.decided_by_layer, Some(0));
6328    }
6329
6330    /// D-15 / ROADMAP criterion 4: `reconcile_layer0_verdict` must consult
6331    /// Layer 1's own `AgentStatus` before transplanting its `verdict`.
6332    ///
6333    /// A regression here costs an unattended Ship on a run whose agent reported
6334    /// failure: the graft would rebuild `(Success, Some(Pass), Some(0))` from a
6335    /// self-contradictory marker, `decide_action` would advance it, and
6336    /// `classify_validate_outcome` would classify Validate as `Passed`.
6337    ///
6338    /// Also carries NC-5's two discrimination cases, which share this fixture.
6339    /// The exploit needs BOTH marker fields; removing either must not reach an
6340    /// affirmative pair. The mandatory opposite-result control lives in
6341    /// `layer0_verdict_graft_still_transplants_a_passing_layer1_verdict` — if
6342    /// that test also produced `None` the fix would be indiscriminate and this
6343    /// one would prove nothing.
6344    #[test]
6345    fn layer0_verdict_graft_declines_when_layer1_status_is_not_success() {
6346        let dir = tempfile::tempdir().unwrap();
6347        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6348        std::fs::create_dir_all(&phase_dir).unwrap();
6349        std::fs::write(
6350            phase_dir.join("16-01-PLAN.md"),
6351            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6352        )
6353        .unwrap();
6354        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6355        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6356        let mut state = state_in(dir.path(), PhaseId::new(16));
6357        state.stage = Stage::Validate;
6358        let approval = vec!["test -f shipped".to_string()];
6359
6360        // The exploit itself: both fields present and mutually contradictory.
6361        std::fs::write(
6362            stdout_path(dir.path(), PhaseId::new(16)),
6363            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
6364        )
6365        .unwrap();
6366        let result = evaluate_agent_result_inner(
6367            dir.path(),
6368            &state,
6369            &GitFlowConfig::default(),
6370            Some(&approval),
6371        )
6372        .unwrap();
6373        assert_eq!(
6374            result.verdict, None,
6375            "self-contradictory marker: the verdict must be declined (D-15)"
6376        );
6377        assert_eq!(result.status, AgentStatus::Success);
6378        assert_eq!(result.decided_by_layer, Some(0));
6379
6380        // NC-5a: removes the `verdict` FIELD, keeps the failed status. `None`
6381        // both pre- and post-fix, so this case cannot discriminate the fix —
6382        // that is the point. The failed status alone is not the exploit.
6383        std::fs::write(
6384            stdout_path(dir.path(), PhaseId::new(16)),
6385            "DEVFLOW_RESULT: {\"status\":\"failed\"}\n",
6386        )
6387        .unwrap();
6388        let result = evaluate_agent_result_inner(
6389            dir.path(),
6390            &state,
6391            &GitFlowConfig::default(),
6392            Some(&approval),
6393        )
6394        .unwrap();
6395        assert_eq!(
6396            result.verdict, None,
6397            "NC-5a removes the `verdict` field: there is no verdict to graft, \
6398             so the result must be None whether or not the fix is present"
6399        );
6400
6401        // NC-5b: removes `verdict: pass` SPECIFICALLY by downgrading it to
6402        // `gaps`, keeping both fields present. Pre-fix this grafted
6403        // `Some(Gaps)`; post-fix it declines like any other non-Success
6404        // Layer 1. Neither state is an affirmative pair — the exploit needs
6405        // `pass`, not merely any verdict.
6406        std::fs::write(
6407            stdout_path(dir.path(), PhaseId::new(16)),
6408            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"gaps\"}\n",
6409        )
6410        .unwrap();
6411        let result = evaluate_agent_result_inner(
6412            dir.path(),
6413            &state,
6414            &GitFlowConfig::default(),
6415            Some(&approval),
6416        )
6417        .unwrap();
6418        assert_ne!(
6419            result.verdict,
6420            Some(Verdict::Pass),
6421            "NC-5b removes `verdict: pass` by downgrading it to `gaps`: this \
6422             case must never reach an affirmative pair"
6423        );
6424        assert_eq!(result.verdict, None);
6425    }
6426
6427    /// NC-5's positive half: the fix declines ONLY when Layer 1's own status is
6428    /// not `Success`, never indiscriminately.
6429    ///
6430    /// This is the case that must produce the OPPOSITE result from
6431    /// `layer0_verdict_graft_declines_when_layer1_status_is_not_success`. If
6432    /// both produced `None` the fix would have disabled 18e's legitimate
6433    /// reconciliation wholesale — re-introducing the 17-03 regression that
6434    /// `reconcile_layer0_verdict` exists to fix — and the pair would prove
6435    /// nothing about D-15, because a measurement whose two arms agree is
6436    /// broken rather than informative.
6437    #[test]
6438    fn layer0_verdict_graft_still_transplants_a_passing_layer1_verdict() {
6439        let dir = tempfile::tempdir().unwrap();
6440        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6441        std::fs::create_dir_all(&phase_dir).unwrap();
6442        std::fs::write(
6443            phase_dir.join("16-01-PLAN.md"),
6444            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6445        )
6446        .unwrap();
6447        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6448        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6449        let mut state = state_in(dir.path(), PhaseId::new(16));
6450        state.stage = Stage::Validate;
6451        let approval = vec!["test -f shipped".to_string()];
6452
6453        std::fs::write(
6454            stdout_path(dir.path(), PhaseId::new(16)),
6455            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
6456        )
6457        .unwrap();
6458        let result = evaluate_agent_result_inner(
6459            dir.path(),
6460            &state,
6461            &GitFlowConfig::default(),
6462            Some(&approval),
6463        )
6464        .unwrap();
6465        assert_eq!(
6466            result.verdict,
6467            Some(Verdict::Pass),
6468            "a passing verdict from a Layer 1 that reported its OWN success \
6469             must still be transplanted (18e); a None here would mean the \
6470             D-15 fix is indiscriminate"
6471        );
6472        assert_eq!(result.status, AgentStatus::Success);
6473        assert_eq!(result.decided_by_layer, Some(0));
6474    }
6475
6476    /// NC-6: with Layer 0 disabled, the same self-contradictory marker never
6477    /// gets laundered at all — Layer 1 reports `Failed` verbatim and
6478    /// `decide_action` routes it to `GateReview`.
6479    ///
6480    /// What the control proves: the GRAFT is the mechanism, not the classifier
6481    /// and not `decide_action`. Removing Layer 0 removes the laundering
6482    /// entirely, so the exploit's precondition is an affirmative Layer-0 probe
6483    /// success — which is exactly why plan 34-04 (999.76), by making
6484    /// `decided_by_layer == Some(0)` common in worktree mode, must not land
6485    /// without the fix this test pins.
6486    ///
6487    /// The routing consequence is asserted here rather than assumed, so a
6488    /// future change to `decide_action`'s `Failed` arm breaks this test rather
6489    /// than silently invalidating the control.
6490    #[test]
6491    fn layer0_disabled_routes_a_self_reported_failure_to_gate_review() {
6492        let dir = tempfile::tempdir().unwrap();
6493        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6494        std::fs::create_dir_all(&phase_dir).unwrap();
6495        // The difference from the fixtures above: Layer 0 is switched off, so
6496        // the cascade falls through to Layer 1 instead of short-circuiting on
6497        // an affirmative probe success.
6498        std::fs::write(
6499            dir.path().join("devflow.toml"),
6500            "external_verify_enabled = false\n",
6501        )
6502        .unwrap();
6503        // Belt AND braces, deliberately. `config::external_verify_enabled`
6504        // consults `DEVFLOW_EXTERNAL_VERIFY_ENABLED` BEFORE `devflow.toml`, and
6505        // `config::tests::env_overrides_file_external_verification` sets that
6506        // variable to "true" process-globally under a mutex private to its own
6507        // module — which cannot serialize against this one. A PLAN declaring
6508        // `external_verify` would therefore let a parallel run of that test
6509        // re-enable Layer 0 here and flake this control into a green.
6510        // Declaring no probe closes that window: with no declared commands and
6511        // no approval vector, `evaluate_layer0` abstains whatever the env says,
6512        // so this test is deterministic under every value of the variable.
6513        std::fs::write(phase_dir.join("16-01-PLAN.md"), "---\nplan: 01\n---\n").unwrap();
6514        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6515        let mut state = state_in(dir.path(), PhaseId::new(16));
6516        state.stage = Stage::Validate;
6517
6518        std::fs::write(
6519            stdout_path(dir.path(), PhaseId::new(16)),
6520            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
6521        )
6522        .unwrap();
6523        // No approval vector — Layer 0 is disabled, so there is nothing to
6524        // approve, and supplying one would re-arm the very arm being removed.
6525        let result =
6526            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
6527                .unwrap();
6528
6529        assert_eq!(
6530            result.status,
6531            AgentStatus::Failed,
6532            "with Layer 0 disabled, Layer 1's self-reported failure stands \
6533             verbatim — there is no affirmative probe success to graft onto"
6534        );
6535        assert_eq!(result.decided_by_layer, Some(1));
6536        assert_eq!(
6537            crate::outcome_policy::decide_action(Stage::Validate, result.status),
6538            crate::outcome_policy::Action::GateReview,
6539            "a self-reported failure must gate for review, never advance"
6540        );
6541    }
6542
6543    /// 18e's reconciliation is scoped to `Stage::Validate` only (flagged
6544    /// assumption in 18-05-PLAN.md): at every other stage an affirmative
6545    /// Layer 0 success must keep `verdict: None`, even when Layer 1's marker
6546    /// carries an explicit verdict.
6547    #[test]
6548    fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
6549        let dir = tempfile::tempdir().unwrap();
6550        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6551        std::fs::create_dir_all(&phase_dir).unwrap();
6552        std::fs::write(
6553            phase_dir.join("16-01-PLAN.md"),
6554            "---\nexternal_verify: \"test -f shipped\"\n---\n",
6555        )
6556        .unwrap();
6557        std::fs::write(dir.path().join("shipped"), "done").unwrap();
6558        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
6559        std::fs::write(
6560            stdout_path(dir.path(), PhaseId::new(16)),
6561            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
6562        )
6563        .unwrap();
6564        let state = state_in(dir.path(), PhaseId::new(16)); // Stage::Code by default
6565        let approval = vec!["test -f shipped".to_string()];
6566
6567        let result = evaluate_agent_result_inner(
6568            dir.path(),
6569            &state,
6570            &GitFlowConfig::default(),
6571            Some(&approval),
6572        )
6573        .unwrap();
6574
6575        assert_eq!(result.status, AgentStatus::Success);
6576        assert_eq!(result.decided_by_layer, Some(0));
6577        assert_eq!(result.verdict, None);
6578    }
6579
6580    /// Ordering edge (17a): with multiple declared probes, ALL must pass for
6581    /// affirmative Success — the first failing probe vetoes the outcome
6582    /// regardless of which position it occupies among the declarations.
6583    #[test]
6584    fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
6585        let dir = tempfile::tempdir().unwrap();
6586        let phase_dir = dir.path().join(".planning/phases/16-reliability");
6587        std::fs::create_dir_all(&phase_dir).unwrap();
6588        // 16-01 comes first alphabetically and passes; 16-02 comes second and fails.
6589        std::fs::write(
6590            phase_dir.join("16-01-PLAN.md"),
6591            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
6592        )
6593        .unwrap();
6594        std::fs::write(
6595            phase_dir.join("16-02-PLAN.md"),
6596            "---\nexternal_verify: \"test -f never-created\"\n---\n",
6597        )
6598        .unwrap();
6599        std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
6600        let mut state = state_in(dir.path(), PhaseId::new(16));
6601        state.stage = Stage::Define;
6602
6603        let approval = vec![
6604            "test -f passing-artifact".to_string(),
6605            "test -f never-created".to_string(),
6606        ];
6607        let result_a = evaluate_agent_result_inner(
6608            dir.path(),
6609            &state,
6610            &GitFlowConfig::default(),
6611            Some(&approval),
6612        )
6613        .unwrap();
6614        assert_eq!(result_a.status, AgentStatus::Failed);
6615        assert!(
6616            result_a
6617                .reason
6618                .as_deref()
6619                .is_some_and(|reason| reason.contains("never-created")),
6620            "unexpected reason: {:?}",
6621            result_a.reason
6622        );
6623
6624        // Swap which position fails: 16-01 now fails, 16-02 passes. The
6625        // overall outcome must still veto — order of declaration must not
6626        // matter.
6627        std::fs::write(
6628            phase_dir.join("16-01-PLAN.md"),
6629            "---\nexternal_verify: \"test -f still-missing\"\n---\n",
6630        )
6631        .unwrap();
6632        std::fs::write(
6633            phase_dir.join("16-02-PLAN.md"),
6634            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
6635        )
6636        .unwrap();
6637        let approval_swapped = vec![
6638            "test -f still-missing".to_string(),
6639            "test -f passing-artifact".to_string(),
6640        ];
6641        let result_b = evaluate_agent_result_inner(
6642            dir.path(),
6643            &state,
6644            &GitFlowConfig::default(),
6645            Some(&approval_swapped),
6646        )
6647        .unwrap();
6648        assert_eq!(result_b.status, AgentStatus::Failed);
6649
6650        // Now make BOTH pass: only then is the outcome Success.
6651        std::fs::write(dir.path().join("still-missing"), "done").unwrap();
6652        let result_c = evaluate_agent_result_inner(
6653            dir.path(),
6654            &state,
6655            &GitFlowConfig::default(),
6656            Some(&approval_swapped),
6657        )
6658        .unwrap();
6659        assert_eq!(result_c.status, AgentStatus::Success);
6660        assert_eq!(result_c.decided_by_layer, Some(0));
6661    }
6662
6663    /// A quota denial in the capture must be visible to the monitor BEFORE it
6664    /// records a verdict about the silence that denial caused.
6665    ///
6666    /// The positive arm of the 2026-08-08 misclassification: a real `seven_day`
6667    /// / `out_of_credits` denial silenced the agent, the idle timer fired, and
6668    /// the resulting record shadowed the classifier that had the right answer.
6669    #[test]
6670    fn a_quota_denial_in_the_capture_is_visible_to_the_monitor() {
6671        let dir = tempfile::tempdir().unwrap();
6672        let root = dir.path();
6673        let phase = PhaseId::new(3);
6674        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6675
6676        std::fs::write(
6677            stdout_path(root, phase),
6678            format!("{V3_INIT_EVENT}\n{}\n", v3_rate_limit_event("rejected")),
6679        )
6680        .unwrap();
6681
6682        assert!(
6683            capture_shows_rate_limit_denial(root, phase),
6684            "an explicit `rejected` quota denial must be detectable, or the monitor \
6685             will record a hang for a pause that is resumable"
6686        );
6687    }
6688
6689    /// Negative control, and the more important half: this must NOT fire on an
6690    /// ordinary capture, or every genuine hang stops being recorded as one.
6691    ///
6692    /// The `allowed` arm is the specific trap — the CLI emits `rate_limit_event`
6693    /// routinely while healthy, and `overageStatus: "rejected"` sits one level
6694    /// below `status: "allowed"`, so any loose nested search matches it.
6695    #[test]
6696    fn an_ordinary_capture_is_not_mistaken_for_a_quota_denial() {
6697        let dir = tempfile::tempdir().unwrap();
6698        let root = dir.path();
6699        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6700
6701        let quiet = PhaseId::new(4);
6702        std::fs::write(stdout_path(root, quiet), format!("{V3_INIT_EVENT}\n")).unwrap();
6703        assert!(
6704            !capture_shows_rate_limit_denial(root, quiet),
6705            "a capture with no rate-limit event at all must read as no denial"
6706        );
6707
6708        let healthy = PhaseId::new(5);
6709        std::fs::write(
6710            stdout_path(root, healthy),
6711            format!("{V3_INIT_EVENT}\n{V3_RATE_LIMIT_EVENT_ALLOWED}\n"),
6712        )
6713        .unwrap();
6714        assert!(
6715            !capture_shows_rate_limit_denial(root, healthy),
6716            "a healthy `status: allowed` event carries `overageStatus: rejected` one \
6717             level down — matching it would suppress the idle timeout on every run"
6718        );
6719
6720        let absent = PhaseId::new(6);
6721        assert!(
6722            !capture_shows_rate_limit_denial(root, absent),
6723            "a missing capture must read as no denial, never as one"
6724        );
6725    }
6726
6727    /// A stage attempt's idle-timeout verdict must not survive into the next
6728    /// attempt.
6729    ///
6730    /// Reproduces the 2026-08-08 observation directly: a record written by a
6731    /// killed Plan stage was still authoritative when the next stage launched,
6732    /// and because `evaluate_layer1` consults it FIRST and returns
6733    /// unconditionally, it overrode a stage that had genuinely succeeded.
6734    #[test]
6735    fn archive_clears_a_previous_attempts_idle_timeout_verdict() {
6736        let dir = tempfile::tempdir().unwrap();
6737        let root = dir.path();
6738        let phase = PhaseId::new(1);
6739        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6740
6741        // A verdict left behind by an earlier, killed attempt.
6742        std::fs::write(
6743            idle_timeout_path(root, phase),
6744            r#"{"status":"idle_timeout","idle_secs":120,"agent_pid":501757,"written_at":1786157328,"commits":[]}"#,
6745        )
6746        .unwrap();
6747        // Precondition, asserted rather than assumed: while it exists it is
6748        // authoritative, which is precisely why it must not persist.
6749        assert!(
6750            evaluate_layer1(root, phase).is_some(),
6751            "fixture precondition: the stale record must be readable as a verdict"
6752        );
6753
6754        archive_phase_files(root, root, phase, 5).unwrap();
6755
6756        assert!(
6757            !idle_timeout_path(root, phase).exists(),
6758            "a previous attempt's timeout verdict survived a stage launch — it \
6759             will now outrank the next stage's real result, for this phase, forever"
6760        );
6761    }
6762
6763    /// Negative control for the test above. The clearing happens at stage
6764    /// LAUNCH, and it must not be reachable in a way that discards a verdict
6765    /// before it has been read: a record with no capture beside it is the
6766    /// stale case, but a record is only ever written mid-attempt, after the
6767    /// launch that would have cleared it.
6768    ///
6769    /// This pins the other half — that clearing the file did not neuter the
6770    /// mechanism, only its lifetime.
6771    #[test]
6772    fn a_current_attempts_idle_timeout_verdict_is_still_authoritative() {
6773        let dir = tempfile::tempdir().unwrap();
6774        let root = dir.path();
6775        let phase = PhaseId::new(2);
6776        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6777
6778        // Stage launched (archive ran), THEN the monitor recorded a timeout —
6779        // the real ordering within one attempt.
6780        archive_phase_files(root, root, phase, 5).unwrap();
6781        std::fs::write(
6782            idle_timeout_path(root, phase),
6783            r#"{"status":"idle_timeout","idle_secs":120,"agent_pid":4242,"written_at":1786159637,"commits":[]}"#,
6784        )
6785        .unwrap();
6786
6787        let verdict = evaluate_layer1(root, phase)
6788            .expect("a verdict recorded during this attempt must still be honoured");
6789        assert_eq!(
6790            verdict.status,
6791            AgentStatus::IdleTimeout,
6792            "the timeout mechanism itself must survive the lifetime fix"
6793        );
6794    }
6795
6796    #[test]
6797    fn archive_moves_captures_into_history_and_removes_pid_file() {
6798        // 16b: prior-stage captures must survive a simulated next-launch by
6799        // appearing under .devflow/history/phase-NN/, not be wiped outright.
6800        let dir = tempfile::tempdir().unwrap();
6801        let root = dir.path();
6802        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6803        std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
6804        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
6805        std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();
6806
6807        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
6808
6809        // The live capture paths are gone (moved, not merely deleted).
6810        assert!(!root.join(".devflow/phase-01-stdout").exists());
6811        assert!(!root.join(".devflow/phase-01-exit").exists());
6812        // Agent-pid is bookkeeping, not diagnostic — still removed outright.
6813        assert!(!root.join(".devflow/phase-01-agent-pid").exists());
6814
6815        let history = history_dir(root, PhaseId::new(1));
6816        let archived: Vec<_> = std::fs::read_dir(&history)
6817            .unwrap()
6818            .flatten()
6819            .map(|e| e.file_name().to_string_lossy().into_owned())
6820            .collect();
6821        let archived_stdout = archived
6822            .iter()
6823            .find(|name| name.ends_with("-stdout"))
6824            .expect("stdout capture should be archived into history");
6825        assert!(archived.iter().any(|name| name.ends_with("-exit")));
6826        let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
6827        assert_eq!(contents, "prior stdout");
6828    }
6829
6830    #[test]
6831    fn archive_is_noop_when_nothing_to_archive() {
6832        let dir = tempfile::tempdir().unwrap();
6833        let root = dir.path();
6834        // Should not panic when there is nothing to archive (first launch).
6835        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
6836        assert!(!history_dir(root, PhaseId::new(1)).exists());
6837    }
6838
6839    #[test]
6840    fn archive_handles_missing_devflow_dir() {
6841        let dir = tempfile::tempdir().unwrap();
6842        let root = dir.path();
6843        // No .devflow dir at all — should not panic.
6844        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
6845    }
6846
6847    #[test]
6848    fn archive_failure_preserves_live_capture_for_retry() {
6849        let dir = tempfile::tempdir().unwrap();
6850        let root = dir.path();
6851        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6852        std::fs::write(stdout_path(root, PhaseId::new(1)), "evidence").unwrap();
6853        // A file where the history directory must be forces create_dir_all
6854        // to fail before the live capture is moved or a monitor can truncate it.
6855        std::fs::write(root.join(".devflow/history"), "blocked").unwrap();
6856
6857        assert!(archive_phase_files(root, root, PhaseId::new(1), 5).is_err());
6858        assert_eq!(
6859            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
6860            "evidence"
6861        );
6862    }
6863
6864    #[test]
6865    fn archive_second_publish_failure_rolls_back_complete_live_pair() {
6866        let dir = tempfile::tempdir().unwrap();
6867        let root = dir.path();
6868        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6869        std::fs::write(stdout_path(root, PhaseId::new(1)), "stdout evidence").unwrap();
6870        std::fs::write(exit_code_path(root, PhaseId::new(1)), "17").unwrap();
6871        let history = history_dir(root, PhaseId::new(1));
6872        std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();
6873
6874        assert!(archive_phase_files_with_stamp(root, root, PhaseId::new(1), 5, "fixed").is_err());
6875
6876        assert_eq!(
6877            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
6878            "stdout evidence"
6879        );
6880        assert_eq!(
6881            std::fs::read_to_string(exit_code_path(root, PhaseId::new(1))).unwrap(),
6882            "17"
6883        );
6884        assert!(!history.join("fixed-stdout").exists());
6885        assert!(!history.join(".pending-fixed").exists());
6886    }
6887
6888    #[test]
6889    fn archive_review_copy_failure_rolls_back_complete_live_pair() {
6890        let dir = tempfile::tempdir().unwrap();
6891        let root = dir.path();
6892        let evidence_root = root.join("phase-worktree");
6893        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6894        std::fs::write(stdout_path(root, PhaseId::new(1)), "stdout evidence").unwrap();
6895        std::fs::write(exit_code_path(root, PhaseId::new(1)), "23").unwrap();
6896        let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
6897        std::fs::create_dir_all(&review).unwrap();
6898
6899        assert!(
6900            archive_phase_files_with_stamp(root, &evidence_root, PhaseId::new(1), 5, "review-copy")
6901                .is_err()
6902        );
6903
6904        assert_eq!(
6905            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
6906            "stdout evidence"
6907        );
6908        assert_eq!(
6909            std::fs::read_to_string(exit_code_path(root, PhaseId::new(1))).unwrap(),
6910            "23"
6911        );
6912        let history = history_dir(root, PhaseId::new(1));
6913        assert!(!history.join("review-copy-stdout").exists());
6914        assert!(!history.join("review-copy-exit").exists());
6915        assert!(!history.join(".pending-review-copy").exists());
6916    }
6917
6918    #[test]
6919    fn archive_snapshots_current_review_into_same_generation() {
6920        let dir = tempfile::tempdir().unwrap();
6921        let root = dir.path();
6922        let evidence_root = root.join("phase-worktree");
6923        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6924        std::fs::write(stdout_path(root, PhaseId::new(1)), "attempt").unwrap();
6925        let phase_dir = evidence_root.join(".planning/phases/01-example");
6926        std::fs::create_dir_all(&phase_dir).unwrap();
6927        std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();
6928
6929        let stamp = archive_phase_files(root, &evidence_root, PhaseId::new(1), 5)
6930            .unwrap()
6931            .unwrap();
6932
6933        assert_eq!(
6934            std::fs::read_to_string(
6935                history_dir(root, PhaseId::new(1)).join(format!("{stamp}-REVIEW.md"))
6936            )
6937            .unwrap(),
6938            "review one"
6939        );
6940    }
6941
6942    #[test]
6943    fn archive_prunes_history_to_retain_count() {
6944        let dir = tempfile::tempdir().unwrap();
6945        let root = dir.path();
6946        std::fs::create_dir_all(root.join(".devflow")).unwrap();
6947
6948        for i in 0..7 {
6949            std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
6950            std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
6951            archive_phase_files(root, root, PhaseId::new(1), 3).unwrap();
6952        }
6953
6954        let history = history_dir(root, PhaseId::new(1));
6955        let stdout_count = std::fs::read_dir(&history)
6956            .unwrap()
6957            .flatten()
6958            .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
6959            .count();
6960        let exit_count = std::fs::read_dir(&history)
6961            .unwrap()
6962            .flatten()
6963            .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
6964            .count();
6965        assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
6966        assert_eq!(exit_count, 3, "expected at most 3 retained generations");
6967    }
6968
6969    /// The set of stamp groups currently surviving in a history directory,
6970    /// derived the same way `prune_history` derives them (`rsplit_once('-')`,
6971    /// keep the left part) so the assertion measures grouping rather than a
6972    /// listing length.
6973    fn surviving_stamps(history: &Path) -> std::collections::BTreeSet<String> {
6974        std::fs::read_dir(history)
6975            .unwrap()
6976            .flatten()
6977            .filter_map(|entry| {
6978                let name = entry.file_name().to_str()?.to_string();
6979                name.rsplit_once('-')
6980                    .map(|(stamp, _suffix)| stamp.to_string())
6981            })
6982            .collect()
6983    }
6984
6985    /// ROADMAP criterion 7's retention half. `DEFAULT_CAPTURE_RETENTION` was
6986    /// `5`, and `archive_phase_files` runs once per launch: a clean five-stage
6987    /// Define→Plan→Code→Validate→Ship run produces 4 archive events and each
6988    /// Validate→Code loop-back adds 2. At `5`, the first loop-back's sixth
6989    /// event evicted Define's capture — silently, with no error and no log.
6990    ///
6991    /// What a regression here costs: a stage capture destroyed before the
6992    /// phase that requested it has read it, which is unrecoverable after the
6993    /// fact because `.devflow/` is the only copy until it is deliberately
6994    /// copied out.
6995    #[test]
6996    fn prune_history_retains_a_full_five_stage_run_with_loop_backs() {
6997        let dir = tempfile::tempdir().unwrap();
6998        let root = dir.path();
6999        let history = history_dir(root, PhaseId::new(1));
7000        std::fs::create_dir_all(&history).unwrap();
7001
7002        let retain = crate::config::DEFAULT_CAPTURE_RETENTION;
7003
7004        // Twelve generations, strictly increasing. The suffix is load-bearing:
7005        // `prune_history` derives a stamp with `rsplit_once('-')` and keeps the
7006        // LEFT part, so a bare `{nanos}-{seq}` name would yield the stamp
7007        // `{nanos}` and then delete `{nanos}-stdout`, which never exists — the
7008        // retain half would false-pass via the `stamps.len() <= retain` early
7009        // return while the evict half could never pass at all.
7010        let stamps: Vec<String> = (0..12)
7011            .map(|i| format!("{}-0", 1_700_000_000_000_000_000u128 + i))
7012            .collect();
7013        for stamp in &stamps {
7014            std::fs::write(history.join(format!("{stamp}-stdout")), "capture").unwrap();
7015        }
7016        // The oldest generation gets a second suffix so eviction-by-stamp-group
7017        // is actually exercised rather than assumed: one evicted stamp must
7018        // take BOTH its files.
7019        std::fs::write(history.join(format!("{}-exit", stamps[0])), "0").unwrap();
7020
7021        prune_history(&history, retain);
7022
7023        let survivors = surviving_stamps(&history);
7024        assert_eq!(
7025            survivors.len(),
7026            12,
7027            "a five-stage run with loop-backs must not lose a capture at the default \
7028             retention; found {survivors:?}"
7029        );
7030        for stamp in &stamps {
7031            assert!(
7032                survivors.contains(stamp),
7033                "generation {stamp} was evicted at exactly the retention boundary"
7034            );
7035        }
7036
7037        // Opposite-result control. Without this half the test would be
7038        // measuring a directory listing, not pruning: `prune_history` returns
7039        // early whenever `stamps.len() <= retain`, so a fixture that never
7040        // crosses the boundary passes identically against a `prune_history`
7041        // that does nothing at all.
7042        let thirteenth = format!("{}-0", 1_700_000_000_000_000_000u128 + 12);
7043        std::fs::write(history.join(format!("{thirteenth}-stdout")), "capture").unwrap();
7044
7045        prune_history(&history, retain);
7046
7047        let after = surviving_stamps(&history);
7048        assert_eq!(
7049            after.len(),
7050            12,
7051            "crossing the boundary by one must evict exactly one stamp group, not zero \
7052             and not several; found {after:?}"
7053        );
7054        assert!(
7055            !after.contains(&stamps[0]),
7056            "the evicted generation must be the OLDEST by stamp order"
7057        );
7058        assert!(
7059            !history.join(format!("{}-exit", stamps[0])).exists(),
7060            "eviction operates on the stamp GROUP: the oldest generation's -exit file must \
7061             go with its -stdout, or pruning is leaking partial generations"
7062        );
7063        assert!(
7064            after.contains(&thirteenth),
7065            "the newest generation must survive its own arrival"
7066        );
7067    }
7068
7069    #[test]
7070    fn evaluate_agent_result_reads_files_end_to_end() {
7071        let dir = tempfile::tempdir().unwrap();
7072        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7073        std::fs::write(
7074            stdout_path(dir.path(), PhaseId::new(6)),
7075            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
7076        )
7077        .unwrap();
7078        std::fs::write(exit_code_path(dir.path(), PhaseId::new(6)), "0").unwrap();
7079        let state = state_in(dir.path(), PhaseId::new(6));
7080
7081        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
7082
7083        assert_eq!(result.status, AgentStatus::Success);
7084        assert_eq!(result.commits, Some(2));
7085        assert_eq!(result.summary.as_deref(), Some("ok"));
7086    }
7087
7088    // ---- exit-code arbitration on a claimed success (31-04, T-31-15) -----
7089    //
7090    // Every test below drives the FULL cascade through
7091    // `evaluate_agent_result_inner`, never the parser's own return value.
7092    // 31-RESEARCH.md § Pitfall 4 records why: a truncation-boundary test that
7093    // checks only `parse_claude_event_result` exercises constraint 9's items 1
7094    // and 2, which the `a557805` root-cause refactor already closed. The
7095    // residual this arbitration exists for lives in the WIRING — Layer 1
7096    // returning before Layer 2 is ever consulted — and only the cascade
7097    // exercises it.
7098
7099    /// A success marker that also claims `verdict: pass` — the shape a naive
7100    /// "carry every other field over" downgrade would have preserved. Used to
7101    /// prove `verdict` is dropped.
7102    ///
7103    /// Correction (34-01, D-15): an earlier version of this comment asserted
7104    /// that keeping the field would classify Validate as Passed because
7105    /// `classify_validate_outcome` matches `Some(Verdict::Pass)` first with the
7106    /// status discarded. That overstated the reachability — `decide_action`
7107    /// intercepts a non-`Success` status before the classifier runs. The
7108    /// corrected record of how the inversion is actually reached lives on
7109    /// [`super::reconcile_layer0_verdict`].
7110    const MARKER_SUCCESS_CLAIMING_PASS: &str =
7111        r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}"#;
7112
7113    /// The residual of constraint 9 that no parser assertion can reach.
7114    ///
7115    /// A capture cut at an exact line boundary is byte-identical to a healthy
7116    /// shorter run, so the stream itself carries no evidence of the tear. The
7117    /// writer that died between flushing turn N and turn N+1 also died
7118    /// non-zero, and that exit code is the only signal left.
7119    #[test]
7120    fn stream_success_cannot_stand_against_nonzero_exit_code() {
7121        let dir = tempfile::tempdir().unwrap();
7122        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7123        std::fs::write(
7124            stdout_path(dir.path(), PhaseId::new(31)),
7125            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS_CLAIMING_PASS),
7126        )
7127        .unwrap();
7128
7129        // NEGATIVE CONTROL, encoded in the test rather than described in prose:
7130        // Layer 1 on its own decides Success here AND reports `verdict: Pass`.
7131        // Without this the assertions below cannot distinguish "the arbitration
7132        // downgraded a success" from "nothing ever claimed success", nor
7133        // "`verdict` was dropped" from "`verdict` was never set".
7134        let layer1 = evaluate_layer1(dir.path(), PhaseId::new(31)).unwrap();
7135        assert_eq!(layer1.status, AgentStatus::Success);
7136        assert_eq!(layer1.verdict, Some(Verdict::Pass));
7137
7138        std::fs::write(exit_code_path(dir.path(), PhaseId::new(31)), "1\n").unwrap();
7139        let state = state_in(dir.path(), PhaseId::new(31));
7140
7141        let result =
7142            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7143                .unwrap();
7144
7145        assert_eq!(result.status, AgentStatus::Failed);
7146        assert_eq!(result.exit_code, Some(1));
7147        assert!(
7148            result.reason.as_deref().is_some_and(|r| r.contains("1")),
7149            "the reason must name the exit code: {:?}",
7150            result.reason
7151        );
7152        // Layer 1 still decided this — the arbitration corrects its verdict, it
7153        // does not hand the decision to Layer 2.
7154        assert_eq!(result.decided_by_layer, Some(1));
7155        // Load-bearing: a downgraded result has no verdict to offer. The
7156        // invariant is structural, not conventional (999.85 / F-34-02): the
7157        // classifier's enumerated status position (`(_, AgentStatus::Success,
7158        // Some(Verdict::Pass))` in `classify_validate_outcome`) and the graft's
7159        // status filter (`reconcile_layer0_verdict`) both reject a verdict
7160        // riding a non-`Success` status. This assertion pins that the
7161        // arbitration drops the verdict outright rather than leaving it to be
7162        // re-classified downstream.
7163        assert_eq!(result.verdict, None);
7164    }
7165
7166    /// The matched negative control for the test above. Without it, that test
7167    /// cannot tell "the arbitration works" from "the arbitration fires on
7168    /// everything".
7169    #[test]
7170    fn stream_success_stands_when_the_exit_code_is_zero() {
7171        let dir = tempfile::tempdir().unwrap();
7172        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7173        std::fs::write(
7174            stdout_path(dir.path(), PhaseId::new(32)),
7175            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS_CLAIMING_PASS),
7176        )
7177        .unwrap();
7178        std::fs::write(exit_code_path(dir.path(), PhaseId::new(32)), "0\n").unwrap();
7179        let state = state_in(dir.path(), PhaseId::new(32));
7180
7181        let result =
7182            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7183                .unwrap();
7184
7185        assert_eq!(result.status, AgentStatus::Success);
7186        assert_eq!(result.decided_by_layer, Some(1));
7187        // The verdict survives an untouched result — proof that the `None`
7188        // asserted in the downgrade test is the arbitration's doing and not a
7189        // property of the fixture.
7190        assert_eq!(result.verdict, Some(Verdict::Pass));
7191    }
7192
7193    /// A missing exit file is not evidence of failure. This matches
7194    /// `evaluate_layer2`'s own tolerance (`Err(_) => return Ok(None)`); a
7195    /// stricter reading here would fail every stage whose monitor had not yet
7196    /// written the file.
7197    #[test]
7198    fn stream_success_stands_when_no_exit_file_exists() {
7199        let dir = tempfile::tempdir().unwrap();
7200        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7201        std::fs::write(
7202            stdout_path(dir.path(), PhaseId::new(33)),
7203            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
7204        )
7205        .unwrap();
7206        assert!(
7207            !exit_code_path(dir.path(), PhaseId::new(33)).exists(),
7208            "fixture precondition: there must be no exit file"
7209        );
7210        let state = state_in(dir.path(), PhaseId::new(33));
7211
7212        let result =
7213            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7214                .unwrap();
7215
7216        assert_eq!(result.status, AgentStatus::Success);
7217        assert_eq!(result.decided_by_layer, Some(1));
7218    }
7219
7220    /// Only a *claimed success* is arbitrated. Downgrading a rate limit to a
7221    /// generic failure would route the run to a human gate instead of the
7222    /// auto-resume cron it needs — the exact harm `rate_limited_result`'s
7223    /// precedence over `detect_claude_envelope_failure` exists to prevent.
7224    #[test]
7225    fn rate_limited_verdict_is_not_arbitrated_by_exit_code() {
7226        let dir = tempfile::tempdir().unwrap();
7227        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7228        std::fs::write(
7229            stdout_path(dir.path(), PhaseId::new(34)),
7230            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
7231        )
7232        .unwrap();
7233        std::fs::write(exit_code_path(dir.path(), PhaseId::new(34)), "1\n").unwrap();
7234        let state = state_in(dir.path(), PhaseId::new(34));
7235
7236        let result =
7237            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7238                .unwrap();
7239
7240        assert_eq!(result.status, AgentStatus::RateLimited);
7241        assert_eq!(
7242            result.reason.as_deref(),
7243            Some("rate limited until 2026-06-18T15:45:30Z"),
7244            "the rate-limit reason must survive verbatim — the resume cron reads it"
7245        );
7246    }
7247
7248    /// Plan 31-02's side-channel verdict survives arbitration unchanged. An
7249    /// `IdleTimeout` collapsed into `Failed` would lose exactly the distinction
7250    /// 31-02 exists to create, and the monitor writes a NON-zero exit for a
7251    /// child it killed, so this is not a hypothetical pairing.
7252    #[test]
7253    fn idle_timeout_verdict_is_not_arbitrated_by_exit_code() {
7254        let dir = tempfile::tempdir().unwrap();
7255        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7256        std::fs::write(
7257            stdout_path(dir.path(), PhaseId::new(35)),
7258            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
7259        )
7260        .unwrap();
7261        write_idle_timeout_record(
7262            dir.path(),
7263            PhaseId::new(35),
7264            &[("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "feat: partial")],
7265        );
7266        std::fs::write(exit_code_path(dir.path(), PhaseId::new(35)), "143\n").unwrap();
7267        let state = state_in(dir.path(), PhaseId::new(35));
7268
7269        let result =
7270            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7271                .unwrap();
7272
7273        assert_eq!(result.status, AgentStatus::IdleTimeout);
7274        assert_eq!(
7275            result.exit_code, None,
7276            "the arbitration must not graft an exit code onto a timeout verdict"
7277        );
7278    }
7279
7280    /// Exit-code fidelity (adversarial review of 31-04, W1). A blanket `Failed`
7281    /// would flatten the two codes `evaluate_layer2` classifies specially, and
7282    /// `outcome_policy::decide_action` routes those to `GateInfra` rather than
7283    /// `GateReview`. The same exit code must not reach two different operator
7284    /// gates depending on whether a stale Layer 1 success happened to be there.
7285    #[test]
7286    fn arbitration_preserves_layer2s_resource_and_unavailable_codes() {
7287        for (code, expected) in [
7288            (137, AgentStatus::ResourceKilled),
7289            (127, AgentStatus::AgentUnavailable),
7290            (2, AgentStatus::Failed),
7291        ] {
7292            let dir = tempfile::tempdir().unwrap();
7293            std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7294            std::fs::write(
7295                stdout_path(dir.path(), PhaseId::new(36)),
7296                v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
7297            )
7298            .unwrap();
7299            std::fs::write(
7300                exit_code_path(dir.path(), PhaseId::new(36)),
7301                format!("{code}\n"),
7302            )
7303            .unwrap();
7304            let state = state_in(dir.path(), PhaseId::new(36));
7305
7306            let arbitrated =
7307                evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
7308                    .unwrap();
7309
7310            assert_eq!(
7311                arbitrated.status, expected,
7312                "exit {code} must arbitrate to {expected:?}, matching evaluate_layer2"
7313            );
7314            assert_eq!(arbitrated.exit_code, Some(code));
7315        }
7316    }
7317
7318    /// D-12's inverse assertion, and the mirror of
7319    /// [`single_doc_envelope_not_consumed_by_claude_stream_parser`].
7320    ///
7321    /// That test pins one direction: today's shipped `--output-format json`
7322    /// envelope must NOT be consumed by the stream parser. This pins the other:
7323    /// a capture produced by plan 31-01's new `stream-json` argv classifies as
7324    /// [`CaptureKind::ClaudeStream`] and is NOT consumed by the
7325    /// single-document envelope path. Without both directions, widening either
7326    /// gate is only half-guarded.
7327    ///
7328    /// Cites `classify()` / `CaptureKind::ClaudeStream` deliberately: the gate
7329    /// predicate `31-CONTEXT.md` and `30-VERIFICATION.md` W-02 still name is no
7330    /// longer a live function — the `a557805` refactor replaced it.
7331    #[test]
7332    fn stream_json_capture_is_not_consumed_by_the_single_document_path() {
7333        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
7334
7335        // The classifier owns it.
7336        assert!(capture_is_claude_stream(&capture));
7337
7338        // Every single-document reader declines it...
7339        assert!(claude_session_id(&capture).is_none());
7340        assert!(detect_claude_envelope_failure(&capture).is_none());
7341        assert!(detect_claude_rate_limit(&capture).is_none());
7342
7343        // ...and the stream parser still owns it, so declining costs no verdict.
7344        assert_eq!(
7345            parse_claude_event_result(&capture).unwrap().status,
7346            AgentStatus::Success
7347        );
7348
7349        // Non-vacuity: the single-document readers are not simply broken — the
7350        // same three answer a real envelope. Without this, the `is_none()`
7351        // assertions above would pass against a reader that returned `None` for
7352        // everything.
7353        let envelope = r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z","session_id":"abc"}"#;
7354        assert_eq!(claude_session_id(envelope).as_deref(), Some("abc"));
7355        assert!(detect_claude_envelope_failure(envelope).is_some());
7356        assert!(detect_claude_rate_limit(envelope).is_some());
7357        assert!(!capture_is_claude_stream(envelope));
7358    }
7359
7360    #[test]
7361    fn evaluate_layer1_finds_devflow_result_in_file() {
7362        let dir = tempfile::tempdir().unwrap();
7363        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7364        std::fs::write(
7365            stdout_path(dir.path(), PhaseId::new(3)),
7366            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
7367        )
7368        .unwrap();
7369
7370        let result = evaluate_layer1(dir.path(), PhaseId::new(3)).unwrap();
7371
7372        assert_eq!(result.status, AgentStatus::Failed);
7373        assert_eq!(result.reason.as_deref(), Some("bad output"));
7374    }
7375
7376    /// The case `consecutive_failures_reaches_ceiling_across_cycles`
7377    /// (`pipeline_outcomes.rs`) silently depends on: a repository with no
7378    /// `feature/phase-NN` branch at all must report 0, not error or panic.
7379    #[test]
7380    fn phase_commit_count_reports_zero_without_a_branch() {
7381        let dir = tempfile::tempdir().unwrap();
7382        git(dir.path(), &["init"]);
7383        git(dir.path(), &["config", "user.email", "devflow@example.com"]);
7384        git(dir.path(), &["config", "user.name", "DevFlow Tests"]);
7385        git(dir.path(), &["config", "commit.gpgsign", "false"]);
7386        git(dir.path(), &["config", "tag.gpgsign", "false"]);
7387        git(dir.path(), &["config", "core.hooksPath", "/dev/null"]);
7388        git(dir.path(), &["checkout", "-b", "develop"]);
7389        std::fs::write(dir.path().join("README.md"), "base\n").unwrap();
7390        git(dir.path(), &["add", "README.md"]);
7391        git(dir.path(), &["commit", "-m", "base"]);
7392
7393        let count = phase_commit_count(dir.path(), &GitFlowConfig::default(), PhaseId::new(999));
7394
7395        assert_eq!(
7396            count,
7397            Some(0),
7398            "git RAN and reported the branch absent — a real observation of zero, \
7399             not a failure to measure"
7400        );
7401    }
7402
7403    /// The paired opposite-result case for
7404    /// `phase_commit_count_reports_zero_without_a_branch` directly above, and
7405    /// the pair is what makes either one mean anything (NC-4).
7406    ///
7407    /// The two differ in exactly one respect: whether the `git` child could be
7408    /// executed at all. The repository is identical in both — no
7409    /// `feature/phase-NN` branch — so a `Some(0)` here would prove the split
7410    /// was made on "was the answer zero" rather than on "did the command run",
7411    /// which is the distinction the whole `Option` exists to carry.
7412    ///
7413    /// Deliberately NOT built on a `git` shim that runs and exits non-zero:
7414    /// that path returns `Ok(status)` from `.output()` and is a real
7415    /// observation, so it would exercise the case above while appearing to
7416    /// cover this one (F-1).
7417    ///
7418    /// **Why an unspawnable working directory rather than `NoGitPath` here —
7419    /// F-1b's recorded fallback, taken on measured evidence.** `NoGitPath`
7420    /// makes `git` unresolvable *process-wide*, and `devflow-core`'s tests
7421    /// shell out to `git` from eight modules that all compile into ONE
7422    /// parallel test binary. Installing it here failed 1-5 unrelated sibling
7423    /// tests per run, nondeterministically, depending on which of them
7424    /// happened to invoke `git` inside the guarded window. Serializing them
7425    /// would mean every present and future `git`-touching test in the crate
7426    /// opting into the same mutex — discipline, not structure, and silently
7427    /// reopened by the next test that forgets.
7428    ///
7429    /// `hermetic_command` sets `cmd.current_dir(dir)`, so a directory that
7430    /// does not exist makes the spawn itself fail and `.output()` return
7431    /// `Err` — the identical arm, reached with no environment mutation at all
7432    /// and therefore no effect on any other test. `phase_commit_count` cannot
7433    /// tell the two causes apart: it sees only `Err`.
7434    ///
7435    /// This route is also independent of the PATH-resolution property C5
7436    /// flags as a latent fragility of `NoGitPath` (a future refactor to an
7437    /// absolute `git` path would disarm that guard silently; it would not
7438    /// disarm this).
7439    #[test]
7440    fn phase_commit_count_reports_none_when_git_cannot_run() {
7441        let dir = tempfile::tempdir().unwrap();
7442        let unspawnable_root = dir.path().join("this-directory-does-not-exist");
7443        assert!(
7444            !unspawnable_root.exists(),
7445            "the fixture depends on this path being absent"
7446        );
7447
7448        let count = phase_commit_count(
7449            &unspawnable_root,
7450            &GitFlowConfig::default(),
7451            PhaseId::new(999),
7452        );
7453
7454        assert_eq!(
7455            count, None,
7456            "a git child that could not be executed is a measurement FAILURE and must \
7457             never be reported as a measured zero"
7458        );
7459    }
7460
7461    /// CR-01 (35-REVIEW), the `rev-list` half. The branch EXISTS, so the
7462    /// `rev-parse` step succeeds and the function reaches its second git call
7463    /// — but `develop` is absent from the checkout, so `A..B` is an invalid
7464    /// range and `rev-list` runs, exits non-zero, and writes nothing to
7465    /// stdout. That used to fall out of `.parse().ok()` as `None`, splitting
7466    /// on whether the output PARSED rather than on whether the command RAN,
7467    /// which contradicts this function's own A-06 rule and the `rev-parse`
7468    /// step directly above it.
7469    ///
7470    /// It is the *permanence* that makes this worth a test: unlike a fork
7471    /// failure, a misconfigured or absent `develop` does not clear on retry,
7472    /// so before the fix every stage of every phase in such a checkout
7473    /// measured as unmeasurable, forever.
7474    ///
7475    /// `phase_commit_count_reports_none_when_git_cannot_run` is the NC-4
7476    /// control — the one case that must still be `None`.
7477    #[test]
7478    fn phase_commit_count_reports_zero_when_the_range_is_invalid() {
7479        let dir = tempfile::tempdir().unwrap();
7480        git(dir.path(), &["init"]);
7481        git(dir.path(), &["config", "user.email", "devflow@example.com"]);
7482        git(dir.path(), &["config", "user.name", "DevFlow Tests"]);
7483        git(dir.path(), &["config", "commit.gpgsign", "false"]);
7484        git(dir.path(), &["config", "tag.gpgsign", "false"]);
7485        git(dir.path(), &["config", "core.hooksPath", "/dev/null"]);
7486        // The feature branch exists; `develop` deliberately does not.
7487        git(dir.path(), &["checkout", "-b", "feature/phase-999"]);
7488        std::fs::write(dir.path().join("README.md"), "base\n").unwrap();
7489        git(dir.path(), &["add", "README.md"]);
7490        git(dir.path(), &["commit", "-m", "base"]);
7491
7492        // The fixture is only meaningful if the second git call really is the
7493        // one that fails, so assert the first one would have succeeded.
7494        assert!(
7495            git_command(dir.path())
7496                .args(["rev-parse", "--verify", "feature/phase-999"])
7497                .output()
7498                .expect("git must be runnable for this fixture")
7499                .status
7500                .success(),
7501            "the branch must verify, or this test exercises the rev-parse arm instead"
7502        );
7503
7504        let count = phase_commit_count(dir.path(), &GitFlowConfig::default(), PhaseId::new(999));
7505
7506        assert_eq!(
7507            count,
7508            Some(0),
7509            "git RAN and reported the range unusable — a measurement, not a failure to \
7510             measure; `None` here is permanent for the whole checkout"
7511        );
7512    }
7513
7514    #[test]
7515    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
7516        let dir = tempfile::tempdir().unwrap();
7517        init_repo_with_feature_commit(dir.path(), PhaseId::new(4));
7518        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7519        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "0").unwrap();
7520        let state = state_in(dir.path(), PhaseId::new(4));
7521
7522        let result = evaluate_layer2(
7523            dir.path(),
7524            PhaseId::new(4),
7525            &GitFlowConfig::default(),
7526            state.stage,
7527        )
7528        .unwrap()
7529        .unwrap();
7530
7531        assert_eq!(result.status, AgentStatus::Success);
7532        assert_eq!(result.exit_code, Some(0));
7533        assert_eq!(result.commits, Some(1));
7534        assert!(result.reason.unwrap().contains("1 commits"));
7535    }
7536
7537    #[test]
7538    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
7539        // exit=0 but the feature branch has 0 commits ahead of develop →
7540        // "no work done" failure (the Layer 2 middle branch).
7541        let dir = tempfile::tempdir().unwrap();
7542        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(4));
7543        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7544        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "0").unwrap();
7545        let state = state_in(dir.path(), PhaseId::new(4));
7546
7547        let result = evaluate_layer2(
7548            dir.path(),
7549            PhaseId::new(4),
7550            &GitFlowConfig::default(),
7551            state.stage,
7552        )
7553        .unwrap()
7554        .unwrap();
7555
7556        assert_eq!(result.status, AgentStatus::Failed);
7557        assert_eq!(result.exit_code, Some(0));
7558        assert_eq!(result.commits, Some(0));
7559        assert!(result.reason.unwrap().contains("no commits"));
7560    }
7561
7562    // HARDEN-07 / criterion 6's two discriminating tests — the layer-level one
7563    // on `evaluate_layer2` and the cascade-level one on
7564    // `evaluate_agent_result` — do NOT live here. They need `git` to be
7565    // unresolvable while `project_root` still EXISTS (Layer 2 reads its exit
7566    // file from that root, so an unspawnable working directory would make the
7567    // exit read fail and return `Ok(None)` for the wrong reason), and only a
7568    // `PATH` guard delivers that combination.
7569    //
7570    // A process-global `PATH` guard is not viable in THIS test binary:
7571    // `devflow-core` shells out to `git` from eight modules that run in
7572    // parallel, and tests call production code that spawns `git` directly, so
7573    // no fixture-helper lock can cover them. Measured twice — 1-5 unrelated
7574    // failures per run before any serialization, and still 1 failure in 8 runs
7575    // after this module's own `git()` helper took the lock
7576    // (`evaluate_layer2_exit_zero_no_commits_is_failed`, whose `git` call
7577    // happens inside `evaluate_layer2` itself).
7578    //
7579    // Both tests therefore live in `devflow-cli`'s `pipeline_outcomes.rs`,
7580    // whose test binary routes every `PATH` mutation through one `ENV_MUTEX`
7581    // its `git`-touching tests already hold. They call these same `pub`
7582    // functions directly, so the assertion is unchanged — only the binary it
7583    // runs in differs. `evaluate_layer2_exit_zero_no_commits_is_failed` below
7584    // remains their NC-11 opposite-result control and is unedited.
7585
7586    #[test]
7587    fn evaluate_layer2_nonzero_exit_is_failed() {
7588        // Non-zero exit code → failure regardless of commit count.
7589        let dir = tempfile::tempdir().unwrap();
7590        init_repo_with_feature_commit(dir.path(), PhaseId::new(4));
7591        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7592        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "1").unwrap();
7593        let state = state_in(dir.path(), PhaseId::new(4));
7594
7595        let result = evaluate_layer2(
7596            dir.path(),
7597            PhaseId::new(4),
7598            &GitFlowConfig::default(),
7599            state.stage,
7600        )
7601        .unwrap()
7602        .unwrap();
7603
7604        assert_eq!(result.status, AgentStatus::Failed);
7605        assert_eq!(result.exit_code, Some(1));
7606        assert!(result.reason.unwrap().contains("exited with code 1"));
7607    }
7608
7609    #[test]
7610    fn layer2_nonzero_exit_is_failed_all_stages() {
7611        // Non-zero exit is Failed regardless of stage — including Define and
7612        // Validate, which are exempt from the zero-commit gate but NOT from
7613        // the exit-code check.
7614        let dir = tempfile::tempdir().unwrap();
7615        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(10));
7616        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7617        std::fs::write(exit_code_path(dir.path(), PhaseId::new(10)), "1").unwrap();
7618
7619        for stage in [
7620            Stage::Define,
7621            Stage::Plan,
7622            Stage::Code,
7623            Stage::Validate,
7624            Stage::Ship,
7625        ] {
7626            let result = evaluate_layer2(
7627                dir.path(),
7628                PhaseId::new(10),
7629                &GitFlowConfig::default(),
7630                stage,
7631            )
7632            .unwrap()
7633            .unwrap();
7634            assert_eq!(
7635                result.status,
7636                AgentStatus::Failed,
7637                "stage {stage:?} should be Failed on nonzero exit"
7638            );
7639        }
7640    }
7641
7642    #[test]
7643    fn layer2_skips_commit_gate_for_define_and_validate() {
7644        let dir = tempfile::tempdir().unwrap();
7645        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(11));
7646        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7647        std::fs::write(exit_code_path(dir.path(), PhaseId::new(11)), "0").unwrap();
7648
7649        for stage in [Stage::Define, Stage::Validate] {
7650            let result = evaluate_layer2(
7651                dir.path(),
7652                PhaseId::new(11),
7653                &GitFlowConfig::default(),
7654                stage,
7655            )
7656            .unwrap()
7657            .unwrap();
7658            assert_ne!(
7659                result.status,
7660                AgentStatus::Failed,
7661                "stage {stage:?} should not be Failed for zero commits"
7662            );
7663        }
7664
7665        // Code stage with the same zero-commit inputs is still Failed
7666        // (existing behavior preserved).
7667        let result = evaluate_layer2(
7668            dir.path(),
7669            PhaseId::new(11),
7670            &GitFlowConfig::default(),
7671            Stage::Code,
7672        )
7673        .unwrap()
7674        .unwrap();
7675        assert_eq!(result.status, AgentStatus::Failed);
7676    }
7677
7678    #[test]
7679    fn evaluate_layer3_falls_back_to_commit_count() {
7680        let dir = tempfile::tempdir().unwrap();
7681        init_repo_with_feature_commit(dir.path(), PhaseId::new(5));
7682
7683        let result =
7684            evaluate_layer3(dir.path(), PhaseId::new(5), &GitFlowConfig::default()).unwrap();
7685
7686        assert_eq!(result.status, AgentStatus::Unknown);
7687        assert_eq!(result.exit_code, None);
7688        assert_eq!(result.commits, Some(1));
7689        assert!(result.reason.unwrap().contains("1 commits"));
7690        assert_eq!(result.decided_by_layer, Some(3));
7691    }
7692
7693    /// D-02/D-03 case 3 (17-03): "process gone, nothing accounted for" — zero
7694    /// commits and no declared external post-condition — is a fail-closed
7695    /// `Failed` outcome that flags human review, not a blanket advanceable
7696    /// `Unknown`. The commits-present case above stays `Unknown` (gated
7697    /// downstream by Plan 04's never-advance dispatch, D-04) — only the
7698    /// zero-commit sub-case is reclassified here.
7699    #[test]
7700    fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
7701        let dir = tempfile::tempdir().unwrap();
7702        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(5));
7703
7704        let result =
7705            evaluate_layer3(dir.path(), PhaseId::new(5), &GitFlowConfig::default()).unwrap();
7706
7707        assert_eq!(result.status, AgentStatus::Failed);
7708        assert_eq!(result.exit_code, None);
7709        assert_eq!(result.commits, Some(0));
7710        assert_eq!(result.decided_by_layer, Some(3));
7711        let reason = result.reason.unwrap();
7712        assert!(reason.contains("no work"), "reason was: {reason}");
7713        assert!(
7714            reason.to_ascii_lowercase().contains("human review"),
7715            "reason was: {reason}"
7716        );
7717    }
7718
7719    /// F-4 (35-01) / HARDEN-07: Layer 3 used to carry its OWN inline commit
7720    /// count with the same lossy `.unwrap_or(0)` collapse `phase_commit_count`
7721    /// had, and classified the resulting zero as `Failed`. Since every path
7722    /// that reaches Layer 2 also reaches Layer 3, fixing only Layer 2 would
7723    /// have relocated the misclassification one layer down rather than
7724    /// removing it.
7725    ///
7726    /// A count that could not be measured is not evidence of absent work. It
7727    /// is strictly less certain than the `commits > 0` case Layer 3 already
7728    /// calls `Unknown`, so `Unknown` is the consistent classification and
7729    /// `Failed` — which asserts a negative — is not.
7730    ///
7731    /// The two tests directly above are this one's required opposite-result
7732    /// controls, and they run in the same suite with their bodies unedited: a
7733    /// branch with one commit still gives `Unknown`/`Some(1)`, and a branch
7734    /// with no commits still gives `Failed`/`Some(0)`. Without them, an
7735    /// implementation that returned `Unknown` unconditionally would pass this
7736    /// test.
7737    ///
7738    /// **No assertion here touches `Action` or anything downstream of
7739    /// `outcome_policy::decide_action` (F-5).** `Failed` and `Unknown` map
7740    /// identically to `Action::GateReview` today, so a dispatch-level
7741    /// assertion would pass against the buggy code too. The observable
7742    /// difference is entirely in the `AgentResult`.
7743    ///
7744    /// Uses the same unspawnable-working-directory route as
7745    /// `phase_commit_count_reports_none_when_git_cannot_run`, for the reason
7746    /// recorded there (F-1b): a process-wide `PATH` guard broke unrelated
7747    /// sibling tests in this crate nondeterministically, and this route
7748    /// reaches the identical `Err` arm with no environment mutation.
7749    #[test]
7750    fn evaluate_layer3_unmeasurable_count_is_unknown_not_failed() {
7751        let dir = tempfile::tempdir().unwrap();
7752        let unspawnable_root = dir.path().join("this-directory-does-not-exist");
7753        assert!(
7754            !unspawnable_root.exists(),
7755            "the fixture depends on this path being absent"
7756        );
7757
7758        let result = evaluate_layer3(
7759            &unspawnable_root,
7760            PhaseId::new(5),
7761            &GitFlowConfig::default(),
7762        )
7763        .unwrap();
7764
7765        assert_ne!(
7766            result.status,
7767            AgentStatus::Failed,
7768            "an unmeasurable commit count must never be classified as absent work — \
7769             this is the outcome criterion 6 exists to remove"
7770        );
7771        assert_eq!(
7772            result.status,
7773            AgentStatus::Unknown,
7774            "asserted positively as well as negatively, so a future change to some \
7775             other non-Failed value still has to confront this test"
7776        );
7777        assert_eq!(
7778            result.commits, None,
7779            "the commit figure must be absent, not a forged Some(0) — the difference \
7780             between 'no work' and 'could not tell' is the whole point"
7781        );
7782        assert_eq!(result.decided_by_layer, Some(3));
7783        let reason = result.reason.unwrap();
7784        assert!(
7785            reason.contains("could not be measured"),
7786            "the reason must name the measurement failure rather than absent work, \
7787             reason was: {reason}"
7788        );
7789    }
7790
7791    #[test]
7792    fn parse_devflow_result_reads_verdict() {
7793        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
7794        let result = parse_devflow_result(stdout).unwrap();
7795        assert_eq!(result.status, AgentStatus::Success);
7796        assert_eq!(result.verdict, Some(Verdict::Gaps));
7797    }
7798
7799    #[test]
7800    fn parse_devflow_result_reads_verdict_pass() {
7801        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
7802        let result = parse_devflow_result(stdout).unwrap();
7803        assert_eq!(result.status, AgentStatus::Success);
7804        assert_eq!(result.verdict, Some(Verdict::Pass));
7805    }
7806
7807    #[test]
7808    fn parse_devflow_result_verdict_absent_is_none() {
7809        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
7810        let result = parse_devflow_result(stdout).unwrap();
7811        assert_eq!(result.status, AgentStatus::Success);
7812        assert_eq!(result.verdict, None);
7813    }
7814
7815    #[test]
7816    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
7817        // An unknown verdict string must not fail the whole marker parse —
7818        // status must still come through as Success with verdict None (T-13-14).
7819        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
7820        let result = parse_devflow_result(unknown).unwrap();
7821        assert_eq!(result.status, AgentStatus::Success);
7822        assert_eq!(result.verdict, None);
7823
7824        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
7825        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
7826        let result = parse_devflow_result(miscased).unwrap();
7827        assert_eq!(result.status, AgentStatus::Success);
7828        assert_eq!(result.verdict, None);
7829    }
7830
7831    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
7832    /// JSON *type* (bool, number, object) must be just as lenient as a
7833    /// malformed string value — before the fix, deserializing straight to
7834    /// `Option<String>` errored out the entire `AgentResult` parse for a
7835    /// type mismatch, defeating the doc comment's "a malformed verdict must
7836    /// never silently drop a valid status" guarantee for this specific case.
7837    #[test]
7838    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
7839        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
7840        let result = parse_devflow_result(bool_verdict).unwrap();
7841        assert_eq!(result.status, AgentStatus::Success);
7842        assert_eq!(result.verdict, None);
7843
7844        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
7845        let result = parse_devflow_result(numeric_verdict).unwrap();
7846        assert_eq!(result.status, AgentStatus::Success);
7847        assert_eq!(result.verdict, None);
7848
7849        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
7850        let result = parse_devflow_result(object_verdict).unwrap();
7851        assert_eq!(result.status, AgentStatus::Success);
7852        assert_eq!(result.verdict, None);
7853    }
7854
7855    /// D-07 (17-01): the two new multi-word variants must serialize with
7856    /// their word boundary preserved — `#[serde(rename_all = "lowercase")]`
7857    /// alone would collapse `ResourceKilled` to `"resourcekilled"` (Pitfall 1).
7858    #[test]
7859    fn multi_word_variants_serialize_with_word_boundary() {
7860        assert_eq!(
7861            serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
7862            "\"resource_killed\""
7863        );
7864        assert_eq!(
7865            serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
7866            "\"agent_unavailable\""
7867        );
7868        assert_eq!(
7869            serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
7870            AgentStatus::ResourceKilled
7871        );
7872        assert_eq!(
7873            serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
7874            AgentStatus::AgentUnavailable
7875        );
7876    }
7877
7878    /// Existing variants must keep their pre-existing lowercase wire form
7879    /// unchanged by the two new variants' additions.
7880    #[test]
7881    fn existing_variants_keep_wire_form() {
7882        assert_eq!(
7883            serde_json::to_string(&AgentStatus::Success).unwrap(),
7884            "\"success\""
7885        );
7886        assert_eq!(
7887            serde_json::to_string(&AgentStatus::Failed).unwrap(),
7888            "\"failed\""
7889        );
7890        assert_eq!(
7891            serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
7892            "\"ratelimited\""
7893        );
7894        assert_eq!(
7895            serde_json::to_string(&AgentStatus::Unknown).unwrap(),
7896            "\"unknown\""
7897        );
7898    }
7899
7900    /// review consensus #1: `as_wire_str()` must never diverge from the serde
7901    /// form for ANY variant — pin it for all eight via a single round-trip
7902    /// assertion (quotes stripped).
7903    ///
7904    /// 31-02: `IdleTimeout` is enumerated here explicitly rather than left to
7905    /// the compiler. `as_wire_str`'s wildcard-free match makes a MISSING arm a
7906    /// compile error, but it cannot catch a WRONG one — an arm returning
7907    /// `"idletimeout"` compiles happily and diverges from the serde form the
7908    /// `#[serde(rename)]` produces. Only enumerating the variant here pins that.
7909    #[test]
7910    fn as_wire_str_matches_serde_form_for_every_variant() {
7911        for variant in [
7912            AgentStatus::Success,
7913            AgentStatus::Failed,
7914            AgentStatus::RateLimited,
7915            AgentStatus::Unknown,
7916            AgentStatus::ResourceKilled,
7917            AgentStatus::AgentUnavailable,
7918            AgentStatus::IdleTimeout,
7919            AgentStatus::Ambiguous,
7920        ] {
7921            let serde_form = serde_json::to_string(&variant).unwrap();
7922            let stripped = serde_form.trim_matches('"');
7923            assert_eq!(
7924                variant.as_wire_str(),
7925                stripped,
7926                "as_wire_str() diverged from serde form for {variant:?}"
7927            );
7928        }
7929    }
7930
7931    #[test]
7932    fn evaluate_layer2_exit_137_is_resource_killed() {
7933        let dir = tempfile::tempdir().unwrap();
7934        init_repo_with_feature_commit(dir.path(), PhaseId::new(20));
7935        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7936        std::fs::write(exit_code_path(dir.path(), PhaseId::new(20)), "137").unwrap();
7937        let state = state_in(dir.path(), PhaseId::new(20));
7938
7939        let result = evaluate_layer2(
7940            dir.path(),
7941            PhaseId::new(20),
7942            &GitFlowConfig::default(),
7943            state.stage,
7944        )
7945        .unwrap()
7946        .unwrap();
7947
7948        assert_eq!(result.status, AgentStatus::ResourceKilled);
7949        assert_eq!(result.exit_code, Some(137));
7950    }
7951
7952    #[test]
7953    fn evaluate_layer2_exit_127_is_agent_unavailable() {
7954        let dir = tempfile::tempdir().unwrap();
7955        init_repo_with_feature_commit(dir.path(), PhaseId::new(21));
7956        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
7957        std::fs::write(exit_code_path(dir.path(), PhaseId::new(21)), "127").unwrap();
7958        let state = state_in(dir.path(), PhaseId::new(21));
7959
7960        let result = evaluate_layer2(
7961            dir.path(),
7962            PhaseId::new(21),
7963            &GitFlowConfig::default(),
7964            state.stage,
7965        )
7966        .unwrap()
7967        .unwrap();
7968
7969        assert_eq!(result.status, AgentStatus::AgentUnavailable);
7970        assert_eq!(result.exit_code, Some(127));
7971    }
7972
7973    // -----------------------------------------------------------------
7974    // 27-03 (D-01/D-03): branch-exists + commit-count evidence resolves
7975    // the caller's own repository under a hostile GIT_DIR, not an
7976    // unrelated one.
7977    // -----------------------------------------------------------------
7978
7979    /// D-03/T-27-08: `evaluate_layer2`'s branch-exists and commit-count
7980    /// evidence (the two production sites at what were base-commit lines
7981    /// 574/583) resolves `project_root`'s own repository even when the
7982    /// process inherited a hostile `GIT_DIR` pointed at an unrelated
7983    /// repository — proven with a real spawned `git` process, not by
7984    /// inspecting a `Command` object alone. Mirrors
7985    /// `version::tests::tag_reads_resolve_caller_root_under_a_hostile_git_dir`
7986    /// (27-03) and `origin_main_ancestor_status_holds_under_a_hostile_git_dir`
7987    /// (`git.rs`, 27-01): the hostile `GIT_DIR` this test's own `<verify>`
7988    /// entries exercise (`GIT_DIR=<hostile>/.git cargo test ... this test`)
7989    /// is injected the same way any inherited-env attack reaches
7990    /// `evaluate_layer2` in production — via the whole process's
7991    /// environment, then down into the spawned child unless the
7992    /// constructor scrubs it.
7993    ///
7994    /// Deliberately tests the mirror direction from the plan's literal
7995    /// framing (real repo HAS the feature branch with a real commit;
7996    /// the standard hostile-`GIT_DIR` harness's throwaway repository does
7997    /// NOT), because the standard harness (`git init -q "$HOSTILE"`, no
7998    /// `feature/phase-NN` branch) cannot itself manufacture a false
7999    /// *positive* — an empty repository has no branch to spuriously
8000    /// report as present. It can, however, still prove the scrub's
8001    /// necessity by manufacturing a false *negative*: before this plan's
8002    /// migration, the two unmigrated `Command::new("git")` sites inherit
8003    /// the poisoned `GIT_DIR` and silently read the hostile repository
8004    /// instead of `project_root` — `rev-parse --verify` reports the real
8005    /// branch absent, the commit count is undercounted to zero, and a
8006    /// real agent's completed work is wrongly classified `Failed`. This
8007    /// is the same trust-boundary violation T-27-08 names (a foreign
8008    /// repository's state substituting for the real one), reached from
8009    /// the opposite direction; the scrub this plan adds removes `GIT_DIR`'s
8010    /// ability to redirect the spawned child at all, closing both
8011    /// directions identically.
8012    /// 27-REVIEW WR-01: this test previously set no hostile environment at
8013    /// all — it asserted ordinary-path behavior and claimed a hostile-
8014    /// `GIT_DIR` proof, so it passed identically with or without the scrub
8015    /// and could never have caught a regression back to a bare
8016    /// `Command::new("git")`. It now uses the spawned-child shape this
8017    /// phase established in `staleness.rs`
8018    /// (`embedded_commit_is_stale_resolves_execution_root_under_a_hostile_git_dir`):
8019    /// `GIT_DIR` is never set on this process (Rust 2024 `unsafe`, unsound
8020    /// under threaded tests — Phase 25 D-14), only on one freshly spawned
8021    /// child that re-invokes this same binary filtered to this one test.
8022    #[test]
8023    fn branch_evidence_resolves_caller_root_under_a_hostile_git_dir() {
8024        const INNER_ROOT: &str = "DEVFLOW_27_03_BRANCH_EVIDENCE_INNER_ROOT";
8025
8026        if let Ok(root) = std::env::var(INNER_ROOT) {
8027            // Inner mode: spawned by the outer half below with GIT_DIR
8028            // pointed at an unrelated foreign repository, scoped to this
8029            // child process only.
8030            let root = std::path::PathBuf::from(root);
8031            let phase = PhaseId::new(27);
8032            let state = state_in(&root, phase);
8033
8034            let result = evaluate_layer2(&root, phase, &GitFlowConfig::default(), state.stage)
8035                .unwrap()
8036                .unwrap();
8037
8038            assert_eq!(
8039                result.status,
8040                AgentStatus::Success,
8041                "evaluate_layer2 must see project_root's own branch/commits, \
8042                 not a hostile GIT_DIR's repository: {result:?}"
8043            );
8044            assert_eq!(result.commits, Some(1));
8045            return;
8046        }
8047
8048        // Outer mode: build the real repository (which HAS the feature
8049        // branch and its commit) plus a second, unrelated foreign
8050        // repository that has neither. Unscrubbed, the child would read the
8051        // foreign repo, find no branch, count zero commits, and misreport a
8052        // real agent's completed work as Failed.
8053        let dir = tempfile::tempdir().unwrap();
8054        let phase = PhaseId::new(27);
8055        init_repo_with_feature_commit(dir.path(), phase);
8056        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
8057        std::fs::write(exit_code_path(dir.path(), phase), "0").unwrap();
8058
8059        let foreign = tempfile::tempdir().unwrap();
8060        git(foreign.path(), &["init", "-q"]);
8061
8062        let exe = std::env::current_exe().expect("current_exe for child re-invocation");
8063        let out = std::process::Command::new(&exe)
8064            // Substring filter, NOT `--exact`: the binary's real test name is
8065            // module-qualified (`agent_result::tests::branch_evidence_...`),
8066            // so `--exact` against the bare name matches nothing, runs zero
8067            // tests, and still exits 0 — a false green.
8068            .arg("branch_evidence_resolves_caller_root_under_a_hostile_git_dir")
8069            .arg("--test-threads=1")
8070            .env(INNER_ROOT, dir.path().to_str().unwrap())
8071            .env("GIT_DIR", foreign.path().join(".git"))
8072            .output()
8073            .expect("spawn hostile child test process");
8074
8075        let stdout = String::from_utf8_lossy(&out.stdout);
8076        // Assert the child actually RAN the test, not merely that it exited
8077        // 0. A filter matching nothing exits 0 with "0 passed".
8078        assert!(
8079            stdout.contains("1 passed"),
8080            "child test process must have run exactly the inner test; \
8081             stdout:\n{stdout}"
8082        );
8083        assert!(
8084            out.status.success(),
8085            "child test process (hostile GIT_DIR pointed at an unrelated \
8086             foreign repository) must still resolve project_root's own \
8087             branch and commits; child exit status {:?}\nstdout:\n{stdout}",
8088            out.status
8089        );
8090    }
8091
8092    /// D-01 (33-CONTEXT.md): `phase_verification_exists` is the sole signal
8093    /// a Validate→Code loop-back consults to tell a mid-arc phase apart from
8094    /// a genuinely gap-flagged one. Covers all three states: no
8095    /// `.planning/phases` directory at all, a phase directory with no
8096    /// verification artifact, and a phase directory that has one — mirroring
8097    /// `phase_review_path`'s directory-prefix-scan idiom.
8098    #[test]
8099    fn phase_verification_exists_finds_the_artifact_by_prefix() {
8100        let dir = tempfile::tempdir().unwrap();
8101        let root = dir.path();
8102
8103        assert!(
8104            !phase_verification_exists(root, PhaseId::new(82)),
8105            "no .planning/phases directory at all must return false, not panic"
8106        );
8107
8108        let phase_dir = root.join(".planning/phases/82-loop-back-fix");
8109        std::fs::create_dir_all(&phase_dir).unwrap();
8110        assert!(
8111            !phase_verification_exists(root, PhaseId::new(82)),
8112            "a phase directory with no {{N}}-VERIFICATION.md must return false"
8113        );
8114
8115        std::fs::write(phase_dir.join("82-VERIFICATION.md"), "verified\n").unwrap();
8116        assert!(
8117            phase_verification_exists(root, PhaseId::new(82)),
8118            "a phase directory holding {{N}}-VERIFICATION.md must return true"
8119        );
8120    }
8121
8122    /// 999.79 (35-05): the fingerprint must be a function of the artifact's
8123    /// BYTES, not of its existence.
8124    ///
8125    /// Both halves are required and neither is redundant. The first half
8126    /// (different bytes → different values) is satisfied by any hash. The
8127    /// second half (identical bytes → identical values) is what rules out a
8128    /// value derived from something incidental — a timestamp, an inode, a
8129    /// counter — which would make every check read "changed" and permanently
8130    /// disable the gaps-only path. A constant-returning implementation fails
8131    /// the first half; a nondeterministic one fails the second.
8132    #[test]
8133    fn phase_verification_fingerprint_differs_when_content_differs() {
8134        let dir = tempfile::tempdir().unwrap();
8135        let root = dir.path();
8136        let phase_dir = root.join(".planning/phases/84-fingerprint");
8137        std::fs::create_dir_all(&phase_dir).unwrap();
8138        let artifact = phase_dir.join("84-VERIFICATION.md");
8139
8140        std::fs::write(&artifact, "verdict: gaps\n").unwrap();
8141        let first = phase_verification_fingerprint(root, PhaseId::new(84))
8142            .expect("an artifact that exists must produce a fingerprint");
8143
8144        let first_again = phase_verification_fingerprint(root, PhaseId::new(84))
8145            .expect("an artifact that exists must produce a fingerprint");
8146        assert_eq!(
8147            first, first_again,
8148            "identical bytes must produce identical fingerprints — a value that changes on \
8149             its own would mark every artifact fresh forever and disable the stale check"
8150        );
8151
8152        std::fs::write(&artifact, "verdict: pass\n").unwrap();
8153        let second = phase_verification_fingerprint(root, PhaseId::new(84))
8154            .expect("an artifact that exists must produce a fingerprint");
8155        assert_ne!(
8156            first, second,
8157            "different bytes must produce different fingerprints — a constant implementation \
8158             would report every re-authored artifact as unchanged"
8159        );
8160    }
8161
8162    /// 999.79 (35-05): an absent artifact yields no fingerprint, which is
8163    /// distinguishable from an artifact whose content happens to hash to zero.
8164    /// Both are asserted here so "absent" can never be conflated with "hashed
8165    /// to the zero value".
8166    #[test]
8167    fn phase_verification_fingerprint_is_none_when_the_artifact_is_absent() {
8168        let dir = tempfile::tempdir().unwrap();
8169        let root = dir.path();
8170
8171        assert_eq!(
8172            phase_verification_fingerprint(root, PhaseId::new(85)),
8173            None,
8174            "no .planning/phases directory at all must yield None, not panic"
8175        );
8176
8177        let phase_dir = root.join(".planning/phases/85-fingerprint");
8178        std::fs::create_dir_all(&phase_dir).unwrap();
8179        assert_eq!(
8180            phase_verification_fingerprint(root, PhaseId::new(85)),
8181            None,
8182            "a phase directory with no {{N}}-VERIFICATION.md must yield None"
8183        );
8184
8185        std::fs::write(phase_dir.join("85-VERIFICATION.md"), "").unwrap();
8186        let empty = phase_verification_fingerprint(root, PhaseId::new(85));
8187        assert!(
8188            empty.is_some(),
8189            "an EMPTY artifact still exists and must yield Some — the control against an \
8190             implementation that conflates 'absent' with 'no bytes'"
8191        );
8192    }
8193    // ------------------------------------------------------------------
8194    // Antigravity `stream-json` parser (phase 41, Task 1)
8195    // ------------------------------------------------------------------
8196    //
8197    // Fixtures mirror the LIVE stream shapes from the round-2 review evidence
8198    // (antigravity-cli 1.1.16, .planning/reviews/phase-41/review-2/): the CLI
8199    // emits one JSON object per line under an `event` key — `init` opens the
8200    // stream, `step_update` carries progress deltas, and `result` is the
8201    // terminal object whose `result.response` STRING holds the agent's final
8202    // message (`result` is an OBJECT, unlike Claude's string `result` field).
8203    // Marker payloads are synthetic (no archived capture contains a real
8204    // DEVFLOW_RESULT marker); the envelope shapes are the observed ones.
8205
8206    const ANTG_INIT: &str = r#"{"event":"init","model":"gemini-3.7-flash-high","inputFormat":"stream-json","outputFormat":"stream-json","printTimeout":"60m"}"#;
8207    const ANTG_STEP: &str = r#"{"event":"step_update","index":0,"text_delta":"..."}"#;
8208    const ANTG_RESULT_MARKER: &str = r#"{"event":"result","result":{"status":"SUCCESS","response":"DEVFLOW_RESULT: {\"status\":\"success\"}\n"}}"#;
8209    const ANTG_RESULT_FAILED_MARKER: &str = r#"{"event":"result","result":{"status":"SUCCESS","response":"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent refused\"}\n"}}"#;
8210    const ANTG_RESULT_MARKER_LESS: &str =
8211        r#"{"event":"result","result":{"status":"SUCCESS","response":"all done, no marker here"}}"#;
8212    const ANTG_RESULT_ERROR: &str = r#"{"event":"result","result":{"status":"ERROR","response":"","error":"stream input message is missing the \"event\" field"}}"#;
8213    // A2 (41-antigravity UAT): the live CLI can emit `status:"ERROR"` with a
8214    // transport-cancel `error` even when the agent's own final `response`
8215    // still carries a success marker (client-side teardown race). These are
8216    // the observed shapes.
8217    const ANTG_RESULT_CANCEL_WITH_MARKER: &str = r#"{"event":"result","result":{"status":"ERROR","response":"DEVFLOW_RESULT: {\"status\":\"success\"}\n","error":"context canceled"}}"#;
8218    const ANTG_RESULT_DEADLINE_WITH_MARKER: &str = r#"{"event":"result","result":{"status":"ERROR","response":"DEVFLOW_RESULT: {\"status\":\"success\"}\n","error":"context deadline exceeded"}}"#;
8219    const ANTG_RESULT_CANCEL_NO_MARKER: &str = r#"{"event":"result","result":{"status":"ERROR","response":"","error":"context canceled"}}"#;
8220    const ANTG_RESULT_CANCEL_WITH_FAILED_MARKER: &str = r#"{"event":"result","result":{"status":"ERROR","response":"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent refused\"}\n","error":"context canceled"}}"#;
8221
8222    #[test]
8223    fn antigravity_event_stream_detects_init_only() {
8224        let init = serde_json::from_str(ANTG_INIT).unwrap();
8225        assert!(
8226            is_antigravity_event_stream(&[init]),
8227            "event-key init opens an antigravity stream"
8228        );
8229        // Claude framing (type/subtype) and Codex framing (type thread.*) must
8230        // NOT satisfy the antigravity gate — disjoint key namespaces (D-03).
8231        let claude = serde_json::json!({"type": "system", "subtype": "init", "session_id": "s1"});
8232        let codex = serde_json::json!({"type": "thread.started", "thread_id": "t1"});
8233        assert!(
8234            !is_antigravity_event_stream(&[claude, codex]),
8235            "claude/codex shapes must not satisfy the antigravity gate"
8236        );
8237        assert!(
8238            !is_antigravity_event_stream(&[]),
8239            "no events is not a stream"
8240        );
8241        // init mid-stream still counts (the gate is existence, not position).
8242        let mid = serde_json::json!({"event": "step_update", "index": 0});
8243        let late_init = serde_json::from_str(ANTG_INIT).unwrap();
8244        assert!(is_antigravity_event_stream(&[mid, late_init]));
8245    }
8246
8247    #[test]
8248    fn antigravity_event_result_extracts_marker_from_live_shape() {
8249        let capture = format!("{ANTG_INIT}\n{ANTG_STEP}\n{ANTG_RESULT_MARKER}\n");
8250        let got = parse_antigravity_event_result(&capture)
8251            .expect("a marker inside result.response must resolve at Layer 1");
8252        assert_eq!(got.status, AgentStatus::Success);
8253        assert_eq!(
8254            got.decided_by_layer,
8255            Some(1),
8256            "marker provenance must be forced to Layer 1, never agent-supplied"
8257        );
8258
8259        // LAST result wins, mirroring the Claude path.
8260        let capture = format!("{ANTG_INIT}\n{ANTG_RESULT_MARKER}\n{ANTG_RESULT_FAILED_MARKER}\n");
8261        let got = parse_antigravity_event_result(&capture).expect("last result's marker must win");
8262        assert_eq!(got.status, AgentStatus::Failed);
8263        assert_eq!(got.reason.as_deref(), Some("agent refused"));
8264    }
8265
8266    #[test]
8267    fn antigravity_event_result_marker_less_defers() {
8268        let capture = format!("{ANTG_INIT}\n{ANTG_STEP}\n{ANTG_RESULT_MARKER_LESS}\n");
8269        assert!(
8270            parse_antigravity_event_result(&capture).is_none(),
8271            "a marker-less final result must defer to Layer 2, never fabricate Success (ANTG-03)"
8272        );
8273    }
8274
8275    #[test]
8276    fn antigravity_event_result_error_envelope_survives_layer1() {
8277        let capture = format!("{ANTG_INIT}\n{ANTG_RESULT_ERROR}\n");
8278        let got = parse_antigravity_event_result(&capture)
8279            .expect("the CLI's ERROR envelope must be decisive at Layer 1 (notice (c))");
8280        assert_eq!(got.status, AgentStatus::Failed);
8281        assert_eq!(
8282            got.reason.as_deref(),
8283            Some("stream input message is missing the \"event\" field"),
8284            "the CLI's explicit reason must survive, not be replaced by Layer 2's exit-code heuristic"
8285        );
8286        assert_eq!(got.decided_by_layer, Some(1));
8287    }
8288
8289    #[test]
8290    fn antigravity_transport_cancel_with_success_marker_is_ambiguous() {
8291        // A2 (41-antigravity UAT): the CLI tore the envelope with a transport
8292        // cancel, but the SAME envelope's response carries a success marker.
8293        // Ambiguous (re-driven), never Success and never a plain Failed gate.
8294        for shape in [
8295            ANTG_RESULT_CANCEL_WITH_MARKER,
8296            ANTG_RESULT_DEADLINE_WITH_MARKER,
8297        ] {
8298            let capture = format!("{ANTG_INIT}\n{shape}\n");
8299            let got = parse_antigravity_event_result(&capture)
8300                .expect("transport-cancel envelope must resolve at Layer 1");
8301            assert_eq!(got.status, AgentStatus::Ambiguous, "shape: {shape}");
8302            assert_eq!(got.decided_by_layer, Some(1));
8303        }
8304    }
8305
8306    #[test]
8307    fn antigravity_transport_cancel_without_marker_is_failed() {
8308        // Transport-cancel WITHOUT a success marker -> plain Failed (unchanged).
8309        let capture = format!("{ANTG_INIT}\n{ANTG_RESULT_CANCEL_NO_MARKER}\n");
8310        let got = parse_antigravity_event_result(&capture)
8311            .expect("transport-cancel without a marker must be a plain Failed");
8312        assert_eq!(got.status, AgentStatus::Failed);
8313        assert_eq!(got.reason.as_deref(), Some("context canceled"));
8314    }
8315
8316    #[test]
8317    fn antigravity_transport_cancel_with_failed_marker_is_failed() {
8318        // A transport cancel whose response carries a FAILED (not success)
8319        // marker is still Failed — only a SUCCESS marker is ambiguous.
8320        let capture = format!("{ANTG_INIT}\n{ANTG_RESULT_CANCEL_WITH_FAILED_MARKER}\n");
8321        let got = parse_antigravity_event_result(&capture)
8322            .expect("transport-cancel + failed marker must be Failed");
8323        assert_eq!(got.status, AgentStatus::Failed);
8324        assert_eq!(got.reason.as_deref(), Some("context canceled"));
8325    }
8326
8327    #[test]
8328    fn antigravity_real_error_envelope_still_failed() {
8329        // A NON-transport-cancel error stays Failed, unchanged by A2.
8330        let capture = format!("{ANTG_INIT}\n{ANTG_RESULT_ERROR}\n");
8331        let got = parse_antigravity_event_result(&capture)
8332            .expect("a real ERROR envelope must be decisive Failed");
8333        assert_eq!(got.status, AgentStatus::Failed);
8334        assert_eq!(
8335            got.reason.as_deref(),
8336            Some("stream input message is missing the \"event\" field")
8337        );
8338    }
8339
8340    #[test]
8341    fn antigravity_event_marker_close_predicate_discriminates() {
8342        let marker_event = serde_json::from_str(ANTG_RESULT_MARKER).unwrap();
8343        assert!(
8344            event_is_top_level_antigravity_result_marker(&marker_event),
8345            "event:result with a marker in result.response must close the antigravity stream (B1)"
8346        );
8347
8348        // Marker-less antigravity result — the transport ran but no marker
8349        // arrived: NOT a close (the capture must be read and evaluated).
8350        let marker_less = serde_json::from_str(ANTG_RESULT_MARKER_LESS).unwrap();
8351        assert!(!event_is_top_level_antigravity_result_marker(&marker_less));
8352
8353        // Claude-shaped result — disjoint schema, never matches the antigravity
8354        // predicate; the Claude predicate is unchanged (the inverse holds).
8355        let claude_result = serde_json::json!({
8356            "type": "result",
8357            "subtype": "success",
8358            "result": "DEVFLOW_RESULT: {\"status\":\"success\"}"
8359        });
8360        assert!(!event_is_top_level_antigravity_result_marker(
8361            &claude_result
8362        ));
8363        assert!(
8364            event_is_top_level_result_marker(&claude_result),
8365            "the Claude close predicate must be untouched by the antigravity work"
8366        );
8367        assert!(
8368            !event_is_top_level_result_marker(&marker_event),
8369            "an antigravity-shaped event must not satisfy the Claude close predicate"
8370        );
8371    }
8372
8373    #[test]
8374    fn antigravity_event_parser_rejects_foreign_shapes() {
8375        // Claude stream capture fed to the antigravity parser -> None.
8376        let claude_capture = concat!(
8377            "{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}\n",
8378            "{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}\n",
8379        );
8380        assert!(
8381            parse_antigravity_event_result(claude_capture).is_none(),
8382            "Claude framing must not be consumed by the antigravity parser"
8383        );
8384        // Antigravity capture fed to the Claude parser -> None (inverse).
8385        let antg_capture = format!("{ANTG_INIT}\n{ANTG_RESULT_MARKER}\n");
8386        assert!(
8387            parse_claude_event_result(&antg_capture).is_none(),
8388            "Antigravity framing must not be consumed by the Claude parser"
8389        );
8390    }
8391
8392    #[test]
8393    fn antigravity_event_torn_tail_fails_closed() {
8394        let capture = format!("{ANTG_INIT}\n{ANTG_RESULT_MARKER}\n{{\"event\":\"result\"");
8395        let got = parse_antigravity_event_result(&capture).expect(
8396            "a torn tail after the last result must fail closed, not trust the intact prefix",
8397        );
8398        assert_eq!(got.status, AgentStatus::Failed);
8399        assert_eq!(got.decided_by_layer, Some(1));
8400    }
8401}