//! Goal mode state machine.
//!
//! This module contains [`GoalTracker`], a pure state machine (no async I/O)
//! modeled after [`PlanModeTracker`](super::plan_mode::PlanModeTracker).
//! The `SessionActor` owns one `GoalTracker` behind a `Mutex` and calls
//! its methods at the appropriate orchestration points.
//!
//! Persisted `"infra_paused"` requires this shell version (one-way upgrade).
//! Unknown wire values (including unknown `*_paused` forms) deserialize to
//! [`GoalStatus::UserPaused`] so a corrupt or forward-version snapshot can
//! never resurrect as a self-driving goal.

use std::path::PathBuf;
use std::time::Instant;

/// Consecutive identical gap fingerprints that trip the stall
/// early-exit: two in a row means the model produced no change in the
/// flagged gaps between attempts, so iterating further is futile and
/// the goal auto-pauses before exhausting the run cap.
pub(crate) const GOAL_CLASSIFIER_STALL_THRESHOLD: u32 = 2;

/// Extra classifier rounds granted (once) when the strategist fires, so its
/// restructure isn't starved under a small cap.
pub(crate) const GOAL_STRATEGIST_CAP_BONUS: u32 = 3;

/// Relaxed stall threshold while a strategist restructure is in flight (cap
/// bonus active): sized to cover the granted bonus rounds, yet bounded so a
/// stuck restructure still exits.
pub(crate) const GOAL_STRATEGIST_STALL_THRESHOLD: u32 =
    GOAL_CLASSIFIER_STALL_THRESHOLD + GOAL_STRATEGIST_CAP_BONUS;

/// Max retained goal-history entries. Only the last is surfaced on the wire
/// (`GoalUpdated.last_event`), but the whole list is persisted, so it is
/// capped (oldest dropped) to keep a long goal's snapshot bounded.
const GOAL_HISTORY_MAX: usize = 64;

// Phase / Status enums

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GoalPhase {
    Idle,
    Planning,
    Executing,
}

/// Lifecycle status of a goal. The paused variants encode the
/// reason the goal was paused — `UserPaused` for Ctrl+C / `/goal pause`,
/// `BackOffPaused` when the classifier run cap is hit, `NoProgressPaused`
/// when the verifier flags the same gaps with no progress before the cap,
/// `InfraPaused` when a turn finishes with an infrastructure error,
/// and `Blocked` when the model determined the goal is not achievable
/// in the current environment. Use [`GoalStatus::is_paused`] to test
/// paused-ness uniformly across all six variants.
///
/// **Backwards-compat serde aliases:** older shells serialized this
/// enum with the default PascalCase form (`"Active"`, `"Paused"`,
/// `"BudgetLimited"`, `"Complete"`). The `#[serde(alias = ...)]`
/// attributes preserve in-flight goal snapshots written by older shells
/// — legacy `"Paused"` maps to `UserPaused` (matches the pager-side
/// fallback). New
/// snapshots emit snake_case per `rename_all`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalStatus {
    #[serde(alias = "Active")]
    Active,
    #[serde(alias = "Paused")]
    UserPaused,
    BackOffPaused,
    /// Verifier flagged the same gaps across consecutive attempts (no
    /// progress) and auto-paused before the run cap. Resumable, same paused
    /// family as `BackOffPaused`; split out so the UI distinguishes a stall
    /// from a cap pause.
    NoProgressPaused,
    /// Infrastructure turn failure (`PromptTurnResult::Err`). The
    /// human-readable reason is stashed in [`GoalOrchestration::pause_message`].
    InfraPaused,
    /// Blocked via `update_goal(blocked_reason: "...")` after 3
    /// consecutive blocked attempts. The human-readable reason is
    /// stashed in [`GoalOrchestration::pause_message`].
    Blocked,
    #[serde(alias = "BudgetLimited")]
    BudgetLimited,
    #[serde(alias = "Complete")]
    Complete,
}

impl<'de> serde::Deserialize<'de> for GoalStatus {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Ok(Self::from_wire_str(&s))
    }
}

impl GoalStatus {
    /// Parse a persisted/wire status string. Unknown values map to
    /// `UserPaused`: a status this shell cannot interpret must restore as
    /// a resumable paused goal, never an Active self-driving one.
    pub fn from_wire_str(s: &str) -> Self {
        match s {
            "active" | "Active" => Self::Active,
            "user_paused" | "paused" | "Paused" => Self::UserPaused,
            // Historical status from shells that had doom-loop auto-pause.
            "doom_loop_paused" => Self::UserPaused,
            "back_off_paused" => Self::BackOffPaused,
            "no_progress_paused" => Self::NoProgressPaused,
            "infra_paused" => Self::InfraPaused,
            "blocked" => Self::Blocked,
            "budget_limited" | "BudgetLimited" => Self::BudgetLimited,
            "complete" | "Complete" => Self::Complete,
            _ => Self::UserPaused,
        }
    }
    /// `true` for any paused variant (`UserPaused`, `BackOffPaused`,
    /// `NoProgressPaused`, `InfraPaused`, `Blocked`).
    pub fn is_paused(&self) -> bool {
        matches!(
            self,
            Self::UserPaused
                | Self::BackOffPaused
                | Self::NoProgressPaused
                | Self::InfraPaused
                | Self::Blocked
        )
    }
}

/// Input to [`GoalTracker::pause`] / [`GoalTracker::pause_with_message`]
/// and the auto-pause helpers. Maps 1:1 to one of the paused variants on
/// [`GoalStatus`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalPauseReason {
    User,
    BackOff,
    /// Verification stage saw no change in the flagged-gap fingerprint
    /// across consecutive attempts and auto-paused before the run cap.
    /// Maps to [`GoalStatus::NoProgressPaused`] — same resumable paused
    /// family as the cap, surfaced distinctly in the UI / telemetry.
    NoProgress,
    /// Goal determined unachievable in this environment — either via
    /// `update_goal(blocked_reason: ...)` after meeting the 3-turn
    /// threshold, or because every verifier refuter classified its gap
    /// as a contradiction / unverifiable blocker. Maps to
    /// [`GoalStatus::Blocked`]; pairs with a human-readable message on
    /// [`GoalOrchestration::pause_message`].
    Verification,
    /// Turn finished with `PromptTurnResult::Err`. Maps to
    /// [`GoalStatus::InfraPaused`]; pairs with a human-readable message on
    /// [`GoalOrchestration::pause_message`].
    Infra,
}

impl GoalPauseReason {
    /// Also used by the graph tracker (`graph_tracker.rs`), which reuses
    /// the goal status vocabulary for graph-level pauses.
    pub(crate) fn to_status(self) -> GoalStatus {
        match self {
            Self::User => GoalStatus::UserPaused,
            Self::BackOff => GoalStatus::BackOffPaused,
            Self::NoProgress => GoalStatus::NoProgressPaused,
            Self::Verification => GoalStatus::Blocked,
            Self::Infra => GoalStatus::InfraPaused,
        }
    }

    /// Short, stable label stashed in the `GoalPaused` history entry's
    /// `detail` so the pager's Recent History distinguishes pause causes.
    /// Shared with the graph tracker's `GraphPaused` entries.
    pub(crate) fn history_detail(self) -> &'static str {
        match self {
            Self::User => "user",
            Self::BackOff => "back_off",
            Self::NoProgress => "no_progress",
            Self::Verification => "blocked",
            Self::Infra => "infra",
        }
    }
}

/// Aggregate verdict produced by the goal-verification stage.
/// `Achieved` indicates the adversarial skeptic panel judged the goal
/// complete; `NotAchieved` means another worker round is warranted.
/// Serialized in snake_case to match `GoalStatus` / `GoalPhase`. The
/// enum name retains the `Classifier` prefix for wire stability across
/// the verification-stage rewire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalClassifierVerdict {
    Achieved,
    NotAchieved,
}

// History

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalEvent {
    GoalCreated,
    PlanningStarted,
    PlanningCompleted,
    PlanningFailed,
    WorkerStarted,
    WorkerCompleted,
    WorkerFailed,
    ContextRotated,
    GoalPaused,
    GoalResumed,
    GoalCompleted,
    GoalCleared,
    BudgetExceeded,
    /// The model tried to stop early (a "giving up"-style bail) while the
    /// goal still had open work and the harness re-nudged it. `detail`
    /// carries the matched stop-pattern label.
    PrematureStopDetected,
    /// Forward-compat sink: a history event written by a newer shell that
    /// this binary doesn't know. Lets an older binary deserialize a newer
    /// snapshot's history instead of failing the whole field.
    #[serde(other)]
    Unknown,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GoalHistoryEntry {
    pub timestamp: String,
    pub event: GoalEvent,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub round: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tokens_used: Option<i64>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unmet: Vec<String>,
}

impl GoalHistoryEntry {
    /// Minimal lifecycle entry stamped with the current time (`round` /
    /// `tokens_used` / `unmet` left empty). Single constructor so the
    /// timestamp + defaults aren't re-spelled at every call site.
    pub(crate) fn now(event: GoalEvent, detail: Option<String>) -> Self {
        Self {
            timestamp: chrono::Utc::now().to_rfc3339(),
            event,
            detail,
            round: None,
            tokens_used: None,
            unmet: Vec::new(),
        }
    }
}

// GoalOrchestration (full persisted state)

/// Generate a short opaque identifier used to scope the per-goal
/// scratch root (`<temp_dir>/kigi-goal-<id>`) and the verifier
/// verdict/details files inside it.
///
/// The id is a 12-char prefix of a UUIDv4 simple form — ~48 bits of
/// entropy, enough to avoid collision between concurrent goals on the
/// same machine while staying short enough that the orchestrator model
/// can copy it verbatim into spawned verifier prompts without
/// truncation or typo risk (see the past-issue memory note on
/// "UUID-in-prompt copy-fidelity failure mode").
pub(crate) fn generate_verifier_id() -> String {
    let mut s = uuid::Uuid::new_v4().simple().to_string();
    s.truncate(12);
    s
}

/// Private per-goal scratch root: `<temp_dir>/kigi-goal-<verifier_id>`.
///
/// Rooted at [`std::env::temp_dir`] (respects `TMPDIR`) and namespaced by the
/// goal's `verifier_id`, so concurrent goals never collide and cleanup of one
/// never touches another. Removed wholesale on every terminal goal transition.
pub(crate) fn goal_scratch_root(verifier_id: &str) -> PathBuf {
    std::env::temp_dir().join(format!("kigi-goal-{verifier_id}"))
}

/// Create (or verify) the goal's scratch root, locked to the owner
/// (0700 on unix). Artifact names under it are predictable from the
/// prompt/log-visible `verifier_id`, so the root itself is the
/// symlink/squat defense: creation is atomic with mode 0700 (no
/// default-mode window), and a pre-existing entry is accepted only if
/// [`verify_owned_real_dir`] passes, then re-pinned to 0700 (safe to
/// chmod: the entry is proven ours and the sticky-bit temp parent
/// prevents swapping it). Callers treat `Err` as "do not write
/// classifier artifacts here".
pub(crate) fn ensure_goal_scratch_root(verifier_id: &str) -> std::io::Result<PathBuf> {
    let root = goal_scratch_root(verifier_id);
    #[cfg(unix)]
    let mut builder = std::fs::DirBuilder::new();
    #[cfg(not(unix))]
    let builder = std::fs::DirBuilder::new();
    #[cfg(unix)]
    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
    match builder.create(&root) {
        Ok(()) => Ok(root),
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
            verify_owned_real_dir(&root)?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
            }
            Ok(root)
        }
        Err(e) => Err(e),
    }
}

/// Shared squat predicate: `Ok` iff `path` is a REAL directory
/// (`symlink_metadata`, never follows) owned by the current euid.
fn verify_owned_real_dir(path: &std::path::Path) -> std::io::Result<()> {
    let meta = std::fs::symlink_metadata(path)?;
    if !meta.is_dir() {
        return Err(std::io::Error::other(
            "goal scratch root exists but is not a real directory (symlink squat?)",
        ));
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        // SAFETY: geteuid has no preconditions and cannot fail.
        if meta.uid() != unsafe { libc::geteuid() } {
            return Err(std::io::Error::other(
                "goal scratch root exists but is not owned by the current user",
            ));
        }
    }
    Ok(())
}

/// Cross-filesystem rescue copy; `create_new` (O_EXCL) makes any
/// pre-existing destination — symlink included — fail the copy instead
/// of being written through.
fn copy_no_follow(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
    let mut src_f = std::fs::File::open(src)?;
    let mut dest_f = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(dest)?;
    std::io::copy(&mut src_f, &mut dest_f)?;
    Ok(())
}

/// Parse `(attempt, skeptic_idx)` out of a per-skeptic report file name
/// (`goal-classifier-{vid}-{attempt}-skeptic-{idx}.md`); `None` otherwise.
fn parse_skeptic_report_order(name: &str) -> Option<(u32, u32)> {
    let stem = name.strip_suffix(".md")?;
    let (head, idx) = stem.rsplit_once("-skeptic-")?;
    let (_, attempt) = head.rsplit_once('-')?;
    Some((attempt.parse().ok()?, idx.parse().ok()?))
}

/// Inline the per-skeptic reports below the just-rescued canonical
/// details file — its path references die with the scratch root, so the
/// rescued file must be self-contained.
///
/// Numeric (attempt DESC, skeptic ASC) order so budget elision drops
/// stale attempts, never the final attempt the canonical body
/// references. Whole files only: each report's on-disk size is checked
/// against the remaining budget BEFORE reading (reports are
/// harness-uncapped and this runs under the tracker lock); the first
/// overflow writes an elision marker and stops. Best-effort.
fn append_skeptic_reports(scratch_root: &std::path::Path, dest: &std::path::Path) {
    use std::io::Write;

    let Ok(entries) = std::fs::read_dir(scratch_root) else {
        return;
    };
    let mut reports: Vec<((u32, u32), PathBuf)> = entries
        .flatten()
        .filter_map(|e| {
            let path = e.path();
            let (attempt, idx) =
                parse_skeptic_report_order(path.file_name()?.to_string_lossy().as_ref())?;
            Some(((attempt, idx), path))
        })
        .collect();
    reports.sort_by_key(|&((attempt, idx), _)| (std::cmp::Reverse(attempt), idx));
    if reports.is_empty() {
        return;
    }
    let Ok(mut out) = std::fs::OpenOptions::new().append(true).open(dest) else {
        return;
    };
    let mut budget = crate::session::goal_classifier::GOAL_VERIFIER_PANEL_MAX_BYTES as u64;
    for (_, report) in reports {
        let name = report.file_name().unwrap_or_default().to_string_lossy();
        let header = format!("\n\n---\n## Inlined skeptic report: {name}\n\n");
        let Ok(len) = std::fs::symlink_metadata(&report).map(|m| m.len()) else {
            continue;
        };
        if header.len() as u64 + len > budget {
            let _ = out
                .write_all(b"\n\n---\n(remaining skeptic reports elided: rescue budget reached)\n");
            return;
        }
        let Ok(body) = std::fs::read_to_string(&report) else {
            continue;
        };
        budget = budget.saturating_sub(header.len() as u64 + body.len() as u64);
        if out
            .write_all(header.as_bytes())
            .and_then(|()| out.write_all(body.as_bytes()))
            .is_err()
        {
            return;
        }
    }
}

/// The goal model's private scratch dir (`<scratch_root>/implementer`).
/// The implementer writes screenshots, temp scripts, and throwaway
/// artifacts here; the skeptics READ it to verify the claimed outputs.
pub(crate) fn implementer_scratch_dir(verifier_id: &str) -> PathBuf {
    goal_scratch_root(verifier_id).join("implementer")
}

