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