Skip to main content

magi/
report.rs

1//! Terminal rendering.
2//!
3//! A run produces a lot of state; the report exists so the operator can decide
4//! what to do next without opening `run.json`. It leads with the disagreement,
5//! because that is the part that carries information: three judges agreeing
6//! tells you nothing the winner's diff does not.
7//!
8//! Colour is a six-line local implementation rather than a crate. The
9//! alternatives all decide *for* you whether the stream supports colour, which
10//! makes the output untestable — `assert!(text.contains("winner  A"))` fails on
11//! an escape sequence the test never asked for.
12use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::{MergeMode, MergeStyle};
16use crate::run::{
17    CommandOutcome, ContinuationOutcome, E2eStatus, GateStatus, JobStatus, Liveness,
18    OperatorFixOutcome, RunState, RunStatus, tail,
19};
20use crate::stats::{RepoStats, Stats};
21use crate::verdict::ReviewVote;
22
23static COLOR: AtomicBool = AtomicBool::new(true);
24
25/// Turn colour on or off for every subsequent render.
26pub fn set_color(on: bool) {
27    COLOR.store(on, Ordering::Relaxed);
28}
29
30fn paint(text: &str, code: &str) -> String {
31    if COLOR.load(Ordering::Relaxed) {
32        format!("\x1b[{code}m{text}\x1b[0m")
33    } else {
34        text.to_owned()
35    }
36}
37
38fn bold(t: &str) -> String {
39    paint(t, "1")
40}
41fn dim(t: &str) -> String {
42    paint(t, "2")
43}
44fn red(t: &str) -> String {
45    paint(t, "31")
46}
47fn green(t: &str) -> String {
48    paint(t, "32")
49}
50fn yellow(t: &str) -> String {
51    paint(t, "33")
52}
53fn cyan(t: &str) -> String {
54    paint(t, "36")
55}
56
57/// Colour for a status word.
58///
59/// `Stalled` is deliberately not green: a run whose judges were taken out by a
60/// rate limit must not look like a healthy `Ready` in a one-line listing.
61///
62/// A `Ready` reached via `[merge] mode = "none"` is a second case that must
63/// not look like a plain `Ready`: that run is done for good, never picked up
64/// by the PR-polling merge watcher or anything else, while an ordinary
65/// `Ready` (a PR closed without merging, an already-concluded re-entry) may
66/// still be a live landing candidate. See [`RunState::unmerged_by_design`].
67fn status_word(state: &RunState) -> String {
68    if state.unmerged_by_design() {
69        return cyan("unmerged (no-op by design)");
70    }
71    let text = state.status.display_label();
72    if state.needs_attention() {
73        return bold(&yellow(&format!("{text} - release needs a human")));
74    }
75    match state.status {
76        RunStatus::Merged => bold(&green(text)),
77        RunStatus::Ready => green(text),
78        RunStatus::Stalled => bold(&yellow(text)),
79        RunStatus::Blocked => yellow(text),
80        RunStatus::Failed => red(text),
81        // Not `Failed`'s red: every candidate agreed, with evidence, that
82        // nothing belongs in this worktree — the opposite of a run that
83        // could not do the work. See `RunStatus::VerifiedNoop`'s own doc.
84        RunStatus::VerifiedNoop => cyan(text),
85        // Muted, not `Blocked`'s yellow: nothing here needs a human's
86        // attention any more, a later attempt already settled the task.
87        RunStatus::Superseded => dim(text),
88        // Neither green nor red: this run landed nothing, and nothing is
89        // wrong - the change reached the base by another route.
90        RunStatus::AlreadyInBase => dim(text),
91        _ => cyan(text),
92    }
93}
94
95/// Colour for a reviewer vote — the same scale a finding's severity gets:
96/// green for no reservations, yellow for proceed-but-look-at-this, red for a
97/// vote that says stop.
98fn vote_tag(vote: ReviewVote) -> String {
99    let text = vote.label();
100    match vote {
101        ReviewVote::Approve => green(text),
102        ReviewVote::ApproveWithFindings => yellow(text),
103        ReviewVote::Reject => red(text),
104    }
105}
106
107/// One-line summary, for `magi list`.
108pub fn line(state: &RunState) -> String {
109    line_with_liveness(state, Liveness::Unknown)
110}
111
112/// [`line`] with the independent process-liveness observation available to
113/// `magi list`.
114pub fn line_with_liveness(state: &RunState, live: Liveness) -> String {
115    let winner = state
116        .tally
117        .as_ref()
118        .map_or("-".to_owned(), |t| t.winner.to_string());
119    let agent = state.winner().map_or("-", |c| c.agent.as_str());
120    // A below-quorum verdict carries an explicit stamp so a row in a listing
121    // reads "stalled" and "2/3 judges" without opening the report.
122    let quorum = match state.tally.as_ref() {
123        Some(t) if !t.met_quorum => format!(
124            "  {}",
125            bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
126        ),
127        Some(t) if t.present > 0 && t.present < t.judges => format!(
128            "  {}",
129            yellow(&format!("judges {}/{}", t.present, t.judges))
130        ),
131        _ => String::new(),
132    };
133    let stale = if !state.status.done() && live == Liveness::Dead {
134        format!(
135            "  {}",
136            bold(&yellow("STALE — driver exited; resume required"))
137        )
138    } else {
139        String::new()
140    };
141    // The full unknown wording is for `magi show`; a row only needs to say
142    // there is no record.
143    let origin = state
144        .origin
145        .as_ref()
146        .map_or("origin unknown".to_owned(), crate::run::Origin::label);
147    format!(
148        "{}  {:<20}  {:>2}c {:>2}j  win {} ({}){quorum}{stale}  {}  {}",
149        dim(&state.id),
150        status_word(state),
151        state.candidates.len(),
152        state.judgements.len(),
153        winner,
154        agent,
155        dim(&format!("[{origin}]")),
156        first_line(&state.instruction)
157    )
158}
159
160/// A plainly visible verdict for a non-terminal run whose recorded driver is
161/// gone. Kept separate from [`active_seats`] because a process can die at a
162/// node boundary with no active seat left in the saved state.
163pub fn liveness_notice(state: &RunState, live: Liveness) -> String {
164    if !state.status.done() && live == Liveness::Dead {
165        format!(
166            "{}\n\n",
167            yellow("STALE — the process driving this run exited; resume it to continue.")
168        )
169    } else {
170        String::new()
171    }
172}
173
174/// A plainly visible note for a run a later attempt at the same task already
175/// replaced — the terminal counterpart of the web UI's "Superseded by …"
176/// line. `later` is the short id of that later attempt, or `None` when this
177/// run is not superseded at all (the common case).
178///
179/// Kept as its own function, concatenated by the caller, rather than an
180/// argument threaded through [`run`] itself: `run` is called from `bump.rs`,
181/// `tui.rs` and every `magi show`/`magi list` path with no notion of "what
182/// replaced this", and adding a parameter there would mean guessing `None`
183/// at every one of those call sites instead of only at the one that knows.
184pub fn superseded_notice(later: Option<&str>) -> String {
185    match later {
186        Some(later) => format!(
187            "{}\n\n",
188            yellow(&format!(
189                "SUPERSEDED — a later attempt at this task ({later}) replaced it; this record \
190                 is kept but is not the one to act on."
191            ))
192        ),
193        None => String::new(),
194    }
195}
196
197fn first_line(text: &str) -> String {
198    let line = text.lines().next().unwrap_or_default();
199    if line.chars().count() > 68 {
200        format!("{}…", line.chars().take(67).collect::<String>())
201    } else {
202        line.to_owned()
203    }
204}
205
206fn short(commit: &str) -> String {
207    commit.chars().take(7).collect()
208}
209
210/// A short badge for a [`crate::run::ContinuationRecord`], distinguishing
211/// "the seat's own report needed to be resumed" from "there was nothing to
212/// address" — a fixer that has never needed this stays silent here, exactly
213/// as a record predating the feature (`continuation: None`) does too.
214fn continuation_note(c: &crate::run::ContinuationRecord) -> String {
215    match c.outcome {
216        ContinuationOutcome::NotNeeded => String::new(),
217        ContinuationOutcome::Resumed => format!(" [resumed x{}]", c.attempts),
218        ContinuationOutcome::Exhausted => format!(" [continuation exhausted x{}]", c.attempts),
219        ContinuationOutcome::QuotaLost => " [continuation: quota]".to_owned(),
220        ContinuationOutcome::NoSession => " [no session to resume]".to_owned(),
221    }
222}
223
224/// Commands seats' own CLIs reported running, across every node — see
225/// [`crate::run::JobRecord`]. Read-only: renders whatever `run.json` already
226/// holds, does not query anything live and does not spawn an agent.
227///
228/// A CLI this crate has no adapter for (every backend but Codex, as of this
229/// writing) never appears here at all — silence is "no evidence", not "no
230/// jobs ran", which the trailing coverage line exists to say once rather
231/// than per seat.
232///
233/// There is deliberately no "running" state anywhere in this. A command a
234/// CLI never reported finishing has no way to be told apart from one that
235/// never started at all: the CLI that would report it has, by construction,
236/// already stopped talking (killed by a timeout, or a park) by the time that
237/// question matters, so no event — real or guessed at — could ever answer
238/// it. Guessing at an unconfirmed event shape to manufacture a "running"
239/// entry is exactly the fabrication this feature must not do; recording that
240/// limit instead is what the task asks for here. `magi show`'s own
241/// seat-completion state ([`active_seats`]) still answers a related but
242/// different question honestly — "has this seat's own turn answered yet" —
243/// and stays the right place to look for that.
244fn jobs_section(state: &RunState) -> String {
245    let mut s = String::new();
246    if state.jobs.is_empty() {
247        // Not silent when it would matter: a run whose roster can actually
248        // report this (Codex, today) but has not yet says so explicitly, so
249        // "no adapter for this backend" and "nothing reported yet" are never
250        // the same blank space to a reader.
251        if state
252            .config
253            .agents
254            .iter()
255            .any(|a| a.kind == crate::config::AgentKind::Codex)
256        {
257            let _ = writeln!(
258                s,
259                "\n{}",
260                dim(
261                    "background jobs: no completed command evidence yet for this run (see \
262                     active seats above for what is still mid-turn)"
263                )
264            );
265        }
266        return s;
267    }
268    let _ = writeln!(
269        s,
270        "\n{}",
271        bold("background jobs (from each seat's own CLI)")
272    );
273    let mut by_seat: std::collections::BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> =
274        std::collections::BTreeMap::new();
275    for j in &state.jobs {
276        by_seat
277            .entry((j.node.as_str(), j.seat.as_str()))
278            .or_default()
279            .push(j);
280    }
281    for ((node, seat), records) in by_seat {
282        let _ = writeln!(s, "  {node}/{seat}");
283        for j in records {
284            let status = match j.status {
285                JobStatus::Completed => green("completed"),
286                JobStatus::Failed => red("failed"),
287                JobStatus::Unknown => yellow("unknown"),
288            };
289            let _ = writeln!(
290                s,
291                "    {} {}{}{}  checked {}",
292                dim(&j.id),
293                status,
294                j.exit_code
295                    .map_or(String::new(), |c| format!(" (exit {c})")),
296                j.round.map_or(String::new(), |r| format!("  round {r}")),
297                j.checked_at
298                    .to_zoned(jiff::tz::TimeZone::system())
299                    .strftime("%Y-%m-%d %H:%M:%S")
300            );
301            let desc = first_line(&j.description);
302            if !desc.trim().is_empty() {
303                let _ = writeln!(s, "      $ {desc}");
304            }
305            let summary = first_line(&j.result_summary);
306            if !summary.trim().is_empty() {
307                let _ = writeln!(s, "      {}", dim(&summary));
308            }
309        }
310    }
311    let _ = writeln!(
312        s,
313        "  {}",
314        dim(
315            "(adapter coverage: codex only today; other backends, and a command a CLI never \
316             reported finishing, leave no entry here — that is unknown, never \"nothing ran\")"
317        )
318    );
319    s
320}
321
322/// Full report for one run.
323pub fn run(state: &RunState) -> String {
324    let mut s = String::new();
325    let _ = writeln!(
326        s,
327        "{} {}  {}",
328        bold("magi run"),
329        bold(&state.id),
330        status_word(state)
331    );
332    let _ = writeln!(
333        s,
334        "  repo    {} ({} @ {})",
335        state.repo.display(),
336        state.base_branch,
337        short(&state.base_commit)
338    );
339    let _ = writeln!(s, "  created {}", state.created_local());
340    let _ = writeln!(s, "  task    {}", first_line(&state.instruction));
341    let _ = writeln!(
342        s,
343        "  origin  {}",
344        crate::run::origin_label(state.origin.as_ref())
345    );
346    let _ = writeln!(s, "  state   {}", state.dir().display());
347
348    let _ = writeln!(s, "\n{}", bold("candidates"));
349    for c in &state.candidates {
350        let flag = match (&c.failed, c.empty, &c.verified_noop) {
351            (Some(e), _, _) => red(&format!("failed: {e}")),
352            // Neither red (nothing failed) nor plain yellow "no change" (that
353            // reads as an unexplained loss): the candidate gave a reason a
354            // human still has to check, not a claim magi itself confirmed.
355            (None, true, Some(_)) => cyan("agent-verified no-op (unconfirmed)"),
356            (None, true, None) => yellow("no change"),
357            _ => format!("{} files, {} commits", c.files, c.commits),
358        };
359        let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
360            bold(&green("  <- winner"))
361        } else {
362            String::new()
363        };
364        let _ = writeln!(
365            s,
366            "  {}  {:<12} {:<30} {:>5}s{}",
367            bold(&c.label.to_string()),
368            c.agent,
369            flag,
370            c.duration_ms / 1000,
371            crown
372        );
373        if let Some(evidence) = &c.verified_noop {
374            let _ = writeln!(s, "      {}", dim(&first_line(evidence)));
375        } else if !c.summary.trim().is_empty() {
376            // The candidate's own account of what it did and why — the
377            // "## SUMMARY" `prompt::implement` asks for — was recorded on
378            // every run but never surfaced here, which left `magi show`
379            // silent about it even when the summary was the whole point (an
380            // implementer explaining *why* it wrote nothing, short of a
381            // verified no-op's own line above). One line, matching the
382            // house style other prose fields get in this report (see the
383            // review findings' `detail` below); the rest is in `run.json`.
384            let _ = writeln!(s, "      {}", dim(&first_line(&c.summary)));
385        }
386    }
387
388    if !state.judgements.is_empty() {
389        let _ = writeln!(s, "\n{}", bold("blind judging"));
390        for j in &state.judgements {
391            match &j.failed {
392                Some(e) => {
393                    let _ = writeln!(
394                        s,
395                        "  judge {}  {}",
396                        j.judge,
397                        red(&format!("no ranking: {e}"))
398                    );
399                }
400                None => {
401                    let _ = writeln!(
402                        s,
403                        "  judge {}  {:<12} {}  confidence {}",
404                        j.judge,
405                        j.agent,
406                        bold(&j.ranking.iter().collect::<String>()),
407                        j.confidence.map_or("-".to_owned(), |c| c.to_string())
408                    );
409                }
410            }
411        }
412    }
413
414    if let Some(t) = &state.tally {
415        if t.deliberated {
416            let _ = writeln!(s, "\n{}", bold("deliberation"));
417            for round in &state.deliberation {
418                for turn in &round.turns {
419                    let _ = writeln!(
420                        s,
421                        "  r{} judge {} -> {}",
422                        round.round,
423                        turn.judge,
424                        turn.tentative.map_or("-".to_owned(), |c| c.to_string())
425                    );
426                }
427            }
428        }
429
430        if !state.votes.is_empty() {
431            let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
432            for v in &state.votes {
433                let _ = writeln!(
434                    s,
435                    "  judge {}  {:<12} {}{}",
436                    v.judge,
437                    v.agent,
438                    bold(&v.vote.unwrap_or('?').to_string()),
439                    if v.changed {
440                        yellow("  (changed after deliberation)")
441                    } else {
442                        String::new()
443                    }
444                );
445            }
446        }
447
448        let _ = writeln!(s, "\n{}", bold("tally"));
449        // A tally with no panel (`uncontested`) must not fall through the
450        // judges/first-choice/after-votes lines below: they are written
451        // unconditionally and every one of them reads, in the words a panel
452        // that collapsed would also produce, as a run that lost its judges
453        // rather than one that never needed them.
454        match &t.uncontested {
455            Some(reason) => {
456                let _ = writeln!(
457                    s,
458                    "  judging       {}",
459                    cyan(&format!("not needed — {reason}"))
460                );
461            }
462            None => {
463                let _ = writeln!(
464                    s,
465                    "  judges        {} present{}",
466                    if t.met_quorum {
467                        green(&format!("{}/{}", t.present, t.judges))
468                    } else {
469                        red(&format!("{}/{}", t.present, t.judges))
470                    },
471                    if t.quorum > 0 {
472                        format!(" ({quorum} required)", quorum = t.quorum)
473                    } else {
474                        String::new()
475                    }
476                );
477                if !t.met_quorum {
478                    let _ = writeln!(
479                        s,
480                        "  {}",
481                        bold(&red("BELOW QUORUM — verdict is not trustworthy"))
482                    );
483                }
484                let _ = writeln!(
485                    s,
486                    "  first choice  {}",
487                    t.first_choice
488                        .iter()
489                        .map(|(k, v)| format!("{k}:{v}"))
490                        .collect::<Vec<_>>()
491                        .join("  ")
492                );
493                let _ = writeln!(
494                    s,
495                    "  initial       {}",
496                    match (t.rankings, t.unanimous_initial) {
497                        (0, _) => red("no usable ranking"),
498                        (1, _) => yellow("one usable ranking - not a consensus"),
499                        (_, true) => green("unanimous"),
500                        (_, false) => yellow("split"),
501                    }
502                );
503                let _ = writeln!(
504                    s,
505                    "  after votes   {}  ({} judge(s) moved)",
506                    if t.unanimous_final {
507                        green("unanimous")
508                    } else {
509                        yellow("still split")
510                    },
511                    t.changed_votes
512                );
513                if let Some(tb) = &t.tie_break {
514                    let _ = writeln!(s, "  tie break     {tb}");
515                }
516            }
517        }
518        if !state.quota.is_empty() {
519            let _ = writeln!(
520                s,
521                "  rate limited  {}",
522                state
523                    .quota
524                    .iter()
525                    .map(|q| q.seat.as_str())
526                    .collect::<Vec<_>>()
527                    .join(", ")
528            );
529        }
530        let _ = writeln!(s, "  winner        {}", bold(&green(&t.winner.to_string())));
531    }
532
533    if !state.handovers.is_empty() {
534        let _ = writeln!(s, "\n{}", bold("seats handed over"));
535        for h in &state.handovers {
536            let _ = writeln!(
537                s,
538                "  {} {}  {} -> {}  ({}: {})",
539                yellow("~"),
540                h.seat,
541                h.from,
542                h.to,
543                h.node,
544                h.reason
545            );
546        }
547    }
548
549    if !state.withheld.is_empty() {
550        let _ = writeln!(s, "\n{}", bold("withheld from commit"));
551        for w in &state.withheld {
552            let _ = writeln!(
553                s,
554                "  {} {}  ({} lockfile; the directory uses {})",
555                yellow("!"),
556                w.path,
557                w.manager,
558                w.kept_by
559            );
560        }
561    }
562
563    if let Some(r) = &state.recovery {
564        let _ = writeln!(s, "\n{}", bold("recovery"));
565        let _ = writeln!(s, "  {}", r.summary());
566        if r.criteria_inherited {
567            let _ = writeln!(s, "  the task's own completion criteria were kept");
568        }
569        match &r.repair {
570            crate::recovery::Repair::NotStarted => {}
571            crate::recovery::Repair::Started { .. } => {
572                let _ = writeln!(s, "  repair: {}", yellow("unfinished"));
573            }
574            crate::recovery::Repair::Done {
575                committed, failed, ..
576            } => {
577                let _ = match failed {
578                    Some(why) => writeln!(s, "  repair: {} {why}", yellow("nothing kept:")),
579                    None if *committed => writeln!(s, "  repair: {}", green("committed")),
580                    None => writeln!(s, "  repair: no commit"),
581                };
582            }
583        }
584    }
585
586    if let Some(g) = &state.graft {
587        let _ = writeln!(s, "\n{}", bold("graft"));
588        let size = match (g.lines, g.limit) {
589            (Some(l), Some(m)) => format!("  {l} changed lines (limit {m})"),
590            (None, Some(m)) => format!("  limit {m} changed lines"),
591            _ => String::new(),
592        };
593        let _ = match &g.outcome {
594            crate::run::GraftOutcome::Applied => writeln!(s, "  {}{size}", green("applied")),
595            crate::run::GraftOutcome::Running => writeln!(s, "  {}{size}", yellow("unfinished")),
596            crate::run::GraftOutcome::RolledBack(why) => {
597                writeln!(s, "  {} {why}{size}", yellow("rolled back:"))
598            }
599        };
600        if let Some(a) = &g.agent {
601            let chain: Vec<String> = state
602                .handovers
603                .iter()
604                .filter(|h| h.node == "graft")
605                .map(|h| format!("{} -> {}", h.from, h.to))
606                .collect();
607            let via = if chain.is_empty() {
608                String::new()
609            } else {
610                format!("  (handovers: {})", chain.join(", "))
611            };
612            let _ = writeln!(s, "  by {a}{via}");
613        }
614        if !g.requested.is_empty() {
615            let view = crate::report_view::build(state, Liveness::Unknown);
616            if let Some(crate::report_view::Section::Graft { graft, .. }) = view
617                .sections
618                .iter()
619                .find(|x| matches!(x, crate::report_view::Section::Graft { .. }))
620            {
621                let _ = writeln!(
622                    s,
623                    "  requested {}  confirmed adopted {}  rejected {}  unconfirmed {}",
624                    graft.requested.len(),
625                    graft.adopted_count,
626                    graft.rejected_count,
627                    graft.unconfirmed_count
628                );
629            }
630        }
631        if let Some(n) = g
632            .adoption
633            .as_ref()
634            .and_then(|a| a.notes.lines().find(|l| !l.trim().is_empty()))
635        {
636            let _ = writeln!(s, "  fixer notes: {}", n.trim());
637        }
638    }
639
640    if !state.reviews.is_empty() {
641        let _ = writeln!(s, "\n{}", bold("review + verification"));
642        for r in &state.reviews {
643            let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
644            // A build/link failure is not a verdict on the patch (a shared
645            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
646            // must not read the same as a real test failure. And a deferred
647            // round is not a passed one: `e2e.is_empty()` alone cannot tell
648            // "not configured" from "skipped on purpose" apart, which is
649            // exactly why `e2e_status` exists rather than reading `e2e`
650            // directly here.
651            let e2e = match r.e2e_status() {
652                E2eStatus::NotConfigured => dim("no e2e"),
653                E2eStatus::Deferred => yellow(&format!(
654                    "e2e deferred{}",
655                    r.e2e_defer_reason
656                        .as_deref()
657                        .map(|why| format!(" ({why})"))
658                        .unwrap_or_default()
659                )),
660                E2eStatus::Passed => green("e2e green"),
661                E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
662                    yellow("e2e could not run (build/link failure)")
663                }
664                E2eStatus::Failed => red("e2e RED"),
665                // Magi's own admission it could not get a command to run —
666                // never the same yellow/red a real attempt earns, since
667                // nothing here is evidence about the patch (see
668                // `E2eStatus::ResourceBlocked`'s own doc).
669                E2eStatus::ResourceBlocked => {
670                    yellow("e2e could not run (shared build cache unavailable)")
671                }
672            };
673            let e2e = if r.verify_retried {
674                format!("{e2e}, retried once")
675            } else {
676                e2e
677            };
678            // Three distinct facts, not two: a round can be *open* (blocking
679            // findings still standing), *incomplete* (a seat never answered,
680            // so what the round says is missing input) or genuinely clean.
681            let status = if r.incomplete() {
682                yellow("incomplete")
683            } else if r.clean {
684                green("clean")
685            } else {
686                yellow("open")
687            };
688            // A missing seat must stay visible even when `warn` policy let
689            // the round gate as clean: the reader should never have to take
690            // "clean" on faith when the panel wasn't full.
691            let panel = if r.incomplete() {
692                let missing: Vec<String> = r
693                    .reviews
694                    .iter()
695                    .filter_map(|x| {
696                        x.failed
697                            .as_ref()
698                            .map(|why| format!("review-{}: {why}", x.reviewer))
699                    })
700                    .collect();
701                format!(
702                    "  {}/{} reviewers answered ({})",
703                    r.answered,
704                    r.expected,
705                    missing.join(", ")
706                )
707            } else {
708                String::new()
709            };
710            // The verdict is the one thing this loop cannot derive from
711            // `blocking`/`e2e` alone: three seats can agree there is nothing
712            // blocking and still split on whether the patch is fine to
713            // proceed as-is, which is exactly the disagreement a vote exists
714            // to surface.
715            let verdict = r.verdict.map_or(String::new(), |v| {
716                format!(
717                    ", verdict {}{}",
718                    vote_tag(v),
719                    if r.vote_split { " (panel split)" } else { "" }
720                )
721            });
722            let _ = writeln!(
723                s,
724                "  round {}  {} @ {}{}{panel}  {raised} finding(s), {} blocking, {e2e}{verdict}{}",
725                r.round,
726                status,
727                short(&r.head),
728                r.verified_head.as_ref().map_or(String::new(), |head| {
729                    format!(
730                        " (verified @ {}{})",
731                        short(head),
732                        r.verified_at.map_or(String::new(), |t| format!(
733                            " on {}",
734                            t.to_zoned(jiff::tz::TimeZone::system())
735                                .strftime("%Y-%m-%d %H:%M:%S")
736                        ))
737                    )
738                }),
739                r.blocking,
740                r.fix.as_ref().map_or(String::new(), |f| {
741                    let tree = if r.progressed {
742                        green("changed")
743                    } else {
744                        yellow("unchanged")
745                    };
746                    let cont = f
747                        .continuation
748                        .as_ref()
749                        .map_or(String::new(), continuation_note);
750                    match &f.failed {
751                        // Never the same shape as "N addressed / M rejected": the
752                        // fixer's diff may well have landed (see the `fix` node's
753                        // own event), but whether it addressed anything is
754                        // unknown, not zero.
755                        Some(reason) => format!(
756                            "  fix: {}, tree {tree}{}{cont}",
757                            yellow(&format!("adoption report lost ({reason})")),
758                            if f.committed {
759                                String::new()
760                            } else {
761                                red(" (NO COMMIT)")
762                            }
763                        ),
764                        None => format!(
765                            "  fix: {} addressed / {} rejected, tree {tree}{}{cont}",
766                            f.addressed.len(),
767                            f.rejected.len(),
768                            if f.committed {
769                                String::new()
770                            } else {
771                                red(" (NO COMMIT)")
772                            }
773                        ),
774                    }
775                })
776            );
777            // The aggregate label above (`e2e`) says the round's overall
778            // verdict; it never named the commands themselves, so a round
779            // with more than one `verify.e2e` command left no way to tell
780            // which one actually failed or was blocked without opening
781            // `run.json` by hand — the same gap `gate`'s own per-command
782            // listing above already closes for the final gate.
783            for o in &r.e2e {
784                let label = if o.resource_blocked {
785                    yellow("blocked")
786                } else if o.ok() {
787                    green("pass")
788                } else {
789                    red("FAIL")
790                };
791                let _ = writeln!(s, "    {label}  {}", o.command);
792                if !o.ok() {
793                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
794                }
795            }
796            for rec in &r.reviews {
797                if let Some(vote) = rec.vote {
798                    let _ = writeln!(s, "      review-{} vote {}", rec.reviewer, vote_tag(vote));
799                }
800                for f in &rec.findings {
801                    let adopted = r
802                        .fix
803                        .as_ref()
804                        .is_some_and(|fix| fix.addressed.contains(&f.id));
805                    let _ = writeln!(
806                        s,
807                        "      {} [{:?}] {}{}",
808                        dim(&f.id),
809                        f.severity,
810                        f.title,
811                        if adopted {
812                            green("  fixed")
813                        } else {
814                            String::new()
815                        }
816                    );
817                }
818            }
819            if let Some(fix) = &r.fix {
820                for rej in &fix.rejected {
821                    let _ = writeln!(
822                        s,
823                        "      {} {}: {}",
824                        dim(&rej.id),
825                        yellow("declined"),
826                        rej.why
827                    );
828                }
829            }
830            // Reconsideration only ever has entries when the round's initial
831            // votes split — an empty list here means the panel agreed the
832            // first time, same as an empty `deliberation` for judges.
833            if !r.reconsideration.is_empty() {
834                let _ = writeln!(s, "      {}", dim("reconsideration:"));
835                for rv in &r.reconsideration {
836                    match rv.vote {
837                        Some(v) => {
838                            let _ = writeln!(
839                                s,
840                                "        review-{} -> {}  {}",
841                                rv.reviewer,
842                                vote_tag(v),
843                                rv.reason
844                            );
845                        }
846                        None => {
847                            let _ = writeln!(
848                                s,
849                                "        review-{} -> {}",
850                                rv.reviewer,
851                                red(&format!(
852                                    "no revote ({})",
853                                    rv.failed.as_deref().unwrap_or("unknown")
854                                ))
855                            );
856                        }
857                    }
858                }
859            }
860        }
861        if state.handed_off_with_open_findings() {
862            let _ = writeln!(
863                s,
864                "\n  {}",
865                yellow(&format!(
866                    "handed off with {} finding(s) still open — gate and e2e were green; \
867                     see above for what a person should still look at",
868                    state.open_findings().len()
869                ))
870            );
871        }
872    }
873
874    if !state.merge_repairs.is_empty() {
875        let _ = writeln!(s, "\n{}", bold("merge-gate repairs"));
876        for r in &state.merge_repairs {
877            let _ = writeln!(
878                s,
879                "  question {}  head {}  {:?}\n  {}",
880                r.question,
881                crate::run::short_of(&r.head),
882                r.phase,
883                r.pr
884            );
885            if let Some(why) = &r.failure {
886                let _ = writeln!(s, "  held: {why}");
887            }
888            if let Some(round) = r.reviews.last() {
889                for f in round.reviews.iter().flat_map(|rec| &rec.findings) {
890                    let _ = writeln!(s, "  original [{}] {:?}: {}", f.id, f.severity, f.title);
891                }
892                for (seat, _, vote) in round.final_votes() {
893                    let _ = writeln!(s, "  original reviewer #{seat}: {:?}", vote);
894                }
895            }
896        }
897        s.push_str(
898            "  Repair never approves a merge; updated results require a fresh owner decision.\n",
899        );
900    }
901
902    if !state.operator_fixes.is_empty() {
903        let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
904        for (i, req) in state.operator_fixes.iter().enumerate() {
905            let _ = writeln!(
906                s,
907                "  [{}] {} finding(s) at {}{}",
908                i + 1,
909                req.findings.len(),
910                req.requested_at
911                    .to_zoned(jiff::tz::TimeZone::system())
912                    .strftime("%Y-%m-%d %H:%M:%S"),
913                if req.stale {
914                    yellow("  stale head, --allow-stale used")
915                } else {
916                    String::new()
917                }
918            );
919            let _ = writeln!(s, "      reason: {}", req.reason);
920            for f in &req.findings {
921                let outcome = match &f.outcome {
922                    OperatorFixOutcome::Pending => yellow("pending"),
923                    OperatorFixOutcome::Addressed => green("addressed"),
924                    OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
925                    OperatorFixOutcome::Unreported => {
926                        red("unreported — no adoption report came back")
927                    }
928                };
929                let _ = writeln!(
930                    s,
931                    "      {} [{:?}] {}  {outcome}",
932                    dim(&f.id),
933                    f.severity,
934                    f.title
935                );
936            }
937            match &req.follow_up_review_run {
938                Some(id) => {
939                    let _ = writeln!(s, "      re-verified by run {id}");
940                }
941                None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
942                    let _ = writeln!(
943                        s,
944                        "      {}",
945                        red("committed, but the follow-up review could not be opened")
946                    );
947                }
948                None => {
949                    let _ = writeln!(s, "      no change committed; nothing to re-verify");
950                }
951            }
952        }
953    }
954
955    if let Some(bs) = &state.base_sync {
956        let _ = writeln!(s, "\n{}", bold("base sync"));
957        let status = if let Some(a) = &bs.already_in {
958            dim(&format!(
959                "already in {} as {} ({} match) - nothing left to land",
960                state.base_branch,
961                a.names(),
962                a.proof.as_str()
963            ))
964        } else if let Some(c) = &bs.conflict {
965            red(&format!("conflict: {}", first_line(c)))
966        } else if bs.behind == 0 {
967            green("in sync")
968        } else {
969            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
970        };
971        let _ = writeln!(
972            s,
973            "  {} @ {}  {status}{}",
974            state.base_branch,
975            short(&bs.tip),
976            if bs.attempts > 0 {
977                format!("  ({} rebase attempt(s))", bs.attempts)
978            } else {
979                String::new()
980            }
981        );
982    }
983
984    if !state.pre_gate.is_empty() {
985        let _ = writeln!(s, "\n{}", bold("pre_gate"));
986        for o in &state.pre_gate {
987            let _ = writeln!(
988                s,
989                "  {}  {}",
990                if o.ok() {
991                    green("pass")
992                } else {
993                    yellow("warn")
994                },
995                o.command
996            );
997            if !o.ok() {
998                let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
999            }
1000        }
1001        if let Some(c) = &state.pre_gate_commit {
1002            let _ = writeln!(s, "  committed mechanical fixes @ {}", short(c));
1003        }
1004    }
1005
1006    // `state.gate.is_empty()` alone cannot tell "never ran" apart from "ran
1007    // with nothing configured" — see `RunState::gate_status`'s own doc — so
1008    // this reads the accessor rather than the raw list.
1009    match state.gate_status() {
1010        GateStatus::NotRun => {}
1011        GateStatus::PassedWithNoCommands => {
1012            let _ = writeln!(s, "\n{}", bold("gate"));
1013            let _ = writeln!(s, "  {}  no gate commands configured", green("pass"));
1014        }
1015        GateStatus::Passed | GateStatus::Failed => {
1016            let _ = writeln!(s, "\n{}", bold("gate"));
1017            for o in &state.gate {
1018                let _ = writeln!(
1019                    s,
1020                    "  {}  {}",
1021                    if o.ok() { green("pass") } else { red("FAIL") },
1022                    o.command
1023                );
1024                if !o.ok() {
1025                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
1026                }
1027            }
1028        }
1029    }
1030
1031    if let Some(m) = &state.merge {
1032        let _ = writeln!(s, "\n{}", bold("merge"));
1033        if m.mode == MergeMode::None {
1034            // `ok: true` here means "magi did nothing, as configured", not
1035            // "landed" — a green `ok` next to a shell command reads as done,
1036            // and the branch is still sitting unmerged.
1037            let _ = writeln!(
1038                s,
1039                "  mode None  {}",
1040                cyan("not landed — nothing to do by design")
1041            );
1042            if let Some(w) = state.winner() {
1043                let _ = writeln!(
1044                    s,
1045                    "  branch {} still exists, unmerged into {}",
1046                    w.branch, state.base_branch
1047                );
1048            }
1049            // The squash caveat only applies to that one style: `--no-ff` and
1050            // `--ff-only` never inherit a candidate's placeholder subject,
1051            // since neither ever discards the pull request body `message`
1052            // that `manual_merge_command` (graph.rs) already puts on the
1053            // squash commit's `-m`.
1054            let _ = writeln!(
1055                s,
1056                "  rebase onto {} before merging by hand{}",
1057                state.base_branch,
1058                if state.config.merge.style == MergeStyle::Squash {
1059                    ", and pass an explicit commit message — a squash merge \
1060                     otherwise inherits the candidate's placeholder subject"
1061                } else {
1062                    ""
1063                }
1064            );
1065            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
1066        } else {
1067            let _ = writeln!(
1068                s,
1069                "  mode {:?}  {}\n  {}",
1070                m.mode,
1071                if m.ok {
1072                    green("ok")
1073                } else if m.empty {
1074                    yellow("empty candidate — nothing to land")
1075                } else {
1076                    yellow("not merged")
1077                },
1078                m.detail.lines().next().unwrap_or("")
1079            );
1080        }
1081        if let Some(pr) = &state.pr
1082            && !pr.red_at_merge.is_empty()
1083        {
1084            let _ = writeln!(
1085                s,
1086                "  {} {}",
1087                yellow("merged with red checks:"),
1088                pr.red_at_merge.join(", ")
1089            );
1090        }
1091    }
1092
1093    if !state.followups.is_empty() {
1094        let _ = writeln!(s, "\n{}", bold("follow-ups"));
1095        for f in &state.followups {
1096            let _ = writeln!(
1097                s,
1098                "  {} {}",
1099                crate::queue::short(&f.task),
1100                f.findings.join(", ")
1101            );
1102        }
1103    }
1104
1105    if !state.bump_decision_attempts.is_empty() {
1106        let _ = writeln!(s, "\n{}", bold("release decision attempts"));
1107        for (n, attempt) in state.bump_decision_attempts.iter().enumerate() {
1108            let _ = writeln!(s, "  {}. {}: {}", n + 1, attempt.agent, attempt.outcome);
1109        }
1110    }
1111    if let Some(b) = &state.release_bump {
1112        let _ = writeln!(s, "\n{}", bold("release bump"));
1113        if let Some(v) = &b.version {
1114            let _ = writeln!(s, "  version v{v}");
1115        }
1116        if let Some(url) = &b.pr_url {
1117            let _ = writeln!(s, "  pr {url}");
1118        }
1119        let _ = writeln!(
1120            s,
1121            "  automerge {}",
1122            if b.automerge_enabled {
1123                green("enabled")
1124            } else if b.merged_directly {
1125                green("not needed (CI was already green; merged directly)")
1126            } else if b.local {
1127                dim(
1128                    "not used (release.mode = local: merged on your approval, released by magi serve)",
1129                )
1130            } else if b.problem.is_some() {
1131                bold(&red("FAILED"))
1132            } else {
1133                dim("not enabled")
1134            }
1135        );
1136        if let Some(p) = &b.problem {
1137            let _ = writeln!(s, "  {}", p.lines().next().unwrap_or(""));
1138        }
1139        if let Some(j) = &b.release {
1140            let state = if j.finished {
1141                "finished"
1142            } else {
1143                j.failed.as_deref().unwrap_or("unfinished")
1144            };
1145            let _ = writeln!(s, "  release {} at {}: {state}", j.tag(), j.commit);
1146            for l in &j.log {
1147                let code = l.code.map_or("none".to_owned(), |c| c.to_string());
1148                let _ = write!(s, "    {} (exit {code})", l.name);
1149                if let Some(o) = &l.output {
1150                    let _ = write!(s, " output {o}");
1151                }
1152                let _ = writeln!(s);
1153            }
1154        }
1155        if let Some(a) = &b.action_required {
1156            let _ = writeln!(s, "  {} {a}", bold(&yellow("action required:")));
1157        }
1158    }
1159
1160    if !state.leaks.is_empty() {
1161        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
1162        for l in &state.leaks {
1163            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
1164        }
1165    }
1166
1167    if let Some(w) = state.winner()
1168        && !w.folded
1169    {
1170        let _ = writeln!(
1171            s,
1172            "\n{} {}\n  branch {}",
1173            bold("winner worktree"),
1174            w.worktree.display(),
1175            w.branch
1176        );
1177    }
1178    s.push_str(&jobs_section(state));
1179    s
1180}
1181
1182/// The seats currently mid-answer, for `magi show` and the raw report route.
1183///
1184/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
1185/// run` / `magi review`'s own `execute()`, and by then this process has
1186/// nothing left in flight to report; the TUI does not track daemon liveness
1187/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
1188/// the web UI's raw-report route — needs this, and both already know how to
1189/// ask whether a daemon is currently driving it.
1190///
1191/// `live` is [`RunState::liveness`]: whether a daemon's heartbeat currently
1192/// names this run, or — absent that claim — whether `driver_pid` still
1193/// answers alive. An [`ActiveSeat`](crate::run::ActiveSeat) left behind by a
1194/// killed process is not lied about as running just because nobody has
1195/// cleared it from disk yet — see that type's own docs for why an entry
1196/// alone is not proof of anything.
1197pub fn active_seats(state: &RunState, live: Liveness) -> String {
1198    let mut s = String::new();
1199    // A run that stopped on an infrastructure wait says so: no agent failed
1200    // and none was asked, and a resume asks the same ones.
1201    if let Some(b) = &state.resource_block {
1202        let _ = writeln!(
1203            s,
1204            "\n{}\n  {}: {} stayed busy past the resource-wait limit; no agent was started \
1205             ({}). Resume to ask the same agents again.",
1206            bold("waiting on a resource"),
1207            b.node,
1208            b.kind,
1209            b.detail
1210        );
1211    }
1212    if state.active.is_empty() {
1213        return s;
1214    }
1215    let _ = writeln!(s, "\n{}", bold("running now"));
1216    let now = jiff::Timestamp::now();
1217    match live {
1218        Liveness::Live => {}
1219        Liveness::Dead => {
1220            let _ = writeln!(
1221                s,
1222                "  {}",
1223                yellow(
1224                    "no live daemon claims this run right now — likely left behind by a killed process"
1225                )
1226            );
1227        }
1228        Liveness::Unknown => {
1229            // `active_all_overrun` is never treated as proof of death here —
1230            // only ever mentioned alongside "could not confirm", since a
1231            // seat legitimately overrunning its budget while the process
1232            // driving it tears the attempt down looks identical on disk (see
1233            // `RunState::active_all_overrun`'s own doc).
1234            let overrun = if state.active_all_overrun(now) {
1235                " — every active seat has already run past its own timeout budget"
1236            } else {
1237                ""
1238            };
1239            let _ = writeln!(
1240                s,
1241                "  {}",
1242                yellow(&format!(
1243                    "whether a process is still driving this run could not be confirmed{overrun}"
1244                ))
1245            );
1246        }
1247    }
1248    for (seat, a) in state.seats_active() {
1249        let retry = if a.attempt > 0 {
1250            format!(" retry {}", a.attempt)
1251        } else {
1252            String::new()
1253        };
1254        // Before the CLI is spawned there is no agent budget to count down.
1255        if let Some(w) = state.resource_wait.as_ref().filter(|w| w.node == a.node) {
1256            let _ = writeln!(
1257                s,
1258                "  {:<12} {:<12}{retry}  waiting for the {} ({}), {}s of {}s; agent not started yet",
1259                seat,
1260                a.node,
1261                w.kind,
1262                w.holder,
1263                (now.as_second() - w.since.as_second()).max(0),
1264                w.limit_secs
1265            );
1266            continue;
1267        }
1268        let _ = writeln!(
1269            s,
1270            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
1271            seat,
1272            a.node,
1273            a.elapsed_secs(now),
1274            a.remaining_secs(now),
1275            a.timeout_secs
1276        );
1277    }
1278    for (task, a) in state.tasks_active() {
1279        let retry = if a.attempt > 0 {
1280            format!(" retry {}", a.attempt)
1281        } else {
1282            String::new()
1283        };
1284        let progress = match (a.index, a.total) {
1285            (Some(i), Some(t)) => format!(" ({i}/{t})"),
1286            _ => String::new(),
1287        };
1288        let _ = writeln!(
1289            s,
1290            "  {:<12} {:<12}{retry}{progress}  {}s elapsed, {}s left of {}s",
1291            task,
1292            a.node,
1293            a.elapsed_secs(now),
1294            a.remaining_secs(now),
1295            a.timeout_secs
1296        );
1297        if let Some(command) = &a.command {
1298            let _ = writeln!(s, "               {command}");
1299        }
1300    }
1301    s
1302}
1303
1304/// Render a whole number of seconds as `1h02m`, `3m05s` or `42s`.
1305fn fmt_secs(secs: i64) -> String {
1306    let secs = secs.max(0);
1307    let h = secs / 3600;
1308    let m = (secs % 3600) / 60;
1309    let s = secs % 60;
1310    if h > 0 {
1311        format!("{h}h{m:02}m")
1312    } else if m > 0 {
1313        format!("{m}m{s:02}s")
1314    } else {
1315        format!("{s}s")
1316    }
1317}
1318
1319/// A fixed-width text bar, `value` proportional to `max` out of `width` cells.
1320///
1321/// Any positive value draws at least one cell, so a short-but-nonzero
1322/// duration is never mistaken for an unmeasured one.
1323fn bar(value: f64, max: f64, width: usize) -> String {
1324    if max <= 0.0 || value <= 0.0 {
1325        return String::new();
1326    }
1327    let cells = ((value / max) * width as f64)
1328        .round()
1329        .clamp(1.0, width as f64) as usize;
1330    "█".repeat(cells)
1331}
1332
1333/// Per-repository summary table: repo name, run count, completion rate.
1334///
1335/// `main.rs`'s `Command::Stats` is what decides *whether* to print this —
1336/// only ahead of the plain (no `--repo`) report, and only when `repos.len() >
1337/// 1` — this function only renders whatever it is handed.
1338pub fn repo_summary(repos: &[RepoStats]) -> String {
1339    let mut s = String::new();
1340    let _ = writeln!(s, "{}", bold("repos"));
1341
1342    // Two different checkouts can share a leaf directory name (`magi` under
1343    // two different roots); disambiguate only those with their parent
1344    // directory rather than paying the extra noise on every row.
1345    let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1346    for r in repos {
1347        *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
1348    }
1349
1350    for r in repos {
1351        let label = if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
1352            match r.repo.parent().and_then(|p| p.file_name()) {
1353                Some(parent) => format!("{} ({})", r.name, parent.to_string_lossy()),
1354                None => r.name.clone(),
1355            }
1356        } else {
1357            r.name.clone()
1358        };
1359        let _ = writeln!(
1360            s,
1361            "  {}  {} runs  {:.0}% completion",
1362            label,
1363            r.stats.totals.runs,
1364            r.stats.totals.completion_rate()
1365        );
1366    }
1367    s
1368}
1369
1370/// Aggregate tables, for `magi stats`.
1371pub fn stats(stats: &Stats) -> String {
1372    let t = &stats.totals;
1373    let mut s = String::new();
1374    let _ = writeln!(s, "{}", bold("runs"));
1375    let _ = writeln!(
1376        s,
1377        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1378        t.runs,
1379        t.merged,
1380        t.ready,
1381        t.blocked,
1382        t.failed,
1383        t.completion_rate()
1384    );
1385    if t.tallied > 0 {
1386        let _ = writeln!(
1387            s,
1388            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1389             {} of those changed a mind, {} converged to unanimous",
1390            t.tallied,
1391            t.split,
1392            t.split_rate(),
1393            t.deliberated,
1394            t.minds_changed,
1395            t.converged
1396        );
1397    }
1398
1399    if !stats.retired_hidden.is_empty() {
1400        let n = stats.retired_hidden.len();
1401        let _ = writeln!(
1402            s,
1403            "{n} retired agent{} hidden: {} (magi stats --all to show)\n",
1404            if n == 1 { "" } else { "s" },
1405            stats.retired_hidden.join(", ")
1406        );
1407    }
1408
1409    let has_competitive = stats.agents.iter().any(|a| a.competitive_entered > 0);
1410    if has_competitive {
1411        let mut comp_agents = stats.agents.clone();
1412        comp_agents.retain(|a| a.competitive_entered > 0);
1413        comp_agents.sort_by(|a, b| {
1414            let a_tier = if a.competitive_entered < 10 { 1 } else { 0 };
1415            let b_tier = if b.competitive_entered < 10 { 1 } else { 0 };
1416            a_tier
1417                .cmp(&b_tier)
1418                .then(
1419                    b.competitive_win_rate()
1420                        .total_cmp(&a.competitive_win_rate()),
1421                )
1422                .then(b.competitive_entered.cmp(&a.competitive_entered))
1423                .then(a.agent.cmp(&b.agent))
1424        });
1425
1426        let _ = writeln!(
1427            s,
1428            "\n{}",
1429            bold("implementation (competitive runs, 2+ candidates)")
1430        );
1431        let _ = writeln!(
1432            s,
1433            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
1434            "agent", "won", "entered", "rate", "empty"
1435        );
1436        for a in &comp_agents {
1437            let low_n_marker = if a.competitive_entered < 10 {
1438                format!(" {}", dim("low n"))
1439            } else {
1440                String::new()
1441            };
1442            let _ = writeln!(
1443                s,
1444                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}{}",
1445                a.agent,
1446                a.competitive_wins,
1447                a.competitive_entered,
1448                a.competitive_win_rate(),
1449                a.empty,
1450                low_n_marker
1451            );
1452        }
1453    }
1454
1455    let has_solo = stats.agents.iter().any(|a| a.solo_entered > 0);
1456    if has_solo {
1457        let mut solo_agents = stats.agents.clone();
1458        solo_agents.retain(|a| a.solo_entered > 0);
1459        solo_agents.sort_by(|a, b| {
1460            let a_tier = if a.solo_entered < 10 { 1 } else { 0 };
1461            let b_tier = if b.solo_entered < 10 { 1 } else { 0 };
1462            a_tier
1463                .cmp(&b_tier)
1464                .then(b.solo_adoption_rate().total_cmp(&a.solo_adoption_rate()))
1465                .then(b.solo_entered.cmp(&a.solo_entered))
1466                .then(a.agent.cmp(&b.agent))
1467        });
1468
1469        let _ = writeln!(
1470            s,
1471            "\n{}",
1472            bold("implementation (solo runs, completion/adoption rate)")
1473        );
1474        let _ = writeln!(
1475            s,
1476            "  {:<14}{:>8}{:>8}{:>8}",
1477            "agent", "adopted", "entered", "rate"
1478        );
1479        for a in &solo_agents {
1480            let low_n_marker = if a.solo_entered < 10 {
1481                format!(" {}", dim("low n"))
1482            } else {
1483                String::new()
1484            };
1485            let _ = writeln!(
1486                s,
1487                "  {:<14}{:>8}{:>8}{:>7.0}%{}",
1488                a.agent,
1489                a.solo_adopted,
1490                a.solo_entered,
1491                a.solo_adoption_rate(),
1492                low_n_marker
1493            );
1494        }
1495    }
1496
1497    if !stats.reviewers.is_empty() {
1498        let _ = writeln!(s, "\n{}", bold("review"));
1499        let _ = writeln!(
1500            s,
1501            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1502            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1503        );
1504        for r in &stats.reviewers {
1505            let _ = writeln!(
1506                s,
1507                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1508                r.agent,
1509                r.rounds,
1510                r.submitted,
1511                r.adopted_per_round(),
1512                r.precision(),
1513                r.unique_rate(),
1514                r.timeout_rate()
1515            );
1516        }
1517    }
1518
1519    if !stats.advisors.is_empty() {
1520        let _ = writeln!(
1521            s,
1522            "\n{} {}",
1523            bold("design deliberation"),
1524            dim(
1525                "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1526            )
1527        );
1528        let _ = writeln!(
1529            s,
1530            "  {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1531            "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1532        );
1533        for a in &stats.advisors {
1534            let _ = writeln!(
1535                s,
1536                "  {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1537                a.agent,
1538                a.seated,
1539                a.proposed,
1540                a.strong,
1541                a.faint,
1542                a.absent,
1543                a.reflection_rate()
1544            );
1545        }
1546    }
1547
1548    if stats.release_bumps.merged > 0 {
1549        let b = &stats.release_bumps;
1550        let _ = writeln!(s, "\n{}", bold("release bumps"));
1551        let _ = writeln!(
1552            s,
1553            "  {} of {} merged run(s) recorded a bump ({:.0}%)",
1554            b.recorded,
1555            b.merged,
1556            b.coverage_rate()
1557        );
1558        let _ = writeln!(
1559            s,
1560            "  {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1561            b.pr_opened,
1562            b.automerge_enabled,
1563            b.automerge_rate(),
1564            b.merged_directly
1565        );
1566        let _ = writeln!(
1567            s,
1568            "  {} clean, {} needed a human ({:.0}%)",
1569            b.clean(),
1570            b.needs_attention,
1571            b.attention_rate()
1572        );
1573    }
1574
1575    if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1576        let _ = writeln!(s, "\n{}", bold("verification"));
1577        let _ = writeln!(
1578            s,
1579            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
1580             review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1581            stats.e2e.rounds,
1582            stats.e2e.failures,
1583            stats.e2e.sole_detections,
1584            stats.e2e.sole_rate(),
1585            stats.e2e.deferred
1586        );
1587    }
1588
1589    if !stats.nodes.is_empty() {
1590        let _ = writeln!(
1591            s,
1592            "\n{}",
1593            bold("node durations (first to last event, per run)")
1594        );
1595        let max_mean = stats
1596            .nodes
1597            .iter()
1598            .map(|n| n.mean_secs())
1599            .fold(0.0_f64, f64::max);
1600        for n in &stats.nodes {
1601            let unmeasured = if n.single > 0 {
1602                format!("  {}", dim(&format!("{} unmeasured", n.single)))
1603            } else {
1604                String::new()
1605            };
1606            let _ = writeln!(
1607                s,
1608                "  {:<14}{:>6} runs{:>8} mean{:>8} max  {}{unmeasured}",
1609                n.node,
1610                n.runs,
1611                fmt_secs(n.mean_secs().round() as i64),
1612                fmt_secs(n.max_secs),
1613                bar(n.mean_secs(), max_mean, 20)
1614            );
1615        }
1616    }
1617    s
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623    use crate::config::Config;
1624    use crate::run::{
1625        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1626        Tally,
1627    };
1628    use std::collections::BTreeMap;
1629    use std::path::PathBuf;
1630    use std::sync::{Mutex, MutexGuard};
1631
1632    /// `COLOR` is process-global, so these tests cannot run concurrently.
1633    static SERIAL: Mutex<()> = Mutex::new(());
1634
1635    fn plain() -> MutexGuard<'static, ()> {
1636        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1637        set_color(false);
1638        guard
1639    }
1640
1641    fn state() -> RunState {
1642        // `run()` prints `state.dir()`, which reads the process-global home;
1643        // pinning it here keeps this test off the operator's real one. The
1644        // directory itself is never read, only its path printed, so nothing
1645        // needs to create or clean it up.
1646        crate::run::pin_test_home();
1647        let mut s = RunState::new(
1648            PathBuf::from("/repo"),
1649            "main".to_owned(),
1650            "abcdef1234".to_owned(),
1651            "add retries to the uploader".to_owned(),
1652            Config::default(),
1653        );
1654        s.candidates = vec![Candidate {
1655            index: 0,
1656            label: 'A',
1657            agent: "opus".to_owned(),
1658            branch: "magi/x/A".to_owned(),
1659            worktree: PathBuf::from("/wt/A"),
1660            summary: String::new(),
1661            stat: String::new(),
1662            files: 3,
1663            commits: 2,
1664            empty: false,
1665            failed: None,
1666            verified_noop: None,
1667            duration_ms: 42_000,
1668            folded: false,
1669        }];
1670        s.tally = Some(Tally {
1671            first_choice: BTreeMap::from([('A', 3)]),
1672            borda: BTreeMap::new(),
1673            winner: 'A',
1674            rankings: 3,
1675            unanimous_initial: true,
1676            deliberated: false,
1677            changed_votes: 0,
1678            unanimous_final: true,
1679            tie_break: None,
1680            judges: 3,
1681            present: 3,
1682            quorum: 2,
1683            met_quorum: true,
1684            uncontested: None,
1685        });
1686        s
1687    }
1688
1689    #[test]
1690    fn run_report_names_the_winner_and_its_author() {
1691        let _guard = plain();
1692        let text = run(&state());
1693        assert!(text.contains("<- winner"), "{text}");
1694        assert!(text.contains("opus"));
1695        assert!(text.contains("3 files, 2 commits"));
1696        assert!(text.contains("winner        A"));
1697        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1698    }
1699
1700    #[test]
1701    fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1702        // Recorded on every run (`prompt::implement`'s `## SUMMARY`), but
1703        // `run()` used to never print it at all — silent even when the
1704        // summary was the one place an implementer explained itself (e.g.
1705        // an investigation task's findings), and readable only by opening
1706        // `run.json` by hand.
1707        let _guard = plain();
1708        let mut s = state();
1709        s.candidates[0].summary =
1710            "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1711                .to_owned();
1712        let text = run(&s);
1713        assert!(
1714            text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1715            "{text}"
1716        );
1717    }
1718
1719    #[test]
1720    fn a_verified_noop_run_does_not_read_as_a_failure() {
1721        // Same spirit as `a_mode_none_merge_does_not_read_as_landed`: a run
1722        // that settled without landing anything must not be misreadable as
1723        // the ordinary failure it is not.
1724        let _guard = plain();
1725        let mut s = state();
1726        s.status = RunStatus::VerifiedNoop;
1727        s.tally = None;
1728        s.candidates = vec![Candidate {
1729            index: 0,
1730            label: 'A',
1731            agent: "opus".to_owned(),
1732            branch: "magi/x/A".to_owned(),
1733            worktree: PathBuf::from("/wt/A"),
1734            summary: String::new(),
1735            stat: String::new(),
1736            files: 0,
1737            commits: 0,
1738            empty: true,
1739            failed: None,
1740            verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1741            duration_ms: 9_000,
1742            folded: false,
1743        }];
1744        let text = run(&s);
1745        assert!(
1746            text.contains("agent-verified no-op"),
1747            "the status and the candidate flag must both say so: {text}"
1748        );
1749        assert!(
1750            text.contains("already fixed by b32cfc4"),
1751            "the evidence itself must be readable, not just the verdict: {text}"
1752        );
1753        assert!(
1754            !text.to_lowercase().contains("failed"),
1755            "a verified no-op must never read as the failure it is not: {text}"
1756        );
1757    }
1758
1759    #[test]
1760    fn colour_is_emitted_only_when_enabled() {
1761        let _guard = plain();
1762        set_color(true);
1763        let coloured = run(&state());
1764        set_color(false);
1765        let plain = run(&state());
1766        assert!(coloured.contains('\x1b'));
1767        assert!(!plain.contains('\x1b'));
1768        assert!(coloured.len() > plain.len());
1769    }
1770
1771    #[test]
1772    fn list_line_is_single_line() {
1773        let _guard = plain();
1774        let l = line(&state());
1775        assert_eq!(l.lines().count(), 1);
1776        assert!(l.contains("add retries"));
1777        assert!(l.contains("win A (opus)"));
1778    }
1779
1780    #[test]
1781    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1782        let _guard = plain();
1783        let mut s = state();
1784        s.tally = Some(Tally {
1785            first_choice: BTreeMap::from([('A', 0)]),
1786            borda: BTreeMap::new(),
1787            winner: 'A',
1788            rankings: 0,
1789            unanimous_initial: false,
1790            deliberated: false,
1791            changed_votes: 0,
1792            unanimous_final: false,
1793            tie_break: None,
1794            judges: 0,
1795            present: 0,
1796            quorum: 0,
1797            met_quorum: true,
1798            uncontested: Some(
1799                "only candidate A produced a usable change; no panel was asked".to_owned(),
1800            ),
1801        });
1802        let text = run(&s);
1803        assert!(
1804            !text.contains("0/3"),
1805            "no panel sat, so the judges line must not read as one that collapsed: {text}"
1806        );
1807        assert!(!text.contains("no usable ranking"), "{text}");
1808        assert!(!text.contains("still split"), "{text}");
1809        assert!(!text.contains("BELOW QUORUM"), "{text}");
1810        assert!(
1811            text.contains("not needed"),
1812            "the report must say judging was skipped, not silent: {text}"
1813        );
1814        assert!(text.contains("winner        A"));
1815    }
1816
1817    #[test]
1818    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1819        let _guard = plain();
1820        let mut s = state();
1821        s.tally = Some(Tally {
1822            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1823            borda: BTreeMap::new(),
1824            winner: 'A',
1825            rankings: 1,
1826            unanimous_initial: false,
1827            deliberated: false,
1828            changed_votes: 0,
1829            unanimous_final: false,
1830            tie_break: None,
1831            judges: 3,
1832            present: 1,
1833            quorum: 2,
1834            met_quorum: false,
1835            uncontested: None,
1836        });
1837        let text = run(&s);
1838        assert!(text.contains("1/3"), "{text}");
1839        assert!(
1840            text.contains("BELOW QUORUM"),
1841            "a real collapse must still be flagged: {text}"
1842        );
1843        assert!(
1844            !text.contains("not needed"),
1845            "a collapsed panel must not be described as one that was never asked: {text}"
1846        );
1847    }
1848
1849    #[test]
1850    fn a_mode_none_merge_does_not_read_as_landed() {
1851        let _guard = plain();
1852        let mut s = state();
1853        s.merge = Some(MergeOutcome {
1854            mode: crate::config::MergeMode::None,
1855            ok: true,
1856            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1857            empty: false,
1858        });
1859        let text = run(&s);
1860        assert!(
1861            !text.contains("  ok"),
1862            "mode none must not be shown as a landed merge: {text}"
1863        );
1864        assert!(text.contains("not landed"), "{text}");
1865        assert!(
1866            text.contains("branch magi/x/A"),
1867            "the report must say what's left behind: {text}"
1868        );
1869        assert!(
1870            text.contains("rebase"),
1871            "the report must point at the hand-landing steps: {text}"
1872        );
1873        assert!(
1874            !text.contains("placeholder subject"),
1875            "the default merge style is `merge`, which never inherits a \
1876             placeholder subject, so the squash caveat must not appear: {text}"
1877        );
1878    }
1879
1880    #[test]
1881    fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1882        let _guard = plain();
1883        let mut s = state();
1884        s.config.merge.style = MergeStyle::Squash;
1885        s.merge = Some(MergeOutcome {
1886            mode: crate::config::MergeMode::None,
1887            ok: true,
1888            detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1889                      retries\""
1890                .to_owned(),
1891            empty: false,
1892        });
1893        let text = run(&s);
1894        assert!(
1895            text.contains("placeholder subject"),
1896            "a squash-style manual merge must warn about the missing message: {text}"
1897        );
1898        assert!(text.contains("--squash"), "{text}");
1899    }
1900
1901    #[test]
1902    fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1903        let _guard = plain();
1904        let mut s = state();
1905        s.status = RunStatus::Ready;
1906        s.merge = Some(MergeOutcome {
1907            mode: crate::config::MergeMode::None,
1908            ok: true,
1909            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1910            empty: false,
1911        });
1912
1913        let list = line(&s);
1914        assert!(
1915            !list.contains(" ready "),
1916            "a mode-none run must not read as a plain ready in `magi list`: {list}"
1917        );
1918        assert!(list.contains("no-op by design"), "{list}");
1919
1920        let full = run(&s);
1921        assert!(
1922            !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1923            "the header line of `magi show` must not say plain ready either: {full}"
1924        );
1925        assert!(full.contains("no-op by design"), "{full}");
1926    }
1927
1928    #[test]
1929    fn an_ordinary_ready_run_still_reads_as_ready() {
1930        let _guard = plain();
1931        let mut s = state();
1932        s.status = RunStatus::Ready;
1933        // A PR closed without merging also ends at `Ready` (see `land.rs`),
1934        // and unlike the honest mode-none no-op it must keep reading as a
1935        // plain `ready` — the label exists to flag design, not every non-merge.
1936        s.merge = Some(MergeOutcome {
1937            mode: crate::config::MergeMode::Pr,
1938            ok: false,
1939            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1940            empty: false,
1941        });
1942
1943        let list = line(&s);
1944        assert!(list.contains("ready"), "{list}");
1945        assert!(!list.contains("no-op by design"), "{list}");
1946    }
1947
1948    #[test]
1949    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1950        let _guard = plain();
1951        let mut s = state();
1952        s.tally = Some(Tally {
1953            first_choice: BTreeMap::from([('A', 0)]),
1954            borda: BTreeMap::new(),
1955            winner: 'A',
1956            rankings: 0,
1957            unanimous_initial: false,
1958            deliberated: false,
1959            changed_votes: 0,
1960            unanimous_final: false,
1961            tie_break: None,
1962            judges: 0,
1963            present: 0,
1964            quorum: 0,
1965            met_quorum: true,
1966            uncontested: Some("only candidate A produced a usable change".to_owned()),
1967        });
1968        let l = line(&s);
1969        assert!(
1970            !l.contains("judges") && !l.contains("quorum"),
1971            "an uncontested run must not carry the same badge a short panel gets: {l}"
1972        );
1973    }
1974
1975    #[test]
1976    fn long_instructions_are_elided() {
1977        let _guard = plain();
1978        let mut s = state();
1979        s.instruction = "x".repeat(200);
1980        assert!(line(&s).contains('…'));
1981    }
1982
1983    #[test]
1984    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1985        let _guard = plain();
1986        let mut lost = state();
1987        lost.reviews = vec![ReviewRound {
1988            round: 1,
1989            head: "abc1234".to_owned(),
1990            verified_head: None,
1991            verified_at: None,
1992            reviews: Vec::new(),
1993            e2e: Vec::new(),
1994            verify_retried: false,
1995            e2e_deferred: false,
1996            e2e_defer_reason: None,
1997            fix: Some(FixRecord {
1998                agent: "opus".to_owned(),
1999                addressed: Vec::new(),
2000                rejected: Vec::new(),
2001                notes: String::new(),
2002                committed: true,
2003                failed: Some("timed out".to_owned()),
2004                duration_ms: 0,
2005                continuation: None,
2006            }),
2007            blocking: 3,
2008            answered: 0,
2009            expected: 0,
2010            clean: false,
2011            progressed: false,
2012            vote_split: false,
2013            reconsideration: Vec::new(),
2014            verdict: None,
2015        }];
2016        let text = run(&lost);
2017        assert!(text.contains("adoption report lost (timed out)"), "{text}");
2018        assert!(
2019            !text.contains("0 addressed"),
2020            "a lost report must never read as `0 addressed`: {text}"
2021        );
2022
2023        let mut rejected_all = state();
2024        rejected_all.reviews = vec![ReviewRound {
2025            round: 1,
2026            head: "abc1234".to_owned(),
2027            verified_head: None,
2028            verified_at: None,
2029            reviews: Vec::new(),
2030            e2e: Vec::new(),
2031            verify_retried: false,
2032            e2e_deferred: false,
2033            e2e_defer_reason: None,
2034            fix: Some(FixRecord {
2035                agent: "opus".to_owned(),
2036                addressed: Vec::new(),
2037                rejected: Vec::new(),
2038                notes: String::new(),
2039                committed: true,
2040                failed: None,
2041                duration_ms: 0,
2042                continuation: None,
2043            }),
2044            blocking: 3,
2045            answered: 0,
2046            expected: 0,
2047            clean: false,
2048            progressed: false,
2049            vote_split: false,
2050            reconsideration: Vec::new(),
2051            verdict: None,
2052        }];
2053        let text2 = run(&rejected_all);
2054        assert!(
2055            text2.contains("0 addressed / 0 rejected"),
2056            "a round the fixer actually reported on keeps the count: {text2}"
2057        );
2058    }
2059
2060    #[test]
2061    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
2062        use crate::run::ReviewRevoteRecord;
2063        use crate::verdict::ReviewVote;
2064
2065        let _guard = plain();
2066        let mut s = state();
2067        s.reviews = vec![ReviewRound {
2068            round: 1,
2069            head: "abc1234".to_owned(),
2070            verified_head: None,
2071            verified_at: None,
2072            reviews: vec![
2073                ReviewRecord {
2074                    attempts: 0,
2075                    reviewer: 1,
2076                    agent: "alpha".to_owned(),
2077                    summary: String::new(),
2078                    findings: Vec::new(),
2079                    vote: Some(ReviewVote::Approve),
2080                    failed: None,
2081                    duration_ms: 0,
2082                },
2083                ReviewRecord {
2084                    attempts: 0,
2085                    reviewer: 2,
2086                    agent: "beta".to_owned(),
2087                    summary: String::new(),
2088                    findings: Vec::new(),
2089                    vote: Some(ReviewVote::Reject),
2090                    failed: None,
2091                    duration_ms: 0,
2092                },
2093            ],
2094            e2e: Vec::new(),
2095            verify_retried: false,
2096            e2e_deferred: false,
2097            e2e_defer_reason: None,
2098            fix: None,
2099            blocking: 0,
2100            answered: 2,
2101            expected: 2,
2102            clean: false,
2103            progressed: false,
2104            vote_split: true,
2105            reconsideration: vec![ReviewRevoteRecord {
2106                reviewer: 2,
2107                agent: "beta".to_owned(),
2108                vote: Some(ReviewVote::ApproveWithFindings),
2109                reason: "the other seat's read holds up".to_owned(),
2110                failed: None,
2111            }],
2112            verdict: Some(ReviewVote::ApproveWithFindings),
2113        }];
2114        let text = run(&s);
2115        assert!(text.contains("review-1 vote"), "{text}");
2116        assert!(text.contains("review-2 vote"), "{text}");
2117        assert!(text.contains("panel split"), "{text}");
2118        assert!(text.contains("reconsideration"), "{text}");
2119        assert!(text.contains("the other seat's read holds up"), "{text}");
2120    }
2121
2122    #[test]
2123    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
2124        // Two independent facts share this one line, and each arrived from a
2125        // different change: a seat that never answered, and a fixer whose
2126        // adoption report was lost. Rendering either must not shadow the
2127        // other, and neither may collapse into the plain `clean`/`open`
2128        // pair the line used to carry.
2129        let _guard = plain();
2130        let mut s = state();
2131        s.reviews = vec![ReviewRound {
2132            round: 1,
2133            head: "abc1234".to_owned(),
2134            verified_head: None,
2135            verified_at: None,
2136            reviews: vec![
2137                ReviewRecord {
2138                    attempts: 0,
2139                    reviewer: 1,
2140                    agent: "alpha".to_owned(),
2141                    summary: String::new(),
2142                    findings: Vec::new(),
2143                    vote: None,
2144                    failed: None,
2145                    duration_ms: 0,
2146                },
2147                ReviewRecord {
2148                    attempts: 0,
2149                    reviewer: 2,
2150                    agent: "beta".to_owned(),
2151                    summary: String::new(),
2152                    findings: Vec::new(),
2153                    vote: None,
2154                    failed: Some("agent timed out".to_owned()),
2155                    duration_ms: 0,
2156                },
2157            ],
2158            e2e: Vec::new(),
2159            verify_retried: false,
2160            e2e_deferred: false,
2161            e2e_defer_reason: None,
2162            fix: Some(FixRecord {
2163                agent: "opus".to_owned(),
2164                addressed: Vec::new(),
2165                rejected: Vec::new(),
2166                notes: String::new(),
2167                committed: true,
2168                failed: Some("timed out".to_owned()),
2169                duration_ms: 0,
2170                continuation: None,
2171            }),
2172            blocking: 0,
2173            answered: 1,
2174            expected: 2,
2175            clean: false,
2176            progressed: true,
2177            vote_split: false,
2178            reconsideration: Vec::new(),
2179            verdict: None,
2180        }];
2181        let text = run(&s);
2182        assert!(text.contains("incomplete"), "{text}");
2183        assert!(text.contains("1/2 reviewers answered"), "{text}");
2184        assert!(text.contains("review-2: agent timed out"), "{text}");
2185        assert!(text.contains("adoption report lost (timed out)"), "{text}");
2186        assert!(
2187            !text.contains("clean"),
2188            "a round missing half its panel must never render as clean: {text}"
2189        );
2190    }
2191
2192    #[test]
2193    fn a_build_failure_is_not_reported_as_a_test_failure() {
2194        let _guard = plain();
2195        let mut s = state();
2196        s.reviews = vec![ReviewRound {
2197            round: 1,
2198            head: "abc1234".to_owned(),
2199            verified_head: None,
2200            verified_at: None,
2201            reviews: Vec::new(),
2202            e2e: vec![CommandOutcome {
2203                command: "cargo test".to_owned(),
2204                code: Some(1),
2205                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2206                duration_ms: 100,
2207                resource_blocked: false,
2208            }],
2209            verify_retried: true,
2210            e2e_deferred: false,
2211            e2e_defer_reason: None,
2212            fix: None,
2213            blocking: 0,
2214            answered: 0,
2215            expected: 0,
2216            clean: false,
2217            progressed: false,
2218            vote_split: false,
2219            reconsideration: Vec::new(),
2220            verdict: None,
2221        }];
2222        let text = run(&s);
2223        assert!(text.contains("could not run"), "{text}");
2224        assert!(text.contains("retried once"), "{text}");
2225        assert!(!text.contains("e2e RED"), "{text}");
2226    }
2227
2228    #[test]
2229    fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2230        let _guard = plain();
2231        let mut s = state();
2232        s.reviews = vec![ReviewRound {
2233            round: 1,
2234            head: "abc1234".to_owned(),
2235            verified_head: None,
2236            verified_at: None,
2237            reviews: Vec::new(),
2238            e2e: vec![CommandOutcome {
2239                command: "(waiting for the shared build cache)".to_owned(),
2240                code: None,
2241                output_tail: "held by run x node e2e seat e2e".to_owned(),
2242                duration_ms: 100,
2243                resource_blocked: true,
2244            }],
2245            verify_retried: false,
2246            e2e_deferred: false,
2247            e2e_defer_reason: None,
2248            fix: None,
2249            blocking: 0,
2250            answered: 0,
2251            expected: 0,
2252            clean: false,
2253            progressed: false,
2254            vote_split: false,
2255            reconsideration: Vec::new(),
2256            verdict: None,
2257        }];
2258        let text = run(&s);
2259        assert!(text.contains("shared build cache unavailable"), "{text}");
2260        assert!(!text.contains("e2e RED"), "{text}");
2261        assert!(!text.contains("build/link failure"), "{text}");
2262    }
2263
2264    #[test]
2265    fn a_round_with_more_than_one_e2e_command_names_each_one() {
2266        // The aggregate `e2e RED` label says the round's overall verdict,
2267        // never which of several `verify.e2e` commands actually failed —
2268        // `magi show` must list each command by name, the same way it
2269        // already does for `gate`.
2270        let _guard = plain();
2271        let mut s = state();
2272        s.reviews = vec![ReviewRound {
2273            round: 1,
2274            head: "abc1234".to_owned(),
2275            verified_head: Some("abc1234".to_owned()),
2276            verified_at: Some(jiff::Timestamp::now()),
2277            reviews: Vec::new(),
2278            e2e: vec![
2279                CommandOutcome {
2280                    command: "cargo test --locked --all-targets".to_owned(),
2281                    code: Some(0),
2282                    output_tail: String::new(),
2283                    duration_ms: 0,
2284                    resource_blocked: false,
2285                },
2286                CommandOutcome {
2287                    command: "cargo make check".to_owned(),
2288                    code: Some(1),
2289                    output_tail: "clippy: unused import".to_owned(),
2290                    duration_ms: 0,
2291                    resource_blocked: false,
2292                },
2293            ],
2294            verify_retried: false,
2295            e2e_deferred: false,
2296            e2e_defer_reason: None,
2297            fix: None,
2298            blocking: 0,
2299            answered: 0,
2300            expected: 0,
2301            clean: false,
2302            progressed: false,
2303            vote_split: false,
2304            reconsideration: Vec::new(),
2305            verdict: None,
2306        }];
2307        let text = run(&s);
2308        assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2309        assert!(text.contains("cargo make check"), "{text}");
2310        assert!(text.contains("clippy: unused import"), "{text}");
2311    }
2312
2313    #[test]
2314    fn a_declined_finding_shows_its_reason() {
2315        use crate::verdict::{Finding, Rejection, Severity};
2316
2317        let _guard = plain();
2318        let mut s = state();
2319        s.status = RunStatus::Ready;
2320        s.reviews = vec![ReviewRound {
2321            round: 1,
2322            head: "deadbee".to_owned(),
2323            verified_head: None,
2324            verified_at: None,
2325            reviews: vec![ReviewRecord {
2326                attempts: 0,
2327                reviewer: 1,
2328                agent: "alpha".to_owned(),
2329                summary: String::new(),
2330                findings: vec![Finding {
2331                    id: "R1-1-1".to_owned(),
2332                    severity: Severity::Major,
2333                    file: None,
2334                    line: None,
2335                    title: "still open".to_owned(),
2336                    detail: String::new(),
2337                }],
2338                vote: None,
2339                failed: None,
2340                duration_ms: 0,
2341            }],
2342            e2e: vec![CommandOutcome {
2343                command: "cargo test".to_owned(),
2344                code: Some(0),
2345                output_tail: String::new(),
2346                duration_ms: 0,
2347                resource_blocked: false,
2348            }],
2349            verify_retried: false,
2350            e2e_deferred: false,
2351            e2e_defer_reason: None,
2352            fix: Some(FixRecord {
2353                agent: "alpha".to_owned(),
2354                addressed: Vec::new(),
2355                rejected: vec![Rejection {
2356                    id: "R1-1-2".to_owned(),
2357                    why: "cannot be triggered from any caller".to_owned(),
2358                }],
2359                notes: String::new(),
2360                committed: true,
2361                failed: None,
2362                duration_ms: 0,
2363                continuation: None,
2364            }),
2365            blocking: 1,
2366            answered: 1,
2367            expected: 1,
2368            clean: false,
2369            progressed: true,
2370            vote_split: false,
2371            reconsideration: Vec::new(),
2372            verdict: None,
2373        }];
2374
2375        let text = run(&s);
2376        assert!(text.contains("R1-1-2"), "{text}");
2377        assert!(text.contains("cannot be triggered"), "{text}");
2378        assert!(text.contains("still open"), "{text}");
2379        assert!(
2380            text.contains("handed off"),
2381            "a mergeable run with an open round must say so: {text}"
2382        );
2383    }
2384
2385    #[test]
2386    fn a_failing_gate_command_shows_its_output() {
2387        let _guard = plain();
2388        let mut s = state();
2389        s.status = RunStatus::Blocked;
2390        s.gate = vec![CommandOutcome {
2391            command: "cargo make check".to_owned(),
2392            code: Some(101),
2393            output_tail: "error[E0308]: mismatched types".to_owned(),
2394            duration_ms: 0,
2395            resource_blocked: false,
2396        }];
2397        s.gate_ran = true;
2398
2399        let text = run(&s);
2400        assert!(text.contains("mismatched types"), "{text}");
2401    }
2402
2403    #[test]
2404    fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
2405        let _guard = plain();
2406        let mut s = state();
2407        s.status = RunStatus::Ready;
2408        s.gate_ran = true;
2409        assert!(s.gate.is_empty());
2410
2411        let text = run(&s);
2412        assert!(
2413            text.contains("gate") && text.contains("no gate commands configured"),
2414            "a run gated on nothing must say so, not read as if the gate never ran: {text}"
2415        );
2416    }
2417
2418    #[test]
2419    fn a_gate_that_has_not_run_yet_shows_nothing() {
2420        let _guard = plain();
2421        let s = state();
2422        assert!(!s.gate_ran);
2423        assert!(s.gate.is_empty());
2424
2425        let text = run(&s);
2426        assert!(
2427            !text.contains("no gate commands configured"),
2428            "an unattempted gate must not be shown as a pass: {text}"
2429        );
2430    }
2431
2432    #[test]
2433    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2434        let _guard = plain();
2435        let mut s = state();
2436        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2437        let text = active_seats(&s, Liveness::Live);
2438        assert!(text.contains("running now"));
2439        assert!(text.contains("judge-2"));
2440        assert!(text.contains("judge"));
2441        assert!(!text.contains("no live daemon"), "{text}");
2442        assert!(!text.contains("could not be confirmed"), "{text}");
2443    }
2444
2445    #[test]
2446    fn active_seats_flags_a_leftover_from_a_dead_process() {
2447        let _guard = plain();
2448        let mut s = state();
2449        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2450        let text = active_seats(&s, Liveness::Dead);
2451        assert!(
2452            text.contains("no live daemon"),
2453            "a stale entry must not read as running: {text}"
2454        );
2455    }
2456
2457    #[test]
2458    fn superseded_notice_names_the_run_that_replaced_it() {
2459        let _guard = plain();
2460        let text = superseded_notice(Some("bbbb"));
2461        assert!(text.contains("SUPERSEDED"), "{text}");
2462        assert!(text.contains("bbbb"), "{text}");
2463    }
2464
2465    #[test]
2466    fn superseded_notice_is_silent_for_the_latest_attempt() {
2467        let _guard = plain();
2468        assert_eq!(superseded_notice(None), "");
2469    }
2470
2471    #[test]
2472    fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2473        let _guard = plain();
2474        let mut s = state();
2475        s.status = RunStatus::Reviewing;
2476        let text = line_with_liveness(&s, Liveness::Dead);
2477        assert!(text.contains("STALE"), "{text}");
2478        assert!(text.contains("resume required"), "{text}");
2479        assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2480    }
2481
2482    /// A manual `magi run` / `magi review` claims no daemon, but its
2483    /// `driver_pid` still answers — this is `Unknown`, never `Dead`: see
2484    /// `RunState::liveness`'s own doc for why an unconfirmed process is never
2485    /// folded into "confirmed dead".
2486    #[test]
2487    fn active_seats_reports_uncertainty_without_claiming_death() {
2488        let _guard = plain();
2489        let mut s = state();
2490        s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2491        let text = active_seats(&s, Liveness::Unknown);
2492        assert!(
2493            text.contains("could not be confirmed"),
2494            "an unproven state must read as uncertain, not dead: {text}"
2495        );
2496        assert!(!text.contains("no live daemon"), "{text}");
2497    }
2498
2499    #[test]
2500    fn active_seats_is_empty_when_nothing_is_running() {
2501        let _guard = plain();
2502        assert_eq!(active_seats(&state(), Liveness::Live), "");
2503    }
2504
2505    /// A running `verify.e2e` / `verify.gate` task is shown alongside seats,
2506    /// with which command is currently running and how far through the list
2507    /// it is — the gap the addendum's second item names: a review-only run
2508    /// that spends minutes inside `cargo test` between reviewer answers and
2509    /// the fix's own commit had nothing at all to show for it before this.
2510    #[test]
2511    fn active_seats_shows_a_running_verify_task_and_its_command() {
2512        let _guard = plain();
2513        let mut s = state();
2514        s.task_command(
2515            "e2e",
2516            "verify",
2517            0,
2518            "cargo test",
2519            2,
2520            3,
2521            std::time::Duration::from_secs(600),
2522        );
2523        let text = active_seats(&s, Liveness::Live);
2524        assert!(text.contains("e2e"), "{text}");
2525        assert!(text.contains("(2/3)"), "{text}");
2526        assert!(text.contains("cargo test"), "{text}");
2527    }
2528
2529    #[test]
2530    fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2531        let _guard = plain();
2532        // The common case today (every backend but codex): silence, not a
2533        // clutter line repeated on every single `magi show`.
2534        assert!(!run(&state()).contains("background jobs"));
2535    }
2536
2537    #[test]
2538    fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2539        let _guard = plain();
2540        let mut s = state();
2541        s.config.agents.push(crate::config::AgentSpec {
2542            id: "codex-one".to_owned(),
2543            kind: crate::config::AgentKind::Codex,
2544            model: None,
2545            command: vec!["codex".to_owned()],
2546            extra_args: Vec::new(),
2547            env: BTreeMap::new(),
2548            prompt_delivery: None,
2549        });
2550        let text = run(&s);
2551        assert!(
2552            text.contains("background jobs"),
2553            "a run that could report this must not read the same as one that never could: \
2554             {text}"
2555        );
2556        assert!(text.contains("no completed command evidence yet"));
2557    }
2558
2559    #[test]
2560    fn recovered_running_and_unreadable_jobs_are_told_apart() {
2561        let _guard = plain();
2562        let mut s = state();
2563        s.jobs = vec![
2564            crate::run::JobRecord {
2565                node: "implement".to_owned(),
2566                round: None,
2567                seat: "impl-A".to_owned(),
2568                id: "item49".to_owned(),
2569                description: "cargo test --test graph_cached_gate".to_owned(),
2570                checked_at: jiff::Timestamp::now(),
2571                status: crate::run::JobStatus::Completed,
2572                exit_code: Some(0),
2573                result_summary: "test result: 2 passed; 0 failed".to_owned(),
2574                source: "codex".to_owned(),
2575            },
2576            crate::run::JobRecord {
2577                node: "fix".to_owned(),
2578                round: None,
2579                seat: "impl-A".to_owned(),
2580                id: "item52".to_owned(),
2581                description: "cargo test --test graph_split".to_owned(),
2582                checked_at: jiff::Timestamp::now(),
2583                status: crate::run::JobStatus::Failed,
2584                exit_code: Some(101),
2585                result_summary: "test result: 1 passed; 1 failed".to_owned(),
2586                source: "codex".to_owned(),
2587            },
2588            crate::run::JobRecord {
2589                node: "fix".to_owned(),
2590                round: None,
2591                seat: "impl-A".to_owned(),
2592                id: "item60".to_owned(),
2593                description: "cargo build".to_owned(),
2594                checked_at: jiff::Timestamp::now(),
2595                status: crate::run::JobStatus::Unknown,
2596                exit_code: None,
2597                result_summary: String::new(),
2598                source: "codex".to_owned(),
2599            },
2600        ];
2601        let text = run(&s);
2602        assert!(text.contains("background jobs"));
2603        assert!(text.contains("item49"));
2604        assert!(text.contains("item52"));
2605        assert!(text.contains("item60"));
2606        // The three states this run actually has evidence for must read
2607        // differently from one another — never collapsed into a single
2608        // "ran" or "did not run".
2609        assert!(text.contains("completed"));
2610        assert!(text.contains("failed"));
2611        assert!(text.contains("unknown"));
2612        // Coverage limit stated once, not fabricated per seat.
2613        assert!(text.contains("adapter coverage"));
2614    }
2615
2616    #[test]
2617    fn a_jobs_own_round_is_shown_when_known() {
2618        let _guard = plain();
2619        let mut s = state();
2620        s.jobs = vec![crate::run::JobRecord {
2621            node: "review".to_owned(),
2622            round: Some(2),
2623            seat: "review-1".to_owned(),
2624            id: "item9".to_owned(),
2625            description: "cargo test --test graph_cached_gate".to_owned(),
2626            checked_at: jiff::Timestamp::now(),
2627            status: crate::run::JobStatus::Completed,
2628            exit_code: Some(0),
2629            result_summary: "test result: 2 passed; 0 failed".to_owned(),
2630            source: "codex".to_owned(),
2631        }];
2632        let text = run(&s);
2633        assert!(
2634            text.contains("round 2"),
2635            "the round this seat's own command ran in must be visible, distinct from magi's \
2636             own recorded verify: {text}"
2637        );
2638    }
2639
2640    #[test]
2641    fn stats_table_renders_without_runs() {
2642        let _guard = plain();
2643        let text = stats(&Stats::default());
2644        assert!(text.contains("0 total"));
2645        assert!(!text.contains("implementation"));
2646        assert!(!text.contains("node durations"));
2647    }
2648
2649    #[test]
2650    fn repo_summary_lists_run_count_and_completion_rate() {
2651        let _guard = plain();
2652        let a = crate::stats::RepoStats {
2653            repo: PathBuf::from("/repos/a"),
2654            name: "a".to_owned(),
2655            stats: Stats {
2656                totals: crate::stats::Totals {
2657                    runs: 4,
2658                    merged: 2,
2659                    ready: 1,
2660                    ..Default::default()
2661                },
2662                ..Default::default()
2663            },
2664        };
2665        let text = repo_summary(&[a]);
2666        assert!(text.contains("repos"), "{text}");
2667        assert!(text.contains("a  4 runs  75% completion"), "{text}");
2668    }
2669
2670    #[test]
2671    fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2672        let _guard = plain();
2673        let a = crate::stats::RepoStats {
2674            repo: PathBuf::from("/roots/one/magi"),
2675            name: "magi".to_owned(),
2676            stats: Stats::default(),
2677        };
2678        let b = crate::stats::RepoStats {
2679            repo: PathBuf::from("/roots/two/magi"),
2680            name: "magi".to_owned(),
2681            stats: Stats::default(),
2682        };
2683        let text = repo_summary(&[a, b]);
2684        assert!(text.contains("magi (one)"), "{text}");
2685        assert!(text.contains("magi (two)"), "{text}");
2686    }
2687
2688    #[test]
2689    fn stats_table_renders_advisor_reflection() {
2690        let _guard = plain();
2691        let st = Stats {
2692            advisors: vec![crate::stats::AdvisorStats {
2693                agent: "alpha".to_owned(),
2694                seated: 3,
2695                proposed: 2,
2696                absent: 1,
2697                faint: 1,
2698                strong: 1,
2699            }],
2700            ..Default::default()
2701        };
2702        let text = stats(&st);
2703        assert!(text.contains("design deliberation"));
2704        assert!(
2705            text.contains("approximate"),
2706            "must note the heuristic: {text}"
2707        );
2708        assert!(text.contains("alpha"));
2709        assert!(text.contains("50%"));
2710    }
2711
2712    #[test]
2713    fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2714        // A repository that has merged runs but never wired up (or never hit)
2715        // the release-bump step must still show "0 of N merged runs recorded
2716        // a bump" - hiding the whole section on `recorded == 0` would make
2717        // that gap invisible instead of reporting it.
2718        let _guard = plain();
2719        let st = Stats {
2720            release_bumps: crate::stats::ReleaseBumpStats {
2721                merged: 3,
2722                ..Default::default()
2723            },
2724            ..Default::default()
2725        };
2726        let text = stats(&st);
2727        assert!(text.contains("release bumps"), "{text}");
2728        assert!(
2729            text.contains("0 of 3 merged run(s) recorded a bump"),
2730            "{text}"
2731        );
2732    }
2733
2734    #[test]
2735    fn stats_table_renders_release_bump_coverage_and_attention() {
2736        let _guard = plain();
2737        let st = Stats {
2738            release_bumps: crate::stats::ReleaseBumpStats {
2739                merged: 4,
2740                recorded: 2,
2741                pr_opened: 2,
2742                automerge_enabled: 1,
2743                merged_directly: 0,
2744                needs_attention: 1,
2745            },
2746            ..Default::default()
2747        };
2748        let text = stats(&st);
2749        assert!(text.contains("release bumps"), "{text}");
2750        assert!(
2751            text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2752            "{text}"
2753        );
2754        assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2755    }
2756
2757    #[test]
2758    fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2759        let _guard = plain();
2760        let st = Stats {
2761            nodes: vec![
2762                crate::stats::NodeDuration {
2763                    node: "implement".to_owned(),
2764                    runs: 2,
2765                    total_secs: 200,
2766                    max_secs: 150,
2767                    single: 1,
2768                },
2769                crate::stats::NodeDuration {
2770                    node: "gate".to_owned(),
2771                    runs: 0,
2772                    total_secs: 0,
2773                    max_secs: 0,
2774                    single: 3,
2775                },
2776            ],
2777            ..Default::default()
2778        };
2779        let text = stats(&st);
2780        assert!(text.contains("node durations"));
2781        assert!(text.contains("implement"));
2782        assert!(text.contains("2 runs"));
2783        assert!(text.contains('█'), "the leader must draw a bar: {text}");
2784        assert!(text.contains("1 unmeasured"));
2785        assert!(text.contains("gate"));
2786        assert!(text.contains("3 unmeasured"));
2787        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2788    }
2789
2790    #[test]
2791    fn implementation_rates_use_independent_low_n_boundaries() {
2792        let _guard = plain();
2793        for (competitive_n, solo_n) in [(9, 10), (10, 9)] {
2794            let text = stats(&Stats {
2795                agents: vec![crate::stats::AgentStats {
2796                    agent: "alpha".to_owned(),
2797                    competitive_entered: competitive_n,
2798                    competitive_wins: 1,
2799                    solo_entered: solo_n,
2800                    solo_adopted: 2,
2801                    ..Default::default()
2802                }],
2803                ..Default::default()
2804            });
2805            let (competitive, solo) = text
2806                .split_once("implementation (solo runs, completion/adoption rate)")
2807                .unwrap();
2808            for (section, n, numerator) in [(competitive, competitive_n, 1), (solo, solo_n, 2)] {
2809                let row = section.lines().find(|line| line.contains("alpha")).unwrap();
2810                let cells: Vec<_> = row.split_whitespace().collect();
2811                assert_eq!(cells[1], numerator.to_string());
2812                assert_eq!(cells[2], n.to_string());
2813                assert_eq!(row.contains("low n"), n < 10);
2814            }
2815        }
2816    }
2817
2818    #[test]
2819    fn stats_table_renders_competitive_and_solo_tables_with_low_n() {
2820        let _guard = plain();
2821        let st = Stats {
2822            agents: vec![
2823                crate::stats::AgentStats {
2824                    agent: "alpha".to_owned(),
2825                    entered: 15,
2826                    wins: 10,
2827                    empty: 0,
2828                    competitive_entered: 12,
2829                    competitive_wins: 9,
2830                    solo_entered: 3,
2831                    solo_adopted: 1,
2832                },
2833                crate::stats::AgentStats {
2834                    agent: "beta".to_owned(),
2835                    entered: 5,
2836                    wins: 2,
2837                    empty: 1,
2838                    competitive_entered: 4,
2839                    competitive_wins: 2,
2840                    solo_entered: 1,
2841                    solo_adopted: 0,
2842                },
2843            ],
2844            ..Default::default()
2845        };
2846        let text = stats(&st);
2847        assert!(text.contains("implementation (competitive runs, 2+ candidates)"));
2848        assert!(text.contains("alpha"));
2849        assert!(text.contains("75%"));
2850        assert!(text.contains("beta"));
2851        assert!(text.contains("50%"));
2852        assert!(text.contains("implementation (solo runs, completion/adoption rate)"));
2853        assert!(text.contains("33%"));
2854        assert!(text.contains("low n"));
2855    }
2856}