/// Skeptic `idx`'s private scratch dir (`<scratch_root>/skeptic-<idx>`).
/// Each skeptic re-runs the verification plan into its OWN dir so N
/// skeptics never overwrite each other or the implementer's outputs.
pub(crate) fn skeptic_scratch_dir(verifier_id: &str, idx: u32) -> PathBuf {
    goal_scratch_root(verifier_id).join(format!("skeptic-{idx}"))
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GoalOrchestration {
    pub goal_id: String,
    pub objective: String,
    pub status: GoalStatus,
    pub phase: GoalPhase,
    pub token_budget: Option<i64>,
    pub elapsed_ms: u64,
    pub created_at: String,
    pub current_subagent_id: Option<String>,
    pub current_subagent_role: Option<String>,
    #[serde(default)]
    pub total_worker_rounds: u32,
    #[serde(default)]
    pub total_verify_rounds: u32,
    #[serde(skip)]
    pub budget_limit_reported: bool,
    /// Session-wide total tokens recorded at goal creation. Seeds the
    /// spend accumulator (`last_session_tokens_seen`) so pre-goal usage
    /// is excluded from the goal's token count.
    #[serde(default)]
    pub token_baseline: i64,
    /// Monotonic high-water mark ratcheted by `SessionActor::goal_tokens`
    /// so wire values never decrease across compactions.
    #[serde(default)]
    pub tokens_used_high_water: i64,
    /// Cumulative parent-session tokens spent on this goal: the sum of
    /// positive per-call deltas of the session token total. Unlike a
    /// `current - baseline` difference, this can never shrink or freeze
    /// when auto-compaction reduces the context-size total. Best-effort
    /// sampling: growth fully consumed by a compaction between two
    /// `goal_tokens` calls is unobserved, and spend accrued since the
    /// last persisted snapshot is lost on crash (bounded by the snapshot
    /// cadence).
    #[serde(default)]
    pub parent_tokens_spent: i64,
    /// Session token total at the previous `SessionActor::goal_tokens`
    /// call — the anchor for the next positive delta. `None` on legacy
    /// snapshots; seeded from `token_baseline` on first use.
    #[serde(default)]
    pub last_session_tokens_seen: Option<i64>,
    pub history: Vec<GoalHistoryEntry>,

    /// Human-readable explanation set when the goal transitions to a
    /// paused state with a meaningful reason. `Blocked` and `InfraPaused`
    /// populate it (via [`GoalTracker::pause_with_message`]),
    /// but the field is orthogonal to status so future reasons can
    /// reuse it. Set only by [`GoalTracker::pause_with_message`];
    /// cleared by every transition out of a paused state —
    /// [`GoalTracker::resume`], [`GoalTracker::complete`], and
    /// [`GoalTracker::budget_limit`] all reset it to `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pause_message: Option<String>,

    /// Short opaque identifier used to scope per-goal artifact paths
    /// owned by the harness. Today's consumers:
    ///
    /// * `goal_classifier.rs` — classifier details / changes diff /
    ///   per-skeptic verdict files (see the
    ///   `GOAL_CLASSIFIER_DETAILS_PATH_TEMPLATE`,
    ///   `GOAL_CLASSIFIER_CHANGES_PATH_TEMPLATE`,
    ///   `GOAL_VERIFIER_VERDICT_PATH_TEMPLATE`,
    ///   `GOAL_VERIFIER_DETAILS_PATH_TEMPLATE` consts).
    ///
    /// Generated by [`generate_verifier_id`] when the goal is created
    /// and persisted alongside the rest of the orchestration so the
    /// same id is reused across pause/resume cycles. The current
    /// model-facing template no longer references this id; the model
    /// is no longer instructed to read per-goal verdict files.
    ///
    /// Older persisted snapshots predate this field; the `serde(default)`
    /// attribute backfills a fresh id on load so verdict-file paths
    /// stay well-formed even after an upgrade.
    #[serde(default = "generate_verifier_id")]
    pub verifier_id: String,

    /// Number of times the goal-achievement classifier has been run
    /// for this goal. Reset only when the goal is recreated.
    #[serde(default)]
    pub classifier_runs_attempted: u32,
    /// Worker rounds since the last verification fired: `+1` per
    /// continuation build, reset to 0 when a classifier attempt is
    /// reserved. Drives the re-verify escalation.
    #[serde(default)]
    pub rounds_since_verify: u32,
    /// Hard cap on classifier runs for this goal. `None` means the
    /// cap has not been configured; `Some(0)` reserves the explicit
    /// "zero runs allowed" case. Mirrors the `token_budget:
    /// Option<i64>` precedent on this struct.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub classifier_max_runs: Option<u32>,
    /// Last aggregate verdict returned by the verification stage, if any.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_classifier_verdict: Option<GoalClassifierVerdict>,
    /// Path to the most recent verification-stage details artifact on disk.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_classifier_details_path: Option<String>,
    /// RFC3339 timestamp of the last classifier run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_classifier_at: Option<String>,
    /// Curated per-refuter gap summary (`build_gaps_summary`) from the
    /// most recent `NotAchieved` verdict. Inlined verbatim into every
    /// continuation directive until a later verdict overwrites it (an
    /// `Achieved` verdict clears it), so the freshest verifier feedback
    /// reaches the model each round rather than once.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_classifier_gaps: Option<String>,
    /// First verification round's full `FINAL_RESPONSE`, replayed as the
    /// breadth anchor on later rounds so a cold skeptic panel sees the whole
    /// deliverable, not just that round's fix note. Captured once (capped);
    /// never cleared on `Achieved` — it must outlive each round.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub first_final_response: Option<String>,
    /// Child session id of skeptic 0 (the persistent reject-gatekeeper)
    /// from the most recent N > 1 verification attempt. The next attempt
    /// resumes it (`resume_from`) so it delta-re-checks the prior gaps
    /// instead of re-analyzing cold. Cleared by [`GoalTracker::from_snapshot`]
    /// (the in-memory token records that anchor a resumed child's marginal
    /// accounting do not survive a restart), on goal completion, and never
    /// set for an N == 1 sole-judge panel.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skeptic0_session_id: Option<String>,
    /// Resolved skeptic index → `{model, agent_type}` assignment, frozen at
    /// the first verification panel and reused on every resume so skeptic-0
    /// (and the cold panel) keep stable models across attempts. Index `i`
    /// holds `pool[i % pool.len()]`; the vector grows (clamped) but never
    /// rewrites a committed index. Empty ⇒ all skeptics inherit the current
    /// model. Persists across snapshot save/restore exactly like
    /// `skeptic0_session_id`, and is reset on the same terminal transitions.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skeptic_model_assignment: Vec<crate::util::config::GoalRoleModel>,
    /// Normalized gap fingerprint of the previous `NotAchieved`
    /// rejection (see `goal_classifier::gap_fingerprint`). Compared
    /// against the next rejection's fingerprint to detect a stuck loop.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_gap_fingerprint: Option<String>,
    /// Count of consecutive rejections carrying the same gap
    /// fingerprint (1 on the first occurrence of a fingerprint). Drives
    /// the stall early-exit in [`GoalTracker::record_classifier_stall`].
    #[serde(default)]
    pub classifier_stall_count: u32,
    /// Count of consecutive `NotAchieved` verifications regardless of
    /// gap content (resets to 0 on an `Achieved` verdict). Drives the
    /// stall-triggered strategist: it fires when this reaches the
    /// configured `goal_strategist_every` (N) and again at each multiple
    /// (2N, 3N, …). Distinct from `classifier_stall_count`, which only
    /// counts *identical*-fingerprint repeats — this catches whack-a-mole
    /// where each round flags a different gap.
    #[serde(default)]
    pub consecutive_not_achieved: u32,
    /// The `consecutive_not_achieved` value at which the strategist last
    /// fired. The trigger fires when `consecutive_not_achieved >=
    /// last_strategist_fired_at + N`, which is SKIP-ROBUST: the synthetic
    /// concurrent-in-flight path can bump the streak past a multiple of N
    /// without landing exactly on it, and a strict `% N == 0` check would
    /// then miss the fire. Reset to 0 with `consecutive_not_achieved`.
    #[serde(default)]
    pub last_strategist_fired_at: u32,
    /// Added to the resolved classifier cap once the strategist has fired.
    #[serde(default)]
    pub strategist_cap_bonus: u32,

    /// Path to the most recent strategist strategy note on disk
    /// (`<session_dir>/goal/strategy.md`, via
    /// [`GoalTracker::strategy_path`]). `None` until the strategist runs.
    /// Surfaced in the continuation directive so the model re-reads it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_strategy_path: Option<String>,
    /// Short narrative recommendation read back from the strategist's
    /// note (capped). Inlined into the continuation directive until a
    /// later strategist run overwrites it; cleared on an `Achieved`
    /// verdict (same replay convention as `last_classifier_gaps`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_strategy_recommendation: Option<String>,

    /// `git rev-parse HEAD` captured at goal creation. Used by the
    /// classifier to diff the worktree against the goal's baseline.
    /// `None` for goals created before the baseline-capture wiring
    /// landed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub changes_baseline_commit: Option<String>,

    /// Path to the goal's plan markdown (`<session_dir>/goal/plan.md`,
    /// via [`GoalTracker::plan_path`]). `None` until a planner writes
    /// one. `is_some()` is the single source of truth for "this goal
    /// has a plan" — gates setup-time fire, the resume-retry path,
    /// and the load-time reconciler. Persisted across restart.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub plan_file: Option<PathBuf>,

    /// Path to the immutable snapshot of the planner's ORIGINAL plan
    /// (`<session_dir>/goal/plan.baseline.md`, via
    /// [`GoalTracker::plan_baseline_path`]). Captured once right after the
    /// planner first writes `plan_file`; never overwritten on later attempts
    /// or restarts. The verifier diffs the CURRENT plan against it
    /// (`capture_plan_changes`) so a skeptic sees every edit the agent made to
    /// `plan.md` during the run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub plan_baseline_file: Option<PathBuf>,

    /// True once the harness created and squat-verified the scratch root AND
    /// the implementer subdir, so prompts can honestly say the dir exists.
    /// `#[serde(skip)]`: recomputed by `from_snapshot` on every reload (the
    /// sole reload path), so a persisted value would be dead-on-read — same as
    /// the recomputed/transient `live_*` fields below.
    #[serde(skip)]
    pub scratch_dir_ready: bool,

    // Transient live-progress fields (not persisted)
    #[serde(skip)]
    pub live_subagent_tokens: u64,
    /// Per-model marginal-token breakdown (model_id, tokens), sorted by
    /// tokens descending. Transient mirror of the active goal's subagent
    /// token records; `#[serde(skip)]` so legacy snapshots deserialize and
    /// it is never persisted.
    #[serde(skip)]
    pub live_tokens_by_model: Vec<(String, u64)>,
    #[serde(skip)]
    pub live_context_window: u64,
    #[serde(skip)]
    pub live_context_pct: u8,
    #[serde(skip)]
    pub live_turn_count: u32,
    #[serde(skip)]
    pub live_tool_call_count: u32,

    /// True while the goal planner subagent is running. Latched by
    /// `emit_goal_planning` and reset after the planner finishes so the
    /// "planning…" badge survives the subagent-spawn / token-accounting
    /// `GoalUpdated`s that fire mid-run. Transient (never persisted).
    #[serde(skip)]
    pub planning_in_flight: bool,

    /// True while the verification skeptic panel is running. Latched around
    /// the verification stage (mirrors `planning_in_flight`) so the
    /// "Verifying…" badge survives the token-accounting / continuation
    /// `GoalUpdated`s that fire mid-verification. Transient (never persisted).
    #[serde(skip)]
    pub verifying_in_flight: bool,
}

impl GoalOrchestration {
    /// Reset ALL strategist state in one place: the consecutive-NotAchieved
    /// streak, the last-fired marker, and the persisted recommendation +
    /// path. Coupling these means a streak reset can never leave a stale
    /// structural recommendation replaying into a clean run (a past
    /// reset/clear asymmetry across branches). Called wherever the goal
    /// starts a fresh streak (`Achieved`/`Blocked` verdicts) or ends/resumes
    /// (`complete`/`budget_limit`/`resume`).
    fn reset_strategist_fields(&mut self) {
        self.consecutive_not_achieved = 0;
        self.last_strategist_fired_at = 0;
        self.strategist_cap_bonus = 0;
        self.last_strategy_path = None;
        self.last_strategy_recommendation = None;
    }

    /// Clear the gap-fingerprint stall streak (shared reset so every caller
    /// zeroes the same fields).
    fn reset_classifier_stall_fields(&mut self) {
        self.last_gap_fingerprint = None;
        self.classifier_stall_count = 0;
    }
}

// GoalTracker (pure state machine)

#[derive(Debug)]
pub struct GoalTracker {
    orchestration: Option<GoalOrchestration>,
    session_dir: PathBuf,
    active_since: Option<Instant>,
}

impl GoalTracker {
    pub fn new(session_dir: PathBuf) -> Self {
        Self {
            orchestration: None,
            session_dir,
            active_since: None,
        }
    }

    /// Restore from a persisted snapshot.
    ///
    /// If the snapshot had an in-flight phase (Planning, Executing),
    /// reset to Idle because subagents don't survive a restart. An
    /// in-flight `Active` goal becomes `UserPaused`; other paused
    /// variants (including [`GoalStatus::InfraPaused`]) are preserved
    /// so `pause_message` and pause cause stay aligned. Clear
    /// `current_subagent_id`.
    pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GoalOrchestration) -> Self {
        match snapshot.phase {
            GoalPhase::Planning | GoalPhase::Executing => {
                snapshot.phase = GoalPhase::Idle;
                if snapshot.status == GoalStatus::Active {
                    snapshot.status = GoalStatus::UserPaused;
                }
                snapshot.current_subagent_id = None;
                snapshot.current_subagent_role = None;
            }
            GoalPhase::Idle => {}
        }
        // `planning_in_flight` / `verifying_in_flight` are `#[serde(skip)]` but
        // in-memory-clone callers bypass that; reset explicitly.
        snapshot.planning_in_flight = false;
        snapshot.verifying_in_flight = false;
        // Token records anchoring a resumed skeptic-0's marginal accounting
        // are in-memory only; a post-restart resume would re-count its full
        // prior cumulative as fresh spend. Cold-spawn instead.
        snapshot.skeptic0_session_id = None;
        // `verifier_id` is snapshot-controlled and embedded in paths later
        // fed to `remove_dir_all`; a non-canonical id (e.g. `/../`) could
        // escape the temp root. Enforce the pinned 12-hex form.
        if snapshot.verifier_id.len() != 12
            || !snapshot.verifier_id.chars().all(|c| c.is_ascii_hexdigit())
        {
            snapshot.verifier_id = generate_verifier_id();
        }
        // Best-effort like `create_goal`: scratch rarely survives a restart
        // but the skeptics expect it. Terminal snapshots had theirs removed
        // deliberately — don't resurrect.
        if !matches!(
            snapshot.status,
            GoalStatus::Complete | GoalStatus::BudgetLimited
        ) {
            // Subdirs only under a verified root — a squatted root must
            // not receive writes through `create_dir_all`. Recompute readiness
            // so a resumed prompt only claims the dir exists when it does.
            snapshot.scratch_dir_ready = match ensure_goal_scratch_root(&snapshot.verifier_id) {
                Ok(_) => {
                    std::fs::create_dir_all(implementer_scratch_dir(&snapshot.verifier_id)).is_ok()
                }
                Err(err) => {
                    tracing::warn!(
                        error = %err,
                        "goal restore: could not secure the scratch root; skipping scratch creation",
                    );
                    false
                }
            };
        } else {
            // The prompt must not claim a dir the terminal transition removed.
            snapshot.scratch_dir_ready = false;
        }
        let active_since = if snapshot.status == GoalStatus::Active {
            Some(Instant::now())
        } else {
            None
        };
        Self {
            orchestration: Some(snapshot),
            session_dir,
            active_since,
        }
    }

    pub fn snapshot(&self) -> Option<&GoalOrchestration> {
        self.orchestration.as_ref()
    }

    pub fn snapshot_mut(&mut self) -> Option<&mut GoalOrchestration> {
        self.orchestration.as_mut()
    }

    pub fn is_active(&self) -> bool {
        self.orchestration
            .as_ref()
            .is_some_and(|o| o.status == GoalStatus::Active)
    }

    pub fn phase(&self) -> Option<GoalPhase> {
        self.orchestration.as_ref().map(|o| o.phase)
    }

    pub fn status(&self) -> Option<GoalStatus> {
        self.orchestration.as_ref().map(|o| o.status)
    }

    pub fn current_subagent_id(&self) -> Option<&str> {
        self.orchestration
            .as_ref()
            .and_then(|o| o.current_subagent_id.as_deref())
    }

    pub fn objective(&self) -> Option<&str> {
        self.orchestration.as_ref().map(|o| o.objective.as_str())
    }

    pub fn token_budget(&self) -> Option<i64> {
        self.orchestration.as_ref().and_then(|o| o.token_budget)
    }

    fn goal_dir(&self) -> PathBuf {
        self.session_dir.join("goal")
    }

    /// Path to the goal's plan markdown (`<session_dir>/goal/plan.md`);
    /// may not exist yet.
    pub fn plan_path(&self) -> PathBuf {
        self.goal_dir().join("plan.md")
    }

    /// Path to the immutable baseline snapshot of the planner's original
    /// plan (`<session_dir>/goal/plan.baseline.md`); written once after
    /// the planner first produces `plan.md`. Sibling of [`Self::plan_path`].
    pub fn plan_baseline_path(&self) -> PathBuf {
        self.goal_dir().join("plan.baseline.md")
    }

    /// Path to the strategist's advisory note (`<session_dir>/goal/strategy.md`).
    /// The strategist writes here, NOT `plan.md`. Its `PlanGuard` snapshots
    /// `plan.md` and restores it byte-for-byte (and on cancellation),
    /// reverting ANY strategist edit. Whole-file restore is safe because the
    /// strategist runs synchronously as the sole writer (the goal turn is
    /// blocked awaiting it), so there's no concurrent implementer edit to
    /// clobber. Sibling of [`Self::plan_path`]; may not exist until the
    /// strategist first runs.
    pub fn strategy_path(&self) -> PathBuf {
        self.goal_dir().join("strategy.md")
    }

    /// Move the last classifier details file out of the scratch root
    /// (which the caller is about to remove) into the durable session
    /// goal dir and update `last_classifier_details_path`, so the
    /// "See <path>" surfaced by the achieved ack stays readable;
    /// per-skeptic reports are inlined below it
    /// ([`append_skeptic_reports`]) to keep it self-contained.
    ///
    /// The stored source path is snapshot-controlled: `..` components
    /// are rejected (`starts_with` is lexical), the source root must
    /// pass [`verify_owned_real_dir`] (a squatted root could stage an
    /// attacker file for the move), and the copy fallback is
    /// [`copy_no_follow`]. The path is stamped only after a move from
    /// the verified root succeeds; otherwise it is left unchanged.
    ///
    /// Runs under the tracker lock — bounded I/O (≤ the panel cap) on
    /// cold, one-shot transitions.
    fn rescue_classifier_details(&mut self) {
        let goal_dir = self.goal_dir();
        let Some(o) = self.orchestration.as_mut() else {
            return;
        };
        let Some(src) = o.last_classifier_details_path.as_deref() else {
            return;
        };
        let src = PathBuf::from(src);
        if src
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            return;
        }
        // Only artifacts inside THIS goal's scratch root are at risk.
        let scratch_root = goal_scratch_root(&o.verifier_id);
        if !src.starts_with(&scratch_root) {
            return;
        }
        // A symlink-squatted root could stage an attacker file for the
        // rename below.
        if verify_owned_real_dir(&scratch_root).is_err() {
            return;
        }
        let Some(name) = src.file_name() else {
            return;
        };
        let dest = goal_dir.join(name);
        let _ = std::fs::create_dir_all(&goal_dir);
        if crate::util::fs::replace_file(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok()
        {
            append_skeptic_reports(&scratch_root, &dest);
            o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
        }
    }

    /// Create a new goal. Replaces any existing orchestration.
    ///
    /// `baseline_commit` is the `git rev-parse HEAD` captured at the
    /// call site (or `None` if not available). It is consumed by
    /// value — callers with `&str` should `.to_string()` at the call
    /// site rather than have the helper clone.
    pub fn create_goal(
        &mut self,
        goal_id: String,
        objective: String,
        token_budget: Option<i64>,
        token_baseline: i64,
        created_at: String,
        baseline_commit: Option<String>,
    ) {
        let _ = std::fs::create_dir_all(self.goal_dir());
        // Replacing a still-active goal: same rescue-then-remove contract
        // as the terminal transitions (the prior goal's details path may
        // already be in user-visible messages).
        if self.orchestration.is_some() {
            self.rescue_classifier_details();
            self.remove_scratch_root();
        }
        // Private per-goal scratch: the implementer dir is created up
        // front (the goal model writes throwaway artifacts here from its
        // first round); each `skeptic-<idx>` dir is created lazily when
        // that skeptic spawns. Best-effort — a creation failure degrades
        // to the model's own fallback, never blocks goal setup.
        let verifier_id = generate_verifier_id();
        // Subdirs only under a verified root (see `ensure_goal_scratch_root`).
        // Capture whether the implementer dir is truly on disk so the prompts
        // only claim "created for you" when it is.
        let scratch_dir_ready = match ensure_goal_scratch_root(&verifier_id) {
            Ok(_) => std::fs::create_dir_all(implementer_scratch_dir(&verifier_id)).is_ok(),
            Err(err) => {
                tracing::warn!(
                    error = %err,
                    "goal create: could not secure the scratch root; skipping scratch creation",
                );
                false
            }
        };
        self.orchestration = Some(GoalOrchestration {
            goal_id,
            objective,
            status: GoalStatus::Active,
            phase: GoalPhase::Executing,
            token_budget,
            elapsed_ms: 0,
            created_at,
            current_subagent_id: None,
            current_subagent_role: None,
            total_worker_rounds: 0,
            total_verify_rounds: 0,
            budget_limit_reported: false,
            token_baseline,
            tokens_used_high_water: 0,
            parent_tokens_spent: 0,
            last_session_tokens_seen: Some(token_baseline),
            history: Vec::new(),
            pause_message: None,
            verifier_id,
            classifier_runs_attempted: 0,
            rounds_since_verify: 0,
            classifier_max_runs: None,
            last_classifier_verdict: None,
            last_classifier_details_path: None,
            last_classifier_at: None,
            last_classifier_gaps: None,
            first_final_response: None,
            skeptic0_session_id: None,
            skeptic_model_assignment: Vec::new(),
            last_gap_fingerprint: None,
            classifier_stall_count: 0,
            consecutive_not_achieved: 0,
            last_strategist_fired_at: 0,
            strategist_cap_bonus: 0,
            last_strategy_path: None,
            last_strategy_recommendation: None,
            changes_baseline_commit: baseline_commit,
            plan_file: None,
            plan_baseline_file: None,
            scratch_dir_ready,
            live_subagent_tokens: 0,
            live_tokens_by_model: Vec::new(),
            live_context_window: 0,
            live_context_pct: 0,
            live_turn_count: 0,
            live_tool_call_count: 0,
            planning_in_flight: false,
            verifying_in_flight: false,
        });
        self.active_since = Some(Instant::now());
        self.record_event(GoalEvent::GoalCreated, None);
    }

    pub fn set_phase(&mut self, phase: GoalPhase) {
        if let Some(o) = &mut self.orchestration {
            o.phase = phase;
        }
    }

    pub fn set_current_subagent(&mut self, id: Option<String>, role: Option<String>) {
        if let Some(o) = &mut self.orchestration {
            o.current_subagent_id = id;
            o.current_subagent_role = role;
        }
    }

    /// Pause the goal with a specific reason. Only transitions from `Active`.
    /// Flushes elapsed time and stops the timer. The `reason` selects one
    /// of the paused variants of [`GoalStatus`]. The `pause_message` field
    /// on the orchestration is NOT modified — to stash a human-readable
    /// reason alongside the pause, use [`Self::pause_with_message`].
    /// Returns `true` if the transition was applied.
    pub fn pause(&mut self, reason: GoalPauseReason) -> bool {
        self.pause_inner(reason, None)
    }

    /// Like [`Self::pause`] but also stores a human-readable `message` on
    /// [`GoalOrchestration::pause_message`]. Used by the `Verification`
    /// reason so the user-visible block reason survives until the next
    /// transition out of the paused state.
    /// Returns `true` if the transition was applied.
    pub fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool {
        self.pause_inner(reason, Some(message))
    }

    fn pause_inner(&mut self, reason: GoalPauseReason, message: Option<String>) -> bool {
        let applied = if let Some(o) = &mut self.orchestration
            && o.status == GoalStatus::Active
        {
            if let Some(since) = self.active_since.take() {
                o.elapsed_ms = o
                    .elapsed_ms
                    .saturating_add(since.elapsed().as_millis() as u64);
            }
            o.status = reason.to_status();
            if message.is_some() {
                o.pause_message = message;
            }
            true
        } else {
            false
        };
        if applied {
            self.record_event(
                GoalEvent::GoalPaused,
                Some(reason.history_detail().to_owned()),
            );
        }
        applied
    }

    /// Resume a paused goal (any paused variant, including `Blocked`). Sets
    /// `active_since`, clears [`GoalOrchestration::pause_message`], and resets
    /// every per-attempt auto-pause counter — `classifier_runs_attempted`, the
    /// strategist streak/recommendation, and the gap-fingerprint stall streak —
    /// so a user re-arm starts fully fresh. Returns `true` if applied.
    pub fn resume(&mut self) -> bool {
        if let Some(o) = &mut self.orchestration
            && o.status.is_paused()
        {
            o.status = GoalStatus::Active;
            o.pause_message = None;
            o.classifier_runs_attempted = 0;
            o.rounds_since_verify = 0;
            o.reset_strategist_fields();
            o.reset_classifier_stall_fields();
            self.active_since = Some(Instant::now());
            self.record_event(GoalEvent::GoalResumed, None);
            return true;
        }
        false
    }

    /// Mark the goal as complete. Accepts `Active` or any paused variant.
    /// Returns `true` if the transition was applied.
    pub fn complete(&mut self) -> bool {
        if let Some(o) = &mut self.orchestration
            && (o.status == GoalStatus::Active || o.status.is_paused())
        {
            if let Some(since) = self.active_since.take() {
                o.elapsed_ms = o
                    .elapsed_ms
                    .saturating_add(since.elapsed().as_millis() as u64);
            }
            o.status = GoalStatus::Complete;
            o.phase = GoalPhase::Idle;
            o.current_subagent_id = None;
            o.current_subagent_role = None;
            o.pause_message = None;
            // Drop the resumed reject-gatekeeper so any later goal starts
            // verification with a fresh, cold skeptic 0.
            o.skeptic0_session_id = None;
            // Sibling of skeptic 0: drop the frozen per-index model
            // assignment so a later goal re-resolves its own panel.
            o.skeptic_model_assignment.clear();
            // Drop the plan baseline alongside skeptic 0: a later goal
            // re-snapshots its own planner's original plan.
            o.plan_baseline_file = None;
            // Terminal transition: reset all strategist state so a
            // recreated/reactivated goal never inherits a stale count or note.
            o.reset_strategist_fields();
            // The achieved ack points the user at the details file, so it
            // must outlive the scratch-root removal below.
            self.rescue_classifier_details();
            self.remove_scratch_root();
            self.record_event(GoalEvent::GoalCompleted, None);
            return true;
        }
        false
    }

    /// Mark the goal as budget-limited. Accepts `Active` or any paused variant.
    /// Returns `true` if the transition was applied.
    pub fn budget_limit(&mut self) -> bool {
        if let Some(o) = &mut self.orchestration
            && (o.status == GoalStatus::Active || o.status.is_paused())
        {
            if let Some(since) = self.active_since.take() {
                o.elapsed_ms = o
                    .elapsed_ms
                    .saturating_add(since.elapsed().as_millis() as u64);
            }
            o.status = GoalStatus::BudgetLimited;
            o.phase = GoalPhase::Idle;
            o.current_subagent_id = None;
            o.current_subagent_role = None;
            o.pause_message = None;
            // Symmetric with `complete`: drop the resumed reject-gatekeeper,
            // the frozen per-index model assignment, and the plan baseline on
            // every terminal goal-ending transition.
            o.skeptic0_session_id = None;
            o.skeptic_model_assignment.clear();
            o.plan_baseline_file = None;
            o.reset_strategist_fields();
            // Symmetric with `complete`.
            self.rescue_classifier_details();
            self.remove_scratch_root();
            self.record_event(GoalEvent::BudgetExceeded, None);
            return true;
        }
        false
    }

    /// Clear the goal entirely (`GoalClear`). Dropping the whole
    /// orchestration also drops `plan_baseline_file` / `skeptic0_session_id`,
    /// so no per-field reset is needed here — but the on-disk scratch root
    /// outlives the struct, so rescue the surfaced details file and remove
    /// the root explicitly first (mirrors the `complete` / `budget_limit`
    /// cleanup).
    pub fn clear(&mut self) {
        self.rescue_classifier_details();
        self.remove_scratch_root();
        self.orchestration = None;
        self.active_since = None;
    }

    /// Best-effort scratch-root removal shared by every terminal
    /// transition; a distinct `verifier_id` per goal means this never
    /// touches a concurrent goal's dir.
    fn remove_scratch_root(&self) {
        if let Some(o) = &self.orchestration {
            let _ = std::fs::remove_dir_all(goal_scratch_root(&o.verifier_id));
        }
    }

    /// Overwrite the transient live-display fields from one subagent's
    /// progress tick. Single-slot, last-writer-wins by design: a display
    /// hint may flip between concurrent children; authoritative totals
    /// come from the token records.
    pub fn update_live_progress(
        &mut self,
        subagent_tokens: u64,
        tokens_by_model: Vec<(String, u64)>,
        context_window: u64,
        context_pct: u8,
        turn_count: u32,
        tool_call_count: u32,
    ) {
        if let Some(o) = &mut self.orchestration {
            o.live_subagent_tokens = subagent_tokens;
            o.live_tokens_by_model = tokens_by_model;
            o.live_context_window = context_window;
            o.live_context_pct = context_pct;
            o.live_turn_count = turn_count;
            o.live_tool_call_count = tool_call_count;
        }
    }

    /// Flush elapsed wall-clock time into `elapsed_ms`.
    pub fn account_elapsed(&mut self) {
        if let Some(o) = &mut self.orchestration
            && let Some(since) = self.active_since
        {
            o.elapsed_ms = o
                .elapsed_ms
                .saturating_add(since.elapsed().as_millis() as u64);
            self.active_since = Some(Instant::now());
        }
    }

    /// Record a `NotAchieved` rejection's gap `fingerprint` and report
    /// whether the goal has stalled — i.e. the same fingerprint has now
    /// appeared on enough consecutive rejections that the model changed
    /// nothing in the flagged gaps. The threshold is
    /// [`GOAL_CLASSIFIER_STALL_THRESHOLD`], relaxed to
    /// [`GOAL_STRATEGIST_STALL_THRESHOLD`] while a strategist restructure is
    /// in flight (cap bonus active). A fingerprint that differs from the
    /// previous one resets the streak to its first occurrence. No-op
    /// (returns `false`) without an orchestration.
    pub fn record_classifier_stall(&mut self, fingerprint: &str) -> bool {
        let Some(o) = self.orchestration.as_mut() else {
            return false;
        };
        if o.last_gap_fingerprint.as_deref() == Some(fingerprint) {
            o.classifier_stall_count = o.classifier_stall_count.saturating_add(1);
        } else {
            o.last_gap_fingerprint = Some(fingerprint.to_string());
            o.classifier_stall_count = 1;
        }
        let threshold = if o.strategist_cap_bonus > 0 {
            GOAL_STRATEGIST_STALL_THRESHOLD
        } else {
            GOAL_CLASSIFIER_STALL_THRESHOLD
        };
        o.classifier_stall_count >= threshold
    }

    /// Undo the most recent attempt-slot reservation. Used when a
    /// rejection is routed to the `Blocked` outcome so it does not
    /// consume the retry budget the user gets back on resume.
    pub fn rollback_classifier_attempt(&mut self) {
        if let Some(o) = self.orchestration.as_mut() {
            o.classifier_runs_attempted = o.classifier_runs_attempted.saturating_sub(1);
        }
    }

    /// Clear the stall streak so the next rejection starts a fresh
    /// fingerprint comparison. Used on the `Blocked` route — a paused-for-
    /// user goal must not carry a half-built streak into its resume.
    pub fn reset_classifier_stall(&mut self) {
        if let Some(o) = self.orchestration.as_mut() {
            o.reset_classifier_stall_fields();
        }
    }

    /// Increment the consecutive-`NotAchieved` streak and return the new
    /// value. Drives the (skip-robust) strategist trigger. No-op (returns
    /// 0) without an orchestration.
    pub fn record_not_achieved_streak(&mut self) -> u32 {
        match self.orchestration.as_mut() {
            Some(o) => {
                o.consecutive_not_achieved = o.consecutive_not_achieved.saturating_add(1);
                o.consecutive_not_achieved
            }
            None => 0,
        }
    }

    /// Atomically evaluate the strategist trigger and claim a fire under a
    /// single lock: if `should_fire(consecutive_not_achieved,
    /// last_strategist_fired_at)` holds, mark the fire at the current streak
    /// (so the next fire needs another N failures) and return
    /// `Some(consecutive_not_achieved)`; otherwise leave state untouched and
    /// return `None`. Folding the check and the record into one critical
    /// section keeps them indivisible, so a streak can never double-fire the
    /// strategist. On fire it also grants the cap bonus and resets the
    /// gap-fingerprint stall streak so the restructure runs against a relaxed,
    /// freshly-measured stall window. No-op (`None`) without an orchestration.
    pub fn claim_strategist_fire(&mut self, should_fire: impl Fn(u32, u32) -> bool) -> Option<u32> {
        let o = self.orchestration.as_mut()?;
        if should_fire(o.consecutive_not_achieved, o.last_strategist_fired_at) {
            o.last_strategist_fired_at = o.consecutive_not_achieved;
            o.strategist_cap_bonus = GOAL_STRATEGIST_CAP_BONUS; // idempotent, not stacked
            o.reset_classifier_stall_fields();
            Some(o.consecutive_not_achieved)
        } else {
            None
        }
    }

    /// Revoke the cap bonus granted by [`Self::claim_strategist_fire`] when
    /// the strategist delivered no restructure. `last_strategist_fired_at`
    /// keeps the claim so the next fire still waits a full window.
    /// Deliberately conservative: the bonus is set, never stacked, so this
    /// also wipes an earlier successful fire's bonus — capping early beats
    /// running unearned rounds under a relaxed stall guard.
    pub fn revoke_strategist_cap_bonus(&mut self) {
        if let Some(o) = self.orchestration.as_mut() {
            o.strategist_cap_bonus = 0;
        }
    }

    /// Reset ALL strategist state (streak + last-fired marker + persisted
    /// recommendation). Called on an `Achieved` verdict (streak broken) and
    /// the `Blocked` route (paused for the user). Symmetric with the
    /// `complete`/`budget_limit`/`resume` resets so a paused/solved goal
    /// never replays a stale recommendation.
    pub fn reset_strategist_state(&mut self) {
        if let Some(o) = self.orchestration.as_mut() {
            o.reset_strategist_fields();
        }
    }

    /// Persist the strategist's latest output path + short recommendation
    /// so the continuation directive can inline them. No-op without an
    /// orchestration.
    pub fn record_strategy_recommendation(&mut self, path: String, recommendation: String) {
        if let Some(o) = self.orchestration.as_mut() {
            o.last_strategy_path = Some(path);
            o.last_strategy_recommendation = Some(recommendation);
        }
    }

    pub fn append_history(&mut self, entry: GoalHistoryEntry) {
        if let Some(o) = &mut self.orchestration {
            o.history.push(entry);
            // Bound the persisted timeline; drop oldest past the cap.
            let overflow = o.history.len().saturating_sub(GOAL_HISTORY_MAX);
            if overflow > 0 {
                o.history.drain(0..overflow);
            }
        }
    }

    /// Append a timestamped lifecycle history entry. The single chokepoint for
    /// every transition (create / pause / resume / complete / budget_limit) so
    /// each reaches `GoalUpdated.last_event` and no branch can forget to record.
    fn record_event(&mut self, event: GoalEvent, detail: Option<String>) {
        self.append_history(GoalHistoryEntry::now(event, detail));
    }
}

