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