Skip to main content

mecha_core/
doctor.rs

1//! `mecha doctor` — every store's distress, read in one pass.
2//!
3//! The incident this exists to end: a revoked OAuth token killed the
4//! scheduling pipeline for three days, and every component recorded its
5//! trouble correctly *in its own store* — an `auth_error.json` marker beside
6//! the credentials, outbox items pending with a release error, frontdoor
7//! requests parked in `awaiting_me`, trigger-ledger rows — while the operator
8//! learned nothing, because nothing reads **across** the stores. Doctor is
9//! that read.
10//!
11//! Two rules carry the design:
12//!
13//! - **Doctor is an observer, never load-bearing.** No network, no model, no
14//!   tokens — and no writes: the stores are read directly rather than through
15//!   the store constructors, because those create and re-chmod their
16//!   directories on open, and an examination that heals the permissions it
17//!   was about to report is measuring itself. Every check is individually
18//!   best-effort: an unreadable or unparseable store is itself a finding
19//!   ("store unreadable: <why>"), never a crash, and one check's failure
20//!   never stops the others.
21//! - **Fixes go through existing commands only.** A [`Remedy`] is an argv —
22//!   `mecha-mail auth personal --provider google`, `mecha outbox review` —
23//!   never a direct mutation of a store. In particular doctor never releases
24//!   an outbox draft: the remedy for stuck drafts is opening the review
25//!   surface, full stop.
26//!
27//! The checks are pure functions over injected store roots and an injected
28//! `now`, which is what makes "a 49-hour-old pending draft" a unit test
29//! instead of a two-day wait.
30
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use std::collections::BTreeMap;
34use std::path::Path;
35
36/// How bad a finding is. Declared broken-first so the derived order is the
37/// display order: what is broken outranks what merely wants attention.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Severity {
41    /// Something is failing right now — a dead login, a release that errored.
42    Broken,
43    /// Nothing is failing, but something has sat unresolved long enough that
44    /// silence is the more likely explanation than intent.
45    Attention,
46}
47
48impl Severity {
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            Severity::Broken => "broken",
52            Severity::Attention => "attention",
53        }
54    }
55}
56
57/// A proposed fix: an existing command, never a store mutation.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Remedy {
60    /// One line saying what running it does — and, where ordering matters,
61    /// what to do first.
62    pub description: String,
63    /// The command as an argv, ready to spawn. Never empty.
64    pub argv: Vec<String>,
65    /// Whether the command needs the real terminal — an OAuth flow, an
66    /// `$EDITOR` — and must therefore inherit stdin and the screen rather
67    /// than being run with its output captured.
68    pub needs_terminal: bool,
69}
70
71/// One observation: which component, how bad, what, and the way out if one
72/// is known.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Finding {
75    pub component: String,
76    pub severity: Severity,
77    pub summary: String,
78    pub detail: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub remedy: Option<Remedy>,
81}
82
83impl Finding {
84    /// The observer rule made a constructor: a store doctor cannot read is a
85    /// finding about that store, never an error that stops the other checks.
86    fn unreadable(component: &str, what: &str, why: impl std::fmt::Display) -> Finding {
87        Finding {
88            component: component.to_string(),
89            severity: Severity::Attention,
90            summary: format!("store unreadable: {what}"),
91            detail: why.to_string(),
92            remedy: None,
93        }
94    }
95}
96
97/// A pending draft older than this with no error has most likely been
98/// forgotten rather than deliberately parked.
99const STUCK_DRAFT_AFTER: chrono::Duration = chrono::Duration::hours(48);
100
101/// A frontdoor request waiting on the user for longer than this is the
102/// stranger-facing silence the front door exists to prevent.
103const STALE_REQUEST_AFTER: chrono::Duration = chrono::Duration::hours(72);
104
105/// Examine every store under `home` and report what is wrong.
106///
107/// `now` is injected for testability; nothing here consults the clock.
108/// Best-effort throughout: each check appends what it found, a failed check
109/// appends a finding about the failure, and no check can stop another.
110pub fn examine(home: &Path, now: DateTime<Utc>) -> Vec<Finding> {
111    let mut findings = Vec::new();
112    findings.extend(check_mail(&home.join("mail")));
113    findings.extend(check_legacy_mail(home));
114    findings.extend(check_outbox(&home.join("outbox"), now));
115    findings.extend(check_questions(&home.join("questions"), now));
116    findings.extend(check_frontdoor(&home.join("requests"), now));
117    findings.extend(check_triggers(&home.join("triggers"), now));
118    findings.extend(check_charter(&home.join("charter.toml")));
119    findings.extend(check_runs(&home.join("sessions")));
120    findings.extend(check_harness(&home.join("learning").join("harness"), now));
121    findings.extend(check_learning(&home.join("learning"), now));
122    // The graph store is `~/.mecha-graph`, a hidden sibling of the mecha home
123    // by that store's own convention — resolved relative to `home` so a test
124    // (or a relocated home) carries its sibling with it.
125    if let Some(parent) = home.parent() {
126        findings.extend(check_graph_nightly(&parent.join(".mecha-graph"), now));
127    }
128    sort(&mut findings);
129    findings
130}
131
132/// Severity first, then component, then insertion order — the shape both the
133/// renderer and the JSON output present.
134pub fn sort(findings: &mut [Finding]) {
135    findings.sort_by(|a, b| {
136        a.severity
137            .cmp(&b.severity)
138            .then_with(|| a.component.cmp(&b.component))
139    });
140}
141
142// --- dead mail auth ---------------------------------------------------------
143
144/// `auth_error.json`, structurally. The writer is `mecha-mail`'s token
145/// lifecycle; the seam is a file of JSON exactly like the frontdoor's
146/// directory-of-JSON, which is why core takes no `mecha-mail` dependency to
147/// read it.
148#[derive(Debug, Deserialize)]
149struct AuthMarker {
150    at: String,
151    message: String,
152}
153
154/// `accounts.toml`, structurally, for the same reason. Only the fields doctor
155/// needs; unknown ones are ignored.
156#[derive(Debug, Default, Deserialize)]
157struct MailAccounts {
158    #[serde(default, rename = "account")]
159    accounts: Vec<MailAccount>,
160}
161
162#[derive(Debug, Deserialize)]
163struct MailAccount {
164    name: String,
165    provider: String,
166    /// Declared lifetime of the refresh credential, in days. See
167    /// `mecha_mail::accounts::AccountEntry::grant_lifetime_days` — absent
168    /// means no known expiry, and no warning.
169    #[serde(default)]
170    grant_lifetime_days: Option<u32>,
171}
172
173/// Scan `<mail>/*/auth_error.json`. Presence means a *permanent* refresh
174/// failure — the marker is written on `invalid_grant` and cleared by the next
175/// successful credential save — so a marker is Broken, not a maybe.
176fn check_mail(mail: &Path) -> Vec<Finding> {
177    let mut out = Vec::new();
178    if !mail.is_dir() {
179        return out;
180    }
181
182    // Provider per account, best-effort: an unparseable registry costs the
183    // `--provider` flag on the remedy, never the finding itself.
184    let declared: Vec<MailAccount> = std::fs::read_to_string(mail.join("accounts.toml"))
185        .ok()
186        .and_then(|text| toml::from_str::<MailAccounts>(&text).ok())
187        .map(|file| file.accounts)
188        .unwrap_or_default();
189    let providers: BTreeMap<String, String> = declared
190        .iter()
191        .map(|a| (a.name.clone(), a.provider.clone()))
192        .collect();
193    let lifetimes: BTreeMap<String, u32> = declared
194        .iter()
195        .filter_map(|a| a.grant_lifetime_days.map(|d| (a.name.clone(), d)))
196        .collect();
197
198    let entries = match std::fs::read_dir(mail) {
199        Ok(entries) => entries,
200        Err(e) => {
201            out.push(Finding::unreadable(
202                "mail",
203                "the mail directory",
204                format!("{}: {e}", mail.display()),
205            ));
206            return out;
207        }
208    };
209
210    for entry in entries.flatten() {
211        let dir = entry.path();
212        if !dir.is_dir() {
213            continue;
214        }
215        let Some(account) = dir.file_name().and_then(|n| n.to_str()).map(String::from) else {
216            continue;
217        };
218        // A grant that predates the triage scopes refreshes cleanly forever
219        // and then 403s the first time something archives. That failure has
220        // no marker — nothing has gone wrong yet — so it is read off the
221        // credential file directly, structurally, like everything else here.
222        out.extend(check_triage_scope(&dir, &account, providers.get(&account)));
223        out.extend(check_grant_age(
224            &dir,
225            &account,
226            providers.get(&account),
227            lifetimes.get(&account).copied(),
228        ));
229
230        let marker_path = dir.join("auth_error.json");
231        if !marker_path.is_file() {
232            continue;
233        }
234        let text = match std::fs::read_to_string(&marker_path) {
235            Ok(text) => text,
236            Err(e) => {
237                out.push(Finding::unreadable(
238                    "mail",
239                    &format!("auth_error.json for `{account}`"),
240                    format!("{}: {e}", marker_path.display()),
241                ));
242                continue;
243            }
244        };
245        match serde_json::from_str::<AuthMarker>(&text) {
246            Ok(marker) => {
247                let provider = providers.get(&account);
248                let mut argv = vec![
249                    "mecha-mail".to_string(),
250                    "auth".to_string(),
251                    account.clone(),
252                ];
253                if let Some(provider) = provider {
254                    argv.push("--provider".to_string());
255                    argv.push(provider.clone());
256                }
257                out.push(Finding {
258                    component: "mail".to_string(),
259                    severity: Severity::Broken,
260                    summary: format!("mail auth for `{account}` is dead"),
261                    // The marker's message already names the exact re-auth
262                    // command, so it rides in the detail — which also covers
263                    // the case where accounts.toml could not say which
264                    // provider the remedy needs.
265                    detail: format!(
266                        "permanent refresh failure since {}: {}",
267                        marker.at, marker.message
268                    ),
269                    remedy: Some(Remedy {
270                        description: format!(
271                            "re-authenticate the `{account}` account (opens an OAuth flow)"
272                        ),
273                        argv,
274                        needs_terminal: true,
275                    }),
276                });
277            }
278            Err(e) => out.push(Finding::unreadable(
279                "mail",
280                &format!("auth_error.json for `{account}` did not parse"),
281                format!("{}: {e}", marker_path.display()),
282            )),
283        }
284    }
285    out
286}
287
288/// The scope a grant was minted with, as `mecha-mail` records it.
289///
290/// Read structurally rather than through `mecha-mail`'s type, for the reason
291/// the whole module gives: doctor takes no dependency on the crates it
292/// examines, and a field it does not know about must not stop it reading the
293/// one it does.
294#[derive(Debug, serde::Deserialize)]
295struct StoredGrant {
296    #[serde(default)]
297    granted_scopes: Option<String>,
298    #[serde(default)]
299    granted_at: Option<String>,
300}
301
302/// How many days before a grant expires to start saying so.
303///
304/// Two, because the remedy is a two-minute re-auth that needs a human at a
305/// terminal — long enough to survive a weekend-adjacent lapse, short enough
306/// that it is not background noise on a 7-day cycle. A warning that fires
307/// for most of the grant's life is a warning nobody reads.
308const GRANT_WARN_WITHIN_DAYS: i64 = 2;
309
310/// Warn before a grant with a known, fixed lifetime expires.
311///
312/// This exists because of the 2026-08-11 outage: Google expires the refresh
313/// token of an app in *Testing* publishing status exactly 7 days after
314/// consent, returns `invalid_grant` when it does — indistinguishable from a
315/// revocation — and scheduling went down for three days. Doctor reported it
316/// correctly *after* the fact. A recurring, dated failure deserves to be
317/// reported before it happens, which is the one thing a marker written on
318/// failure can never do.
319///
320/// Silent unless the lifetime was declared: see
321/// `AccountEntry::grant_lifetime_days` for why this is not inferred.
322fn check_grant_age(
323    dir: &Path,
324    account: &str,
325    provider: Option<&String>,
326    lifetime_days: Option<u32>,
327) -> Vec<Finding> {
328    let Some(lifetime) = lifetime_days.filter(|d| *d > 0) else {
329        return Vec::new();
330    };
331    let Ok(text) = std::fs::read_to_string(dir.join("oauth.json")) else {
332        return Vec::new();
333    };
334    let Ok(grant) = serde_json::from_str::<StoredGrant>(&text) else {
335        return Vec::new(); // already reported by the scope check
336    };
337    // An un-stamped grant predates the field. Its age is genuinely unknown,
338    // and inventing one would either cry wolf or promise safety — so say
339    // nothing and let the next re-auth start the clock honestly.
340    let Some(granted_at) = grant.granted_at.as_deref() else {
341        return Vec::new();
342    };
343    let Ok(granted) = chrono::DateTime::parse_from_rfc3339(granted_at) else {
344        return Vec::new();
345    };
346    let expires = granted.with_timezone(&chrono::Utc) + chrono::Duration::days(lifetime as i64);
347    // Hours, then round *up* to whole days. `num_days()` truncates toward
348    // zero, so a grant with 47 hours left reports "1 day" — which is both
349    // wrong and the wrong direction, since it makes the warning look more
350    // urgent than it is and then says "1 day" again tomorrow.
351    let hours_left = (expires - chrono::Utc::now()).num_hours();
352    let left = (hours_left as f64 / 24.0).ceil() as i64;
353    if left > GRANT_WARN_WITHIN_DAYS {
354        return Vec::new();
355    }
356    let when = if hours_left < 0 {
357        "has expired".to_string()
358    } else if hours_left < 24 {
359        "expires within a day".to_string()
360    } else {
361        format!("expires in {left} days")
362    };
363    let mut argv = vec![
364        "mecha-mail".to_string(),
365        "auth".to_string(),
366        account.to_string(),
367    ];
368    if let Some(p) = provider {
369        argv.push("--provider".to_string());
370        argv.push(p.clone());
371    }
372    vec![Finding {
373        component: "mail".to_string(),
374        severity: Severity::Attention,
375        summary: format!("`{account}` sign-in {when}"),
376        detail: format!(
377            "this grant lasts {lifetime} days from consent ({granted_at}) and refreshing does \
378             not extend it. Re-authenticate before it lapses — once it does, the failure looks \
379             like a revoked token and every scheduled run using this account stops."
380        ),
381        remedy: Some(Remedy {
382            description: format!("re-authenticate `{account}` now (opens an OAuth flow)"),
383            argv,
384            needs_terminal: true,
385        }),
386    }]
387}
388
389/// Which scope each provider needs before the triage verbs work. Mirrors
390/// `mecha_mail::token::triage_scope_for`; duplicated rather than imported
391/// because the seam here is a directory of JSON, not a crate dependency.
392fn triage_scope_for(provider: &str) -> Option<&'static str> {
393    match provider {
394        "google" => Some("gmail.modify"),
395        "outlook" | "microsoft" => Some("Mail.ReadWrite"),
396        _ => None,
397    }
398}
399
400/// Report an account whose OAuth grant does not cover archive/spam/read-state.
401///
402/// Only reported when the provider is known: guessing which scope a grant
403/// should carry would turn an unrecognised provider into a permanent false
404/// finding, and a doctor that cries wolf stops being read. An **absent**
405/// `granted_scopes` counts as not covered, which is correct rather than
406/// harsh — every grant written before the field existed predates the scopes
407/// too.
408///
409/// `Attention`, not `Broken`: nothing is failing right now — mail reads,
410/// sends and stages drafts exactly as before — but the first archive will
411/// fail, and that is precisely the "silence is the likely explanation"
412/// shape this severity is for. On a managed Microsoft tenant the remedy may
413/// also need an administrator rather than the user, so the detail says so
414/// instead of implying a re-auth alone will fix it.
415fn check_triage_scope(dir: &Path, account: &str, provider: Option<&String>) -> Vec<Finding> {
416    let Some(provider) = provider else {
417        return Vec::new();
418    };
419    let Some(needed) = triage_scope_for(provider) else {
420        return Vec::new();
421    };
422    let path = dir.join("oauth.json");
423    let Ok(text) = std::fs::read_to_string(&path) else {
424        // No credentials is not a scope problem; the account simply is not
425        // signed in, which other checks and the first real call will say.
426        return Vec::new();
427    };
428    let Ok(grant) = serde_json::from_str::<StoredGrant>(&text) else {
429        return vec![Finding::unreadable(
430            "mail",
431            &format!("oauth.json for `{account}` did not parse"),
432            format!("{}", path.display()),
433        )];
434    };
435    if grant
436        .granted_scopes
437        .as_deref()
438        .is_some_and(|g| g.contains(needed))
439    {
440        return Vec::new();
441    }
442    let admin_note = if provider == "outlook" || provider == "microsoft" {
443        " Microsoft blocks `Mail.ReadWrite` from end-user consent under its \
444         recommended policy, so on a managed tenant an administrator has to \
445         grant it to the app registration before this can succeed."
446    } else {
447        ""
448    };
449    vec![Finding {
450        component: "mail".to_string(),
451        severity: Severity::Attention,
452        summary: format!("`{account}` cannot archive, spam or mark mail read"),
453        detail: format!(
454            "the stored grant does not include `{needed}`, so mail_triage will fail on this \
455             account. Reading, sending and calendar work are unaffected.{admin_note}"
456        ),
457        remedy: Some(Remedy {
458            description: format!(
459                "re-authenticate `{account}` to add the triage scope (opens an OAuth flow)"
460            ),
461            argv: vec![
462                "mecha-mail".to_string(),
463                "auth".to_string(),
464                account.to_string(),
465                "--provider".to_string(),
466                provider.clone(),
467            ],
468            needs_terminal: true,
469        }),
470    }]
471}
472
473#[cfg(test)]
474mod grant_age_tests {
475    use super::*;
476
477    fn store(dir: &Path, granted_at: Option<&str>) {
478        std::fs::create_dir_all(dir).unwrap();
479        let stamp = granted_at
480            .map(|g| format!(r#","granted_at":"{g}""#))
481            .unwrap_or_default();
482        std::fs::write(
483            dir.join("oauth.json"),
484            format!(r#"{{"client_id":"i","access_token":"a","refresh_token":"r","expires_at":1{stamp}}}"#),
485        )
486        .unwrap();
487    }
488
489    fn days_ago(n: i64) -> String {
490        (chrono::Utc::now() - chrono::Duration::days(n)).to_rfc3339()
491    }
492
493    /// The 7-day Testing clock, reported before it fires rather than after.
494    #[test]
495    fn a_grant_nearing_its_declared_lifetime_is_reported_early() {
496        let tmp = std::env::temp_dir().join(format!("mecha-grant-{}", std::process::id()));
497        let g = "google".to_string();
498
499        // Fresh: silent. A warning that fires all week is not a warning.
500        store(&tmp, Some(&days_ago(1)));
501        assert!(check_grant_age(&tmp, "personal", Some(&g), Some(7)).is_empty());
502
503        // Day 5 of 7 — two days left, inside the window.
504        store(&tmp, Some(&days_ago(5)));
505        let f = check_grant_age(&tmp, "personal", Some(&g), Some(7));
506        assert_eq!(f.len(), 1, "should warn with 2 days left");
507        assert!(
508            f[0].summary.contains("expires in 2 days"),
509            "{}",
510            f[0].summary
511        );
512        assert!(f[0].remedy.as_ref().unwrap().needs_terminal);
513
514        // Under 24h: worded without a misleading whole-day count.
515        store(&tmp, Some(&days_ago(7)));
516        let f = check_grant_age(&tmp, "personal", Some(&g), Some(7));
517        assert!(f[0].summary.contains("within a day"), "{}", f[0].summary);
518
519        // Past it: still a finding, worded as past.
520        store(&tmp, Some(&days_ago(9)));
521        let f = check_grant_age(&tmp, "personal", Some(&g), Some(7));
522        assert!(f[0].summary.contains("has expired"), "{}", f[0].summary);
523
524        // No declared lifetime: silent however old. Not inferred, ever.
525        assert!(check_grant_age(&tmp, "personal", Some(&g), None).is_empty());
526
527        // Un-stamped grant: age unknown, so no claim either way.
528        store(&tmp, None);
529        assert!(check_grant_age(&tmp, "personal", Some(&g), Some(7)).is_empty());
530
531        std::fs::remove_dir_all(&tmp).ok();
532    }
533}
534
535/// The legacy per-provider stores — `<home>/google/oauth.json` and
536/// `<home>/outlook/oauth.json`, still served by the shipped `mecha-google`
537/// and `mecha-outlook` binaries and what `mecha-mail import` exists to
538/// migrate — get the same marker written beside their credentials by the
539/// same token lifecycle. A doctor that reads only the registry layout
540/// reports "all clear" over a dead legacy login.
541fn check_legacy_mail(home: &Path) -> Vec<Finding> {
542    let mut out = Vec::new();
543    for provider in ["google", "outlook"] {
544        let marker_path = home.join(provider).join("auth_error.json");
545        if !marker_path.is_file() {
546            continue;
547        }
548        let text = match std::fs::read_to_string(&marker_path) {
549            Ok(text) => text,
550            Err(e) => {
551                out.push(Finding::unreadable(
552                    "mail",
553                    &format!("auth_error.json for the legacy {provider} store"),
554                    format!("{}: {e}", marker_path.display()),
555                ));
556                continue;
557            }
558        };
559        match serde_json::from_str::<AuthMarker>(&text) {
560            Ok(marker) => out.push(Finding {
561                component: "mail".to_string(),
562                severity: Severity::Broken,
563                summary: format!("legacy {provider} mail auth is dead"),
564                // The marker's message names the exact re-auth command (the
565                // writer derives it from the store's directory), so it rides
566                // in the detail.
567                detail: format!(
568                    "permanent refresh failure since {}: {}",
569                    marker.at, marker.message
570                ),
571                remedy: Some(Remedy {
572                    description: format!(
573                        "bring the legacy {provider} login into the unified registry — \
574                         and re-authenticate it per the detail, which no import fixes"
575                    ),
576                    argv: vec![
577                        "mecha-mail".to_string(),
578                        "import".to_string(),
579                        provider.to_string(),
580                        "--provider".to_string(),
581                        provider.to_string(),
582                    ],
583                    needs_terminal: false,
584                }),
585            }),
586            Err(e) => out.push(Finding::unreadable(
587                "mail",
588                &format!("auth_error.json for the legacy {provider} store did not parse"),
589                format!("{}: {e}", marker_path.display()),
590            )),
591        }
592    }
593    out
594}
595
596// --- stuck outbox items -----------------------------------------------------
597
598/// Read the outbox items directly — one JSON file per item, the store's own
599/// on-disk contract — so that examining the store never creates or re-chmods
600/// it the way [`crate::outbox::OutboxStore::open`] deliberately does.
601fn check_outbox(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
602    let mut out = Vec::new();
603    if !root.is_dir() {
604        return out;
605    }
606    let entries = match std::fs::read_dir(root) {
607        Ok(entries) => entries,
608        Err(e) => {
609            out.push(Finding::unreadable(
610                "outbox",
611                "the outbox directory",
612                format!("{}: {e}", root.display()),
613            ));
614            return out;
615        }
616    };
617
618    let review = Remedy {
619        description: "open the outbox review surface — doctor never releases a draft".to_string(),
620        argv: vec!["mecha".into(), "outbox".into(), "review".into()],
621        needs_terminal: true,
622    };
623
624    let mut stale: Vec<String> = Vec::new();
625    for entry in entries.flatten() {
626        let path = entry.path();
627        if path.extension().and_then(|e| e.to_str()) != Some("json") {
628            continue;
629        }
630        let item: crate::outbox::OutboxItem =
631            match std::fs::read_to_string(&path).map(|t| serde_json::from_str(&t)) {
632                Ok(Ok(item)) => item,
633                Ok(Err(e)) => {
634                    out.push(Finding::unreadable(
635                        "outbox",
636                        &format!(
637                            "item {} did not parse",
638                            path.file_name().unwrap_or_default().to_string_lossy()
639                        ),
640                        format!("{}: {e}", path.display()),
641                    ));
642                    continue;
643                }
644                Err(e) => {
645                    out.push(Finding::unreadable(
646                        "outbox",
647                        &format!(
648                            "item {} could not be read",
649                            path.file_name().unwrap_or_default().to_string_lossy()
650                        ),
651                        format!("{}: {e}", path.display()),
652                    ));
653                    continue;
654                }
655            };
656        if item.status != "pending" {
657            continue;
658        }
659        if let Some(error) = &item.error {
660            out.push(Finding {
661                component: "outbox".to_string(),
662                severity: Severity::Broken,
663                summary: format!("release failed: {error}"),
664                detail: format!(
665                    "{} · {} — still pending; the draft is good, the delivery was not",
666                    item.id, item.summary
667                ),
668                remedy: Some(review.clone()),
669            });
670        } else if age_of(&item.created_at, now).is_some_and(|age| age > STUCK_DRAFT_AFTER) {
671            stale.push(format!(
672                "{} · {} — staged {}",
673                item.id,
674                item.summary,
675                render_age(now, &item.created_at)
676            ));
677        }
678    }
679
680    if !stale.is_empty() {
681        // read_dir order is arbitrary; ids sort by staging time.
682        stale.sort();
683        out.push(Finding {
684            component: "outbox".to_string(),
685            severity: Severity::Attention,
686            summary: format!(
687                "{} draft{} pending for more than 48h",
688                stale.len(),
689                if stale.len() == 1 { "" } else { "s" }
690            ),
691            detail: stale.join("\n"),
692            remedy: Some(review),
693        });
694    }
695    out
696}
697
698// --- questions nobody answered ----------------------------------------------
699
700/// A question older than this has most likely been missed rather than
701/// deliberately left.
702///
703/// **Shorter than a stale draft's 48h, and deliberately so.** A pending draft
704/// is work already done, sitting safely until someone looks. An unanswered
705/// question is a *delegation frozen mid-flight*: the run stopped, the task is
706/// parked in `waiting`, and nothing moves until a person types one sentence.
707/// The cost of the wait is higher, so the patience is shorter.
708const UNANSWERED_QUESTION_AFTER: chrono::Duration = chrono::Duration::hours(24);
709
710/// Read the question records directly, for the reason [`check_outbox`] does:
711/// an examination that creates or re-chmods the store is measuring itself.
712fn check_questions(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
713    let mut out = Vec::new();
714    if !root.is_dir() {
715        // Never asked is not a problem, and must not read as one. A machine
716        // that has delegated no tasks looks exactly like this.
717        return out;
718    }
719    let entries = match std::fs::read_dir(root) {
720        Ok(entries) => entries,
721        Err(e) => {
722            out.push(Finding::unreadable(
723                "questions",
724                "the question store",
725                format!("{}: {e}", root.display()),
726            ));
727            return out;
728        }
729    };
730
731    let mut stale: Vec<String> = Vec::new();
732    for entry in entries.flatten() {
733        let path = entry.path();
734        if path.extension().and_then(|e| e.to_str()) != Some("json") {
735            continue;
736        }
737        let q: crate::questions::Question =
738            match std::fs::read_to_string(&path).map(|t| serde_json::from_str(&t)) {
739                Ok(Ok(q)) => q,
740                Ok(Err(e)) => {
741                    out.push(Finding::unreadable(
742                        "questions",
743                        &format!(
744                            "question {} did not parse",
745                            path.file_name().unwrap_or_default().to_string_lossy()
746                        ),
747                        format!("{}: {e}", path.display()),
748                    ));
749                    continue;
750                }
751                Err(e) => {
752                    out.push(Finding::unreadable(
753                        "questions",
754                        &format!(
755                            "question {} could not be read",
756                            path.file_name().unwrap_or_default().to_string_lossy()
757                        ),
758                        format!("{}: {e}", path.display()),
759                    ));
760                    continue;
761                }
762            };
763        if !q.is_open() {
764            continue;
765        }
766        if age_of(&q.asked_at, now).is_some_and(|age| age > UNANSWERED_QUESTION_AFTER) {
767            stale.push(format!(
768                "{} · {} — asked {}",
769                crate::questions::QuestionStore::short(&q.id),
770                q.summary(),
771                render_age(now, &q.asked_at)
772            ));
773        }
774    }
775
776    if !stale.is_empty() {
777        stale.sort();
778        out.push(Finding {
779            component: "questions".to_string(),
780            severity: Severity::Attention,
781            summary: format!(
782                "{} question{} unanswered for more than 24h — {} run{} cannot continue",
783                stale.len(),
784                if stale.len() == 1 { "" } else { "s" },
785                stale.len(),
786                if stale.len() == 1 { "" } else { "s" }
787            ),
788            detail: stale.join("\n"),
789            // Lists rather than answers, on doctor's rule: findings propose
790            // and a human disposes. An answer is the owner's words, and a
791            // remedy that supplied them would be inventing the thing the
792            // question exists to obtain.
793            remedy: Some(Remedy {
794                description: "see what the agent is stuck on — doctor never answers for you"
795                    .to_string(),
796                argv: vec!["mecha".into(), "questions".into(), "list".into()],
797                needs_terminal: true,
798            }),
799        });
800    }
801    out
802}
803
804// --- frontdoor --------------------------------------------------------------
805
806/// The states that mean a request is waiting on the user rather than on the
807/// requester: `extracted` awaits triage, `awaiting_me` awaits a draft review,
808/// and `triaged` is triage's "I drafted nothing — this needs a person":
809/// nothing ever re-triages it, so left alone it waits forever, invisibly.
810/// (`needs_info` waits on the stranger, and `drained` on the extraction pass.)
811const WAITING_ON_ME: [&str; 3] = [
812    crate::frontdoor::EXTRACTED,
813    crate::frontdoor::AWAITING_ME,
814    crate::frontdoor::TRIAGED,
815];
816
817/// Read the request records directly, for the same no-side-effects reason as
818/// the outbox — [`crate::frontdoor::Frontdoor::open`] creates the directory.
819fn check_frontdoor(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
820    let mut out = Vec::new();
821    if !root.is_dir() {
822        return out;
823    }
824    let entries = match std::fs::read_dir(root) {
825        Ok(entries) => entries,
826        Err(e) => {
827            out.push(Finding::unreadable(
828                "frontdoor",
829                "the request store",
830                format!("{}: {e}", root.display()),
831            ));
832            return out;
833        }
834    };
835
836    let list = Remedy {
837        description: "list the frontdoor queue".to_string(),
838        argv: vec!["mecha".into(), "frontdoor".into(), "list".into()],
839        needs_terminal: false,
840    };
841
842    let mut stale: Vec<(i64, String)> = Vec::new();
843    for entry in entries.flatten() {
844        let path = entry.path();
845        if path.extension().and_then(|e| e.to_str()) != Some("json") {
846            continue;
847        }
848        let Ok(Ok(record)) = std::fs::read_to_string(&path)
849            .map(|t| serde_json::from_str::<crate::frontdoor::Record>(&t))
850        else {
851            // The frontdoor store itself skips unreadable records; doctor
852            // says so instead, because silent skipping is the disease here.
853            out.push(Finding::unreadable(
854                "frontdoor",
855                &format!(
856                    "request {} did not parse",
857                    path.file_name().unwrap_or_default().to_string_lossy()
858                ),
859                path.display().to_string(),
860            ));
861            continue;
862        };
863        if record.state == crate::frontdoor::EXTRACTION_FAILED {
864            out.push(Finding {
865                component: "frontdoor".to_string(),
866                severity: Severity::Broken,
867                summary: format!(
868                    "request {} failed extraction and waits for a human",
869                    record.seq
870                ),
871                detail: format!(
872                    "{} ({}) — {}",
873                    record.seq,
874                    record.type_id,
875                    record
876                        .extraction_error
877                        .as_deref()
878                        .unwrap_or("no error recorded")
879                ),
880                remedy: Some(list.clone()),
881            });
882        } else if WAITING_ON_ME.contains(&record.state.as_str())
883            && request_age(&record, now).is_some_and(|age| age > STALE_REQUEST_AFTER)
884        {
885            stale.push((
886                record.seq,
887                format!(
888                    "{} ({}) — {}, received {}",
889                    record.seq,
890                    record.type_id,
891                    record.state,
892                    render_age(now, &record.created_at)
893                ),
894            ));
895        }
896    }
897
898    if !stale.is_empty() {
899        // read_dir order is arbitrary; the queue reads oldest-first by seq.
900        stale.sort_by_key(|(seq, _)| *seq);
901        out.push(Finding {
902            component: "frontdoor".to_string(),
903            severity: Severity::Attention,
904            summary: format!(
905                "{} request{} waiting on you for more than 72h",
906                stale.len(),
907                if stale.len() == 1 { "" } else { "s" }
908            ),
909            detail: stale
910                .into_iter()
911                .map(|(_, line)| line)
912                .collect::<Vec<_>>()
913                .join("\n"),
914            remedy: Some(list),
915        });
916    }
917    out
918}
919
920/// How long a request has waited: from when it arrived here (`drained_at`),
921/// falling back to when the stranger sent it. Unparseable stamps mean the age
922/// is unknown, and unknown never counts as stale — a doctor that guesses is
923/// worse than one that says nothing.
924fn request_age(record: &crate::frontdoor::Record, now: DateTime<Utc>) -> Option<chrono::Duration> {
925    age_of(&record.drained_at, now).or_else(|| age_of(&record.created_at, now))
926}
927
928// --- trigger health ---------------------------------------------------------
929
930/// How many recent runs the reliability check averages over.
931///
932/// Five, because one bad morning is not a trend and a long window would hide a
933/// trigger that broke this week behind a month of health.
934const HEALTH_WINDOW: usize = 5;
935
936/// Below this many calls in the window, no rate is reported.
937///
938/// A rate over three calls is noise, and a doctor that cries wolf stops being
939/// read — the same reasoning as the scope check declining to guess.
940const HEALTH_MIN_CALLS: u32 = 10;
941
942/// The share of failed calls that is worth a human's attention.
943///
944/// A third. Deliberately not near-zero: a model that tries a path, is told it
945/// does not exist, and tries the right one has done nothing wrong, and errors
946/// are how a run learns about its environment. What this is looking for is a
947/// trigger whose environment has moved out from under it.
948const HEALTH_ERROR_RATE: f64 = 1.0 / 3.0;
949
950/// Read the trigger files and the ledger directly — same reason as above:
951/// [`crate::trigger::TriggerStore::open`] creates and re-chmods the root.
952fn check_triggers(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
953    let mut out = Vec::new();
954    if !root.is_dir() {
955        return out;
956    }
957    let entries = match std::fs::read_dir(root) {
958        Ok(entries) => entries,
959        Err(e) => {
960            out.push(Finding::unreadable(
961                "triggers",
962                "the trigger store",
963                format!("{}: {e}", root.display()),
964            ));
965            return out;
966        }
967    };
968
969    let mut triggers: Vec<crate::trigger::Trigger> = Vec::new();
970    for entry in entries.flatten() {
971        let path = entry.path();
972        if path.extension().and_then(|e| e.to_str()) != Some("toml") {
973            continue;
974        }
975        let name = path
976            .file_stem()
977            .and_then(|s| s.to_str())
978            .unwrap_or_default()
979            .to_string();
980        match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::trigger::Trigger>(&t))
981        {
982            Ok(Ok(mut trigger)) => {
983                trigger.name = name;
984                triggers.push(trigger);
985            }
986            _ => out.push(Finding::unreadable(
987                "triggers",
988                &format!("trigger file `{name}.toml` did not parse"),
989                path.display().to_string(),
990            )),
991        }
992    }
993
994    // One ledger scan for both questions: the newest row that actually *ran*
995    // per trigger, and the newest *accounted slot* per trigger (manual runs
996    // carry no slot, so they are invisible to the schedule on purpose).
997    let mut recent: BTreeMap<String, Vec<crate::trigger::RunRecord>> = BTreeMap::new();
998    let mut last_slot: BTreeMap<String, DateTime<Utc>> = BTreeMap::new();
999    let ledger = root.join("runs.jsonl");
1000    if ledger.is_file() {
1001        match std::fs::read_to_string(&ledger) {
1002            Ok(text) => {
1003                for line in text.lines().filter(|l| !l.trim().is_empty()) {
1004                    // A torn line is the store's problem, not this row's
1005                    // neighbours': skip it the way the ledger reader does.
1006                    let Ok(row) = serde_json::from_str::<crate::trigger::RunRecord>(line) else {
1007                        continue;
1008                    };
1009                    if let Some(slot) = row.slot {
1010                        let newest = last_slot.entry(row.trigger.clone()).or_insert(slot);
1011                        if slot > *newest {
1012                            *newest = slot;
1013                        }
1014                    }
1015                    // A skip is a row, not a run: a skipped-stale or
1016                    // skipped-overlap appended after an error is bookkeeping,
1017                    // not a recovery, and keying on the literal last row let
1018                    // it hide the failure the operator needed to see.
1019                    if matches!(
1020                        row.status,
1021                        crate::trigger::RunStatus::Ok | crate::trigger::RunStatus::Error
1022                    ) {
1023                        let window = recent.entry(row.trigger.clone()).or_default();
1024                        window.push(row);
1025                        if window.len() > HEALTH_WINDOW {
1026                            window.remove(0);
1027                        }
1028                    }
1029                }
1030            }
1031            Err(e) => out.push(Finding::unreadable(
1032                "triggers",
1033                "the run ledger",
1034                format!("{}: {e}", ledger.display()),
1035            )),
1036        }
1037    }
1038
1039    for trigger in &triggers {
1040        if !trigger.enabled {
1041            continue;
1042        }
1043
1044        // The most recent run failed: a manual run is the safe probe, because
1045        // it records a row with no slot and so never advances the schedule.
1046        let window = recent.get(&trigger.name);
1047        if let Some(row) = window.and_then(|w| w.last()) {
1048            if row.status == crate::trigger::RunStatus::Error {
1049                out.push(Finding {
1050                    component: "triggers".to_string(),
1051                    severity: Severity::Attention,
1052                    summary: format!("trigger `{}`'s most recent run failed", trigger.name),
1053                    detail: format!(
1054                        "started {}: {}",
1055                        row.started_at.to_rfc3339(),
1056                        row.error.as_deref().unwrap_or("no error recorded")
1057                    ),
1058                    remedy: Some(Remedy {
1059                        description: format!(
1060                            "run `{}` by hand — a manual run is evidence, not a fire; it never advances the schedule",
1061                            trigger.name
1062                        ),
1063                        argv: vec![
1064                            "mecha".into(),
1065                            "trigger".into(),
1066                            "run".into(),
1067                            trigger.name.clone(),
1068                        ],
1069                        needs_terminal: false,
1070                    }),
1071                });
1072            }
1073        }
1074
1075        // Reliability across the window. An unattended run has nobody
1076        // watching it fail: the briefing still arrives, the ledger still says
1077        // `ok`, and a trigger failing a third of its calls looks exactly like
1078        // one that works. Silent below a floor of calls, because a rate over
1079        // three of them is noise, and unknown is never a finding.
1080        let (calls, errors) = window
1081            .map(|w| {
1082                w.iter().fold((0u32, 0u32), |(c, e), r| {
1083                    (c + r.tool_calls, e + r.tool_errors)
1084                })
1085            })
1086            .unwrap_or((0, 0));
1087        if calls >= HEALTH_MIN_CALLS && f64::from(errors) / f64::from(calls) >= HEALTH_ERROR_RATE {
1088            let runs = window.map(Vec::len).unwrap_or(0);
1089            out.push(Finding {
1090                component: "triggers".to_string(),
1091                severity: Severity::Attention,
1092                summary: format!(
1093                    "trigger `{}` failed {errors} of {calls} tool calls",
1094                    trigger.name
1095                ),
1096                detail: format!(
1097                    "across its last {runs} run(s){}. A run's answer arrives either way, so this is invisible in the ledger's status — and per-step reliability is what decides how long a task the run can finish, so a third of the calls failing is not a third of the work lost.",
1098                    if window.is_some_and(|w| w.last().is_some_and(|r| r.ended_on_failed_call)) {
1099                        ", and the most recent run answered with its last call failed"
1100                    } else {
1101                        ""
1102                    }
1103                ),
1104                // Reading is the remedy: what to change is in the transcript,
1105                // and doctor never decides that.
1106                remedy: Some(Remedy {
1107                    description: format!("read `{}`'s recent runs", trigger.name),
1108                    argv: vec![
1109                        "mecha".into(),
1110                        "trigger".into(),
1111                        "show".into(),
1112                        trigger.name.clone(),
1113                    ],
1114                    needs_terminal: false,
1115                }),
1116            });
1117        }
1118
1119        // The null run: it fired, it succeeded, and it did nothing. The rate
1120        // check above cannot see this one — a rate over zero calls is
1121        // undefined rather than bad — so a trigger that made thirty calls a
1122        // morning and now makes none is silent in every signal the ledger
1123        // carries. Found by a sibling arc hitting the same shape one layer
1124        // down, where `mecha mail classify` returned success having classified
1125        // 0 of 16.
1126        //
1127        // Measured against the trigger's *own* history, never an absolute
1128        // floor: a prompt that legitimately needs no tools makes zero calls
1129        // every morning, and a check that called that broken would be wrong
1130        // about the healthiest trigger on the machine. So the earlier runs in
1131        // the window have to show the work that stopped.
1132        if let Some(window) = window {
1133            let newest = window.last();
1134            let before: u32 = window[..window.len().saturating_sub(1)]
1135                .iter()
1136                .map(|r| r.tool_calls)
1137                .sum();
1138            // Only an `ok` run: an errored one already has a finding above,
1139            // and two findings for one fact leave neither meaning anything.
1140            let stopped = newest
1141                .is_some_and(|r| r.tool_calls == 0 && r.status == crate::trigger::RunStatus::Ok)
1142                && before >= HEALTH_MIN_CALLS;
1143            if stopped {
1144                out.push(Finding {
1145                    component: "triggers".to_string(),
1146                    severity: Severity::Attention,
1147                    summary: format!(
1148                        "trigger `{}`'s most recent run did no work",
1149                        trigger.name
1150                    ),
1151                    detail: format!(
1152                        "it succeeded having made no tool calls, where its previous {} run(s) made {before}. A run that does nothing and reports success is indistinguishable from a healthy one in every other signal — the status is `ok`, the schedule advanced, and the answer arrived.",
1153                        window.len() - 1
1154                    ),
1155                    remedy: Some(Remedy {
1156                        description: format!("read `{}`'s recent runs", trigger.name),
1157                        argv: vec![
1158                            "mecha".into(),
1159                            "trigger".into(),
1160                            "show".into(),
1161                            trigger.name.clone(),
1162                        ],
1163                        needs_terminal: false,
1164                    }),
1165                });
1166            }
1167        }
1168
1169        // A catch-up-always trigger whose accounted slots stopped advancing:
1170        // a healthy daemon fires the most recent slot every tick, so more
1171        // than two slots newer than the last accounted one means nothing is
1172        // ticking at all. Cheap by construction — three `prev_at_or_before`
1173        // calls, no schedule re-derivation.
1174        if trigger.catch_up != crate::trigger::CatchUp::Always {
1175            continue;
1176        }
1177        let Some(anchor) = last_slot.get(&trigger.name).copied().or(trigger.created_at) else {
1178            // No ledger row and no creation stamp: there is no baseline to
1179            // measure staleness against, and unknown is not stale.
1180            continue;
1181        };
1182        let tz = trigger.tz(None);
1183        let step = chrono::Duration::seconds(1);
1184        let missed_more_than_two = trigger
1185            .schedule
1186            .prev_at_or_before(now, tz)
1187            .and_then(|s0| trigger.schedule.prev_at_or_before(s0 - step, tz))
1188            .and_then(|s1| trigger.schedule.prev_at_or_before(s1 - step, tz))
1189            .is_some_and(|s2| s2 > anchor);
1190        if missed_more_than_two {
1191            out.push(Finding {
1192                component: "triggers".to_string(),
1193                severity: Severity::Attention,
1194                summary: format!("trigger `{}` has missed more than two slots", trigger.name),
1195                detail: format!(
1196                    "last accounted slot {}; with catch_up=always a healthy scheduler fires \
1197                     the most recent slot every tick, so the daemon or its timer may be down \
1198                     (systemctl --user status mecha-triggers)",
1199                    anchor.to_rfc3339()
1200                ),
1201                // No argv on purpose: running the trigger by hand would not
1202                // restart whatever stopped ticking.
1203                remedy: None,
1204            });
1205        }
1206    }
1207    out
1208}
1209
1210// --- run quality ------------------------------------------------------------
1211
1212/// How many sessions back a run-quality check reads.
1213///
1214/// Doctor runs in one pass with no network and no model, and each session is
1215/// a file read — so this is a budget, not a claim about relevance. Two hundred
1216/// covers weeks of ordinary use and stays well inside "fast enough to run
1217/// whenever you wonder".
1218const RUNS_WINDOW: usize = 200;
1219
1220/// Below this many runs *for one model*, no rate is reported.
1221///
1222/// Twenty rather than the trigger check's ten, because these rates are
1223/// population statistics across mixed work rather than one job doing the same
1224/// thing every morning, and the noise is correspondingly higher.
1225const RUNS_MIN: usize = 20;
1226
1227/// The share of runs finishing over a failed call that is worth saying out
1228/// loud. Deliberately high: rule-based evaluators are measured to *under*
1229/// report success — they mark good trajectories as failures more often than
1230/// humans do (AgentRewardBench) — so a low bar here would fire constantly on
1231/// runs that were fine, and a doctor that cries wolf stops being read.
1232const ENDED_ON_FAILURE_RATE: f64 = 0.20;
1233
1234/// The share of attempted tool calls the environment refuses.
1235const TOOL_ERROR_RATE: f64 = 0.25;
1236
1237/// And below this many *calls* across the window, no rate at all. Runs and
1238/// calls are different denominators: twenty runs can hold four calls.
1239const RUNS_MIN_CALLS: u64 = 20;
1240
1241/// The share of runs the *harness* cut short. `Interrupted` is excluded from
1242/// the numerator by [`cut_short`]: a person pressing Ctrl-C is the system
1243/// working, and counting it would make an attentive user look like a problem.
1244const CUT_SHORT_RATE: f64 = 0.25;
1245
1246/// Did the harness end this run? One definition, on [`crate::agent::StopCause`],
1247/// shared with the candidate gate's metric — see its doc for why there were two.
1248fn cut_short(stats: &crate::session::RunStats) -> bool {
1249    stats.stop_cause.is_some_and(|c| c.cut_short())
1250}
1251
1252/// A charter that fails to load degrades every run to un-chartered with
1253/// nothing but a stderr line the TUI's alternate screen covers for the whole
1254/// session (`setup.rs::prepare_tools`) — the same discovery gap `mecha
1255/// skills` exists to close for a bad `SKILL.md`, with no `/charter` modal yet
1256/// to close it here. `Charter::load` is read-only and creates nothing, so
1257/// calling it directly is safe under doctor's own rule against healing what
1258/// it is about to report.
1259fn check_charter(path: &Path) -> Vec<Finding> {
1260    // `exists()`, not `is_file()`: the latter also reads false for a
1261    // directory sitting at this path or a broken symlink, which would
1262    // silently report a broken charter as "nothing written yet" instead of
1263    // falling through to `Charter::load` below and getting a real `Err` —
1264    // `read_to_string` on a directory fails with its own I/O error rather
1265    // than `NotFound`, so `Charter::load` already tells the two apart
1266    // correctly once it's actually called.
1267    if !path.exists() {
1268        return Vec::new();
1269    }
1270    let remedy = |description: &str| {
1271        Some(Remedy {
1272            description: description.to_string(),
1273            argv: vec!["mecha".to_string(), "charter".to_string()],
1274            needs_terminal: false,
1275        })
1276    };
1277    match crate::charter::Charter::load(path) {
1278        Err(e) => vec![Finding {
1279            component: "charter".to_string(),
1280            severity: Severity::Broken,
1281            summary: "charter did not load".to_string(),
1282            detail: format!(
1283                "{}: {e:#} — every run is proceeding un-chartered",
1284                path.display()
1285            ),
1286            remedy: remedy("see the parse error and fix charter.toml"),
1287        }],
1288        // Loads and is usable, but costs more of the cached prefix than
1289        // argued — a warning, not a failure: it still rides in every prompt
1290        // exactly as authored, the same "warns and still loads" shape
1291        // `over_budget_domains` gives the learned-rules cap.
1292        Ok(charter) if charter.over_budget() => vec![Finding {
1293            component: "charter".to_string(),
1294            severity: Severity::Attention,
1295            summary: "charter is over its character budget".to_string(),
1296            detail: format!(
1297                "{} is {} characters, over the {}-character budget",
1298                path.display(),
1299                charter.char_count(),
1300                crate::charter::CHARTER_CHAR_BUDGET,
1301            ),
1302            remedy: remedy("review the charter and trim it"),
1303        }],
1304        // A file that exists and parses *cleanly* to zero lines is an
1305        // authoring mistake by construction — nobody writes an empty charter
1306        // on purpose — and otherwise indistinguishable from never having
1307        // written one at all: `load` returns `Ok`, `prompt_block` returns
1308        // `None`, and `prepare_tools` prints nothing. This is not the
1309        // typo'd-table-name case: `RawCharter` denies unknown fields, so
1310        // `[[lines]]` instead of `[[line]]` is a load error and reaches the
1311        // `Err` arm above, not this one. What lands here is a file that is
1312        // empty, or holds only comments.
1313        Ok(charter) if charter.is_empty() => vec![Finding {
1314            component: "charter".to_string(),
1315            severity: Severity::Attention,
1316            summary: "charter file exists but has no lines".to_string(),
1317            detail: format!(
1318                "{} parsed cleanly with zero `[[line]]` entries — nothing from it \
1319                 rides in any prompt",
1320                path.display()
1321            ),
1322            remedy: remedy("see what's actually in the charter file"),
1323        }],
1324        Ok(_) => Vec::new(),
1325    }
1326}
1327
1328/// Report population-level run quality: the signals that are invisible in any
1329/// single run and obvious across a few hundred.
1330///
1331/// Split by model, because a corpus spanning two has no single rate worth
1332/// quoting — the blend is true and useless, and a threshold on it fires for
1333/// the wrong model. Silent until there is enough of one model to say
1334/// anything, which is the same rule as everywhere else here: unknown is not a
1335/// finding.
1336fn check_runs(sessions: &Path) -> Vec<Finding> {
1337    use crate::runlog::{Corpus, Scan};
1338
1339    let mut out = Vec::new();
1340    if !sessions.is_dir() {
1341        return out;
1342    }
1343    let corpus = match Corpus::scan(
1344        sessions,
1345        &Scan {
1346            max_sessions: Some(RUNS_WINDOW),
1347            since: None,
1348        },
1349    ) {
1350        Ok(c) => c,
1351        Err(e) => {
1352            out.push(Finding::unreadable(
1353                "runs",
1354                "the session store",
1355                format!("{}: {e}", sessions.display()),
1356            ));
1357            return out;
1358        }
1359    };
1360
1361    // Per-file rot, not the store-level failure above: every reader over
1362    // this store is best-effort by design (`Session::list` skips a
1363    // headerless file, `Corpus::scan` a torn body), which is right for the
1364    // readers and wrong as a *diagnosis* — a store losing one transcript at
1365    // a time was invisible from every surface at once. Doctor is the one
1366    // reader whose job is the store itself.
1367    if corpus.unreadable > 0 {
1368        out.push(Finding::unreadable(
1369            "runs",
1370            &format!("{} transcript(s) in the session store", corpus.unreadable),
1371            // Precise about what the counter can actually see: both
1372            // increments are a file that could not be read or carries no
1373            // session header — `Session::read` and `outcomes_attributed`
1374            // skip malformed *lines* without erroring, so line-level rot
1375            // inside a readable transcript is deliberately not claimed here.
1376            format!(
1377                "{}: files with a .jsonl extension that could not be read \
1378                 or carry no session header; every reader silently skips them",
1379                sessions.display()
1380            ),
1381        ));
1382    }
1383
1384    let remedy = |what: &str| {
1385        Some(Remedy {
1386            description: format!("read the run-quality summary ({what})"),
1387            argv: vec![
1388                "mecha".into(),
1389                "sessions".into(),
1390                "health".into(),
1391                "--days".into(),
1392                "30".into(),
1393            ],
1394            needs_terminal: false,
1395        })
1396    };
1397
1398    for (model, runs) in corpus.by_model() {
1399        if runs.len() < RUNS_MIN {
1400            continue;
1401        }
1402        let n = runs.len();
1403
1404        if let Some(rate) = runs.rate_of(|r| r.stats.ended_on_failed_call) {
1405            if rate >= ENDED_ON_FAILURE_RATE {
1406                out.push(Finding {
1407                    component: "runs".to_string(),
1408                    severity: Severity::Attention,
1409                    summary: format!(
1410                        "{:.0}% of `{model}` runs finished on a failed tool call",
1411                        rate * 100.0
1412                    ),
1413                    detail: format!(
1414                        "{} of {n} recent run(s). The model stopped of its own accord with its last call failed, and the answer it wrote may report success over it — which nothing in the text or the stop reason can show.",
1415                        runs.ended_on_failed_call()
1416                    ),
1417                    remedy: remedy("which runs, and what failed"),
1418                });
1419            }
1420        }
1421
1422        if let Some(rate) = runs.tool_error_rate() {
1423            // The sibling trigger check states the rule this one omitted: a
1424            // rate over three calls is noise. Twenty conversational runs that
1425            // made four calls between them must not raise a finding because
1426            // one of them errored.
1427            if rate >= TOOL_ERROR_RATE && runs.tool_calls() >= RUNS_MIN_CALLS {
1428                out.push(Finding {
1429                    component: "runs".to_string(),
1430                    severity: Severity::Attention,
1431                    summary: format!(
1432                        "`{model}` runs fail {:.0}% of their tool calls",
1433                        rate * 100.0
1434                    ),
1435                    detail: format!(
1436                        "{} of {} call(s) across {n} run(s) were refused by the environment. Errors are how a run learns where it is, so some are healthy — a quarter of them says something moved: a renamed path, a revoked grant, a tool whose schema the model keeps mis-filling.",
1437                        runs.tool_errors(),
1438                        runs.tool_calls()
1439                    ),
1440                    remedy: remedy("which tool, and how it failed"),
1441                });
1442            }
1443        }
1444
1445        if let Some(rate) = runs.rate_of(|r| cut_short(&r.stats)) {
1446            if rate >= CUT_SHORT_RATE {
1447                let cut = runs.rows.iter().filter(|r| cut_short(&r.stats)).count();
1448                out.push(Finding {
1449                    component: "runs".to_string(),
1450                    severity: Severity::Attention,
1451                    summary: format!(
1452                        "the harness cut {:.0}% of `{model}` runs short",
1453                        rate * 100.0
1454                    ),
1455                    detail: format!(
1456                        "{cut} of {n} recent run(s) hit a turn, token or cost ceiling, or tripped the loop guard. A budget that stops a quarter of runs is measuring the budget rather than the work — the answers are truncated and say so only in `stop_cause`. Cancellations are not counted here.",
1457                    ),
1458                    remedy: remedy("which ceiling, and how often"),
1459                });
1460            }
1461        }
1462    }
1463    out
1464}
1465
1466// --- graph nightly silence --------------------------------------------------
1467
1468/// The two daily jobs that keep the knowledge graph current, each of which
1469/// writes `<prefix>YYYYMMDD.log` on *every* run — a deferred night says so in
1470/// the log — so a day with no file means the script never started. That is
1471/// exactly the failure cron cannot report: no MTA, and the script's own
1472/// logging begins after the point where an exec failure kills it (measured
1473/// 2026-08-17, when a missing execute bit cost a night of vet and gossip and
1474/// nothing anywhere said so).
1475const GRAPH_NIGHTLIES: &[(&str, &str)] = &[
1476    ("nightly-", "the graph's own sweep (ingest, extract, decay)"),
1477    ("mecha-nightly-", "the mecha half (vet, precheck, gossip)"),
1478];
1479
1480/// A staged harness candidate older than this is the nightly loop waiting on
1481/// a review nobody knows is due — the same shape as a stuck draft, one store
1482/// over. Not blocking anything, hence the longer leash.
1483const STALE_CANDIDATE_AFTER: chrono::Duration = chrono::Duration::hours(72);
1484
1485/// Below this many origin-excluded reflections, a learner that has not run is
1486/// a young install, not a starved one — the silence carries no information.
1487/// High on the doctor rule: a finding that fires on every fresh setup trains
1488/// the reader to skip the component it names.
1489const STARVED_LEARNER_MIN_EXCLUDED: usize = 10;
1490
1491/// The starved learner: reflections keep arriving, the origin gate keeps
1492/// excluding them, and no domain ever reaches `learn`'s floor — so the rule
1493/// learner reports success every night and has produced nothing for weeks.
1494///
1495/// This is the null-run bug one layer up from the trigger version: every
1496/// stage exits 0, the ledger says `ok`, and the only evidence is a
1497/// distribution across a file nothing was reading. The check counts and
1498/// never judges the gate itself — the exclusions are the provenance design
1499/// working as specified — so the finding proposes a *decision*, not a
1500/// command: accept the rate, or change what evidence the loop can use. That
1501/// is why its remedy is the dry-run that shows the classifications, never
1502/// anything that loosens the gate.
1503fn check_learning(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
1504    let mut out = Vec::new();
1505    let path = root.join("reflections.jsonl");
1506    if !path.is_file() {
1507        return out;
1508    }
1509    let text = match std::fs::read_to_string(&path) {
1510        Ok(t) => t,
1511        Err(e) => {
1512            out.push(Finding::unreadable(
1513                "learning",
1514                "the reflections archive",
1515                format!("{}: {e}", path.display()),
1516            ));
1517            return out;
1518        }
1519    };
1520
1521    let mut total = 0usize;
1522    let mut excluded = 0usize;
1523    let mut newest_excluded: Option<DateTime<Utc>> = None;
1524    // domain → clean unprocessed count.
1525    let mut waiting: std::collections::BTreeMap<String, usize> = Default::default();
1526    for line in text.lines().filter(|l| !l.trim().is_empty()) {
1527        let Ok(r) = serde_json::from_str::<crate::learning::Reflexion>(line) else {
1528            // One torn line is not the archive; the counts below are still
1529            // a lower bound, which is the fail-quiet direction for a check
1530            // whose finding asks a person to make a decision.
1531            continue;
1532        };
1533        total += 1;
1534        // `r.origin` is the miner's decision at write time, and the store is
1535        // append-only — `r.learnable()` is what `learn.rs` actually admits
1536        // today, re-derived rather than stored, on the same split
1537        // `Session::taint_timeline` makes about checkpoints. Reading
1538        // `r.origin` directly here disagreed with the gate on exactly the
1539        // records a harness-voice fix reclassifies: they would sit in
1540        // `waiting` forever (never marked processed, since `learn` skips
1541        // them), silently suppressing this very finding with the records
1542        // that caused the starvation.
1543        if r.learnable() {
1544            if !r.is_processed {
1545                *waiting.entry(r.domain.clone()).or_default() += 1;
1546            }
1547        } else if r.dropped_at.is_none() {
1548            // `learnable()` checks the drop before it checks provenance, so
1549            // `!r.learnable()` alone cannot tell "the gate excluded this" from
1550            // "the owner refused this" — and `/learning`'s whole point is
1551            // letting the owner do the latter. Counting a drop as a provenance
1552            // exclusion would make dropping ten lessons the owner disagrees
1553            // with (the intended use of that key) both trip this finding and
1554            // report the refusal as the gate's doing, with a remedy
1555            // (`mecha reflect --dry-run`) that answers a question nobody
1556            // asked. Only what provenance itself blocked counts here.
1557            excluded += 1;
1558            if let Ok(t) = DateTime::parse_from_rfc3339(&r.created_at) {
1559                let t = t.with_timezone(&Utc);
1560                if newest_excluded.is_none_or(|n| t > n) {
1561                    newest_excluded = Some(t);
1562                }
1563            }
1564        }
1565    }
1566
1567    let floor = crate::learning::LEARN_MIN_REFLECTIONS;
1568    // Any domain at the floor means learn will consolidate on its next pass:
1569    // not starved, whatever the exclusion count says.
1570    if waiting.values().any(|&n| n >= floor) {
1571        return out;
1572    }
1573    if excluded < STARVED_LEARNER_MIN_EXCLUDED {
1574        return out;
1575    }
1576    // A loop nothing has fed for a month is dormant, not starved — the
1577    // distinction matters because the remedy for dormant is elsewhere
1578    // (triggers, reflect itself), and this finding must not shadow it.
1579    let alive = newest_excluded.is_some_and(|t| now.signed_duration_since(t).num_days() <= 30);
1580    if !alive {
1581        return out;
1582    }
1583
1584    let pool = if waiting.is_empty() {
1585        "none clean and unprocessed".to_string()
1586    } else {
1587        waiting
1588            .iter()
1589            .map(|(d, n)| format!("{d} {n}/{floor}"))
1590            .collect::<Vec<_>>()
1591            .join(", ")
1592    };
1593    out.push(Finding {
1594        component: "learning".to_string(),
1595        severity: Severity::Attention,
1596        summary: format!(
1597            "the rule learner is starved: {excluded} of {total} reflections excluded by \
1598             origin, and no domain reaches the learn floor of {floor}"
1599        ),
1600        detail: format!(
1601            "reflect keeps mining and the provenance gate keeps excluding — the gate working \
1602             as designed, every night, with nothing downstream to show for it. Clean pool: \
1603             {pool}. The excluded records stay readable in {} — some are third-party evidence \
1604             the gate held back, some may be mecha's own words correctly kept out of a \
1605             feedback loop; the decision this proposes is yours, not a command's: read what \
1606             got excluded, and change what evidence the loop may consolidate if the mix \
1607             looks wrong.",
1608            path.display()
1609        ),
1610        remedy: Some(Remedy {
1611            description: "see how new interventions classify — doctor never loosens the gate"
1612                .to_string(),
1613            argv: vec!["mecha".into(), "reflect".into(), "--dry-run".into()],
1614            needs_terminal: false,
1615        }),
1616    });
1617    out
1618}
1619
1620/// Scan `<learning>/harness/candidates` for staged candidates waiting on the
1621/// user. Quiet when the store has never existed — the loop not being wired is
1622/// not a finding. Reads the files directly, on the rule that an examination
1623/// must not heal (or create) what it reports on.
1624fn check_harness(root: &Path, now: DateTime<Utc>) -> Vec<Finding> {
1625    let mut out = Vec::new();
1626    let dir = root.join("candidates");
1627    if !dir.is_dir() {
1628        return out;
1629    }
1630    let entries = match std::fs::read_dir(&dir) {
1631        Ok(entries) => entries,
1632        Err(e) => {
1633            out.push(Finding::unreadable(
1634                "harness",
1635                "the harness candidate directory",
1636                format!("{}: {e}", dir.display()),
1637            ));
1638            return out;
1639        }
1640    };
1641    let mut stale: Vec<String> = Vec::new();
1642    for entry in entries.flatten() {
1643        let path = entry.path();
1644        if path.extension().and_then(|e| e.to_str()) != Some("json") {
1645            continue;
1646        }
1647        let cand: crate::harness::HarnessCandidate =
1648            match std::fs::read_to_string(&path).map(|t| serde_json::from_str(&t)) {
1649                Ok(Ok(cand)) => cand,
1650                Ok(Err(e)) => {
1651                    out.push(Finding::unreadable(
1652                        "harness",
1653                        &format!(
1654                            "candidate {} did not parse",
1655                            path.file_name().unwrap_or_default().to_string_lossy()
1656                        ),
1657                        format!("{}: {e}", path.display()),
1658                    ));
1659                    continue;
1660                }
1661                Err(e) => {
1662                    out.push(Finding::unreadable(
1663                        "harness",
1664                        &format!(
1665                            "candidate {} could not be read",
1666                            path.file_name().unwrap_or_default().to_string_lossy()
1667                        ),
1668                        format!("{}: {e}", path.display()),
1669                    ));
1670                    continue;
1671                }
1672            };
1673        if !cand.pending() {
1674            continue;
1675        }
1676        let old_enough = chrono::DateTime::parse_from_rfc3339(&cand.created_at)
1677            .map(|t| {
1678                now.signed_duration_since(t.with_timezone(&chrono::Utc)) > STALE_CANDIDATE_AFTER
1679            })
1680            // An unparseable stamp cannot prove the candidate is fresh.
1681            .unwrap_or(true);
1682        if old_enough {
1683            stale.push(format!("{} · {:?} {}", cand.id, cand.class, cand.change));
1684        }
1685    }
1686    if !stale.is_empty() {
1687        stale.sort();
1688        out.push(Finding {
1689            component: "harness".to_string(),
1690            severity: Severity::Attention,
1691            summary: format!(
1692                "{} harness candidate(s) staged for more than {}h",
1693                stale.len(),
1694                STALE_CANDIDATE_AFTER.num_hours()
1695            ),
1696            detail: stale.join("\n"),
1697            remedy: Some(Remedy {
1698                description: "review the staged candidates — doctor never accepts one".to_string(),
1699                argv: vec!["mecha".into(), "harness".into(), "list".into()],
1700                needs_terminal: false,
1701            }),
1702        });
1703    }
1704    out
1705}
1706
1707/// Scan `<graph store>/logs` for each nightly family's newest dated log.
1708///
1709/// Quiet when the store, the logs directory, or a family has never existed —
1710/// absence is "not installed", which is not a finding. The bar is "newer than
1711/// the day before yesterday": today's file legitimately does not exist before
1712/// that job's cron slot, so yesterday's is the newest a healthy quiet morning
1713/// can show.
1714fn check_graph_nightly(store: &Path, now: DateTime<Utc>) -> Vec<Finding> {
1715    let mut out = Vec::new();
1716    let logs = store.join("logs");
1717    if !logs.is_dir() {
1718        return out;
1719    }
1720    let names: Vec<String> = match std::fs::read_dir(&logs) {
1721        Ok(entries) => entries
1722            .flatten()
1723            .filter_map(|e| e.file_name().to_str().map(String::from))
1724            .collect(),
1725        Err(e) => {
1726            out.push(Finding::unreadable(
1727                "graph",
1728                "the graph nightly logs",
1729                format!("{}: {e}", logs.display()),
1730            ));
1731            return out;
1732        }
1733    };
1734
1735    for (prefix, what) in GRAPH_NIGHTLIES {
1736        let newest = names
1737            .iter()
1738            .filter_map(|n| {
1739                n.strip_prefix(prefix)?
1740                    .strip_suffix(".log")
1741                    .and_then(|d| chrono::NaiveDate::parse_from_str(d, "%Y%m%d").ok())
1742            })
1743            .max();
1744        // Never ran at all: indistinguishable from "this half is not set up",
1745        // and a doctor that guesses teaches people to ignore it.
1746        let Some(newest) = newest else { continue };
1747        let days_quiet = (now.date_naive() - newest).num_days();
1748        if days_quiet > 1 {
1749            out.push(Finding {
1750                component: "graph".to_string(),
1751                severity: Severity::Attention,
1752                summary: format!(
1753                    "the graph nightly ({}) has not run for {days_quiet} days",
1754                    prefix.trim_end_matches('-'),
1755                ),
1756                detail: format!(
1757                    "{what} last wrote {}{}.log under {}; it logs every \
1758                     run including deferred ones, so a missing day means the \
1759                     script never started — cron reports that nowhere",
1760                    prefix,
1761                    newest.format("%Y%m%d"),
1762                    logs.display(),
1763                ),
1764                remedy: Some(Remedy {
1765                    description: "list the cron entries that fire the graph nightlies, \
1766                                  then run the silent one by hand and read its error"
1767                        .to_string(),
1768                    argv: vec!["crontab".into(), "-l".into()],
1769                    needs_terminal: false,
1770                }),
1771            });
1772        }
1773    }
1774    out
1775}
1776
1777// --- shared helpers ---------------------------------------------------------
1778
1779/// The age of an RFC 3339 stamp, or `None` when it does not parse — unknown
1780/// must never masquerade as old (or as fresh).
1781fn age_of(stamp: &str, now: DateTime<Utc>) -> Option<chrono::Duration> {
1782    DateTime::parse_from_rfc3339(stamp)
1783        .ok()
1784        .map(|at| now - at.with_timezone(&Utc))
1785}
1786
1787/// "49h ago", "3d ago", or the raw stamp when it does not parse.
1788fn render_age(now: DateTime<Utc>, stamp: &str) -> String {
1789    match age_of(stamp, now) {
1790        Some(age) if age >= chrono::Duration::days(2) => format!("{}d ago", age.num_days()),
1791        Some(age) if age >= chrono::Duration::hours(1) => format!("{}h ago", age.num_hours()),
1792        Some(age) => format!("{}m ago", age.num_minutes().max(0)),
1793        None => stamp.to_string(),
1794    }
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800    use crate::agent::Taint;
1801    use crate::outbox::{OutboxItem, OutboxKind};
1802    use serde_json::json;
1803    use std::path::PathBuf;
1804
1805    fn utc(s: &str) -> DateTime<Utc> {
1806        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
1807    }
1808
1809    const NOW: &str = "2026-08-14T12:00:00Z";
1810
1811    /// A scratch mecha home, unique per test and thread.
1812    fn home(name: &str) -> PathBuf {
1813        let dir = std::env::temp_dir().join(format!(
1814            "mecha-doctor-test-{name}-{}-{:?}",
1815            std::process::id(),
1816            std::thread::current().id()
1817        ));
1818        let _ = std::fs::remove_dir_all(&dir);
1819        std::fs::create_dir_all(&dir).unwrap();
1820        dir
1821    }
1822
1823    fn write_marker(home: &Path, account: &str, body: &str) {
1824        let dir = home.join("mail").join(account);
1825        std::fs::create_dir_all(&dir).unwrap();
1826        std::fs::write(dir.join("auth_error.json"), body).unwrap();
1827    }
1828
1829    fn valid_marker() -> String {
1830        json!({
1831            "at": "2026-08-11T09:00:00Z",
1832            "message": "the refresh token was revoked — run `mecha-mail auth personal --provider google` to sign in again",
1833        })
1834        .to_string()
1835    }
1836
1837    fn pending_item(home: &Path, id: &str, created_at: &str, error: Option<&str>) {
1838        let item = OutboxItem {
1839            id: id.to_string(),
1840            status: "pending".into(),
1841            tool: "mail__send".into(),
1842            kind: OutboxKind::Message,
1843            args_before: json!({"to": "a@x.org"}),
1844            args: json!({"to": "a@x.org"}),
1845            summary: "mail__send to a@x.org".into(),
1846            session_id: None,
1847            workspace: None,
1848            taint: Taint::default(),
1849            created_at: created_at.to_string(),
1850            resolved_at: None,
1851            reason: None,
1852            error: error.map(String::from),
1853        };
1854        let dir = home.join("outbox");
1855        std::fs::create_dir_all(&dir).unwrap();
1856        std::fs::write(
1857            dir.join(format!("{id}.json")),
1858            serde_json::to_string_pretty(&item).unwrap(),
1859        )
1860        .unwrap();
1861    }
1862
1863    fn request(home: &Path, seq: i64, state: &str, drained_at: &str) {
1864        let dir = home.join("requests");
1865        std::fs::create_dir_all(&dir).unwrap();
1866        let record = json!({
1867            "seq": seq,
1868            "type_id": "meeting",
1869            "state": state,
1870            "created_at": drained_at,
1871            "drained_at": drained_at,
1872            "valid": true,
1873            "values": {},
1874            "free_text": [],
1875        });
1876        std::fs::write(
1877            dir.join(format!("{seq:010}-meeting.json")),
1878            record.to_string(),
1879        )
1880        .unwrap();
1881    }
1882
1883    fn trigger_file(home: &Path, name: &str, extra: &str) {
1884        let dir = home.join("triggers");
1885        std::fs::create_dir_all(&dir).unwrap();
1886        std::fs::write(
1887            dir.join(format!("{name}.toml")),
1888            format!(
1889                "schedule = \"0 7 * * *\"\nprompt = \"brief me\"\ntimezone = \"UTC\"\n\
1890                 created_at = \"2026-08-01T00:00:00Z\"\n{extra}"
1891            ),
1892        )
1893        .unwrap();
1894    }
1895
1896    fn ledger_row(home: &Path, row: &serde_json::Value) {
1897        use std::io::Write;
1898        let dir = home.join("triggers");
1899        std::fs::create_dir_all(&dir).unwrap();
1900        let mut file = std::fs::OpenOptions::new()
1901            .create(true)
1902            .append(true)
1903            .open(dir.join("runs.jsonl"))
1904            .unwrap();
1905        writeln!(file, "{row}").unwrap();
1906    }
1907
1908    fn of<'a>(findings: &'a [Finding], component: &str) -> Vec<&'a Finding> {
1909        findings
1910            .iter()
1911            .filter(|f| f.component == component)
1912            .collect()
1913    }
1914
1915    #[test]
1916    fn a_dead_auth_marker_is_found_and_an_absent_one_is_not() {
1917        let home = home("dead-auth");
1918        write_marker(&home, "personal", &valid_marker());
1919        // A healthy account: a directory with credentials and no marker.
1920        std::fs::create_dir_all(home.join("mail").join("dartmouth")).unwrap();
1921        std::fs::write(
1922            home.join("mail").join("accounts.toml"),
1923            "[[account]]\nname = \"personal\"\nprovider = \"google\"\n\
1924             [[account]]\nname = \"dartmouth\"\nprovider = \"outlook\"\n",
1925        )
1926        .unwrap();
1927
1928        let findings = examine(&home, utc(NOW));
1929        let mail = of(&findings, "mail");
1930        assert_eq!(mail.len(), 1, "{findings:#?}");
1931        assert_eq!(mail[0].severity, Severity::Broken);
1932        assert!(mail[0].summary.contains("personal"), "{}", mail[0].summary);
1933        let remedy = mail[0].remedy.as_ref().expect("a dead login has a way out");
1934        assert_eq!(
1935            remedy.argv,
1936            vec!["mecha-mail", "auth", "personal", "--provider", "google"]
1937        );
1938        assert!(
1939            remedy.needs_terminal,
1940            "an OAuth flow needs the real terminal"
1941        );
1942
1943        let _ = std::fs::remove_dir_all(&home);
1944    }
1945
1946    #[test]
1947    fn a_provider_the_registry_cannot_name_is_omitted_from_the_remedy_not_guessed() {
1948        let home = home("no-registry");
1949        // No accounts.toml at all.
1950        write_marker(&home, "personal", &valid_marker());
1951
1952        let findings = examine(&home, utc(NOW));
1953        let mail = of(&findings, "mail");
1954        assert_eq!(mail.len(), 1);
1955        let remedy = mail[0].remedy.as_ref().unwrap();
1956        assert_eq!(remedy.argv, vec!["mecha-mail", "auth", "personal"]);
1957        // The marker's message names the full command, and it rides in the
1958        // detail so the operator still sees the provider.
1959        assert!(
1960            mail[0].detail.contains("--provider google"),
1961            "{}",
1962            mail[0].detail
1963        );
1964
1965        let _ = std::fs::remove_dir_all(&home);
1966    }
1967
1968    /// Legacy per-provider stores (`<home>/google/oauth.json`, still served
1969    /// by the shipped `mecha-google` binary) get the same marker beside
1970    /// their credentials — and the old scan, which read only
1971    /// `<home>/mail/*/`, walked straight past it.
1972    #[test]
1973    fn a_marker_in_a_legacy_per_provider_store_is_found_and_proposes_import() {
1974        let home = home("legacy-auth");
1975        let dir = home.join("google");
1976        std::fs::create_dir_all(&dir).unwrap();
1977        std::fs::write(
1978            dir.join("auth_error.json"),
1979            json!({
1980                "at": "2026-08-11T09:00:00Z",
1981                "message": "account `google`: refresh token expired or revoked — run `mecha-mail auth google --provider google` (invalid_grant)",
1982            })
1983            .to_string(),
1984        )
1985        .unwrap();
1986
1987        let findings = examine(&home, utc(NOW));
1988        let mail = of(&findings, "mail");
1989        assert_eq!(mail.len(), 1, "{findings:#?}");
1990        assert_eq!(mail[0].severity, Severity::Broken);
1991        assert!(
1992            mail[0].summary.contains("legacy google"),
1993            "{}",
1994            mail[0].summary
1995        );
1996        // The marker's message names the exact re-auth command; it must ride
1997        // in the detail.
1998        assert!(
1999            mail[0]
2000                .detail
2001                .contains("run `mecha-mail auth google --provider google`"),
2002            "{}",
2003            mail[0].detail
2004        );
2005        let remedy = mail[0].remedy.as_ref().expect("a way out");
2006        assert_eq!(
2007            remedy.argv,
2008            vec!["mecha-mail", "import", "google", "--provider", "google"]
2009        );
2010
2011        let _ = std::fs::remove_dir_all(&home);
2012    }
2013
2014    #[test]
2015    fn an_unparseable_marker_is_a_store_unreadable_finding_not_a_crash() {
2016        let home = home("bad-marker");
2017        write_marker(&home, "personal", "{ this is not json");
2018
2019        let findings = examine(&home, utc(NOW));
2020        let mail = of(&findings, "mail");
2021        assert_eq!(mail.len(), 1, "{findings:#?}");
2022        assert!(
2023            mail[0].summary.starts_with("store unreadable:"),
2024            "{}",
2025            mail[0].summary
2026        );
2027        assert!(mail[0].summary.contains("personal"), "{}", mail[0].summary);
2028
2029        let _ = std::fs::remove_dir_all(&home);
2030    }
2031
2032    #[test]
2033    fn a_pending_item_with_an_error_is_broken_and_a_resolved_one_is_not() {
2034        let home = home("outbox-error");
2035        pending_item(
2036            &home,
2037            "20260814-000001-aaa",
2038            NOW,
2039            Some("server unreachable"),
2040        );
2041        // A sent item with an old date and even an error field: never flagged.
2042        let mut sent = json!({
2043            "id": "20260810-000001-bbb",
2044            "status": "sent",
2045            "tool": "mail__send",
2046            "args_before": {},
2047            "args": {},
2048            "summary": "mail__send",
2049            "created_at": "2026-08-01T00:00:00Z",
2050        });
2051        sent["error"] = json!(null);
2052        std::fs::write(
2053            home.join("outbox").join("20260810-000001-bbb.json"),
2054            sent.to_string(),
2055        )
2056        .unwrap();
2057
2058        let findings = examine(&home, utc(NOW));
2059        let outbox = of(&findings, "outbox");
2060        assert_eq!(outbox.len(), 1, "{findings:#?}");
2061        assert_eq!(outbox[0].severity, Severity::Broken);
2062        assert!(
2063            outbox[0]
2064                .summary
2065                .contains("release failed: server unreachable"),
2066            "{}",
2067            outbox[0].summary
2068        );
2069        let remedy = outbox[0].remedy.as_ref().unwrap();
2070        assert_eq!(remedy.argv, vec!["mecha", "outbox", "review"]);
2071
2072        let _ = std::fs::remove_dir_all(&home);
2073    }
2074
2075    fn question(home: &Path, id: &str, asked_at: &str, status: &str) {
2076        let dir = home.join("questions");
2077        std::fs::create_dir_all(&dir).unwrap();
2078        let q = crate::questions::Question {
2079            id: id.into(),
2080            status: status.into(),
2081            question: "Which address should the letter go to?".into(),
2082            options: vec![],
2083            session_id: "sess-1".into(),
2084            task_id: Some("task-9".into()),
2085            workspace: None,
2086            taint: Default::default(),
2087            asked_at: asked_at.into(),
2088            answered_at: None,
2089            answer: None,
2090        };
2091        std::fs::write(
2092            dir.join(format!("{id}.json")),
2093            serde_json::to_string_pretty(&q).unwrap(),
2094        )
2095        .unwrap();
2096    }
2097
2098    /// Shorter patience than a stale draft's 48h, because the cost differs: a
2099    /// pending draft is finished work sitting safely, while an unanswered
2100    /// question is a run that stopped and a task parked in `waiting`.
2101    #[test]
2102    fn an_unanswered_question_is_stale_at_25_hours_and_not_at_23() {
2103        let home = home("questions-stale");
2104        question(
2105            &home,
2106            "20260813-100000-aaaaaaaa",
2107            "2026-08-13T10:00:00Z",
2108            "open",
2109        );
2110        let findings = examine(&home, utc(NOW));
2111        let qs = of(&findings, "questions");
2112        assert_eq!(qs.len(), 1, "{findings:#?}");
2113        assert_eq!(qs[0].severity, Severity::Attention);
2114        assert!(
2115            qs[0].summary.contains("cannot continue"),
2116            "{:?}",
2117            qs[0].summary
2118        );
2119        assert_eq!(
2120            qs[0].remedy.as_ref().unwrap().argv,
2121            vec!["mecha", "questions", "list"],
2122            "doctor lists what is stuck; it never answers for the owner"
2123        );
2124
2125        let fresh = home;
2126        let _ = std::fs::remove_dir_all(fresh.join("questions"));
2127        question(
2128            &fresh,
2129            "20260813-130000-bbbbbbbb",
2130            "2026-08-13T13:00:00Z",
2131            "open",
2132        );
2133        assert!(of(&examine(&fresh, utc(NOW)), "questions").is_empty());
2134        let _ = std::fs::remove_dir_all(&fresh);
2135    }
2136
2137    /// An answered question is history, however old. Ageing the record rather
2138    /// than the *waiting* would turn the permanent archive into a permanent
2139    /// finding — the store never deletes, so this would only ever grow.
2140    #[test]
2141    fn an_answered_question_never_ages_into_a_finding() {
2142        let home = home("questions-answered");
2143        question(
2144            &home,
2145            "20260701-100000-cccccccc",
2146            "2026-07-01T10:00:00Z",
2147            "answered",
2148        );
2149        question(
2150            &home,
2151            "20260701-100000-dddddddd",
2152            "2026-07-01T10:00:00Z",
2153            "abandoned",
2154        );
2155        assert!(of(&examine(&home, utc(NOW)), "questions").is_empty());
2156        let _ = std::fs::remove_dir_all(&home);
2157    }
2158
2159    /// Never having asked is not a problem and must not read as one — a
2160    /// machine that has delegated no tasks has no question store at all.
2161    #[test]
2162    fn a_store_that_was_never_created_is_not_a_finding() {
2163        let home = home("questions-absent");
2164        assert!(of(&examine(&home, utc(NOW)), "questions").is_empty());
2165        let _ = std::fs::remove_dir_all(&home);
2166    }
2167
2168    #[test]
2169    fn a_pending_draft_is_stale_at_49_hours_and_not_at_47() {
2170        let home = home("outbox-stale");
2171        // 49h before NOW.
2172        pending_item(&home, "20260812-110000-old", "2026-08-12T11:00:00Z", None);
2173        let findings = examine(&home, utc(NOW));
2174        let outbox = of(&findings, "outbox");
2175        assert_eq!(outbox.len(), 1, "{findings:#?}");
2176        assert_eq!(outbox[0].severity, Severity::Attention);
2177        assert!(outbox[0].summary.contains("pending for more than 48h"));
2178        assert_eq!(
2179            outbox[0].remedy.as_ref().unwrap().argv,
2180            vec!["mecha", "outbox", "review"],
2181            "the remedy is the review surface, never send"
2182        );
2183
2184        // 47h old: a person may simply not have reviewed yet.
2185        let fresh = home;
2186        let _ = std::fs::remove_dir_all(fresh.join("outbox"));
2187        pending_item(&fresh, "20260812-130000-new", "2026-08-12T13:00:00Z", None);
2188        let findings = examine(&fresh, utc(NOW));
2189        assert!(of(&findings, "outbox").is_empty(), "{findings:#?}");
2190
2191        let _ = std::fs::remove_dir_all(&fresh);
2192    }
2193
2194    fn harness_candidate(home: &Path, id: &str, created_at: &str, status: &str) {
2195        let dir = home.join("learning").join("harness").join("candidates");
2196        std::fs::create_dir_all(&dir).unwrap();
2197        let cand = crate::harness::HarnessCandidate {
2198            id: id.into(),
2199            created_at: created_at.into(),
2200            class: crate::candidate::ChangeClass::Config,
2201            change: "compact_at_tokens=24000".into(),
2202            metric: crate::candidate::Metric::CutShort,
2203            rationale: "test".into(),
2204            evidence: String::new(),
2205            model: None,
2206            status: status.into(),
2207            measurement: None,
2208            resolved_at: None,
2209            reason: None,
2210        };
2211        std::fs::write(
2212            dir.join(format!("{id}.json")),
2213            serde_json::to_string_pretty(&cand).unwrap(),
2214        )
2215        .unwrap();
2216    }
2217
2218    fn reflection_line(id: &str, origin: &str, processed: bool, created_at: &str) -> String {
2219        reflection_line_with_intervention(id, origin, processed, created_at, "")
2220    }
2221
2222    /// Same record, with the `intervention` text a caller wants to control —
2223    /// for a reflection stored `clean` before `is_harness_voice` existed,
2224    /// whose *effective* provenance (`Reflexion::provenance`) is `Derived`
2225    /// once the text is mecha's own.
2226    fn reflection_line_with_intervention(
2227        id: &str,
2228        origin: &str,
2229        processed: bool,
2230        created_at: &str,
2231        intervention: &str,
2232    ) -> String {
2233        serde_json::json!({
2234            "id": id,
2235            "domain": "behavior",
2236            "session_id": "s",
2237            "trigger": "steer",
2238            "context": "",
2239            "intervention": intervention,
2240            "reflexion_text": "test",
2241            "is_processed": processed,
2242            "created_at": created_at,
2243            "origin": origin,
2244        })
2245        .to_string()
2246    }
2247
2248    /// A reflection the owner refused with `/learning`'s `d` key — the
2249    /// counterpart provenance exclusion is blind to: `learnable()` checks
2250    /// the drop before it checks origin, so a naive `!learnable()` count
2251    /// cannot tell "the gate excluded this" from "the owner refused this".
2252    fn reflection_line_dropped(id: &str, origin: &str, created_at: &str) -> String {
2253        serde_json::json!({
2254            "id": id,
2255            "domain": "behavior",
2256            "session_id": "s",
2257            "trigger": "steer",
2258            "context": "",
2259            "intervention": "",
2260            "reflexion_text": "test",
2261            "is_processed": false,
2262            "created_at": created_at,
2263            "origin": origin,
2264            "dropped_at": created_at,
2265        })
2266        .to_string()
2267    }
2268
2269    fn write_reflections(home: &Path, lines: &[String]) {
2270        let dir = home.join("learning");
2271        std::fs::create_dir_all(&dir).unwrap();
2272        std::fs::write(dir.join("reflections.jsonl"), lines.join("\n")).unwrap();
2273    }
2274
2275    #[test]
2276    fn a_learner_fed_only_excluded_evidence_is_starved_and_a_met_floor_is_not() {
2277        let home = home("learning-starved");
2278        // 12 recent exclusions, one clean reflection stuck below the floor.
2279        let mut lines: Vec<String> = (0..12)
2280            .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2281            .collect();
2282        lines.push(reflection_line(
2283            "c1",
2284            "clean",
2285            false,
2286            "2026-08-05T00:00:00Z",
2287        ));
2288        write_reflections(&home, &lines);
2289
2290        let findings = examine(&home, utc(NOW));
2291        let learning = of(&findings, "learning");
2292        assert_eq!(learning.len(), 1, "{findings:#?}");
2293        assert_eq!(learning[0].severity, Severity::Attention);
2294        assert!(
2295            learning[0].summary.contains("starved"),
2296            "{}",
2297            learning[0].summary
2298        );
2299        assert!(
2300            learning[0].summary.contains("12 of 13"),
2301            "{}",
2302            learning[0].summary
2303        );
2304        assert_eq!(
2305            learning[0].remedy.as_ref().unwrap().argv,
2306            vec!["mecha", "reflect", "--dry-run"],
2307            "the remedy shows classifications; nothing may loosen the gate"
2308        );
2309
2310        // A domain at the floor means learn runs tonight: not starved.
2311        lines.push(reflection_line(
2312            "c2",
2313            "clean",
2314            false,
2315            "2026-08-06T00:00:00Z",
2316        ));
2317        lines.push(reflection_line(
2318            "c3",
2319            "clean",
2320            false,
2321            "2026-08-07T00:00:00Z",
2322        ));
2323        write_reflections(&home, &lines);
2324        let findings = examine(&home, utc(NOW));
2325        assert!(of(&findings, "learning").is_empty(), "{findings:#?}");
2326
2327        let _ = std::fs::remove_dir_all(&home);
2328    }
2329
2330    /// Dropping ten lessons is `/learning`'s intended use, and must not read
2331    /// as the provenance gate failing: "refused by the owner" and "held back
2332    /// by the gate" are opposite findings with opposite remedies, and
2333    /// conflating them would report a person's own decisions back to them as
2334    /// a starved learner.
2335    #[test]
2336    fn an_owners_drop_is_not_a_provenance_exclusion() {
2337        let home = home("learning-dropped");
2338        // Twelve reflections the owner refused by hand — enough to trip the
2339        // old, unsplit count, and recent enough to read as alive if it did.
2340        let lines: Vec<String> = (0..12)
2341            .map(|i| reflection_line_dropped(&format!("d{i}"), "untrusted", "2026-08-13T12:00:00Z"))
2342            .collect();
2343        write_reflections(&home, &lines);
2344        assert!(
2345            of(&examine(&home, utc(NOW)), "learning").is_empty(),
2346            "a dozen owner refusals must not read as a starved learner"
2347        );
2348
2349        // Mixed with genuine provenance exclusions below the finding's own
2350        // floor: still quiet, because the drops must not pad the count that
2351        // decides whether the gate — not the owner — is the story.
2352        let mut lines = lines;
2353        lines.extend((0..5).map(|i| {
2354            reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z")
2355        }));
2356        write_reflections(&home, &lines);
2357        assert!(
2358            of(&examine(&home, utc(NOW)), "learning").is_empty(),
2359            "5 genuine exclusions is below the floor even with 12 drops beside them"
2360        );
2361
2362        // Past the floor on genuine exclusions alone, the finding fires and
2363        // its own count excludes every drop.
2364        lines.extend((5..10).map(|i| {
2365            reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z")
2366        }));
2367        write_reflections(&home, &lines);
2368        let findings = examine(&home, utc(NOW));
2369        let learning = of(&findings, "learning");
2370        assert_eq!(learning.len(), 1, "{findings:#?}");
2371        assert!(
2372            learning[0].summary.contains("10 of"),
2373            "the 12 drops must not be counted as excluded: {}",
2374            learning[0].summary
2375        );
2376
2377        let _ = std::fs::remove_dir_all(&home);
2378    }
2379
2380    /// Two reflections stored `clean` before `is_harness_voice` existed —
2381    /// mecha's own nudge, mined as though a person had typed it — must not
2382    /// count toward the waiting pool just because the *stored* field says
2383    /// clean. `learn` skips them via `learnable()` and never marks them
2384    /// processed, so counting them here would let them sit in `waiting`
2385    /// forever and permanently suppress the very starvation they caused.
2386    #[test]
2387    fn a_reflection_stored_clean_before_harness_voice_existed_does_not_count_as_waiting() {
2388        let home = home("learning-harness-voice");
2389        let mut lines: Vec<String> = (0..10)
2390            .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2391            .collect();
2392        // Two self-authored nudges, recorded `clean` at the time.
2393        lines.push(reflection_line_with_intervention(
2394            "h1",
2395            "clean",
2396            false,
2397            "2026-08-05T00:00:00Z",
2398            crate::agent::FINAL_ANSWER_NUDGE,
2399        ));
2400        lines.push(reflection_line_with_intervention(
2401            "h2",
2402            "clean",
2403            false,
2404            "2026-08-06T00:00:00Z",
2405            crate::agent::FINAL_ANSWER_NUDGE,
2406        ));
2407        // One genuine clean reflection: the floor is 3, so under the old
2408        // origin-only count this domain would read 3/3 (not starved) —
2409        // under `learnable()` it reads 1/3, and the finding still fires.
2410        lines.push(reflection_line(
2411            "c1",
2412            "clean",
2413            false,
2414            "2026-08-07T00:00:00Z",
2415        ));
2416        write_reflections(&home, &lines);
2417
2418        let findings = examine(&home, utc(NOW));
2419        let learning = of(&findings, "learning");
2420        assert_eq!(learning.len(), 1, "{findings:#?}");
2421        assert!(
2422            learning[0].summary.contains("starved"),
2423            "the two harness-voice records must not read as met-floor evidence: {}",
2424            learning[0].summary
2425        );
2426
2427        let _ = std::fs::remove_dir_all(&home);
2428    }
2429
2430    #[test]
2431    fn thin_or_dormant_exclusion_is_not_starvation() {
2432        let home = home("learning-thin");
2433        // Nine exclusions: below the evidence floor, silence means nothing yet.
2434        let lines: Vec<String> = (0..9)
2435            .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2436            .collect();
2437        write_reflections(&home, &lines);
2438        assert!(of(&examine(&home, utc(NOW)), "learning").is_empty());
2439
2440        // Twelve exclusions, all long stale: a dormant loop, not a starved one
2441        // — the newest excluded reflection is months before NOW.
2442        let lines: Vec<String> = (0..12)
2443            .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-05-01T12:00:00Z"))
2444            .collect();
2445        write_reflections(&home, &lines);
2446        assert!(of(&examine(&home, utc(NOW)), "learning").is_empty());
2447
2448        // Processed clean reflections do not count toward the waiting pool —
2449        // consumed evidence is not a pool the floor can be met from.
2450        let mut lines: Vec<String> = (0..12)
2451            .map(|i| reflection_line(&format!("u{i}"), "untrusted", false, "2026-08-13T12:00:00Z"))
2452            .collect();
2453        for i in 0..3 {
2454            lines.push(reflection_line(
2455                &format!("p{i}"),
2456                "clean",
2457                true,
2458                "2026-08-05T00:00:00Z",
2459            ));
2460        }
2461        write_reflections(&home, &lines);
2462        let findings = examine(&home, utc(NOW));
2463        assert_eq!(of(&findings, "learning").len(), 1, "{findings:#?}");
2464
2465        let _ = std::fs::remove_dir_all(&home);
2466    }
2467
2468    #[test]
2469    fn a_staged_harness_candidate_is_stale_at_73_hours_and_not_at_71() {
2470        let home = home("harness-stale");
2471        // 73h before NOW.
2472        harness_candidate(&home, "hc-old", "2026-08-11T11:00:00Z", "staged");
2473        // Resolved candidates never nag, however old.
2474        harness_candidate(&home, "hc-done", "2026-08-01T00:00:00Z", "rejected");
2475        let findings = examine(&home, utc(NOW));
2476        let harness = of(&findings, "harness");
2477        assert_eq!(harness.len(), 1, "{findings:#?}");
2478        assert_eq!(harness[0].severity, Severity::Attention);
2479        assert!(harness[0].summary.contains("staged for more than 72h"));
2480        assert!(
2481            harness[0].detail.contains("hc-old"),
2482            "{}",
2483            harness[0].detail
2484        );
2485        assert_eq!(
2486            harness[0].remedy.as_ref().unwrap().argv,
2487            vec!["mecha", "harness", "list"],
2488            "the remedy is the review surface, never accept"
2489        );
2490
2491        // 71h old: the person may simply not have looked yet.
2492        let _ = std::fs::remove_dir_all(home.join("learning"));
2493        harness_candidate(&home, "hc-new", "2026-08-11T13:00:00Z", "staged");
2494        let findings = examine(&home, utc(NOW));
2495        assert!(of(&findings, "harness").is_empty(), "{findings:#?}");
2496
2497        let _ = std::fs::remove_dir_all(&home);
2498    }
2499
2500    #[test]
2501    fn a_failed_extraction_is_broken_at_any_age() {
2502        let home = home("frontdoor-failed");
2503        request(&home, 12, crate::frontdoor::EXTRACTION_FAILED, NOW);
2504
2505        let findings = examine(&home, utc(NOW));
2506        let front = of(&findings, "frontdoor");
2507        assert_eq!(front.len(), 1, "{findings:#?}");
2508        assert_eq!(front[0].severity, Severity::Broken);
2509        assert!(front[0].summary.contains("12"), "{}", front[0].summary);
2510        assert_eq!(
2511            front[0].remedy.as_ref().unwrap().argv,
2512            vec!["mecha", "frontdoor", "list"]
2513        );
2514
2515        let _ = std::fs::remove_dir_all(&home);
2516    }
2517
2518    #[test]
2519    fn a_request_waiting_on_me_is_stale_at_73_hours_and_not_at_71() {
2520        let home = home("frontdoor-stale");
2521        // 73h before NOW.
2522        request(
2523            &home,
2524            1,
2525            crate::frontdoor::AWAITING_ME,
2526            "2026-08-11T11:00:00Z",
2527        );
2528        let findings = examine(&home, utc(NOW));
2529        let front = of(&findings, "frontdoor");
2530        assert_eq!(front.len(), 1, "{findings:#?}");
2531        assert_eq!(front[0].severity, Severity::Attention);
2532        assert!(front[0].summary.contains("waiting on you"));
2533
2534        // 71h: not yet.
2535        let _ = std::fs::remove_dir_all(home.join("requests"));
2536        request(
2537            &home,
2538            2,
2539            crate::frontdoor::AWAITING_ME,
2540            "2026-08-11T13:00:00Z",
2541        );
2542        let findings = examine(&home, utc(NOW));
2543        assert!(of(&findings, "frontdoor").is_empty(), "{findings:#?}");
2544
2545        // And a state waiting on the *requester* is never the user's fault.
2546        let _ = std::fs::remove_dir_all(home.join("requests"));
2547        request(
2548            &home,
2549            3,
2550            crate::frontdoor::NEEDS_INFO,
2551            "2026-08-01T00:00:00Z",
2552        );
2553        let findings = examine(&home, utc(NOW));
2554        assert!(of(&findings, "frontdoor").is_empty(), "{findings:#?}");
2555
2556        let _ = std::fs::remove_dir_all(&home);
2557    }
2558
2559    /// `triaged` means "triage considered it and drafted nothing — a person
2560    /// has to decide", and nothing ever re-triages it: left off the
2561    /// waiting-on-me list it waits forever, invisibly.
2562    #[test]
2563    fn a_triaged_request_nothing_will_revisit_goes_stale() {
2564        let home = home("frontdoor-triaged");
2565        // 73h before NOW.
2566        request(&home, 4, crate::frontdoor::TRIAGED, "2026-08-11T11:00:00Z");
2567        // Older still, but waiting on the *stranger*: never the user's fault.
2568        request(
2569            &home,
2570            5,
2571            crate::frontdoor::NEEDS_INFO,
2572            "2026-08-01T00:00:00Z",
2573        );
2574
2575        let findings = examine(&home, utc(NOW));
2576        let front = of(&findings, "frontdoor");
2577        assert_eq!(front.len(), 1, "{findings:#?}");
2578        assert_eq!(front[0].severity, Severity::Attention);
2579        assert!(front[0].detail.contains("triaged"), "{}", front[0].detail);
2580        assert!(
2581            !front[0].detail.contains("needs_info"),
2582            "needs_info waits on the requester: {}",
2583            front[0].detail
2584        );
2585
2586        let _ = std::fs::remove_dir_all(&home);
2587    }
2588
2589    #[test]
2590    fn a_trigger_whose_last_run_failed_is_flagged_with_the_manual_probe() {
2591        let home = home("trigger-failed");
2592        trigger_file(&home, "morning", "");
2593        ledger_row(
2594            &home,
2595            &json!({
2596                "trigger": "morning",
2597                "slot": "2026-08-13T07:00:00Z",
2598                "started_at": "2026-08-13T07:00:01Z",
2599                "status": "ok",
2600                "summary": "fine",
2601            }),
2602        );
2603        ledger_row(
2604            &home,
2605            &json!({
2606                "trigger": "morning",
2607                "slot": "2026-08-14T07:00:00Z",
2608                "started_at": "2026-08-14T07:00:01Z",
2609                "status": "error",
2610                "error": "provider unreachable",
2611            }),
2612        );
2613
2614        let findings = examine(&home, utc(NOW));
2615        let triggers = of(&findings, "triggers");
2616        assert_eq!(triggers.len(), 1, "{findings:#?}");
2617        assert_eq!(triggers[0].severity, Severity::Attention);
2618        assert!(triggers[0].summary.contains("morning"));
2619        assert!(triggers[0].detail.contains("provider unreachable"));
2620        assert_eq!(
2621            triggers[0].remedy.as_ref().unwrap().argv,
2622            vec!["mecha", "trigger", "run", "morning"],
2623            "a manual run is the safe probe: it never advances the schedule"
2624        );
2625
2626        let _ = std::fs::remove_dir_all(&home);
2627    }
2628
2629    /// A skip is a row, not a run: the overlap/staleness bookkeeping the
2630    /// scheduler appends after a failure must not read as a recovery. The
2631    /// old check keyed on the literal last ledger row and reported nothing.
2632    #[test]
2633    fn a_skip_row_after_a_failed_run_does_not_hide_the_failure() {
2634        let home = home("trigger-skip-hides-error");
2635        trigger_file(&home, "morning", "");
2636        ledger_row(
2637            &home,
2638            &json!({
2639                "trigger": "morning",
2640                "slot": "2026-08-13T07:00:00Z",
2641                "started_at": "2026-08-13T07:00:01Z",
2642                "status": "error",
2643                "error": "provider unreachable",
2644            }),
2645        );
2646        ledger_row(
2647            &home,
2648            &json!({
2649                "trigger": "morning",
2650                "slot": "2026-08-14T07:00:00Z",
2651                "started_at": "2026-08-14T07:00:01Z",
2652                "status": "skipped-stale",
2653            }),
2654        );
2655
2656        let findings = examine(&home, utc(NOW));
2657        let triggers = of(&findings, "triggers");
2658        assert_eq!(triggers.len(), 1, "{findings:#?}");
2659        assert!(
2660            triggers[0].summary.contains("most recent run failed"),
2661            "{}",
2662            triggers[0].summary
2663        );
2664        assert!(triggers[0].detail.contains("provider unreachable"));
2665
2666        let _ = std::fs::remove_dir_all(&home);
2667    }
2668
2669    #[test]
2670    fn an_ok_run_followed_by_a_skip_is_healthy() {
2671        let home = home("trigger-ok-then-skip");
2672        trigger_file(&home, "morning", "");
2673        ledger_row(
2674            &home,
2675            &json!({
2676                "trigger": "morning",
2677                "slot": "2026-08-13T07:00:00Z",
2678                "started_at": "2026-08-13T07:00:01Z",
2679                "status": "ok",
2680            }),
2681        );
2682        ledger_row(
2683            &home,
2684            &json!({
2685                "trigger": "morning",
2686                "slot": "2026-08-14T07:00:00Z",
2687                "started_at": "2026-08-14T07:00:01Z",
2688                "status": "skipped-overlap",
2689            }),
2690        );
2691
2692        let findings = examine(&home, utc(NOW));
2693        assert!(of(&findings, "triggers").is_empty(), "{findings:#?}");
2694
2695        let _ = std::fs::remove_dir_all(&home);
2696    }
2697
2698    #[test]
2699    fn a_trigger_quietly_failing_a_third_of_its_calls_is_reported() {
2700        // Every run says `ok` and every briefing arrived. The only place the
2701        // degradation exists is the call counts, which nothing read before.
2702        let home = home("trigger-tool-errors");
2703        trigger_file(&home, "morning", "");
2704        for day in 10..15 {
2705            ledger_row(
2706                &home,
2707                &json!({
2708                    "trigger": "morning",
2709                    "slot": format!("2026-08-{day}T07:00:00Z"),
2710                    "started_at": format!("2026-08-{day}T07:00:01Z"),
2711                    "status": "ok",
2712                    "summary": "briefed",
2713                    "tool_calls": 6,
2714                    "tool_errors": 3,
2715                }),
2716            );
2717        }
2718
2719        let findings = examine(&home, utc(NOW));
2720        let triggers = of(&findings, "triggers");
2721        assert_eq!(triggers.len(), 1, "{findings:#?}");
2722        assert_eq!(triggers[0].severity, Severity::Attention);
2723        assert!(
2724            triggers[0].summary.contains("15 of 30"),
2725            "{}",
2726            triggers[0].summary
2727        );
2728        assert_eq!(
2729            triggers[0].remedy.as_ref().unwrap().argv,
2730            vec!["mecha", "trigger", "show", "morning"],
2731            "reading is the remedy — what to change is in the transcript"
2732        );
2733
2734        let _ = std::fs::remove_dir_all(&home);
2735    }
2736
2737    #[test]
2738    fn a_handful_of_failed_calls_is_not_a_trend() {
2739        // Two rules at once, and both are about not crying wolf. A rate over
2740        // three calls is noise, so the floor holds; and errors are how a run
2741        // learns about its environment, so a rate under the bar is silence
2742        // rather than a quieter finding.
2743        let home = home("trigger-tool-errors-quiet");
2744        trigger_file(&home, "morning", "");
2745        // Under the call floor, though every call failed.
2746        ledger_row(
2747            &home,
2748            &json!({
2749                "trigger": "morning",
2750                "slot": "2026-08-14T07:00:00Z",
2751                "started_at": "2026-08-14T07:00:01Z",
2752                "status": "ok",
2753                "tool_calls": 3,
2754                "tool_errors": 3,
2755            }),
2756        );
2757        assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
2758
2759        // Over the floor, under the rate.
2760        ledger_row(
2761            &home,
2762            &json!({
2763                "trigger": "morning",
2764                "slot": "2026-08-15T07:00:00Z",
2765                "started_at": "2026-08-15T07:00:01Z",
2766                "status": "ok",
2767                "tool_calls": 40,
2768                "tool_errors": 4,
2769            }),
2770        );
2771        assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
2772
2773        let _ = std::fs::remove_dir_all(&home);
2774    }
2775
2776    #[test]
2777    fn a_trigger_that_stopped_doing_anything_is_reported() {
2778        // The null run. Status `ok`, schedule advanced, answer delivered, and
2779        // no work done — invisible in every signal the ledger carried before.
2780        let home = home("trigger-stopped-working");
2781        trigger_file(&home, "morning", "");
2782        for day in 10..14 {
2783            ledger_row(
2784                &home,
2785                &json!({
2786                    "trigger": "morning",
2787                    "slot": format!("2026-08-{day}T07:00:00Z"),
2788                    "started_at": format!("2026-08-{day}T07:00:01Z"),
2789                    "status": "ok",
2790                    "tool_calls": 8,
2791                    "tool_errors": 0,
2792                }),
2793            );
2794        }
2795        ledger_row(
2796            &home,
2797            &json!({
2798                "trigger": "morning",
2799                "slot": "2026-08-14T07:00:00Z",
2800                "started_at": "2026-08-14T07:00:01Z",
2801                "status": "ok",
2802                "summary": "nothing to report",
2803                "tool_calls": 0,
2804                "tool_errors": 0,
2805            }),
2806        );
2807
2808        let findings = examine(&home, utc(NOW));
2809        let triggers = of(&findings, "triggers");
2810        assert_eq!(triggers.len(), 1, "{findings:#?}");
2811        assert!(
2812            triggers[0].summary.contains("did no work"),
2813            "{}",
2814            triggers[0].summary
2815        );
2816        assert!(triggers[0].detail.contains("made 32"));
2817
2818        let _ = std::fs::remove_dir_all(&home);
2819    }
2820
2821    #[test]
2822    fn a_trigger_that_never_needed_tools_is_not_broken_for_not_using_them() {
2823        // The reason this is measured against the trigger's own history and
2824        // never an absolute floor: a prompt that needs no tools makes zero
2825        // calls every morning, and calling that broken would be wrong about
2826        // the healthiest trigger on the machine.
2827        let home = home("trigger-never-used-tools");
2828        trigger_file(&home, "haiku", "");
2829        for day in 10..15 {
2830            ledger_row(
2831                &home,
2832                &json!({
2833                    "trigger": "haiku",
2834                    "slot": format!("2026-08-{day}T07:00:00Z"),
2835                    "started_at": format!("2026-08-{day}T07:00:01Z"),
2836                    "status": "ok",
2837                    "tool_calls": 0,
2838                    "tool_errors": 0,
2839                }),
2840            );
2841        }
2842        assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
2843
2844        let _ = std::fs::remove_dir_all(&home);
2845    }
2846
2847    #[test]
2848    fn a_failed_run_that_did_no_work_is_reported_once_not_twice() {
2849        // An errored run already has a finding naming the error. Reporting the
2850        // absence of work on top of it would be two findings for one fact,
2851        // and a reader who has to decide which of two rows is the real one is
2852        // reading a worse report than one row.
2853        let home = home("trigger-failed-no-work");
2854        trigger_file(&home, "morning", "");
2855        for day in 10..14 {
2856            ledger_row(
2857                &home,
2858                &json!({
2859                    "trigger": "morning",
2860                    "slot": format!("2026-08-{day}T07:00:00Z"),
2861                    "started_at": format!("2026-08-{day}T07:00:01Z"),
2862                    "status": "ok",
2863                    "tool_calls": 8,
2864                    "tool_errors": 0,
2865                }),
2866            );
2867        }
2868        ledger_row(
2869            &home,
2870            &json!({
2871                "trigger": "morning",
2872                "slot": "2026-08-14T07:00:00Z",
2873                "started_at": "2026-08-14T07:00:01Z",
2874                "status": "error",
2875                "error": "provider unreachable",
2876                "tool_calls": 0,
2877                "tool_errors": 0,
2878            }),
2879        );
2880
2881        let triggers = of(&examine(&home, utc(NOW)), "triggers")
2882            .into_iter()
2883            .cloned()
2884            .collect::<Vec<_>>();
2885        assert_eq!(triggers.len(), 1, "{triggers:#?}");
2886        assert!(triggers[0].detail.contains("provider unreachable"));
2887
2888        let _ = std::fs::remove_dir_all(&home);
2889    }
2890
2891    /// The regression this pins: a corrupt transcript was invisible from
2892    /// every surface at once — `Session::list` skips it "quietly",
2893    /// `sessions appraise` counts it nowhere, and doctor said "nothing
2894    /// wrong". Every reader stays best-effort; doctor is the one whose job
2895    /// is the store itself, so the skip count surfaces here.
2896    #[test]
2897    fn an_unreadable_transcript_is_a_finding_not_an_empty_queue() {
2898        let home = home("runs-unreadable");
2899        let dir = home.join("sessions");
2900        std::fs::create_dir_all(&dir).unwrap();
2901        std::fs::write(dir.join("20260828T000000-torn.jsonl"), "not json\n").unwrap();
2902
2903        let all = examine(&home, utc(NOW));
2904        let findings = of(&all, "runs");
2905        assert_eq!(findings.len(), 1, "{findings:#?}");
2906        assert!(
2907            findings[0].summary.contains("unreadable") && findings[0].summary.contains('1'),
2908            "{}",
2909            findings[0].summary
2910        );
2911
2912        let _ = std::fs::remove_dir_all(&home);
2913    }
2914
2915    /// Write `n` runs of one model into the session store, so the
2916    /// population checks have something to be a population of.
2917    fn runs_in(
2918        home: &Path,
2919        model: &str,
2920        n: usize,
2921        stats: impl Fn(usize) -> crate::session::RunStats,
2922    ) {
2923        let dir = home.join("sessions");
2924        std::fs::create_dir_all(&dir).unwrap();
2925        for i in 0..n {
2926            let session = crate::session::Session::create(
2927                &dir,
2928                crate::session::SessionMeta {
2929                    // The model rides in the id: two calls to this helper
2930                    // in one test must not collide, or the second silently
2931                    // rewrites the first's transcripts and the fixture stops
2932                    // describing what the test says it does.
2933                    id: format!("2026080{}T00000{i:03}-{model}", 1 + i % 9),
2934                    created_at: utc(NOW),
2935                    provider: "local".into(),
2936                    model: model.to_string(),
2937                    workspace: std::path::PathBuf::from("/tmp"),
2938                    title: None,
2939                },
2940            )
2941            .unwrap();
2942            session
2943                .append(&crate::session::Record::Outcome(stats(i)))
2944                .unwrap();
2945        }
2946    }
2947
2948    fn run_stats(
2949        calls: u32,
2950        errors: u32,
2951        ended_failed: bool,
2952        cause: crate::agent::StopCause,
2953    ) -> crate::session::RunStats {
2954        crate::session::RunStats {
2955            tool_calls: calls,
2956            tool_errors: errors,
2957            ended_on_failed_call: ended_failed,
2958            stop_cause: Some(cause),
2959            ..Default::default()
2960        }
2961    }
2962
2963    #[test]
2964    fn a_model_that_keeps_finishing_over_failures_is_reported() {
2965        use crate::agent::StopCause;
2966        let home = home("runs-ended-on-failure");
2967        // A third of runs end over a failure; everything else is healthy.
2968        runs_in(&home, "tiny-local", 30, |i| {
2969            run_stats(6, 0, i % 3 == 0, StopCause::Completed)
2970        });
2971
2972        let all = examine(&home, utc(NOW));
2973        let findings = of(&all, "runs");
2974        assert_eq!(findings.len(), 1, "{findings:#?}");
2975        assert!(
2976            findings[0].summary.contains("tiny-local"),
2977            "{}",
2978            findings[0].summary
2979        );
2980        assert!(
2981            findings[0].summary.contains("33%"),
2982            "{}",
2983            findings[0].summary
2984        );
2985        assert_eq!(
2986            findings[0].remedy.as_ref().unwrap().argv,
2987            vec!["mecha", "sessions", "health", "--days", "30"],
2988            "reading is the remedy; doctor never decides what to change"
2989        );
2990
2991        let _ = std::fs::remove_dir_all(&home);
2992    }
2993
2994    #[test]
2995    fn a_cancelled_run_is_not_the_harness_cutting_it_short() {
2996        use crate::agent::StopCause;
2997        // A person pressing Ctrl-C is the system working, and counting it
2998        // would make an attentive user look like a problem.
2999        let home = home("runs-interrupted");
3000        runs_in(&home, "tiny-local", 30, |_| {
3001            run_stats(6, 0, false, StopCause::Interrupted)
3002        });
3003        let findings = examine(&home, utc(NOW));
3004        assert!(of(&findings, "runs").is_empty());
3005        let _ = std::fs::remove_dir_all(&home);
3006    }
3007
3008    #[test]
3009    fn a_turn_ceiling_stopping_a_quarter_of_runs_is_a_finding() {
3010        use crate::agent::StopCause;
3011        let home = home("runs-max-turns");
3012        runs_in(&home, "tiny-local", 30, |_| {
3013            run_stats(6, 0, false, StopCause::MaxTurns)
3014        });
3015        let all = examine(&home, utc(NOW));
3016        let findings = of(&all, "runs");
3017        assert_eq!(findings.len(), 1, "{findings:#?}");
3018        assert!(
3019            findings[0].summary.contains("cut"),
3020            "{}",
3021            findings[0].summary
3022        );
3023        let _ = std::fs::remove_dir_all(&home);
3024    }
3025
3026    #[test]
3027    fn a_thin_sample_of_one_model_says_nothing_about_it() {
3028        use crate::agent::StopCause;
3029        // Every run terrible, and still silent: nineteen runs is not a
3030        // population, and unknown is not a finding.
3031        let home = home("runs-thin");
3032        runs_in(&home, "tiny-local", 19, |_| {
3033            run_stats(6, 6, true, StopCause::MaxTurns)
3034        });
3035        let all = examine(&home, utc(NOW));
3036        assert!(of(&all, "runs").is_empty());
3037        let _ = std::fs::remove_dir_all(&home);
3038    }
3039
3040    #[test]
3041    fn a_bad_model_does_not_drag_a_good_one_into_a_finding() {
3042        use crate::agent::StopCause;
3043        // The reason rates split: blended, these two average to a rate that
3044        // describes neither, and a threshold on it names the wrong model.
3045        let home = home("runs-two-models");
3046        runs_in(&home, "steady", 25, |_| {
3047            run_stats(10, 0, false, StopCause::Completed)
3048        });
3049        runs_in(&home, "flaky", 25, |_| {
3050            run_stats(10, 9, false, StopCause::Completed)
3051        });
3052
3053        let all = examine(&home, utc(NOW));
3054        let findings = of(&all, "runs");
3055        assert_eq!(findings.len(), 1, "{findings:#?}");
3056        assert!(
3057            findings[0].summary.contains("flaky"),
3058            "{}",
3059            findings[0].summary
3060        );
3061        assert!(
3062            !findings[0].summary.contains("steady"),
3063            "the healthy model was named in a finding about the other one"
3064        );
3065        let _ = std::fs::remove_dir_all(&home);
3066    }
3067
3068    #[test]
3069    fn a_ledger_written_before_the_counts_existed_reports_nothing() {
3070        // Not a perfect score, and not a division by zero: no data is not a
3071        // finding, which is the rule the whole module runs on.
3072        let home = home("trigger-tool-errors-bare");
3073        trigger_file(&home, "morning", "");
3074        ledger_row(
3075            &home,
3076            &json!({
3077                "trigger": "morning",
3078                "slot": "2026-08-14T07:00:00Z",
3079                "started_at": "2026-08-14T07:00:01Z",
3080                "status": "ok",
3081            }),
3082        );
3083        assert!(of(&examine(&home, utc(NOW)), "triggers").is_empty());
3084
3085        let _ = std::fs::remove_dir_all(&home);
3086    }
3087
3088    #[test]
3089    fn a_disabled_trigger_is_nobody_s_emergency() {
3090        let home = home("trigger-disabled");
3091        trigger_file(&home, "morning", "enabled = false\n");
3092        ledger_row(
3093            &home,
3094            &json!({
3095                "trigger": "morning",
3096                "started_at": "2026-08-14T07:00:01Z",
3097                "status": "error",
3098                "error": "boom",
3099            }),
3100        );
3101        let findings = examine(&home, utc(NOW));
3102        assert!(of(&findings, "triggers").is_empty(), "{findings:#?}");
3103        let _ = std::fs::remove_dir_all(&home);
3104    }
3105
3106    #[test]
3107    fn a_catch_up_trigger_whose_slots_stopped_advancing_names_the_daemon() {
3108        let home = home("trigger-stale");
3109        trigger_file(&home, "morning", "");
3110        // Last accounted slot five days ago; daily at 07:00 UTC, so slots on
3111        // the 10th..14th are all unaccounted — far more than two.
3112        ledger_row(
3113            &home,
3114            &json!({
3115                "trigger": "morning",
3116                "slot": "2026-08-09T07:00:00Z",
3117                "started_at": "2026-08-09T07:00:01Z",
3118                "status": "ok",
3119            }),
3120        );
3121
3122        let findings = examine(&home, utc(NOW));
3123        let triggers = of(&findings, "triggers");
3124        assert_eq!(triggers.len(), 1, "{findings:#?}");
3125        assert_eq!(triggers[0].severity, Severity::Attention);
3126        assert!(triggers[0].summary.contains("missed more than two slots"));
3127        assert!(
3128            triggers[0].detail.contains("daemon"),
3129            "{}",
3130            triggers[0].detail
3131        );
3132        assert!(
3133            triggers[0].remedy.is_none(),
3134            "running the trigger would not restart the scheduler"
3135        );
3136
3137        // A current ledger is healthy: this morning's 07:00 accounted for.
3138        ledger_row(
3139            &home,
3140            &json!({
3141                "trigger": "morning",
3142                "slot": "2026-08-14T07:00:00Z",
3143                "started_at": "2026-08-14T07:00:01Z",
3144                "status": "ok",
3145            }),
3146        );
3147        let findings = examine(&home, utc(NOW));
3148        assert!(of(&findings, "triggers").is_empty(), "{findings:#?}");
3149
3150        let _ = std::fs::remove_dir_all(&home);
3151    }
3152
3153    /// The observer rule, which matters most: one poisoned store must not
3154    /// suppress what the other checks found — and must itself be reported.
3155    #[cfg(unix)]
3156    #[test]
3157    fn one_poisoned_store_does_not_suppress_the_others() {
3158        use std::os::unix::fs::PermissionsExt;
3159        // Root reads through 0o000 like it is not there, and the test would
3160        // be vacuous.
3161        if unsafe { libc::geteuid() } == 0 {
3162            return;
3163        }
3164
3165        let home = home("poisoned");
3166        write_marker(&home, "personal", &valid_marker());
3167        let outbox = home.join("outbox");
3168        std::fs::create_dir_all(&outbox).unwrap();
3169        std::fs::set_permissions(&outbox, std::fs::Permissions::from_mode(0o000)).unwrap();
3170
3171        let findings = examine(&home, utc(NOW));
3172
3173        // Restore before asserting, so a failure can still clean up.
3174        std::fs::set_permissions(&outbox, std::fs::Permissions::from_mode(0o700)).unwrap();
3175
3176        let mail = of(&findings, "mail");
3177        assert_eq!(mail.len(), 1, "the mail finding survived: {findings:#?}");
3178        assert_eq!(mail[0].severity, Severity::Broken);
3179        let broken_store = of(&findings, "outbox");
3180        assert_eq!(broken_store.len(), 1, "{findings:#?}");
3181        assert!(
3182            broken_store[0].summary.starts_with("store unreadable:"),
3183            "{}",
3184            broken_store[0].summary
3185        );
3186
3187        let _ = std::fs::remove_dir_all(&home);
3188    }
3189
3190    /// Finding-6 drift pin, reader half. Twin test (same golden bytes):
3191    /// `mecha_mail::token::tests::record_auth_error_serialises_the_golden_marker_byte_for_byte`
3192    /// in mecha-mail/src/token.rs — the crates share no types on purpose
3193    /// (the seam is a file of JSON), so a field rename on either side would
3194    /// pass both suites separately and silently kill this finding at
3195    /// runtime. If this literal changes, change the twin's too.
3196    #[test]
3197    fn the_golden_marker_literal_parses_into_the_dead_auth_finding() {
3198        const GOLDEN: &str = r#"{
3199  "at": "2026-08-11T09:00:00Z",
3200  "message": "account `personal`: refresh token expired or revoked — run `mecha-mail auth personal --provider google` (invalid_grant: Token has been revoked.)"
3201}"#;
3202        let home = home("golden-marker");
3203        write_marker(&home, "personal", GOLDEN);
3204
3205        let findings = examine(&home, utc(NOW));
3206        let mail = of(&findings, "mail");
3207        assert_eq!(mail.len(), 1, "{findings:#?}");
3208        assert_eq!(mail[0].severity, Severity::Broken);
3209        assert!(
3210            mail[0].detail.contains("since 2026-08-11T09:00:00Z"),
3211            "the marker's `at` must reach the detail: {}",
3212            mail[0].detail
3213        );
3214        assert!(
3215            mail[0]
3216                .detail
3217                .contains("run `mecha-mail auth personal --provider google`"),
3218            "the marker's `message` must reach the detail: {}",
3219            mail[0].detail
3220        );
3221
3222        let _ = std::fs::remove_dir_all(&home);
3223    }
3224
3225    #[test]
3226    fn findings_sort_broken_first() {
3227        let mut findings = vec![
3228            Finding {
3229                component: "outbox".into(),
3230                severity: Severity::Attention,
3231                summary: "stale".into(),
3232                detail: String::new(),
3233                remedy: None,
3234            },
3235            Finding {
3236                component: "mail".into(),
3237                severity: Severity::Broken,
3238                summary: "dead".into(),
3239                detail: String::new(),
3240                remedy: None,
3241            },
3242        ];
3243        sort(&mut findings);
3244        assert_eq!(findings[0].severity, Severity::Broken);
3245    }
3246
3247    #[test]
3248    fn an_empty_home_is_healthy() {
3249        let home = home("empty");
3250        assert!(examine(&home, utc(NOW)).is_empty());
3251        let _ = std::fs::remove_dir_all(&home);
3252    }
3253
3254    // --- graph nightly silence ---
3255
3256    /// A graph store nested inside a unique scratch dir, so no test plants a
3257    /// `.mecha-graph` beside another test's home in the shared temp dir.
3258    fn graph_store(name: &str) -> PathBuf {
3259        let store = home(name).join(".mecha-graph");
3260        std::fs::create_dir_all(store.join("logs")).unwrap();
3261        store
3262    }
3263
3264    fn nightly_log(store: &Path, file: &str) {
3265        std::fs::write(store.join("logs").join(file), "ran\n").unwrap();
3266    }
3267
3268    // NOW is 2026-08-14: a 08-12 log is two days quiet (stale), 08-13 is
3269    // yesterday (the newest a healthy quiet morning can show).
3270
3271    #[test]
3272    fn a_graph_nightly_that_stopped_writing_logs_is_a_finding() {
3273        let store = graph_store("graph-stale");
3274        nightly_log(&store, "nightly-20260812.log");
3275        let findings = check_graph_nightly(&store, utc(NOW));
3276        assert_eq!(findings.len(), 1);
3277        assert_eq!(findings[0].component, "graph");
3278        assert_eq!(findings[0].severity, Severity::Attention);
3279        assert!(
3280            findings[0].summary.contains("2 days"),
3281            "{}",
3282            findings[0].summary
3283        );
3284        assert!(
3285            findings[0].detail.contains("nightly-20260812.log"),
3286            "{}",
3287            findings[0].detail
3288        );
3289    }
3290
3291    #[test]
3292    fn yesterdays_log_is_healthy_because_todays_slot_may_not_have_fired() {
3293        let store = graph_store("graph-yesterday");
3294        nightly_log(&store, "nightly-20260813.log");
3295        nightly_log(&store, "mecha-nightly-20260813.log");
3296        assert!(check_graph_nightly(&store, utc(NOW)).is_empty());
3297    }
3298
3299    /// The two families age independently: the sweep running every night must
3300    /// not vouch for the vet/gossip half — that is exactly how 2026-08-17
3301    /// stayed invisible.
3302    #[test]
3303    fn each_nightly_family_is_judged_alone() {
3304        let store = graph_store("graph-split");
3305        nightly_log(&store, "nightly-20260814.log");
3306        nightly_log(&store, "mecha-nightly-20260811.log");
3307        let findings = check_graph_nightly(&store, utc(NOW));
3308        assert_eq!(findings.len(), 1);
3309        assert!(
3310            findings[0].summary.contains("mecha-nightly"),
3311            "{}",
3312            findings[0].summary
3313        );
3314    }
3315
3316    /// The `nightly-` scan must not claim `mecha-nightly-` files as its own:
3317    /// a fresh mecha-nightly log would otherwise hide a dead sweep.
3318    #[test]
3319    fn the_shorter_prefix_does_not_claim_the_longer_familys_logs() {
3320        let store = graph_store("graph-prefix");
3321        nightly_log(&store, "mecha-nightly-20260814.log");
3322        nightly_log(&store, "nightly-20260810.log");
3323        let findings = check_graph_nightly(&store, utc(NOW));
3324        assert_eq!(findings.len(), 1);
3325        assert!(
3326            findings[0].detail.contains("nightly-20260810.log"),
3327            "{}",
3328            findings[0].detail
3329        );
3330    }
3331
3332    /// Absence is "not installed", never a finding — a missing store, an
3333    /// empty log directory, and names that parse to no date all stay quiet.
3334    #[test]
3335    fn a_graph_that_never_ran_is_not_a_finding() {
3336        let missing = home("graph-missing").join(".mecha-graph");
3337        assert!(check_graph_nightly(&missing, utc(NOW)).is_empty());
3338
3339        let empty = graph_store("graph-empty");
3340        assert!(check_graph_nightly(&empty, utc(NOW)).is_empty());
3341
3342        let odd = graph_store("graph-odd-names");
3343        nightly_log(&odd, "nightly-garbage.log");
3344        nightly_log(&odd, "gossip-20260812.jsonl");
3345        assert!(check_graph_nightly(&odd, utc(NOW)).is_empty());
3346    }
3347
3348    /// The examine wiring: the store is found as the home's hidden sibling.
3349    #[test]
3350    fn examine_reads_the_graph_store_beside_the_home() {
3351        let scratch = home("graph-sibling");
3352        let mecha_home = scratch.join(".mecha");
3353        std::fs::create_dir_all(&mecha_home).unwrap();
3354        let store = scratch.join(".mecha-graph");
3355        std::fs::create_dir_all(store.join("logs")).unwrap();
3356        nightly_log(&store, "nightly-20260810.log");
3357        let findings = examine(&mecha_home, utc(NOW));
3358        assert_eq!(findings.len(), 1);
3359        assert_eq!(findings[0].component, "graph");
3360        let _ = std::fs::remove_dir_all(&scratch);
3361    }
3362
3363    #[test]
3364    fn a_malformed_charter_is_broken_and_names_the_remedy() {
3365        let home = home("charter-broken");
3366        std::fs::write(
3367            home.join("charter.toml"),
3368            "[[line]]\nid = \"a\"\ntext = \"one\"\n[[line]]\nid = \"a\"\ntext = \"two\"\n",
3369        )
3370        .unwrap();
3371
3372        let findings = check_charter(&home.join("charter.toml"));
3373        assert_eq!(findings.len(), 1, "{findings:#?}");
3374        assert_eq!(findings[0].severity, Severity::Broken);
3375        assert!(
3376            findings[0].detail.contains("used more than once"),
3377            "{}",
3378            findings[0].detail
3379        );
3380        assert_eq!(
3381            findings[0].remedy.as_ref().unwrap().argv,
3382            vec!["mecha", "charter"]
3383        );
3384
3385        let _ = std::fs::remove_dir_all(&home);
3386    }
3387
3388    #[test]
3389    fn a_charter_over_budget_is_attention_not_broken_and_still_named_loaded() {
3390        let home = home("charter-over-budget");
3391        let long = "x".repeat(3000);
3392        std::fs::write(
3393            home.join("charter.toml"),
3394            format!("[[line]]\nid = \"only\"\ntext = \"{long}\"\n"),
3395        )
3396        .unwrap();
3397
3398        let findings = check_charter(&home.join("charter.toml"));
3399        assert_eq!(findings.len(), 1, "{findings:#?}");
3400        // Attention, not Broken: the document is valid and still loads in
3401        // full — it only costs more of the prefix than argued.
3402        assert_eq!(findings[0].severity, Severity::Attention);
3403        assert!(
3404            findings[0].summary.contains("budget"),
3405            "{}",
3406            findings[0].summary
3407        );
3408
3409        let _ = std::fs::remove_dir_all(&home);
3410    }
3411
3412    #[test]
3413    fn a_healthy_charter_and_a_missing_one_are_both_silent() {
3414        let home = home("charter-healthy");
3415        assert!(
3416            check_charter(&home.join("charter.toml")).is_empty(),
3417            "no file at all"
3418        );
3419
3420        std::fs::write(
3421            home.join("charter.toml"),
3422            "[[line]]\nid = \"a\"\ntext = \"protect the owner\"\n",
3423        )
3424        .unwrap();
3425        assert!(check_charter(&home.join("charter.toml")).is_empty());
3426
3427        let _ = std::fs::remove_dir_all(&home);
3428    }
3429
3430    #[test]
3431    fn a_genuinely_empty_charter_file_is_flagged_not_silent() {
3432        // A file that exists and parses cleanly (a comment, or nothing at
3433        // all) to zero `[[line]]` entries — as opposed to a typo'd table
3434        // name, which `RawCharter`'s `deny_unknown_fields` now turns into a
3435        // load error instead, covered by the next test.
3436        let home = home("charter-empty-comment");
3437        std::fs::write(home.join("charter.toml"), "# no priorities written yet\n").unwrap();
3438
3439        let findings = check_charter(&home.join("charter.toml"));
3440        assert_eq!(findings.len(), 1, "{findings:#?}");
3441        assert_eq!(findings[0].severity, Severity::Attention);
3442        assert!(
3443            findings[0].summary.contains("no lines"),
3444            "{}",
3445            findings[0].summary
3446        );
3447
3448        let _ = std::fs::remove_dir_all(&home);
3449    }
3450
3451    #[test]
3452    fn a_directory_at_the_charter_path_is_broken_not_silently_absent() {
3453        // `is_file()` would read this as "nothing written yet" and stay
3454        // silent; `exists()` lets it reach `Charter::load`, whose
3455        // `read_to_string` fails on a directory with a real I/O error rather
3456        // than `NotFound`.
3457        let home = home("charter-is-a-directory");
3458        std::fs::create_dir_all(home.join("charter.toml")).unwrap();
3459
3460        let findings = check_charter(&home.join("charter.toml"));
3461        assert_eq!(findings.len(), 1, "{findings:#?}");
3462        assert_eq!(findings[0].severity, Severity::Broken);
3463
3464        let _ = std::fs::remove_dir_all(&home);
3465    }
3466
3467    #[test]
3468    fn a_typo_d_table_name_beside_a_real_line_is_broken_not_silently_short() {
3469        let home = home("charter-typo-table");
3470        std::fs::write(
3471            home.join("charter.toml"),
3472            "[[line]]\nid = \"a\"\ntext = \"one\"\n\n[[lines]]\nid = \"b\"\ntext = \"two\"\n",
3473        )
3474        .unwrap();
3475
3476        let findings = check_charter(&home.join("charter.toml"));
3477        assert_eq!(findings.len(), 1, "{findings:#?}");
3478        assert_eq!(findings[0].severity, Severity::Broken);
3479
3480        let _ = std::fs::remove_dir_all(&home);
3481    }
3482}