// Test helpers (shared across goal_tracker + goal_orchestrator tests)

#[cfg(test)]
pub(crate) fn make_base_orchestration() -> GoalOrchestration {
    GoalOrchestration {
        goal_id: "g-test".into(),
        objective: "test objective".into(),
        status: GoalStatus::Active,
        phase: GoalPhase::Idle,
        token_budget: None,
        elapsed_ms: 0,
        created_at: "2026-01-01T00:00:00Z".into(),
        current_subagent_id: None,
        current_subagent_role: None,
        total_worker_rounds: 0,
        total_verify_rounds: 0,
        budget_limit_reported: false,
        token_baseline: 0,
        tokens_used_high_water: 0,
        parent_tokens_spent: 0,
        last_session_tokens_seen: Some(0),
        history: Vec::new(),
        pause_message: None,
        verifier_id: generate_verifier_id(),
        classifier_runs_attempted: 0,
        rounds_since_verify: 0,
        classifier_max_runs: None,
        last_classifier_verdict: None,
        last_classifier_details_path: None,
        last_classifier_at: None,
        last_classifier_gaps: None,
        first_final_response: None,
        skeptic0_session_id: None,
        skeptic_model_assignment: Vec::new(),
        last_gap_fingerprint: None,
        classifier_stall_count: 0,
        consecutive_not_achieved: 0,
        last_strategist_fired_at: 0,
        strategist_cap_bonus: 0,
        last_strategy_path: None,
        last_strategy_recommendation: None,
        changes_baseline_commit: None,
        plan_file: None,
        plan_baseline_file: None,
        scratch_dir_ready: false,
        live_subagent_tokens: 0,
        live_tokens_by_model: Vec::new(),
        live_context_window: 0,
        live_context_pct: 0,
        live_turn_count: 0,
        live_tool_call_count: 0,
        planning_in_flight: false,
        verifying_in_flight: false,
    }
}

// Tests

#[cfg(test)]
mod tests {
    use super::*;

    fn make_tracker() -> GoalTracker {
        GoalTracker::new(PathBuf::from("/tmp/test-goal-session"))
    }

    fn activate_tracker(t: &mut GoalTracker) {
        t.create_goal(
            "goal-1".into(),
            "Build a widget".into(),
            Some(100_000),
            0,
            "2026-01-01T00:00:00Z".into(),
            None,
        );
    }

    use super::make_base_orchestration;

    #[test]
    fn create_goal_activates_and_starts_timer() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        assert!(t.is_active());
        assert_eq!(t.phase(), Some(GoalPhase::Executing));
        assert_eq!(t.status(), Some(GoalStatus::Active));
        assert_eq!(t.objective(), Some("Build a widget"));
        assert_eq!(t.token_budget(), Some(100_000));
        assert!(t.active_since.is_some());
    }

    #[test]
    fn lifecycle_transitions_record_history_events() {
        // Created/paused/resumed/completed each append a history entry that
        // reaches `GoalUpdated.last_event`.
        use crate::session::goal_orchestrator::build_goal_updated;

        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(
            matches!(
                t.snapshot().unwrap().history.last().map(|e| &e.event),
                Some(GoalEvent::GoalCreated)
            ),
            "create_goal must record GoalCreated"
        );

        assert!(t.pause(GoalPauseReason::User));
        {
            let last = t.snapshot().unwrap().history.last().unwrap();
            assert!(matches!(last.event, GoalEvent::GoalPaused));
            assert_eq!(
                last.detail.as_deref(),
                Some("user"),
                "pause records its cause as the history detail"
            );
        }

        assert!(t.resume());
        assert!(matches!(
            t.snapshot().unwrap().history.last().map(|e| &e.event),
            Some(GoalEvent::GoalResumed)
        ));

        assert!(t.complete());
        let o = t.snapshot().unwrap();
        assert!(matches!(
            o.history.last().map(|e| &e.event),
            Some(GoalEvent::GoalCompleted)
        ));
        // The latest event must surface on the wire as `last_event`.
        match build_goal_updated(o, 0, 0) {
            crate::extensions::notification::SessionUpdate::GoalUpdated { last_event, .. } => {
                assert_eq!(last_event.as_deref(), Some("goal_completed"));
            }
            other => panic!("expected GoalUpdated, got {other:?}"),
        }
    }

    #[test]
    fn premature_stop_event_maps_to_wire_string() {
        // PrematureStopDetected serializes to its wire label and carries the
        // pattern via `last_event_detail`.
        use crate::session::goal_orchestrator::build_goal_updated;

        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.append_history(GoalHistoryEntry {
            timestamp: "t".into(),
            event: GoalEvent::PrematureStopDetected,
            detail: Some("giving_up".into()),
            round: None,
            tokens_used: None,
            unmet: Vec::new(),
        });
        match build_goal_updated(t.snapshot().unwrap(), 0, 0) {
            crate::extensions::notification::SessionUpdate::GoalUpdated {
                last_event,
                last_event_detail,
                ..
            } => {
                assert_eq!(last_event.as_deref(), Some("premature_stop_detected"));
                assert_eq!(last_event_detail.as_deref(), Some("giving_up"));
            }
            other => panic!("expected GoalUpdated, got {other:?}"),
        }
    }

    #[test]
    fn append_history_caps_oldest_entries() {
        // The persisted timeline is bounded; the newest entry is retained.
        let mut t = make_tracker();
        activate_tracker(&mut t); // seeds 1 GoalCreated entry
        for i in 0..(GOAL_HISTORY_MAX + 10) {
            t.append_history(GoalHistoryEntry {
                timestamp: format!("t{i}"),
                event: GoalEvent::WorkerStarted,
                detail: None,
                round: None,
                tokens_used: None,
                unmet: Vec::new(),
            });
        }
        let o = t.snapshot().unwrap();
        assert_eq!(o.history.len(), GOAL_HISTORY_MAX, "history must be capped");
        assert_eq!(
            o.history.last().unwrap().timestamp,
            format!("t{}", GOAL_HISTORY_MAX + 10 - 1),
            "newest entry retained; oldest dropped"
        );
    }

    #[test]
    fn goal_event_unknown_string_deserializes_to_unknown() {
        // #[serde(other)] forward-compat: an unknown event from a newer shell
        // deserializes to `Unknown` instead of failing the field.
        let unknown: GoalEvent = serde_json::from_str("\"some_future_event\"").unwrap();
        assert!(matches!(unknown, GoalEvent::Unknown));
        let known: GoalEvent = serde_json::from_str("\"goal_paused\"").unwrap();
        assert!(matches!(known, GoalEvent::GoalPaused));
    }

    #[test]
    fn pause_records_cause_specific_history_detail() {
        // All six pause reasons record a distinct history `detail` (the
        // `history_detail` mapping, exercised via the real pause path).
        for (reason, expected) in [
            (GoalPauseReason::User, "user"),
            (GoalPauseReason::User, "user"),
            (GoalPauseReason::BackOff, "back_off"),
            (GoalPauseReason::NoProgress, "no_progress"),
            (GoalPauseReason::Verification, "blocked"),
            (GoalPauseReason::Infra, "infra"),
        ] {
            let mut t = make_tracker();
            activate_tracker(&mut t);
            assert!(t.pause(reason), "pause must apply for {reason:?}");
            let last = t.snapshot().unwrap().history.last().unwrap();
            assert!(matches!(last.event, GoalEvent::GoalPaused));
            assert_eq!(last.detail.as_deref(), Some(expected), "for {reason:?}");
        }
    }

    #[test]
    fn budget_limit_records_history_via_chokepoint() {
        // budget_limit records its own BudgetExceeded entry via the chokepoint,
        // like the other lifecycle transitions.
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(t.budget_limit());
        assert!(matches!(
            t.snapshot().unwrap().history.last().map(|e| &e.event),
            Some(GoalEvent::BudgetExceeded)
        ));
    }

    #[test]
    fn plan_path_is_session_scoped() {
        let t = GoalTracker::new(PathBuf::from("/tmp/plan-path-session-xyz"));
        assert_eq!(
            t.plan_path(),
            PathBuf::from("/tmp/plan-path-session-xyz/goal/plan.md"),
        );
    }

    #[test]
    fn create_goal_initialises_plan_file_to_none() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(t.snapshot().unwrap().plan_file.is_none());
    }

    #[test]
    fn plan_file_round_trips_through_serde_when_some() {
        let mut o = make_base_orchestration();
        let path = PathBuf::from("/tmp/plan-rt-session/goal/plan.md");
        o.plan_file = Some(path.clone());

        let json = serde_json::to_string(&o).unwrap();
        assert!(
            json.contains("\"plan_file\":\"/tmp/plan-rt-session/goal/plan.md\""),
            "plan_file must appear on the wire as a string: {json}",
        );

        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.plan_file, Some(path));
    }

    #[test]
    fn plan_file_omitted_from_json_when_none() {
        let o = make_base_orchestration();
        assert!(o.plan_file.is_none());

        let json = serde_json::to_string(&o).unwrap();
        assert!(
            !json.contains("plan_file"),
            "plan_file=None must be skipped on serialize: {json}",
        );

        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert!(restored.plan_file.is_none());
    }

    /// A legacy snapshot written before this PR has no `plan_file`
    /// key; `#[serde(default)]` must backfill `None`.
    #[test]
    fn plan_file_backfills_none_on_legacy_snapshot() {
        const LEGACY: &str = r#"{
            "goal_id": "g-legacy",
            "objective": "legacy goal",
            "status": "active",
            "phase": "Idle",
            "token_budget": null,
            "elapsed_ms": 0,
            "created_at": "2026-01-01T00:00:00Z",
            "current_subagent_id": null,
            "current_subagent_role": null,
            "total_worker_rounds": 0,
            "total_verify_rounds": 0,
            "token_baseline": 0,
            "history": [],
            "verifier_id": "abcdef012345"
        }"#;
        let loaded: GoalOrchestration =
            serde_json::from_str(LEGACY).expect("legacy snapshot must deserialize");
        assert!(loaded.plan_file.is_none());
    }

    /// `generate_verifier_id` must produce 12-char hex strings and
    /// fresh values on every call. The fixed length is part of the
    /// public contract — verifier file paths embed it verbatim, and
    /// drift here would silently invalidate the documented
    /// `kigi-goal-<12 hex chars>` scratch-root format (and the
    /// 12-hex restore validation in `from_snapshot`).
    #[test]
    fn generate_verifier_id_is_short_hex_and_unique() {
        let a = generate_verifier_id();
        let b = generate_verifier_id();
        assert_eq!(a.len(), 12, "verifier id must be exactly 12 chars: {a}");
        assert_eq!(b.len(), 12, "verifier id must be exactly 12 chars: {b}");
        assert!(
            a.chars().all(|c| c.is_ascii_hexdigit()),
            "verifier id must be hex: {a}"
        );
        assert_ne!(a, b, "two successive ids must differ");
    }

    /// `create_goal` populates `verifier_id` and that id survives every
    /// pause/resume/complete transition unchanged. The id is the
    /// stable handle the model uses to read verdict files, so any
    /// mid-lifecycle reassignment would orphan the previously written
    /// verdicts.
    #[test]
    fn verifier_id_is_stable_across_pause_resume() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let original = t.snapshot().unwrap().verifier_id.clone();
        assert_eq!(original.len(), 12);

        assert!(t.pause(GoalPauseReason::User));
        assert_eq!(t.snapshot().unwrap().verifier_id, original);

        assert!(t.resume());
        assert_eq!(t.snapshot().unwrap().verifier_id, original);

        assert!(t.pause_with_message(GoalPauseReason::Verification, "stuck on flaky test".into()));
        assert_eq!(t.snapshot().unwrap().verifier_id, original);

        assert!(t.resume());
        assert_eq!(t.snapshot().unwrap().verifier_id, original);

        assert!(t.complete());
        assert_eq!(t.snapshot().unwrap().verifier_id, original);
    }

    /// Serde round-trip must preserve `verifier_id`, and a legacy
    /// snapshot without the field must round-trip to a freshly
    /// generated id (not panic, not empty). This guards both the
    /// stability contract (existing goals keep their id after a
    /// shell restart) and the backwards-compat contract (pre-field
    /// snapshots load cleanly with a backfilled id).
    #[test]
    fn verifier_id_serde_roundtrip_and_backfill() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let original_id = t.snapshot().unwrap().verifier_id.clone();

        let json = serde_json::to_string(t.snapshot().unwrap()).unwrap();
        assert!(
            json.contains(&format!("\"verifier_id\":\"{original_id}\"")),
            "serialized snapshot must include verifier_id: {json}"
        );
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.verifier_id, original_id);

        // Legacy snapshot — drop the verifier_id key entirely and
        // confirm serde backfills a fresh 12-char hex id.
        let legacy: serde_json::Value = {
            let mut v: serde_json::Value = serde_json::from_str(&json).unwrap();
            v.as_object_mut().unwrap().remove("verifier_id");
            v
        };
        let backfilled: GoalOrchestration =
            serde_json::from_value(legacy).expect("legacy snapshot must deserialize");
        assert_eq!(
            backfilled.verifier_id.len(),
            12,
            "backfilled id must be 12 chars: {}",
            backfilled.verifier_id
        );
        assert!(
            backfilled
                .verifier_id
                .chars()
                .all(|c| c.is_ascii_hexdigit()),
            "backfilled id must be hex: {}",
            backfilled.verifier_id
        );
    }

    #[test]
    fn record_classifier_stall_trips_on_two_consecutive_identical_fingerprints() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(
            !t.record_classifier_stall("fp-a"),
            "first occurrence of a fingerprint is not a stall"
        );
        assert!(
            t.record_classifier_stall("fp-a"),
            "the same fingerprint twice running trips the stall early-exit"
        );
        assert_eq!(t.snapshot().unwrap().classifier_stall_count, 2);
    }

    #[test]
    fn record_classifier_stall_resets_when_fingerprint_changes() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(!t.record_classifier_stall("fp-a"));
        assert!(t.record_classifier_stall("fp-a"));
        assert!(
            !t.record_classifier_stall("fp-b"),
            "a different fingerprint resets the streak to its first occurrence"
        );
        assert_eq!(t.snapshot().unwrap().classifier_stall_count, 1);
        assert!(
            t.record_classifier_stall("fp-b"),
            "the new fingerprint then trips on its own second occurrence"
        );
    }

    #[test]
    fn reset_classifier_stall_clears_streak_so_next_occurrence_is_first() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        // Build a streak, then reset it (as the Blocked route does).
        assert!(!t.record_classifier_stall("fp-a"));
        {
            let o = t.snapshot().unwrap();
            assert_eq!(o.classifier_stall_count, 1);
            assert_eq!(o.last_gap_fingerprint.as_deref(), Some("fp-a"));
        }
        t.reset_classifier_stall();
        {
            let o = t.snapshot().unwrap();
            assert_eq!(o.classifier_stall_count, 0);
            assert!(o.last_gap_fingerprint.is_none());
        }
        // The same fingerprint is now a fresh first occurrence — it must
        // NOT immediately trip the stall.
        assert!(
            !t.record_classifier_stall("fp-a"),
            "after reset, a repeat of the old fingerprint must not re-stall"
        );
    }

    /// Firing the strategist clears the gap-fingerprint stall streak so the
    /// restructure is measured from a clean slate.
    #[test]
    fn strategist_fire_resets_classifier_stall_streak() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        // Seed a partial stall streak (a pre-strategist gap repeat).
        assert!(!t.record_classifier_stall("fp-pre"));
        {
            let o = t.snapshot().unwrap();
            assert_eq!(o.classifier_stall_count, 1);
            assert_eq!(o.last_gap_fingerprint.as_deref(), Some("fp-pre"));
        }
        let _ = t.record_not_achieved_streak();
        assert!(t.claim_strategist_fire(|_, _| true).is_some());
        let o = t.snapshot().unwrap();
        assert_eq!(
            o.classifier_stall_count, 0,
            "fire must clear the stall count"
        );
        assert!(
            o.last_gap_fingerprint.is_none(),
            "fire must clear the stall fingerprint"
        );
    }

    /// While the strategist bonus is active, identical gap fingerprints get the
    /// relaxed stall window instead of tripping at the default threshold of 2,
    /// and the window stays bounded (still exits at its own threshold).
    #[test]
    fn relaxed_stall_threshold_applies_while_strategist_bonus_active() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let _ = t.record_not_achieved_streak();
        assert!(t.claim_strategist_fire(|_, _| true).is_some());
        assert!(t.snapshot().unwrap().strategist_cap_bonus > 0);

        // Identical fingerprints below the relaxed threshold must NOT stall.
        for n in 1..GOAL_STRATEGIST_STALL_THRESHOLD {
            assert!(
                !t.record_classifier_stall("fp-loop"),
                "count {n} must not stall inside the relaxed window"
            );
        }
        // Bounded: the relaxed window still exits at its own threshold.
        assert!(
            t.record_classifier_stall("fp-loop"),
            "relaxed window trips at GOAL_STRATEGIST_STALL_THRESHOLD"
        );
    }

    /// Once strategist state resets (clearing the cap bonus) the stall
    /// threshold reverts to the strict default — the relaxation is scoped to
    /// the post-strategist window only, never weakening the guard outside it.
    #[test]
    fn relaxed_stall_threshold_reverts_after_strategist_reset() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let _ = t.record_not_achieved_streak();
        assert!(t.claim_strategist_fire(|_, _| true).is_some());

        // Two identical fingerprints under the relaxed window: no stall yet.
        assert!(!t.record_classifier_stall("fp-x"));
        assert!(!t.record_classifier_stall("fp-x"));
        assert_eq!(t.snapshot().unwrap().classifier_stall_count, 2);

        // Clearing strategist state drops the bonus, restoring the strict threshold.
        t.reset_strategist_state();
        assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
        assert!(
            t.record_classifier_stall("fp-x"),
            "after strategist reset the strict default threshold applies again"
        );
    }

    #[test]
    fn rollback_classifier_attempt_decrements_saturating() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.snapshot_mut().unwrap().classifier_runs_attempted = 2;
        t.rollback_classifier_attempt();
        assert_eq!(t.snapshot().unwrap().classifier_runs_attempted, 1);
        t.snapshot_mut().unwrap().classifier_runs_attempted = 0;
        t.rollback_classifier_attempt();
        assert_eq!(
            t.snapshot().unwrap().classifier_runs_attempted,
            0,
            "rollback must saturate at zero"
        );
    }

    #[test]
    fn full_lifecycle_create_to_complete() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        t.set_phase(GoalPhase::Executing);
        assert_eq!(t.phase(), Some(GoalPhase::Executing));

        t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));
        assert_eq!(t.current_subagent_id(), Some("sub-1"));

        assert!(t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
        assert_eq!(t.phase(), Some(GoalPhase::Idle));
        assert!(t.current_subagent_id().is_none());
        assert!(t.active_since.is_none());
    }

    #[test]
    fn pause_only_from_active() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        assert!(t.pause(GoalPauseReason::User));
        assert_eq!(t.status(), Some(GoalStatus::UserPaused));
        assert!(!t.is_active());

        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
        assert!(t.is_active());
    }

    #[test]
    fn pause_from_complete_is_noop() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.complete();

        assert!(!t.pause(GoalPauseReason::User));
        assert_eq!(t.status(), Some(GoalStatus::Complete));
    }

    #[test]
    fn pause_from_budget_limited_is_noop() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.budget_limit();

        assert!(!t.pause(GoalPauseReason::User));
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
    }

    #[test]
    fn resume_only_from_paused_variants() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        assert!(!t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));

        t.budget_limit();
        assert!(!t.resume());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
    }

    #[test]
    fn resume_sets_active_since() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::User);
        assert!(t.active_since.is_none());

        t.resume();
        assert!(t.active_since.is_some());
    }

    #[test]
    fn resume_resets_classifier_runs_attempted() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        // A goal that hit the classifier cap and BackOff-paused.
        {
            let o = t.snapshot_mut().unwrap();
            o.classifier_runs_attempted = 4;
            o.classifier_max_runs = Some(4);
        }
        t.pause(GoalPauseReason::BackOff);
        assert_eq!(t.status(), Some(GoalStatus::BackOffPaused));

        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
        // Counter reset for a fresh budget; cap untouched.
        assert_eq!(t.snapshot().unwrap().classifier_runs_attempted, 0);
        assert_eq!(t.snapshot().unwrap().classifier_max_runs, Some(4));
    }

    /// Resume re-arms for a fresh attempt, so it clears the stall streak too —
    /// the first post-resume round must not inherit a count that insta-pauses.
    #[test]
    fn resume_clears_classifier_stall_streak() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        // Build the streak up to the default stall threshold, then pause.
        assert!(!t.record_classifier_stall("fp-a"));
        assert!(t.record_classifier_stall("fp-a"));
        t.pause(GoalPauseReason::NoProgress);

        assert!(t.resume());
        {
            let o = t.snapshot().unwrap();
            assert_eq!(o.classifier_stall_count, 0);
            assert!(o.last_gap_fingerprint.is_none());
        }
        // A repeat of the same fingerprint now starts fresh, not at a carried count.
        assert!(!t.record_classifier_stall("fp-a"));
    }

    #[test]
    fn create_goal_initialises_strategist_streak_to_zero() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert_eq!(t.snapshot().unwrap().consecutive_not_achieved, 0);
    }

    #[test]
    fn record_not_achieved_streak_increments_and_returns_new_count() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert_eq!(t.record_not_achieved_streak(), 1);
        assert_eq!(t.record_not_achieved_streak(), 2);
        assert_eq!(t.record_not_achieved_streak(), 3);
        assert_eq!(t.snapshot().unwrap().consecutive_not_achieved, 3);
    }

    #[test]
    fn claim_strategist_fire_marks_current_streak() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let _ = t.record_not_achieved_streak();
        let _ = t.record_not_achieved_streak();
        assert_eq!(t.claim_strategist_fire(|_, _| true), Some(2));
        let o = t.snapshot().unwrap();
        assert_eq!(
            (o.consecutive_not_achieved, o.last_strategist_fired_at),
            (2, 2)
        );
        let _ = t.record_not_achieved_streak();
        let o = t.snapshot().unwrap();
        assert_eq!(
            (o.consecutive_not_achieved, o.last_strategist_fired_at),
            (3, 2),
            "streak advances but last-fired stays until the next fire",
        );
    }

    #[test]
    fn claim_strategist_fire_skips_and_preserves_state_when_predicate_false() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let _ = t.record_not_achieved_streak();
        assert_eq!(t.claim_strategist_fire(|_, _| false), None);
        let o = t.snapshot().unwrap();
        assert_eq!(o.last_strategist_fired_at, 0, "no fire => marker untouched");
        assert_eq!(o.strategist_cap_bonus, 0, "no fire => no cap bonus");
    }

    /// A strategist fire grants the cap bonus; reset clears it.
    #[test]
    fn strategist_fire_grants_cap_bonus_then_reset_clears_it() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
        let _ = t.record_not_achieved_streak();
        let _ = t.record_not_achieved_streak();
        let _ = t.claim_strategist_fire(|_, _| true);
        assert_eq!(
            t.snapshot().unwrap().strategist_cap_bonus,
            GOAL_STRATEGIST_CAP_BONUS,
        );
        t.reset_strategist_state();
        assert_eq!(t.snapshot().unwrap().strategist_cap_bonus, 0);
    }

    /// `reset_strategist_state` clears the streak, the last-fired marker,
    /// AND the persisted recommendation together — the single reset used by
    /// every branch so they can't drift.
    #[test]
    fn reset_strategist_state_clears_streak_marker_and_recommendation() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let _ = t.record_not_achieved_streak();
        let _ = t.record_not_achieved_streak();
        let _ = t.claim_strategist_fire(|_, _| true);
        t.record_strategy_recommendation("/tmp/goal/strategy.md".into(), "split it".into());

        t.reset_strategist_state();

        let o = t.snapshot().unwrap();
        assert_eq!(o.consecutive_not_achieved, 0);
        assert_eq!(o.last_strategist_fired_at, 0);
        assert!(o.last_strategy_path.is_none());
        assert!(o.last_strategy_recommendation.is_none());
    }

    /// resume / complete / budget_limit must ALL clear the recommendation +
    /// streak + last-fired marker (symmetric with the reset, so a
    /// paused/terminal goal never replays a stale structural note).
    #[test]
    fn lifecycle_transitions_clear_all_strategist_state() {
        let seed = |t: &mut GoalTracker| {
            let _ = t.record_not_achieved_streak();
            let _ = t.claim_strategist_fire(|_, _| true);
            t.record_strategy_recommendation("/tmp/s.md".into(), "do X".into());
        };
        let assert_clean = |t: &GoalTracker| {
            let o = t.snapshot().unwrap();
            assert_eq!(o.consecutive_not_achieved, 0);
            assert_eq!(o.last_strategist_fired_at, 0);
            assert!(o.last_strategy_path.is_none());
            assert!(o.last_strategy_recommendation.is_none());
        };

        // resume
        let mut t = make_tracker();
        activate_tracker(&mut t);
        seed(&mut t);
        t.pause(GoalPauseReason::BackOff);
        assert!(t.resume());
        assert_clean(&t);

        // complete
        let mut t = make_tracker();
        activate_tracker(&mut t);
        seed(&mut t);
        assert!(t.complete());
        assert_clean(&t);

        // budget_limit
        let mut t = make_tracker();
        activate_tracker(&mut t);
        seed(&mut t);
        assert!(t.budget_limit());
        assert_clean(&t);
    }

    #[test]
    fn strategy_recommendation_record_round_trip() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.record_strategy_recommendation(
            "/tmp/goal/strategy.md".into(),
            "split the monolith".into(),
        );
        let o = t.snapshot().unwrap();
        assert_eq!(
            o.last_strategy_path.as_deref(),
            Some("/tmp/goal/strategy.md")
        );
        assert_eq!(
            o.last_strategy_recommendation.as_deref(),
            Some("split the monolith")
        );
    }

    /// Legacy snapshots predate `consecutive_not_achieved` /
    /// `last_strategy_*`; `#[serde(default)]` must backfill them so an
    /// upgrade doesn't fail to deserialize.
    #[test]
    fn strategist_fields_backfill_on_legacy_snapshot() {
        const LEGACY: &str = r#"{
            "goal_id": "g-legacy",
            "objective": "legacy goal",
            "status": "active",
            "phase": "Idle",
            "token_budget": null,
            "elapsed_ms": 0,
            "created_at": "2026-01-01T00:00:00Z",
            "current_subagent_id": null,
            "current_subagent_role": null,
            "total_worker_rounds": 0,
            "total_verify_rounds": 0,
            "token_baseline": 0,
            "history": [],
            "verifier_id": "abcdef012345"
        }"#;
        let loaded: GoalOrchestration =
            serde_json::from_str(LEGACY).expect("legacy snapshot must deserialize");
        assert_eq!(loaded.consecutive_not_achieved, 0);
        assert_eq!(loaded.last_strategist_fired_at, 0);
        assert!(loaded.last_strategy_path.is_none());
        assert!(loaded.last_strategy_recommendation.is_none());
    }

    #[test]
    fn complete_from_active_succeeds() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.set_current_subagent(Some("sub-1".into()), Some("worker".into()));

        assert!(t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
        assert!(t.current_subagent_id().is_none());
        assert!(t.snapshot().unwrap().current_subagent_role.is_none());
    }

    #[test]
    fn complete_from_paused_succeeds() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::User);

        assert!(t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
    }

    #[test]
    fn complete_from_budget_limited_is_noop() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.budget_limit();

        assert!(!t.complete());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
    }

    #[test]
    fn complete_from_complete_is_noop() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.complete();

        assert!(!t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
    }

    #[test]
    fn budget_limit_from_active_succeeds() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.set_phase(GoalPhase::Executing);

        assert!(t.budget_limit());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
        assert_eq!(t.phase(), Some(GoalPhase::Idle));
        assert!(t.current_subagent_id().is_none());
        assert!(t.active_since.is_none());
    }

    #[test]
    fn budget_limit_from_complete_is_noop() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.complete();

        assert!(!t.budget_limit());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
    }

    #[test]
    fn pause_reason_maps_to_correct_status() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        assert!(t.pause(GoalPauseReason::User));
        assert_eq!(t.status(), Some(GoalStatus::UserPaused));

        t.resume();
        assert!(t.pause(GoalPauseReason::User));
        assert_eq!(t.status(), Some(GoalStatus::UserPaused));

        t.resume();
        assert!(t.pause(GoalPauseReason::BackOff));
        assert_eq!(t.status(), Some(GoalStatus::BackOffPaused));

        // Stall (NoProgress) maps to a status distinct from the cap (BackOff).
        t.resume();
        assert!(t.pause(GoalPauseReason::NoProgress));
        assert_eq!(t.status(), Some(GoalStatus::NoProgressPaused));

        t.resume();
        assert!(t.pause_with_message(GoalPauseReason::Infra, "Turn failed: rate limit".into()));
        assert_eq!(t.status(), Some(GoalStatus::InfraPaused));
    }

    #[test]
    fn is_paused_matches_all_paused_variants() {
        assert!(GoalStatus::UserPaused.is_paused());
        assert!(GoalStatus::UserPaused.is_paused());
        assert!(GoalStatus::BackOffPaused.is_paused());
        assert!(GoalStatus::NoProgressPaused.is_paused());
        assert!(GoalStatus::InfraPaused.is_paused());
        assert!(GoalStatus::Blocked.is_paused());
        assert!(!GoalStatus::Active.is_paused());
        assert!(!GoalStatus::Complete.is_paused());
        assert!(!GoalStatus::BudgetLimited.is_paused());
    }

    /// `NoProgressPaused` round-trips through both serde wire forms (snake_case
    /// `Serialize` + `from_wire_str`) and stays distinct from the cap pause's
    /// `back_off_paused`.
    #[test]
    fn no_progress_paused_round_trips_distinctly_from_back_off() {
        assert_eq!(
            GoalStatus::from_wire_str("no_progress_paused"),
            GoalStatus::NoProgressPaused
        );
        let json = serde_json::to_string(&GoalStatus::NoProgressPaused).unwrap();
        assert_eq!(json, "\"no_progress_paused\"");
        let back: GoalStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(back, GoalStatus::NoProgressPaused);
        // The cap pause keeps its own wire form — the two must not collapse.
        assert_eq!(
            GoalStatus::from_wire_str("back_off_paused"),
            GoalStatus::BackOffPaused
        );
        assert_ne!(GoalStatus::NoProgressPaused, GoalStatus::BackOffPaused);
    }

    #[test]
    fn resume_from_user_paused() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::User);
        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
    }

    #[test]
    fn resume_from_back_off_paused() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::BackOff);
        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
    }

    #[test]
    fn resume_from_infra_paused() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause_with_message(GoalPauseReason::Infra, "Turn failed: auth".into());
        assert_eq!(
            t.snapshot().and_then(|o| o.pause_message.clone()),
            Some("Turn failed: auth".into())
        );
        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
        assert!(t.snapshot().unwrap().pause_message.is_none());
    }

    #[test]
    fn complete_from_back_off_paused() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::BackOff);
        assert!(t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
    }

    #[test]
    fn budget_limit_from_user_paused() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::User);
        assert!(t.budget_limit());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
    }

    #[test]
    fn budget_limit_from_back_off_paused() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::BackOff);
        assert!(t.budget_limit());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
    }

    #[test]
    fn pause_with_verification_reason_transitions_to_blocked() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        assert!(t.pause(GoalPauseReason::Verification));
        assert_eq!(t.status(), Some(GoalStatus::Blocked));
        assert!(t.status().unwrap().is_paused());
    }

    #[test]
    fn pause_with_message_stores_message_and_transitions() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        assert!(t.pause_with_message(GoalPauseReason::Verification, "no windows sdk".into()));
        assert_eq!(t.status(), Some(GoalStatus::Blocked));
        assert_eq!(
            t.snapshot().unwrap().pause_message.as_deref(),
            Some("no windows sdk"),
        );
    }

    #[test]
    fn resume_clears_pause_message_and_returns_to_active() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause_with_message(GoalPauseReason::Verification, "X".into());
        assert_eq!(t.snapshot().unwrap().pause_message.as_deref(), Some("X"));

        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
        assert!(t.snapshot().unwrap().pause_message.is_none());
    }

    #[test]
    fn pause_without_message_leaves_existing_pause_message_intact() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause_with_message(GoalPauseReason::Verification, "X".into());

        t.snapshot_mut().unwrap().status = GoalStatus::Active;
        t.active_since = Some(Instant::now());

        assert!(t.pause(GoalPauseReason::User));
        assert_eq!(t.snapshot().unwrap().pause_message.as_deref(), Some("X"));
    }

    #[test]
    fn pause_message_survives_serde_round_trip() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause_with_message(GoalPauseReason::Verification, "blocker".into());

        let json = serde_json::to_string(t.snapshot().unwrap()).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.status, GoalStatus::Blocked);
        assert_eq!(restored.pause_message.as_deref(), Some("blocker"));

        let mut t2 = make_tracker();
        activate_tracker(&mut t2);
        t2.pause_with_message(GoalPauseReason::Verification, "blocker".into());
        t2.resume();
        let json2 = serde_json::to_string(t2.snapshot().unwrap()).unwrap();
        let restored2: GoalOrchestration = serde_json::from_str(&json2).unwrap();
        assert!(restored2.pause_message.is_none());
    }

    #[test]
    fn resume_from_blocked_transitions_to_active() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::Verification);
        assert!(t.resume());
        assert_eq!(t.status(), Some(GoalStatus::Active));
    }

    #[test]
    fn complete_from_blocked_succeeds() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::Verification);
        assert!(t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
    }

    #[test]
    fn budget_limit_from_blocked_succeeds() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause(GoalPauseReason::Verification);
        assert!(t.budget_limit());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
    }

    #[test]
    fn pause_reason_verification_maps_to_blocked_status() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(t.pause(GoalPauseReason::Verification));
        assert_eq!(t.status(), Some(GoalStatus::Blocked));
    }

    #[test]
    fn complete_from_blocked_clears_pause_message() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause_with_message(GoalPauseReason::Verification, "X".into());
        assert_eq!(t.snapshot().unwrap().pause_message.as_deref(), Some("X"));

        assert!(t.complete());
        assert_eq!(t.status(), Some(GoalStatus::Complete));
        assert!(t.snapshot().unwrap().pause_message.is_none());
    }

    #[test]
    fn budget_limit_from_blocked_clears_pause_message() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.pause_with_message(GoalPauseReason::Verification, "Y".into());
        assert_eq!(t.snapshot().unwrap().pause_message.as_deref(), Some("Y"));

        assert!(t.budget_limit());
        assert_eq!(t.status(), Some(GoalStatus::BudgetLimited));
        assert!(t.snapshot().unwrap().pause_message.is_none());
    }

    #[test]
    fn complete_flushes_elapsed_before_clearing_timer() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let before = t.snapshot().unwrap().elapsed_ms;
        t.complete();
        assert!(t.snapshot().unwrap().elapsed_ms >= before);
        assert!(t.active_since.is_none());
    }

    #[test]
    fn budget_limit_flushes_elapsed_before_clearing_timer() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let before = t.snapshot().unwrap().elapsed_ms;
        t.budget_limit();
        assert!(t.snapshot().unwrap().elapsed_ms >= before);
        assert!(t.active_since.is_none());
    }

    #[test]
    fn clear_removes_orchestration() {
        let mut t = make_tracker();
        activate_tracker(&mut t);

        t.clear();
        assert!(t.snapshot().is_none());
        assert!(!t.is_active());
        assert!(t.active_since.is_none());
    }

    #[test]
    fn serde_round_trip_preserves_data() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.set_phase(GoalPhase::Executing);

        let original = t.snapshot().unwrap().clone();
        let json = serde_json::to_string(&original).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();

        assert_eq!(restored.goal_id, original.goal_id);
        assert_eq!(restored.objective, original.objective);
        assert_eq!(restored.status, original.status);
        assert_eq!(restored.phase, original.phase);
    }

    #[test]
    fn serde_skip_fields_reset_on_deserialize() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        t.update_live_progress(
            100,
            vec![("kigi-4".to_owned(), 60), ("kigi-3".to_owned(), 40)],
            200_000,
            50,
            3,
            10,
        );
        // The transient field is set on the live snapshot...
        assert_eq!(t.snapshot().unwrap().live_tokens_by_model.len(), 2);

        let json = serde_json::to_string(t.snapshot().unwrap()).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();

        assert_eq!(restored.live_subagent_tokens, 0);
        // ...but `#[serde(skip)]` drops it on round-trip.
        assert!(restored.live_tokens_by_model.is_empty());
        assert_eq!(restored.live_context_window, 0);
        assert_eq!(restored.live_context_pct, 0);
        assert_eq!(restored.live_turn_count, 0);
        assert_eq!(restored.live_tool_call_count, 0);
        assert!(!restored.budget_limit_reported);
    }

    #[test]
    fn legacy_pascal_case_paused_deserializes_to_user_paused() {
        let legacy = r#""Paused""#;
        let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
        assert_eq!(parsed, GoalStatus::UserPaused);
    }

    #[test]
    fn legacy_pascal_case_other_variants_deserialize() {
        for (legacy, expected) in [
            (r#""Active""#, GoalStatus::Active),
            (r#""BudgetLimited""#, GoalStatus::BudgetLimited),
            (r#""Complete""#, GoalStatus::Complete),
        ] {
            let parsed: GoalStatus = serde_json::from_str(legacy).unwrap();
            assert_eq!(parsed, expected, "legacy {legacy} must parse");
        }
    }

    #[test]
    fn legacy_infra_paused_deserializes() {
        let parsed: GoalStatus = serde_json::from_str(r#""infra_paused""#).unwrap();
        assert_eq!(parsed, GoalStatus::InfraPaused);
    }

    #[test]
    fn unknown_future_paused_status_deserializes_to_user_paused() {
        let parsed: GoalStatus = serde_json::from_str(r#""error_paused""#).unwrap();
        assert_eq!(parsed, GoalStatus::UserPaused);
    }

    /// Any unknown wire status — not just `*_paused` forms — must restore
    /// as a resumable paused goal, never an Active self-driving one.
    #[test]
    fn unknown_non_paused_status_deserializes_to_user_paused_not_active() {
        for wire in [r#""quarantined""#, r#""v9_super_active""#, r#""""#] {
            let parsed: GoalStatus = serde_json::from_str(wire).unwrap();
            assert_eq!(parsed, GoalStatus::UserPaused, "wire {wire}");
        }
        assert_eq!(
            GoalStatus::from_wire_str("not-a-status"),
            GoalStatus::UserPaused,
        );
    }

    #[test]
    fn serde_round_trip_infra_paused_with_pause_message() {
        let mut o = make_base_orchestration();
        o.status = GoalStatus::InfraPaused;
        o.pause_message = Some("Turn failed: rate limit".into());
        let json = serde_json::to_string(&o).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.status, GoalStatus::InfraPaused);
        assert_eq!(
            restored.pause_message.as_deref(),
            Some("Turn failed: rate limit")
        );
    }

    #[test]
    fn from_snapshot_resets_planning_to_idle_paused() {
        let mut orchestration = make_base_orchestration();
        orchestration.phase = GoalPhase::Planning;
        orchestration.current_subagent_id = Some("sub-old".into());
        orchestration.current_subagent_role = Some("planner".into());

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp/test"), orchestration);
        let o = t.snapshot().unwrap();
        assert_eq!(o.phase, GoalPhase::Idle);
        assert_eq!(o.status, GoalStatus::UserPaused);
        assert!(o.current_subagent_id.is_none());
        assert!(o.current_subagent_role.is_none());
        assert!(t.active_since.is_none());
    }

    #[test]
    fn from_snapshot_resets_executing() {
        let mut orchestration = make_base_orchestration();
        orchestration.phase = GoalPhase::Executing;

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert_eq!(t.snapshot().unwrap().phase, GoalPhase::Idle);
        assert_eq!(t.snapshot().unwrap().status, GoalStatus::UserPaused);
    }

    #[test]
    fn from_snapshot_preserves_infra_paused_on_restart() {
        let mut orchestration = make_base_orchestration();
        orchestration.phase = GoalPhase::Executing;
        orchestration.status = GoalStatus::InfraPaused;
        orchestration.pause_message = Some("Turn failed: upstream unavailable".into());

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        let o = t.snapshot().unwrap();
        assert_eq!(o.phase, GoalPhase::Idle);
        assert_eq!(o.status, GoalStatus::InfraPaused);
        assert_eq!(
            o.pause_message.as_deref(),
            Some("Turn failed: upstream unavailable")
        );
    }

    /// Guards that `from_snapshot` preserves `plan_file` across reloads.
    #[test]
    fn from_snapshot_preserves_plan_file_some() {
        let mut orchestration = make_base_orchestration();
        let path = PathBuf::from("/tmp/from-snapshot-session/goal/plan.md");
        orchestration.plan_file = Some(path.clone());

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert_eq!(t.snapshot().unwrap().plan_file, Some(path));
    }

    /// The temp-dir scratch doesn't survive a restart; restore must
    /// recreate it so the next panel's skeptics can read claimed outputs.
    #[test]
    fn from_snapshot_recreates_implementer_scratch_dir() {
        let orchestration = make_base_orchestration();
        let scratch = implementer_scratch_dir(&orchestration.verifier_id);
        assert!(!scratch.exists(), "fresh verifier_id ⇒ no dir yet");

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert!(
            scratch.is_dir(),
            "restore must recreate {} like create_goal does",
            scratch.display(),
        );
        assert!(
            t.snapshot().unwrap().scratch_dir_ready,
            "recreating the dir must mark scratch_dir_ready true",
        );
        let _ = std::fs::remove_dir_all(goal_scratch_root(&t.snapshot().unwrap().verifier_id));
    }

    /// A terminal snapshot's scratch root was deliberately removed by its
    /// transition; restore must not resurrect it.
    #[test]
    fn from_snapshot_skips_scratch_recreation_for_terminal_status() {
        for status in [GoalStatus::Complete, GoalStatus::BudgetLimited] {
            let mut orchestration = make_base_orchestration();
            orchestration.status = status;
            let scratch = implementer_scratch_dir(&orchestration.verifier_id);

            let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
            assert!(
                !scratch.exists(),
                "terminal status {status:?} must not recreate {}",
                scratch.display(),
            );
            assert!(
                !t.snapshot().unwrap().scratch_dir_ready,
                "terminal status {status:?} must leave scratch_dir_ready false",
            );
        }
    }

    /// Anything off the pinned 12-hex `verifier_id` form must be
    /// regenerated, never used as a path component.
    #[test]
    fn from_snapshot_regenerates_non_canonical_verifier_id() {
        for bad in ["../../../escape", "abcd", "abcdef01234Z", ""] {
            let mut orchestration = make_base_orchestration();
            orchestration.verifier_id = bad.to_string();

            let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
            let vid = &t.snapshot().unwrap().verifier_id;
            assert_ne!(vid, bad, "non-canonical id must be replaced");
            assert_eq!(vid.len(), 12);
            assert!(vid.chars().all(|c| c.is_ascii_hexdigit()));
            let _ = std::fs::remove_dir_all(goal_scratch_root(vid));
        }
        // A canonical id is kept verbatim.
        let orchestration = make_base_orchestration();
        let original = orchestration.verifier_id.clone();
        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert_eq!(t.snapshot().unwrap().verifier_id, original);
        let _ = std::fs::remove_dir_all(goal_scratch_root(&original));
    }

    /// Full raw-JSON snapshot path: an unknown wire status must come back
    /// from `from_snapshot` as a resumable `UserPaused` goal, never Active.
    #[test]
    fn from_snapshot_raw_json_unknown_status_restores_user_paused() {
        const FORWARD_VERSION: &str = r#"{
            "goal_id": "g-forward",
            "objective": "from a newer shell",
            "status": "hyper_active_v9",
            "phase": "Idle",
            "token_budget": null,
            "elapsed_ms": 0,
            "created_at": "2026-01-01T00:00:00Z",
            "current_subagent_id": null,
            "current_subagent_role": null,
            "total_worker_rounds": 0,
            "total_verify_rounds": 0,
            "token_baseline": 0,
            "history": [],
            "verifier_id": "abcdef012345"
        }"#;
        let snapshot: GoalOrchestration =
            serde_json::from_str(FORWARD_VERSION).expect("forward-version snapshot must parse");
        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), snapshot);
        assert_eq!(
            t.status(),
            Some(GoalStatus::UserPaused),
            "unknown status must restore paused, not self-driving",
        );
        assert!(t.active_since.is_none());
        let _ = std::fs::remove_dir_all(goal_scratch_root(&t.snapshot().unwrap().verifier_id));
    }

    /// Restoring the id would over-count a resumed skeptic-0's prior
    /// cumulative as fresh spend (token records are in-memory only).
    #[test]
    fn from_snapshot_drops_skeptic0_session_id() {
        let mut orchestration = make_base_orchestration();
        orchestration.skeptic0_session_id = Some("child-from-before-restart".into());

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert!(
            t.snapshot().unwrap().skeptic0_session_id.is_none(),
            "restore must cold-spawn the next panel",
        );
        let _ = std::fs::remove_dir_all(goal_scratch_root(&t.snapshot().unwrap().verifier_id));
    }

    /// A failed strategist run revokes the cap bonus while keeping the
    /// fire claimed, so the next fire still waits a full window.
    #[test]
    fn revoke_strategist_cap_bonus_keeps_fire_claim() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let _ = t.record_not_achieved_streak();
        assert!(t.claim_strategist_fire(|_, _| true).is_some());
        assert_eq!(
            t.snapshot().unwrap().strategist_cap_bonus,
            GOAL_STRATEGIST_CAP_BONUS,
        );

        t.revoke_strategist_cap_bonus();
        let o = t.snapshot().unwrap();
        assert_eq!(o.strategist_cap_bonus, 0, "failed fire must revoke bonus");
        assert_eq!(
            o.last_strategist_fired_at, 1,
            "the claim survives so the trigger does not retry every round",
        );
    }

    #[test]
    fn from_snapshot_idle_active_sets_active_since() {
        let orchestration = make_base_orchestration(); // Idle + Active
        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert_eq!(t.snapshot().unwrap().status, GoalStatus::Active);
        assert!(t.active_since.is_some());
    }

    #[test]
    fn from_snapshot_idle_complete_no_active_since() {
        let mut orchestration = make_base_orchestration();
        orchestration.status = GoalStatus::Complete;

        let t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert_eq!(t.snapshot().unwrap().status, GoalStatus::Complete);
        assert!(t.active_since.is_none());
    }

    #[test]
    fn from_snapshot_restore_then_resume_tracks_elapsed() {
        let mut orchestration = make_base_orchestration();
        orchestration.phase = GoalPhase::Executing;
        orchestration.elapsed_ms = 5000;

        let mut t = GoalTracker::from_snapshot(PathBuf::from("/tmp"), orchestration);
        assert_eq!(t.status(), Some(GoalStatus::UserPaused));
        assert!(t.active_since.is_none());

        assert!(t.resume());
        assert!(t.active_since.is_some());

        let before = t.snapshot().unwrap().elapsed_ms;
        assert_eq!(before, 5000);
        t.account_elapsed();
        assert!(t.snapshot().unwrap().elapsed_ms >= 5000);
    }

    /// A snapshot written by an older shell will omit all nine
    /// classifier fields (incl. the stall `last_gap_fingerprint` /
    /// `classifier_stall_count` and the persisted `last_classifier_gaps`
    /// summary added later). Deserialization must
    /// succeed and every new field must come back at its
    /// `#[serde(default)]` value. The
    /// JSON literal below doubles as a documented v0 schema contract
    /// — if a future PR breaks legacy load, this test fails *because*
    /// the documented shape no longer parses, not because the in-code
    /// serialization shape happened to drift in sync.
    #[test]
    fn classifier_fields_backwards_compat_defaults_on_legacy_snapshot() {
        const LEGACY_SNAPSHOT_JSON: &str = r#"{
            "goal_id": "g-legacy",
            "objective": "legacy goal",
            "status": "active",
            "phase": "Executing",
            "token_budget": null,
            "tokens_used": 0,
            "elapsed_ms": 0,
            "created_at": "2026-01-01T00:00:00Z",
            "current_subagent_id": null,
            "current_subagent_role": null,
            "total_worker_rounds": 0,
            "total_verify_rounds": 0,
            "token_baseline": 0,
            "finished_subagent_tokens": 0,
            "history": [],
            "verifier_id": "abcdef012345"
        }"#;
        let legacy: GoalOrchestration =
            serde_json::from_str(LEGACY_SNAPSHOT_JSON).expect("legacy snapshot must deserialize");
        assert_eq!(legacy.goal_id, "g-legacy");
        assert_eq!(legacy.verifier_id, "abcdef012345");
        assert_eq!(legacy.classifier_runs_attempted, 0);
        assert!(legacy.classifier_max_runs.is_none());
        assert!(legacy.last_classifier_verdict.is_none());
        assert!(legacy.last_classifier_details_path.is_none());
        assert!(legacy.last_classifier_at.is_none());
        assert!(legacy.last_classifier_gaps.is_none());
        assert!(legacy.skeptic0_session_id.is_none());
        assert!(legacy.changes_baseline_commit.is_none());
        assert!(legacy.last_gap_fingerprint.is_none());
        assert_eq!(legacy.classifier_stall_count, 0);
        // New transient `#[serde(skip)]` field defaults empty for a
        // legacy snapshot that predates it.
        assert!(legacy.live_tokens_by_model.is_empty());
        // Spend-accumulator backfill: zero spent, `None` anchor so the
        // first `goal_tokens` call seeds from `token_baseline`.
        assert_eq!(legacy.parent_tokens_spent, 0);
        assert_eq!(legacy.last_session_tokens_seen, None);
    }

    /// Legacy on-disk snapshots carry the dropped `tokens_used` and
    /// `finished_subagent_tokens` fields. Verify they are silently
    /// ignored on load and the struct deserializes cleanly.
    #[test]
    fn goal_orchestration_serde_drops_legacy_tokens_used_field() {
        const LEGACY: &str = r#"{
            "goal_id": "g-old",
            "objective": "legacy",
            "status": "active",
            "phase": "Idle",
            "token_budget": null,
            "tokens_used": 123,
            "elapsed_ms": 0,
            "created_at": "2026-01-01T00:00:00Z",
            "current_subagent_id": null,
            "current_subagent_role": null,
            "total_worker_rounds": 0,
            "total_verify_rounds": 0,
            "token_baseline": 0,
            "finished_subagent_tokens": 456,
            "history": [],
            "verifier_id": "abcdef012345"
        }"#;
        let loaded: GoalOrchestration =
            serde_json::from_str(LEGACY).expect("legacy snapshot must deserialize");
        assert_eq!(loaded.goal_id, "g-old");
        assert_eq!(loaded.token_baseline, 0);
        // Round-trip and verify the dropped keys do not reappear at
        // the top level; `tokens_used` on history entries (Option<i64>)
        // is a distinct field and `history` is empty here so the
        // simple `contains` check is sound.
        let round = serde_json::to_string(&loaded).unwrap();
        assert!(
            !round.contains("\"tokens_used\""),
            "legacy field re-serialized: {round}"
        );
        assert!(
            !round.contains("\"finished_subagent_tokens\""),
            "legacy field re-serialized: {round}"
        );
    }

    /// Populating every new field and round-tripping through serde
    /// must return identical values. This guards both the on-disk
    /// schema shape and the `Eq`-by-field semantics the verification
    /// stage orchestrator relies on.
    #[test]
    fn classifier_fields_serde_round_trip_preserves_all_fields() {
        let mut o = make_base_orchestration();
        o.classifier_runs_attempted = 2;
        o.classifier_max_runs = Some(3);
        o.last_classifier_verdict = Some(GoalClassifierVerdict::NotAchieved);
        o.last_classifier_details_path = Some("/tmp/goal-classifier-abc.md".to_string());
        o.last_classifier_at = Some("2026-05-24T12:00:00Z".to_string());
        o.last_classifier_gaps = Some("- [skeptic 0, high] still on fire".to_string());
        o.skeptic0_session_id = Some("0190abcd-skeptic0".to_string());
        o.changes_baseline_commit = Some("abc123def456".to_string());

        let json = serde_json::to_string(&o).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();

        assert_eq!(restored.classifier_runs_attempted, 2);
        assert_eq!(restored.classifier_max_runs, Some(3));
        assert_eq!(
            restored.last_classifier_verdict,
            Some(GoalClassifierVerdict::NotAchieved)
        );
        assert_eq!(
            restored.last_classifier_details_path.as_deref(),
            Some("/tmp/goal-classifier-abc.md")
        );
        assert_eq!(
            restored.last_classifier_at.as_deref(),
            Some("2026-05-24T12:00:00Z")
        );
        assert_eq!(
            restored.last_classifier_gaps.as_deref(),
            Some("- [skeptic 0, high] still on fire")
        );
        assert_eq!(
            restored.skeptic0_session_id.as_deref(),
            Some("0190abcd-skeptic0")
        );
        assert_eq!(
            restored.changes_baseline_commit.as_deref(),
            Some("abc123def456")
        );

        // Contract: `Option::None` fields must NOT serialize as
        // `"key": null`. Verify the round-trip JSON omits the keys when
        // we reset them to None on a freshly-created orchestration.
        let mut base = make_base_orchestration();
        base.classifier_max_runs = None;
        base.last_classifier_verdict = None;
        base.last_classifier_details_path = None;
        base.last_classifier_at = None;
        base.last_classifier_gaps = None;
        base.skeptic0_session_id = None;
        base.changes_baseline_commit = None;
        let json_none = serde_json::to_string(&base).unwrap();
        for key in [
            "classifier_max_runs",
            "last_classifier_verdict",
            "last_classifier_details_path",
            "last_classifier_at",
            "last_classifier_gaps",
            "skeptic0_session_id",
            "changes_baseline_commit",
        ] {
            assert!(
                !json_none.contains(key),
                "None-valued field {key} must be skipped on serialize, got: {json_none}"
            );
        }
    }

    /// `skeptic0_session_id` round-trips through serde when populated
    /// (it must persist across a snapshot save/restore within a session
    /// so the next attempt can resume skeptic 0). Mirrors the
    /// `last_classifier_gaps` round-trip contract.
    #[test]
    fn skeptic0_session_id_round_trips_through_serde() {
        let mut o = make_base_orchestration();
        o.skeptic0_session_id = Some("0190-skeptic0-child".to_string());
        let json = serde_json::to_string(&o).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(
            restored.skeptic0_session_id.as_deref(),
            Some("0190-skeptic0-child"),
        );
    }

    /// Round-trips through serde so a resumed goal keeps its breadth anchor,
    /// and is skipped on serialize when `None`.
    #[test]
    fn first_final_response_round_trips_through_serde() {
        let mut o = make_base_orchestration();
        o.first_final_response = Some("Round 1: built the whole feature; 14 tests pass.".into());
        let json = serde_json::to_string(&o).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(
            restored.first_final_response.as_deref(),
            Some("Round 1: built the whole feature; 14 tests pass."),
        );

        let base = make_base_orchestration();
        let json_none = serde_json::to_string(&base).unwrap();
        assert!(
            !json_none.contains("first_final_response"),
            "None-valued first_final_response must be skipped on serialize: {json_none}",
        );
    }

    /// Every terminal goal-ending transition (`complete`, `budget_limit`)
    /// clears the resumed skeptic-0 id so a later goal starts verification
    /// with a fresh, cold skeptic 0.
    #[test]
    fn terminal_transitions_clear_skeptic0_session_id() {
        for ending in ["complete", "budget_limit"] {
            let mut t = make_tracker();
            activate_tracker(&mut t);
            t.snapshot_mut().unwrap().skeptic0_session_id = Some("prior-child".to_string());
            let applied = match ending {
                "complete" => t.complete(),
                _ => t.budget_limit(),
            };
            assert!(applied, "{ending} transition must apply");
            assert!(
                t.snapshot().unwrap().skeptic0_session_id.is_none(),
                "{ending}() must clear skeptic0_session_id",
            );
        }
    }

    /// `skeptic_model_assignment` (the frozen per-index pool) round-trips
    /// through serde so the assignment survives a snapshot save/restore and
    /// stays stable across resumes.
    #[test]
    fn skeptic_model_assignment_round_trips_through_serde() {
        let mut o = make_base_orchestration();
        o.skeptic_model_assignment = vec![
            crate::util::config::GoalRoleModel {
                model: "kigi-4".to_string(),
                agent_type: "general-purpose".to_string(),
            },
            crate::util::config::GoalRoleModel {
                model: "kigi-4.5".to_string(),
                agent_type: "cursor".to_string(),
            },
        ];
        let json = serde_json::to_string(&o).unwrap();
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(
            restored.skeptic_model_assignment,
            o.skeptic_model_assignment
        );
    }

    /// A legacy snapshot (no `skeptic_model_assignment` key) deserializes to
    /// an empty assignment (serde default) — all skeptics inherit.
    #[test]
    fn legacy_snapshot_without_skeptic_model_assignment_deserializes_empty() {
        const LEGACY: &str = r#"{
            "goal_id": "g-legacy",
            "objective": "legacy goal",
            "status": "active",
            "phase": "Idle",
            "token_budget": null,
            "elapsed_ms": 0,
            "created_at": "2026-01-01T00:00:00Z",
            "current_subagent_id": null,
            "current_subagent_role": null,
            "total_worker_rounds": 0,
            "total_verify_rounds": 0,
            "token_baseline": 0,
            "history": [],
            "verifier_id": "abcdef012345"
        }"#;
        let restored: GoalOrchestration =
            serde_json::from_str(LEGACY).expect("legacy snapshot must deserialize");
        assert!(restored.skeptic_model_assignment.is_empty());
    }

    /// Sibling of `skeptic0_session_id`: every terminal goal-ending
    /// transition (`complete`, `budget_limit`) clears the frozen per-index
    /// model assignment so a later goal re-resolves its own panel.
    #[test]
    fn terminal_transitions_clear_skeptic_model_assignment() {
        for ending in ["complete", "budget_limit"] {
            let mut t = make_tracker();
            activate_tracker(&mut t);
            t.snapshot_mut().unwrap().skeptic_model_assignment =
                vec![crate::util::config::GoalRoleModel {
                    model: "kigi-4".to_string(),
                    agent_type: "general-purpose".to_string(),
                }];
            let applied = match ending {
                "complete" => t.complete(),
                _ => t.budget_limit(),
            };
            assert!(applied, "{ending} transition must apply");
            assert!(
                t.snapshot().unwrap().skeptic_model_assignment.is_empty(),
                "{ending}() must clear skeptic_model_assignment",
            );
        }
    }

    /// `plan_baseline_file` is a sibling of `skeptic0_session_id` and must
    /// be cleared by the SAME terminal transitions so a later goal
    /// re-snapshots its own planner's original plan.
    #[test]
    fn terminal_transitions_clear_plan_baseline_file() {
        for ending in ["complete", "budget_limit"] {
            let mut t = make_tracker();
            activate_tracker(&mut t);
            t.snapshot_mut().unwrap().plan_baseline_file =
                Some(PathBuf::from("/tmp/sess/goal/plan.baseline.md"));
            let applied = match ending {
                "complete" => t.complete(),
                _ => t.budget_limit(),
            };
            assert!(applied, "{ending} transition must apply");
            assert!(
                t.snapshot().unwrap().plan_baseline_file.is_none(),
                "{ending}() must clear plan_baseline_file",
            );
        }
    }

    /// The scratch path helpers derive the pinned layout from
    /// `temp_dir()` + `verifier_id`: a `kigi-goal-<vid>` root with an
    /// `implementer/` subdir and per-index `skeptic-<idx>/` subdirs.
    #[test]
    fn scratch_path_helpers_derive_pinned_layout() {
        let root = goal_scratch_root("vid123");
        assert_eq!(root, std::env::temp_dir().join("kigi-goal-vid123"));
        assert_eq!(
            implementer_scratch_dir("vid123"),
            std::env::temp_dir()
                .join("kigi-goal-vid123")
                .join("implementer"),
        );
        assert_eq!(
            skeptic_scratch_dir("vid123", 2),
            std::env::temp_dir()
                .join("kigi-goal-vid123")
                .join("skeptic-2"),
        );
    }

    /// Two skeptics in the SAME goal get DISTINCT scratch dirs so their
    /// re-runs never overwrite each other (the intra-goal screenshot race).
    #[test]
    fn distinct_skeptics_get_distinct_scratch_dirs() {
        assert_ne!(skeptic_scratch_dir("vid", 0), skeptic_scratch_dir("vid", 1),);
        // ...and neither equals the implementer dir.
        assert_ne!(
            skeptic_scratch_dir("vid", 0),
            implementer_scratch_dir("vid")
        );
    }

    /// `create_goal` creates the scratch root + implementer dir up front.
    #[test]
    fn create_goal_creates_implementer_scratch_dir() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let vid = t.snapshot().unwrap().verifier_id.clone();
        let dir = implementer_scratch_dir(&vid);
        assert!(dir.is_dir(), "implementer scratch dir must exist: {dir:?}");
        assert!(
            t.snapshot().unwrap().scratch_dir_ready,
            "create_goal must mark scratch_dir_ready true when the dir was created",
        );
        // Clean up the real on-disk dir this test created.
        let _ = std::fs::remove_dir_all(goal_scratch_root(&vid));
    }

    /// The root is 0700 from the instant it exists (atomic-mode create,
    /// no chmod window) and a re-ensure re-pins the mode.
    #[cfg(unix)]
    #[test]
    fn ensure_goal_scratch_root_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let vid = generate_verifier_id();
        let root = ensure_goal_scratch_root(&vid).unwrap();
        let mode = std::fs::metadata(&root).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o700, "scratch root must be 0700: {root:?}");
        // Idempotent re-ensure keeps (re-applies) the mode.
        let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755));
        let _ = ensure_goal_scratch_root(&vid).unwrap();
        let mode = std::fs::metadata(&root).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o700, "re-ensure must restore 0700: {root:?}");
        let _ = std::fs::remove_dir_all(&root);
    }

    /// A pre-planted symlink root is rejected: never followed, the
    /// victim dir never chmodded, nothing created behind it.
    #[cfg(unix)]
    #[test]
    fn ensure_goal_scratch_root_rejects_preplanted_symlink_root() {
        use std::os::unix::fs::PermissionsExt;
        let vid = generate_verifier_id();
        let victim = tempfile::tempdir().unwrap();
        let victim_mode_before = std::fs::metadata(victim.path())
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        let root = goal_scratch_root(&vid);
        std::os::unix::fs::symlink(victim.path(), &root).unwrap();

        let result = ensure_goal_scratch_root(&vid);
        let _ = std::fs::remove_file(&root);

        assert!(result.is_err(), "a symlinked root must be rejected");
        let victim_mode_after = std::fs::metadata(victim.path())
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(
            victim_mode_before, victim_mode_after,
            "the victim dir must not be chmodded through the symlink",
        );
        assert_eq!(
            std::fs::read_dir(victim.path()).unwrap().count(),
            0,
            "nothing may be created behind the symlink",
        );
    }

    /// A non-directory squat (regular file at the root path) is also
    /// rejected rather than written around.
    #[cfg(unix)]
    #[test]
    fn ensure_goal_scratch_root_rejects_file_squat() {
        let vid = generate_verifier_id();
        let root = goal_scratch_root(&vid);
        std::fs::write(&root, b"squat").unwrap();
        let result = ensure_goal_scratch_root(&vid);
        let _ = std::fs::remove_file(&root);
        assert!(result.is_err(), "a file squat must be rejected");
    }

    /// Starting a new goal over a still-active one removes the prior goal's
    /// scratch root (no orphan), while creating the new one.
    #[test]
    fn create_goal_over_active_removes_prior_scratch_root() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        let old_vid = t.snapshot().unwrap().verifier_id.clone();
        let old_root = goal_scratch_root(&old_vid);
        assert!(old_root.is_dir(), "prior scratch root must exist");

        // Re-create over the active goal without an intervening terminal
        // transition.
        activate_tracker(&mut t);
        let new_vid = t.snapshot().unwrap().verifier_id.clone();
        assert_ne!(old_vid, new_vid, "a fresh goal gets a fresh verifier_id");
        assert!(
            !old_root.exists(),
            "prior scratch root must be removed on create-over-active: {old_root:?}",
        );
        assert!(
            implementer_scratch_dir(&new_vid).is_dir(),
            "the new goal's implementer dir must exist",
        );
        let _ = std::fs::remove_dir_all(goal_scratch_root(&new_vid));
    }

    /// All three terminal transitions rescue the details file (the
    /// achieved ack surfaces its path AFTER `complete()`): pins the
    /// rescued location, the updated stored path, and the inlined
    /// per-skeptic reports (numeric order; see `append_skeptic_reports`).
    #[test]
    fn terminal_transitions_rescue_classifier_details_file() {
        for ending in ["complete", "budget_limit", "clear"] {
            let session = tempfile::tempdir().unwrap();
            let mut t = GoalTracker::new(session.path().to_path_buf());
            activate_tracker(&mut t);
            let vid = t.snapshot().unwrap().verifier_id.clone();
            let root = goal_scratch_root(&vid);
            let details = root.join("goal-classifier-x-1.md");
            std::fs::write(&details, "panel verdict body").unwrap();
            std::fs::write(
                root.join("goal-classifier-x-1-skeptic-0.md"),
                "SKEPTIC_ZERO_BODY",
            )
            .unwrap();
            std::fs::write(
                root.join("goal-classifier-x-1-skeptic-1.md"),
                "SKEPTIC_ONE_BODY",
            )
            .unwrap();
            t.snapshot_mut().unwrap().last_classifier_details_path =
                Some(details.to_string_lossy().into_owned());

            match ending {
                "complete" => assert!(t.complete()),
                "budget_limit" => assert!(t.budget_limit()),
                _ => t.clear(),
            }

            let rescued = session.path().join("goal").join("goal-classifier-x-1.md");
            let body = std::fs::read_to_string(&rescued).unwrap_or_else(|e| {
                panic!("{ending}: details must survive the scratch-root removal: {e}")
            });
            assert!(
                body.starts_with("panel verdict body"),
                "{ending}: canonical body leads the rescued file:\n{body}",
            );
            let s0 = body
                .find("SKEPTIC_ZERO_BODY")
                .unwrap_or_else(|| panic!("{ending}: skeptic-0 report inlined:\n{body}"));
            let s1 = body
                .find("SKEPTIC_ONE_BODY")
                .unwrap_or_else(|| panic!("{ending}: skeptic-1 report inlined:\n{body}"));
            assert!(s0 < s1, "{ending}: reports inlined in name order:\n{body}");
            assert!(
                body.contains("## Inlined skeptic report: goal-classifier-x-1-skeptic-0.md"),
                "{ending}: each inlined report carries a per-file header:\n{body}",
            );
            assert!(
                !goal_scratch_root(&vid).exists(),
                "{ending}: scratch root still removed",
            );
            if ending != "clear" {
                assert_eq!(
                    t.snapshot()
                        .unwrap()
                        .last_classifier_details_path
                        .as_deref(),
                    Some(rescued.to_string_lossy().as_ref()),
                    "{ending}: stored path must point at the rescued copy",
                );
            }
        }
    }

    /// Replacing a still-active goal (`create_goal` over-active) is the
    /// 4th root-removal site and must rescue like its terminal siblings.
    #[test]
    fn create_goal_over_active_rescues_prior_details_file() {
        let session = tempfile::tempdir().unwrap();
        let mut t = GoalTracker::new(session.path().to_path_buf());
        activate_tracker(&mut t);
        let old_vid = t.snapshot().unwrap().verifier_id.clone();
        let details = goal_scratch_root(&old_vid).join("goal-classifier-x-1.md");
        std::fs::write(&details, "prior goal verdict").unwrap();
        t.snapshot_mut().unwrap().last_classifier_details_path =
            Some(details.to_string_lossy().into_owned());

        activate_tracker(&mut t); // replace without a terminal transition

        let rescued = session.path().join("goal").join("goal-classifier-x-1.md");
        assert_eq!(
            std::fs::read_to_string(&rescued).unwrap(),
            "prior goal verdict",
            "replace path must rescue the prior goal's details file",
        );
        assert!(!goal_scratch_root(&old_vid).exists());
        let _ = std::fs::remove_dir_all(goal_scratch_root(&t.snapshot().unwrap().verifier_id));
    }

    /// Source guards: a non-scratch stored path is a no-op, and a
    /// lexically-inside `..`-escaping path is rejected unread.
    #[test]
    fn rescue_classifier_details_guards_source_path() {
        // Non-scratch path → no-op.
        let session = tempfile::tempdir().unwrap();
        let mut t = GoalTracker::new(session.path().to_path_buf());
        activate_tracker(&mut t);
        let elsewhere = tempfile::tempdir().unwrap();
        let outside = elsewhere.path().join("user-notes.md");
        std::fs::write(&outside, "not ours").unwrap();
        let outside_str = outside.to_string_lossy().into_owned();
        t.snapshot_mut().unwrap().last_classifier_details_path = Some(outside_str.clone());
        assert!(t.complete());
        assert!(outside.exists(), "non-scratch source must not be moved");
        assert_eq!(
            t.snapshot()
                .unwrap()
                .last_classifier_details_path
                .as_deref(),
            Some(outside_str.as_str()),
            "stored path must stay untouched on the no-op guard",
        );

        // `..`-escaping path (lexically under the root) → rejected.
        let session = tempfile::tempdir().unwrap();
        let mut t = GoalTracker::new(session.path().to_path_buf());
        activate_tracker(&mut t);
        let vid = t.snapshot().unwrap().verifier_id.clone();
        let victim = std::env::temp_dir().join(format!("goal-rescue-victim-{vid}.md"));
        std::fs::write(&victim, "victim body").unwrap();
        let traversal = goal_scratch_root(&vid)
            .join("..")
            .join(victim.file_name().unwrap());
        let traversal_str = traversal.to_string_lossy().into_owned();
        t.snapshot_mut().unwrap().last_classifier_details_path = Some(traversal_str.clone());
        assert!(t.complete());
        assert!(
            victim.exists(),
            "a `..`-escaping source must never be moved",
        );
        assert_eq!(
            t.snapshot()
                .unwrap()
                .last_classifier_details_path
                .as_deref(),
            Some(traversal_str.as_str()),
            "stored path must stay untouched on the traversal guard",
        );
        let _ = std::fs::remove_file(&victim);
    }

    /// A symlink-squatted root fails the rescue: the attacker-staged
    /// file is neither moved into the goal dir nor stamped.
    #[cfg(unix)]
    #[test]
    fn rescue_classifier_details_rejects_symlink_squatted_root() {
        let session = tempfile::tempdir().unwrap();
        let mut t = GoalTracker::new(session.path().to_path_buf());
        activate_tracker(&mut t);
        let vid = t.snapshot().unwrap().verifier_id.clone();
        let root = goal_scratch_root(&vid);
        std::fs::remove_dir_all(&root).unwrap();
        let attacker = tempfile::tempdir().unwrap();
        std::fs::write(
            attacker.path().join("goal-classifier-x-1.md"),
            "ATTACKER BODY",
        )
        .unwrap();
        std::os::unix::fs::symlink(attacker.path(), &root).unwrap();
        let staged = root.join("goal-classifier-x-1.md");
        let staged_str = staged.to_string_lossy().into_owned();
        t.snapshot_mut().unwrap().last_classifier_details_path = Some(staged_str.clone());

        assert!(t.complete());
        let _ = std::fs::remove_file(&root);

        assert!(
            !session
                .path()
                .join("goal")
                .join("goal-classifier-x-1.md")
                .exists(),
            "an attacker-staged file must not be blessed into the goal dir",
        );
        assert_eq!(
            t.snapshot()
                .unwrap()
                .last_classifier_details_path
                .as_deref(),
            Some(staged_str.as_str()),
            "stored path must stay unstamped on a squatted root",
        );
        assert_eq!(
            std::fs::read_to_string(attacker.path().join("goal-classifier-x-1.md")).unwrap(),
            "ATTACKER BODY",
            "the staged file must be untouched",
        );
    }

    /// Numeric latest-attempt-first inlining: budget elision drops the
    /// stale attempt, never the final one (lexical order — `-1-` before
    /// `-10-` — would invert that).
    #[test]
    fn append_skeptic_reports_prefers_latest_attempt_numerically() {
        let scratch = tempfile::tempdir().unwrap();
        let dest = scratch.path().join("canonical.md");
        std::fs::write(&dest, "canonical body").unwrap();
        // Stale attempt 1: alone larger than the whole budget.
        std::fs::write(
            scratch.path().join("goal-classifier-v-1-skeptic-0.md"),
            "s".repeat(crate::session::goal_classifier::GOAL_VERIFIER_PANEL_MAX_BYTES + 1),
        )
        .unwrap();
        // Final attempt 10: two small reports.
        std::fs::write(
            scratch.path().join("goal-classifier-v-10-skeptic-0.md"),
            "LATEST_S0_BODY",
        )
        .unwrap();
        std::fs::write(
            scratch.path().join("goal-classifier-v-10-skeptic-1.md"),
            "LATEST_S1_BODY",
        )
        .unwrap();

        append_skeptic_reports(scratch.path(), &dest);

        let body = std::fs::read_to_string(&dest).unwrap();
        let s0 = body
            .find("LATEST_S0_BODY")
            .expect("final attempt's skeptic-0 report inlined");
        let s1 = body
            .find("LATEST_S1_BODY")
            .expect("final attempt's skeptic-1 report inlined");
        assert!(s0 < s1, "skeptics ascend within an attempt:\n{body}");
        assert!(
            body.contains("(remaining skeptic reports elided"),
            "the oversized stale report must be elided:\n{body}",
        );
        assert!(
            !body.contains("ssss"),
            "the stale attempt-1 body must not crowd out the final attempt:\n{body}",
        );
    }

    /// `copy_no_follow` must refuse a pre-planted destination symlink
    /// (O_EXCL) instead of writing the rescued content through it.
    #[cfg(unix)]
    #[test]
    fn copy_no_follow_refuses_destination_symlink() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src.md");
        std::fs::write(&src, "rescued body").unwrap();
        let victim = dir.path().join("victim.md");
        std::fs::write(&victim, "precious").unwrap();
        let dest = dir.path().join("dest.md");
        std::os::unix::fs::symlink(&victim, &dest).unwrap();

        assert!(
            copy_no_follow(&src, &dest).is_err(),
            "an existing destination (symlink) must fail the copy",
        );
        assert_eq!(
            std::fs::read_to_string(&victim).unwrap(),
            "precious",
            "the symlink's victim must be untouched",
        );
    }

    /// Every terminal goal-ending transition (`complete`, `budget_limit`,
    /// `clear`) removes the private scratch root from disk — mirrors
    /// `terminal_transitions_clear_plan_baseline_file`.
    #[test]
    fn terminal_transitions_remove_scratch_root() {
        for ending in ["complete", "budget_limit", "clear"] {
            let mut t = make_tracker();
            activate_tracker(&mut t);
            let vid = t.snapshot().unwrap().verifier_id.clone();
            let root = goal_scratch_root(&vid);
            // create_goal already made implementer/; assert the precondition.
            assert!(
                root.is_dir(),
                "{ending}: scratch root must exist pre-transition"
            );
            match ending {
                "complete" => {
                    assert!(t.complete());
                }
                "budget_limit" => {
                    assert!(t.budget_limit());
                }
                _ => t.clear(),
            }
            assert!(
                !root.exists(),
                "{ending}() must remove the scratch root {root:?}",
            );
        }
    }

    #[test]
    fn plan_baseline_path_is_session_scoped_sibling_of_plan() {
        let t = GoalTracker::new(PathBuf::from("/tmp/plan-baseline-session-xyz"));
        assert_eq!(
            t.plan_baseline_path(),
            PathBuf::from("/tmp/plan-baseline-session-xyz/goal/plan.baseline.md"),
        );
    }

    #[test]
    fn create_goal_initialises_plan_baseline_file_to_none() {
        let mut t = make_tracker();
        activate_tracker(&mut t);
        assert!(t.snapshot().unwrap().plan_baseline_file.is_none());
    }

    /// `plan_baseline_file` round-trips through serde when populated (it
    /// must persist across a snapshot save/restore so the baseline survives
    /// a shell restart), and is omitted from the wire when `None`.
    #[test]
    fn plan_baseline_file_round_trips_through_serde() {
        let mut o = make_base_orchestration();
        o.plan_baseline_file = Some(PathBuf::from("/tmp/sess/goal/plan.baseline.md"));
        let json = serde_json::to_string(&o).unwrap();
        assert!(
            json.contains("\"plan_baseline_file\":\"/tmp/sess/goal/plan.baseline.md\""),
            "plan_baseline_file must appear on the wire: {json}",
        );
        let restored: GoalOrchestration = serde_json::from_str(&json).unwrap();
        assert_eq!(
            restored.plan_baseline_file.as_deref(),
            Some(std::path::Path::new("/tmp/sess/goal/plan.baseline.md")),
        );

        let none = make_base_orchestration();
        let json_none = serde_json::to_string(&none).unwrap();
        assert!(
            !json_none.contains("plan_baseline_file"),
            "None-valued plan_baseline_file must be skipped on serialize: {json_none}",
        );
        let restored_none: GoalOrchestration = serde_json::from_str(&json_none).unwrap();
        assert!(restored_none.plan_baseline_file.is_none());
    }

    /// The verdict enum must serialize in snake_case so it stays
    /// consistent with `GoalStatus` and the rest of the goal-tracker
    /// enums. Both variants are asserted explicitly — no wildcard.
    #[test]
    fn classifier_verdict_serializes_as_snake_case() {
        assert_eq!(
            serde_json::to_string(&GoalClassifierVerdict::Achieved).unwrap(),
            "\"achieved\""
        );
        assert_eq!(
            serde_json::to_string(&GoalClassifierVerdict::NotAchieved).unwrap(),
            "\"not_achieved\""
        );
        // Symmetric: deserialization accepts the same shape.
        let a: GoalClassifierVerdict = serde_json::from_str("\"achieved\"").unwrap();
        let n: GoalClassifierVerdict = serde_json::from_str("\"not_achieved\"").unwrap();
        assert_eq!(a, GoalClassifierVerdict::Achieved);
        assert_eq!(n, GoalClassifierVerdict::NotAchieved);
    }

    /// `create_goal` must thread `baseline_commit` straight into
    /// `changes_baseline_commit` on the new orchestration record.
    /// Passing `None` must leave the field `None`.
    #[test]
    fn create_goal_plumbs_baseline_commit_into_orchestration() {
        let mut t = make_tracker();
        t.create_goal(
            "goal-bc".into(),
            "obj".into(),
            None,
            0,
            "2026-01-01T00:00:00Z".into(),
            Some("deadbeef".to_string()),
        );
        assert_eq!(
            t.snapshot().unwrap().changes_baseline_commit.as_deref(),
            Some("deadbeef")
        );

        let mut t2 = make_tracker();
        t2.create_goal(
            "goal-none".into(),
            "obj".into(),
            None,
            0,
            "2026-01-01T00:00:00Z".into(),
            None,
        );
        assert!(t2.snapshot().unwrap().changes_baseline_commit.is_none());
    }
}
