Skip to main content

zero_commands/
dispatch.rs

1//! Dispatcher — resolves a [`Command`] into a [`DispatchOutput`].
2//!
3//! The dispatcher is the boundary between "the operator asked for
4//! X" and "the engine did Y." It owns the HTTP client, reads the
5//! shared `EngineState`, and returns structured output that the
6//! caller (TUI or non-interactive entrypoint) renders.
7
8use std::sync::Arc;
9
10use parking_lot::RwLock;
11use zero_engine_client::{EngineState, ExecuteSide, HttpClient, LiveControlResponse};
12use zero_operator_state::label::Label;
13
14use crate::command::{
15    AutoAction, Command, ConfigAction, DISCLOSURE_OVERRIDE_CONFIRM, HeadlessAction, ModeTarget,
16    OverlayTarget, StateOverrideLabel, VerboseAction,
17};
18use crate::config::{ConfigSource, DoctorSeverity};
19use crate::friction::FrictionDecision;
20use crate::parse::parse_line;
21use crate::risk::RiskDirection;
22use crate::session::{ReplayKind, SessionSource};
23use crate::supervisor::{
24    AutoRequest, AutoSource, SupervisorAction, SupervisorError, SupervisorReply, SupervisorSource,
25};
26
27/// One atomic output action the caller must handle. A single
28/// dispatch can emit multiple — e.g. `/help` emits several `Line`s.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum OutputLine {
31    /// Informational, rendered as system text.
32    System(String),
33    /// Multi-field engine output, rendered as command text.
34    Command(String),
35    /// Rendered amber — an advisory, not a block.
36    Warn(String),
37    /// Rendered red + bold — operator must not miss.
38    Alert(String),
39}
40
41impl OutputLine {
42    pub fn system(s: impl Into<String>) -> Self {
43        Self::System(s.into())
44    }
45    pub fn command(s: impl Into<String>) -> Self {
46        Self::Command(s.into())
47    }
48    pub fn warn(s: impl Into<String>) -> Self {
49        Self::Warn(s.into())
50    }
51    pub fn alert(s: impl Into<String>) -> Self {
52        Self::Alert(s.into())
53    }
54}
55
56/// One replayed log entry bound for the conversation pane.
57///
58/// Separate from [`OutputLine`] because replay lines must be
59/// appended **silently** — re-persisting every row during a
60/// `/resume` would double-count the prior session's events in
61/// the new session's `events` table. The TUI's
62/// `AppState::apply_dispatch` routes [`OutputLine`] through
63/// `push` (which records) and [`ReplayLine`] through
64/// `append_silent` (which does not).
65///
66/// `at_ms` preserves the original wall-clock timestamp so
67/// rendered "age" readings stay truthful on replay — a
68/// freshly-stamped row would lie about when the event actually
69/// happened.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ReplayLine {
72    pub kind: ReplayKind,
73    pub at_ms: i64,
74    pub text: String,
75}
76
77/// What the dispatcher produced. Mode changes and quits are
78/// separate side-channel effects so the caller can apply them
79/// without string-parsing lines back.
80//
81// Four `bool` fields (`quit`, `clear_log`, `coaching_reset`,
82// `dismiss_overlay`) trip `clippy::struct_excessive_bools`. They
83// resist the suggested collapse-into-enum refactor for a concrete
84// reason: they are orthogonal side-channel effects, not mutually
85// exclusive states. A single command can legitimately emit
86// `{clear_log: true, dismiss_overlay: true}` (e.g. `/clear`
87// clears both the log and any floating overlay in one tick), or
88// `{quit: true, dismiss_overlay: true}` on a quit from inside an
89// open overlay. An enum would force the dispatcher to encode
90// combinations as variants and every consumer (TUI `apply_dispatch`)
91// to re-split them — a lossy projection followed by a re-derivation,
92// which is exactly the shape the honesty bar rejects. Each field
93// is documented with its exact meaning inline; adding a fifth
94// bool should be a deliberate decision, not a silent growth.
95#[allow(clippy::struct_excessive_bools)]
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub struct DispatchOutput {
98    pub lines: Vec<OutputLine>,
99    /// Replayed log rows the TUI should append **without**
100    /// persisting. Empty for every command except `/resume`, where
101    /// the dispatcher emits one entry per event from the prior
102    /// session's event log. Kept as a separate field (rather than
103    /// an `OutputLine::Replayed` variant) so the routing rule
104    /// "replay = silent, lines = recorded" is enforced at the
105    /// type level — a future contributor cannot accidentally flip
106    /// a normal line into silent mode or vice versa.
107    pub replay_lines: Vec<ReplayLine>,
108    pub mode_change: Option<ModeTarget>,
109    /// Open a modal overlay on top of the current mode. The
110    /// dispatcher only signals intent; the TUI resolves
111    /// presentation + dismissal keybinds.
112    pub show_overlay: Option<OverlayTarget>,
113    pub quit: bool,
114    pub clear_log: bool,
115    pub risk: Option<RiskDirection>,
116    /// Friction decision applied to the command. Always present
117    /// when `risk` is. `Proceed` means "the command was run
118    /// immediately"; `Pause` / `TypedConfirm` means "the command
119    /// was *not* run — the caller must honor the friction before
120    /// re-dispatching." That split is why `Decision` is emitted
121    /// as data rather than baked into the line output.
122    pub friction: Option<FrictionDecision>,
123    /// Carries the resolved [`Command`] when [`DispatchOutput::friction`]
124    /// is `Pause` or `TypedConfirm`, so the caller (TUI) can open
125    /// a friction overlay and, after the pause elapses and any
126    /// typed confirmation lands, re-run the command with
127    /// [`run_bypass_friction`]. `None` for `Proceed` (the command
128    /// already ran) and for commands without a risk direction.
129    pub pending_command: Option<Command>,
130    /// Verbose-mode intent emitted by `/verbose`. `None` means
131    /// "leave it alone." A `Some(new_state)` carries the target
132    /// boolean the TUI should swing to — the dispatcher resolves
133    /// `Toggle` against [`DispatchContext::verbose_snapshot`] so
134    /// the TUI is free of the toggle semantics and every
135    /// downstream caller sees an absolute state, not an
136    /// instruction.
137    pub verbose_toggle: Option<bool>,
138    /// Wrap-off intent emitted by `/wrap-off`. `Some(true)`
139    /// means "skip wrap on the next /quit / session end for
140    /// this session only" — the flag never persists across
141    /// sessions (per ADDENDUM_A §9.1). `None` means the
142    /// command did not touch the flag.
143    pub wrap_off_toggle: Option<bool>,
144    /// Coaching-reset intent emitted by `/coaching reset`.
145    /// `true` means the TUI should empty its coaching buffer.
146    /// Kept as a boolean (not an `Option<()>`) so the default
147    /// value is immediately legible.
148    pub coaching_reset: bool,
149    /// Dismiss any active modal overlay. Used by commands whose
150    /// "purpose" is to clear operator context (`/clear`) or by
151    /// failure paths in commands that might otherwise leave a
152    /// stale overlay floating (e.g. `/evaluate <coin>` when the
153    /// engine returns an empty body — we emit an alert line and
154    /// signal dismissal so the operator is not left staring at
155    /// an older, unrelated verdict card). Ignored when
156    /// [`DispatchOutput::show_overlay`] is `Some` — opening and
157    /// closing in the same tick would be contradictory, and
158    /// `show_overlay` wins because the data path is the reason
159    /// the command ran.
160    pub dismiss_overlay: bool,
161}
162
163impl DispatchOutput {
164    #[must_use]
165    pub fn with_line(mut self, l: OutputLine) -> Self {
166        self.lines.push(l);
167        self
168    }
169}
170
171/// A thin read-only handle to the operator's current behavioural
172/// label. The dispatcher consults this on every risk-increasing
173/// command to compute the [`FrictionDecision`].
174///
175/// Implementations:
176/// - [`StaticLabel`] — a fixed label; used in tests and when the
177///   engine has not yet reported one.
178/// - (future) `EngineLabel` — polled from `GET /operator/state`.
179///
180/// The trait is intentionally tiny so a future scheduler or CI
181/// runner can plug in its own source without depending on the
182/// `engine-client` crate.
183pub trait StateSource: Send + Sync + 'static {
184    fn label(&self) -> Label;
185}
186
187/// Trivial `StateSource` that returns the same label on every
188/// call. The default value is [`Label::Steady`] — the "no friction,
189/// nothing abnormal" label — so a fresh `DispatchContext` does
190/// not accidentally gate commands when the engine has not yet
191/// reported a state.
192#[derive(Debug, Clone, Copy)]
193pub struct StaticLabel(pub Label);
194
195impl StaticLabel {
196    #[must_use]
197    pub const fn steady() -> Self {
198        Self(Label::Steady)
199    }
200    #[must_use]
201    pub const fn tilt() -> Self {
202        Self(Label::Tilt)
203    }
204}
205
206impl Default for StaticLabel {
207    fn default() -> Self {
208        Self::steady()
209    }
210}
211
212impl StateSource for StaticLabel {
213    fn label(&self) -> Label {
214        self.0
215    }
216}
217
218/// Shared context for dispatch. The HTTP client is optional — the
219/// TUI launches even when the engine is unreachable, and commands
220/// that need it degrade to a clear error line.
221#[derive(Clone)]
222pub struct DispatchContext {
223    pub http: Option<HttpClient>,
224    pub engine: Arc<RwLock<EngineState>>,
225    /// Operator-state source. Defaults to `StaticLabel::steady()`
226    /// so freshly-constructed contexts never accidentally gate.
227    pub state: Arc<dyn StateSource>,
228    /// Session store, if persistence is enabled. `None` when
229    /// `--no-persist` is set or the DB failed to open — the
230    /// session-cohort commands (`/sessions`, `/resume`, `/fork`,
231    /// `/save`) then surface a single "persistence disabled"
232    /// alert rather than pretending.
233    pub sessions: Option<Arc<dyn SessionSource>>,
234    /// Config introspection source. `None` in tests + headless
235    /// paths; `/config show|doctor` then emit a single
236    /// "unavailable" alert rather than panicking. Wired in
237    /// production by `zero/src/main.rs` over `zero_config`.
238    pub config: Option<Arc<dyn ConfigSource>>,
239    /// Current verbose-rendering state, snapshotted at dispatch
240    /// time by the caller. Lets `/verbose toggle` resolve into
241    /// an absolute target without the dispatcher needing a
242    /// trait-level callback. Defaults to `false` so commands
243    /// that never touch verbosity do not need to set it.
244    pub verbose: bool,
245    /// Current wrap-off state, snapshotted at dispatch time.
246    /// Lets `/wrap-off` become a no-op (with honest wording)
247    /// when already disabled; mirrors `verbose` for the same
248    /// reason — dispatcher stays pure.
249    pub wrap_off: bool,
250    /// Engine Auto-mode source. `None` when the engine is
251    /// unreachable or no adapter has been installed — the
252    /// dispatcher then surfaces `/auto` as "unavailable"
253    /// rather than pretending. Wired in production by
254    /// `crates/zero/src/main.rs` atop the engine client.
255    pub auto: Option<Arc<dyn AutoSource>>,
256    /// Operator-local supervisor source. `None` when no
257    /// daemon adapter is installed — the dispatcher then
258    /// surfaces `/headless` as "unavailable" and `/kill`
259    /// falls through to the non-compound path. Wired by the
260    /// future `zero-headless` adapter (ADR-006).
261    pub supervisor: Option<Arc<dyn SupervisorSource>>,
262}
263
264impl std::fmt::Debug for DispatchContext {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.debug_struct("DispatchContext")
267            .field("http_connected", &self.http.is_some())
268            .field("label", &self.state.label())
269            .field("sessions_enabled", &self.sessions.is_some())
270            .field("config_enabled", &self.config.is_some())
271            .field("auto_enabled", &self.auto.is_some())
272            .field("supervisor_enabled", &self.supervisor.is_some())
273            .finish_non_exhaustive()
274    }
275}
276
277impl DispatchContext {
278    #[must_use]
279    pub fn new(http: Option<HttpClient>, engine: Arc<RwLock<EngineState>>) -> Self {
280        Self {
281            http,
282            engine,
283            state: Arc::new(StaticLabel::steady()),
284            sessions: None,
285            config: None,
286            verbose: false,
287            wrap_off: false,
288            auto: None,
289            supervisor: None,
290        }
291    }
292
293    /// Override the operator-state source.
294    #[must_use]
295    pub fn with_state(mut self, src: Arc<dyn StateSource>) -> Self {
296        self.state = src;
297        self
298    }
299
300    /// Attach a session store. Enables the session-cohort
301    /// commands; without this they surface an "unavailable" alert.
302    #[must_use]
303    pub fn with_sessions(mut self, src: Arc<dyn SessionSource>) -> Self {
304        self.sessions = Some(src);
305        self
306    }
307
308    /// Attach a config introspection source. Enables
309    /// `/config show` + `/config doctor`; without this they
310    /// surface an "unavailable" alert.
311    #[must_use]
312    pub fn with_config(mut self, src: Arc<dyn ConfigSource>) -> Self {
313        self.config = Some(src);
314        self
315    }
316
317    /// Snapshot the caller's current verbose state so
318    /// `/verbose toggle` can resolve into an absolute target
319    /// without a round-trip to the TUI.
320    #[must_use]
321    pub const fn with_verbose(mut self, on: bool) -> Self {
322        self.verbose = on;
323        self
324    }
325
326    /// Snapshot the caller's wrap-off state so `/wrap-off`
327    /// can surface an honest "already off" line without the
328    /// dispatcher calling back into the TUI.
329    #[must_use]
330    pub const fn with_wrap_off(mut self, on: bool) -> Self {
331        self.wrap_off = on;
332        self
333    }
334
335    /// Attach an Auto-mode source. Enables `/auto on|off|status`;
336    /// without this they surface an "unavailable" alert.
337    #[must_use]
338    pub fn with_auto(mut self, src: Arc<dyn AutoSource>) -> Self {
339        self.auto = Some(src);
340        self
341    }
342
343    /// Attach a supervisor source. Enables
344    /// `/headless start|stop|status` and the `/kill` compound
345    /// tear-down. Without this `/headless` surfaces an
346    /// "unavailable" alert and `/kill` falls through to the
347    /// non-compound path.
348    #[must_use]
349    pub fn with_supervisor(mut self, src: Arc<dyn SupervisorSource>) -> Self {
350        self.supervisor = Some(src);
351        self
352    }
353}
354
355/// Parse, resolve, execute. Returns `Ok(None)` when the line is
356/// empty — callers should skip rendering in that case.
357///
358/// # Errors
359///
360/// Never returns an error at this layer; engine failures become
361/// structured `OutputLine::Alert` entries so the operator sees
362/// them in context rather than as an unhandled result. The
363/// `Result` signature is kept because future commands (`/rate`,
364/// `/plan`, `/execute`) will need it.
365pub async fn dispatch(ctx: &DispatchContext, input: &str) -> Result<Option<DispatchOutput>, Never> {
366    let parsed = parse_line(input);
367    let Some(cmd) = crate::command::resolve(&parsed) else {
368        return Ok(None);
369    };
370    let risk = cmd.risk();
371    let label = ctx.state.label();
372
373    // M2 §3: derive an engine-side risk context so `decide_with_risk`
374    // can reach L3 (guardrail proximity) / L4 (halted). A fresh
375    // `DispatchContext` without a populated engine mirror
376    // produces `RiskContext::default()`, which `decide_with_risk`
377    // treats identically to `decide` — the L2 cap is preserved
378    // exactly where the M1 code had it. The scope on the `read`
379    // guard is deliberately tight: only the fields needed to
380    // build the context are copied out, so the dispatcher does
381    // not hold the `RwLock` across the `run` await point.
382    let (risk_ctx, halt_reason, reread_phrase) = {
383        let eng = ctx.engine.read();
384        eng.risk
385            .as_ref()
386            .map(|stat| {
387                let r = &stat.value;
388                let rc = zero_operator_state::RiskContext::from_engine(
389                    r.drawdown_pct,
390                    r.last_drawdown_alert_pct,
391                    r.is_halted(),
392                );
393                let halt_reason = halt_reason_label(r);
394                let reread = reread_phrase_from_risk(r.drawdown_pct, r.last_drawdown_alert_pct);
395                (rc, halt_reason, reread)
396            })
397            .unwrap_or_default()
398    };
399
400    let decision = crate::friction::decide_with_risk(
401        risk,
402        label,
403        risk_ctx,
404        halt_reason.as_deref(),
405        reread_phrase,
406    );
407
408    // Only `Proceed` runs the command immediately. `Pause` /
409    // `TypedConfirm` / `WaitAndReread` surface as friction
410    // metadata + an explanatory line; the caller (TUI /
411    // automation) is responsible for honoring the pause/re-read
412    // and re-dispatching via `run_bypass_friction`. `HardStop`
413    // surfaces the refusal line and the command is dropped — no
414    // re-dispatch path, no `pending_command` to carry.
415    //
416    // CRITICAL: this branch must not be short-circuited for
417    // `Reduces` commands. `decide_with_risk` already guarantees
418    // `Reduces → Proceed` (tested in
419    // `friction::tests::decide_with_risk_reduces_always_proceeds_even_when_halted`),
420    // but the asymmetry is load-bearing — a regression that
421    // added a pause branch here is the 2 AM failure mode the
422    // whole architecture exists to prevent.
423    let mut out = if matches!(decision, FrictionDecision::Proceed) {
424        run(ctx, &cmd).await
425    } else {
426        friction_advisory(&cmd, label, &decision)
427    };
428    // Decide whether to carry the command through to the caller
429    // *before* moving `decision` into `out`. When the command was
430    // not run because of friction and is *not* a refusal, carry
431    // the resolved `Command` so the TUI can re-invoke via
432    // `run_bypass_friction` after honoring the pause / re-read.
433    // L4 `HardStop` explicitly excludes the pending path — the
434    // whole point of a refusal is that no re-dispatch can undo
435    // it. Proceed path leaves `pending_command = None` — the
436    // command already ran.
437    let carry_pending = !matches!(decision, FrictionDecision::Proceed) && !decision.is_refusal();
438    out.risk = Some(risk);
439    out.friction = Some(decision);
440    if carry_pending {
441        out.pending_command = Some(cmd);
442    }
443    Ok(Some(out))
444}
445
446/// Pick a human-legible halt label from the engine's halt
447/// booleans. Priority: `stop_failure_halt` > `global_halt` >
448/// `halted`. `None` when the engine is not halted.
449///
450/// Kept near the dispatcher (not on `Risk`) because this is a
451/// friction-layer concern: the engine-client crate intentionally
452/// mirrors the wire shape without re-deriving labels. See
453/// `Risk::halt_reason` for the engine's own free-form field
454/// (surfaces in UI) — that one may be absent even when a halt
455/// boolean is set, so this picker cannot defer to it alone.
456fn halt_reason_label(risk: &zero_engine_client::models::Risk) -> Option<String> {
457    if risk.stop_failure_halt {
458        Some("stop_failure_halt".to_string())
459    } else if risk.global_halt {
460        Some("global_halt".to_string())
461    } else if risk.halted {
462        // Prefer the engine's own reason string when present —
463        // it is the richest description available on the wire.
464        // Fall back to the bare flag name so operators always see
465        // something concrete.
466        Some(
467            risk.halt_reason
468                .clone()
469                .unwrap_or_else(|| "halted".to_string()),
470        )
471    } else {
472        None
473    }
474}
475
476/// Format the L3 re-read phrase from engine-reported drawdown
477/// numbers. Returns `None` when either field is missing — the
478/// decision layer falls back to `FALLBACK_REREAD_PHRASE` so the
479/// operator still gets something concrete to type.
480fn reread_phrase_from_risk(
481    drawdown_pct: Option<f64>,
482    last_drawdown_alert_pct: Option<f64>,
483) -> Option<String> {
484    let dd = drawdown_pct?;
485    let alert = last_drawdown_alert_pct?;
486    let delta = (alert - dd).abs();
487    Some(format!(
488        "i acknowledge drawdown {dd:.2}% is within {delta:.2}pp of the {alert:.2}% hard alert"
489    ))
490}
491
492/// Run a [`Command`] **without** consulting the friction ladder.
493///
494/// This is the post-friction invocation path. The caller (TUI) has
495/// honored the friction — either waited the required pause or
496/// accepted a typed confirmation — and now asks the dispatcher to
497/// execute. The returned output carries `risk` + `friction =
498/// Proceed` so downstream logging + tests see a uniform shape.
499///
500/// # Safety rails
501///
502/// This function does **not** lower the risk-asymmetry invariant.
503/// `Reduces` and `Neutral` commands reach the same `run` handler
504/// as they do via the regular path; there is nothing here that a
505/// caller could exploit to execute a never-gated command
506/// differently. Calling `run_bypass_friction` on a `Reduces`
507/// command is harmless — the command proceeds, same as always.
508pub async fn run_bypass_friction(ctx: &DispatchContext, cmd: Command) -> DispatchOutput {
509    let risk = cmd.risk();
510    let mut out = run(ctx, &cmd).await;
511    out.risk = Some(risk);
512    out.friction = Some(FrictionDecision::Proceed);
513    out
514}
515
516fn friction_advisory(cmd: &Command, label: Label, d: &FrictionDecision) -> DispatchOutput {
517    // A single log breadcrumb. The pause countdown + typed-confirm
518    // live in the TUI's friction-pause overlay (driven by
519    // `friction` + `pending_command`); duplicating their wording
520    // here would be log noise. Non-TUI callers (scripted runs,
521    // tests) still see enough to know the command was gated and
522    // by what level.
523    let mut out = DispatchOutput::default();
524    match d {
525        FrictionDecision::Proceed => {}
526        FrictionDecision::Pause { pause, level } => {
527            out.lines.push(OutputLine::warn(format!(
528                "{name}: friction {level:?} — state={label}, pause {pause}s",
529                name = cmd.name(),
530                pause = pause.as_secs(),
531            )));
532        }
533        FrictionDecision::TypedConfirm { pause, level } => {
534            let word = d.confirm_word().map_or_else(
535                || crate::friction::TYPED_CONFIRM_WORD.to_string(),
536                std::borrow::Cow::into_owned,
537            );
538            out.lines.push(OutputLine::alert(format!(
539                "{name}: friction {level:?} — state={label}, {pause}s pause + type '{word}'",
540                name = cmd.name(),
541                pause = pause.as_secs(),
542            )));
543        }
544        FrictionDecision::WaitAndReread {
545            pause,
546            level,
547            phrase,
548        } => {
549            out.lines.push(OutputLine::alert(format!(
550                "{name}: friction {level:?} — state={label}, {pause}s pause + re-read: '{phrase}'",
551                name = cmd.name(),
552                pause = pause.as_secs(),
553            )));
554        }
555        FrictionDecision::HardStop { level, reason } => {
556            // L4 is a refusal — surface it as `alert` so the TUI
557            // lands the operator on the strongest log colour. No
558            // "pause + type" hint: nothing the operator types
559            // will un-refuse this. The Reduces path remains open
560            // (`/kill`, `/break`, …).
561            out.lines.push(OutputLine::alert(format!(
562                "{name}: friction {level:?} REFUSED — state={label}, reason={reason}. \
563                 Only risk-reducing commands are accepted while the engine is halted.",
564                name = cmd.name(),
565            )));
566        }
567    }
568    out
569}
570
571async fn run(ctx: &DispatchContext, cmd: &Command) -> DispatchOutput {
572    match cmd {
573        Command::Help => help(),
574        Command::Quit => DispatchOutput {
575            quit: true,
576            lines: vec![OutputLine::system("exiting")],
577            ..Default::default()
578        },
579        Command::Clear => DispatchOutput {
580            clear_log: true,
581            // Clear is the operator's "clean slate" affordance —
582            // dismissing any lingering modal overlay is part of
583            // that contract. Without this, a stale verdict/state
584            // card can survive a `/clear` and keep obscuring new
585            // output until dismissed manually.
586            dismiss_overlay: true,
587            ..Default::default()
588        },
589        Command::SwitchMode(m) => DispatchOutput {
590            mode_change: Some(*m),
591            ..Default::default()
592        },
593        Command::Status => status(ctx).await,
594        Command::Brief => brief(ctx).await,
595        Command::Risk => risk_cmd(ctx).await,
596        Command::HyperliquidStatus { symbol } => hl_status_cmd(ctx, symbol.as_deref()).await,
597        Command::HyperliquidAccount => hl_account_cmd(ctx).await,
598        Command::HyperliquidReconcile => hl_reconcile_cmd(ctx).await,
599        Command::LiveCertify => live_certify_cmd(ctx).await,
600        Command::LiveCockpit => live_cockpit_cmd(ctx).await,
601        Command::LiveEvidence => live_evidence_cmd(ctx).await,
602        Command::LiveReceipts => live_receipts_cmd(ctx).await,
603        Command::LiveCanaryPolicy => live_canary_policy_cmd(ctx).await,
604        Command::RuntimeParity => runtime_parity_cmd(ctx).await,
605        Command::Immune => immune_cmd(ctx).await,
606        Command::Quote { symbol } => quote_cmd(ctx, symbol.as_deref()).await,
607        Command::Regime { coin } => regime_cmd(ctx, coin.as_deref()).await,
608        Command::Evaluate { coin, extras } => evaluate_cmd(ctx, coin.as_deref(), extras).await,
609        Command::Positions => positions_cmd(ctx).await,
610        Command::Pulse { limit } => pulse_cmd(ctx, *limit).await,
611        Command::Approaching => approaching_cmd(ctx).await,
612        Command::Rejections { coin, limit } => rejections_cmd(ctx, coin.as_deref(), *limit).await,
613        Command::Kill => kill_cmd(ctx).await,
614        Command::FlattenAll => flatten_cmd(ctx).await,
615        Command::PauseEntries => pause_cmd(ctx).await,
616        Command::ResumeEntries => resume_entries_cmd(ctx).await,
617        Command::Break { minutes } => break_stub(ctx, *minutes).await,
618        Command::Execute => execute_stub(),
619        Command::ExecuteOrder {
620            coin,
621            side,
622            size,
623            error,
624        } => {
625            execute_cmd(
626                ctx,
627                coin.as_deref(),
628                *side,
629                size.as_deref(),
630                error.as_deref(),
631            )
632            .await
633        }
634        Command::State => DispatchOutput {
635            show_overlay: Some(OverlayTarget::State),
636            ..Default::default()
637        },
638        Command::Sessions { limit } => sessions_cmd(ctx, *limit),
639        Command::Resume { needle } => resume_cmd(ctx, needle.as_deref()),
640        Command::Fork => fork_cmd(ctx),
641        Command::Save { label } => save_cmd(ctx, label.as_deref()),
642        Command::Replay { needle } => replay_cmd(ctx, needle.as_deref()),
643        Command::Share { needle } => share_cmd(ctx, needle.as_deref()),
644        Command::Heat => heat_cmd(ctx).await,
645        Command::Config { action } => config_cmd(ctx, action),
646        Command::Verbose { action } => verbose_cmd(ctx, action),
647        Command::StateOverride { label } => state_override_cmd(*label),
648        Command::Continue => continue_cmd(),
649        Command::Close { coin } => close_cmd(coin.as_deref()),
650        Command::WrapOff => wrap_off_cmd(),
651        Command::CoachingReset => coaching_reset_cmd(),
652        Command::DisclosureOverride { confirmed } => disclosure_override_cmd(*confirmed),
653        Command::Rate { trade_id, rating } => rate_cmd(ctx, trade_id.as_deref(), *rating).await,
654        Command::ZeroPrefix { rest } => zero_prefix_hint(rest),
655        Command::Auto { action } => auto_cmd(ctx, action),
656        Command::Headless { action } => headless_cmd(ctx, action),
657        Command::Unknown(head) => DispatchOutput {
658            lines: vec![OutputLine::warn(format!(
659                "unknown command: /{head}  (try /help)"
660            ))],
661            ..Default::default()
662        },
663    }
664}
665
666fn help() -> DispatchOutput {
667    let mut out = DispatchOutput::default();
668    out.lines.push(OutputLine::system("commands:"));
669    out.lines
670        .extend(HELP_LINES.iter().copied().map(OutputLine::system));
671    // Diagnostic commands grouped at the bottom so an operator
672    // scanning for "what do I type when things are broken" finds
673    // them without hunting. `/doctor` is the top-level alias for
674    // `/config doctor`; both routes exist so operators who think
675    // in either shape land somewhere.
676    out.lines.push(OutputLine::system(
677        "  /doctor              — diagnose config + secrets (alias for /config doctor)",
678    ));
679    out.lines.push(OutputLine::system(
680        "  /config show         — show resolved config values",
681    ));
682    out.lines.push(OutputLine::system(
683        "mode switches are also on Ctrl+1..5. Ctrl+0 returns to Conversation.",
684    ));
685    out
686}
687
688const HELP_LINES: &[&str] = &[
689    "  /help                — this list",
690    "  /quit                — exit",
691    "  /clear               — clear the log",
692    "  /conv /decisions /heat-mode /pos-mode — switch modes",
693    "  /status              — engine status",
694    "  /brief               — morning briefing",
695    "  /risk                — guardrail summary",
696    "  /hl-status [coin]    — read-only Hyperliquid info status",
697    "  /hl-account          — read-only Hyperliquid account truth",
698    "  /hl-reconcile        — Hyperliquid account reconciliation",
699    "  /live-certify        — dry-run live execution certification",
700    "  /live-cockpit        — live readiness cockpit",
701    "  /live-evidence       — hash-only live evidence bundle",
702    "  /live-receipts       — public-safe execution receipts",
703    "  /live-canary         — live canary readiness and proof policy",
704    "  /runtime-parity      — production-parity OODA report",
705    "  /immune              — immune breaker state",
706    "  /quote <coin>        — active paper quote source",
707    "  /heat                — composite heat (risk + circuit state)",
708    "  /regime [coin]       — market regime",
709    "  /evaluate <coin>     — gate verdict (overlay)",
710    "  /pos                 — open positions",
711    "  /pulse [N]           — recent engine events",
712    "  /approaching         — coins near a gate",
713    "  /rejections [coin] [N] — recent gate rejections",
714    "  /kill /flatten-all /pause-entries /break /close  — risk-reducers (instant)",
715    "  /resume-entries      — resume new entries (friction-gated)",
716    "  /close <coin>        — close a single position",
717    "  /execute <coin> <buy|sell> <size> — place order (gated)",
718    "  /state               — full operator-state overview (any key closes)",
719    "  /state-override <L>  — declare operator-state label (gated)",
720    "  /continue            — acknowledge coaching notice",
721    "  /coaching reset      — clear coaching notice buffer",
722    "  /wrap-off            — skip daily wrap (this session only)",
723    "  /disclosure-override --i-know-what-i-am-doing  — bypass progressive disclosure",
724];
725
726/// Render the "you're already inside zero" hint when an operator
727/// types a shell-style `zero …` invocation at the TUI prompt.
728///
729/// Resolution rules (honesty bar: every suggestion must actually
730/// exist, or the hint makes the tool look broken):
731///
732/// - `zero doctor` → suggest `/doctor` (lands today via the
733///   alias we just added).
734/// - `zero --version` / `zero version` → suggest `/quit` to
735///   return to the shell; the version banner is only exposed
736///   out-of-TUI because in-TUI it would show at startup anyway.
737///   Reference to a `/version` command is deliberately avoided
738///   — that command does not exist inside the TUI.
739/// - `zero init` / `zero run` / anything else → suggest
740///   `/quit` + re-invoke; these are shell-only entry points and
741///   no in-TUI equivalent exists. Telling an operator to
742///   `/init` would reproduce the ghost-command mistake from
743///   `zero pair`.
744/// - `zero` alone → suggest `/help`; the operator may just be
745///   exploring.
746///
747/// The hint echoes the literal tail the operator typed so they
748/// see their own intent reflected back (trust-builds-through-
749/// precision), then offers the one or two correct next steps.
750fn zero_prefix_hint(rest: &str) -> DispatchOutput {
751    let tail = rest.trim();
752    let hint = match tail {
753        "" => "you're already inside zero — try `/help` to list commands".to_owned(),
754        "doctor" | "doctor --fix" | "doctor --format json" => {
755            "you're already inside zero — try `/doctor` (or `/config doctor`)".to_owned()
756        }
757        "version" | "--version" | "-V" => {
758            "you're already inside zero — the version banner printed at startup; `/quit` returns to the shell".to_owned()
759        }
760        other => format!(
761            "you're already inside zero — `{other}` is a shell subcommand. `/quit` returns to the shell, or try `/help`"
762        ),
763    };
764    DispatchOutput {
765        lines: vec![OutputLine::warn(hint)],
766        ..Default::default()
767    }
768}
769
770fn require_http<'a>(ctx: &'a DispatchContext, out: &mut DispatchOutput) -> Option<&'a HttpClient> {
771    if let Some(c) = &ctx.http {
772        Some(c)
773    } else {
774        out.lines.push(OutputLine::alert(
775            "no engine client configured — run `zero init` or set ZERO_API_URL",
776        ));
777        None
778    }
779}
780
781async fn status(ctx: &DispatchContext) -> DispatchOutput {
782    let mut out = DispatchOutput::default();
783    let Some(http) = require_http(ctx, &mut out) else {
784        return out;
785    };
786    match http.v2_status().await {
787        Ok(s) => {
788            let regime = s.regime().unwrap_or("—");
789            let conf = match (s.engine_confidence(), s.confidence_level()) {
790                (Some(score), Some(level)) => format!("{score:.0} ({level})"),
791                (Some(score), None) => format!("{score:.0}"),
792                (None, Some(level)) => level.to_string(),
793                (None, None) => "—".into(),
794            };
795            let eq = s.equity().map_or("—".into(), |v| format!("${v:.2}"));
796            let open = s.open().map_or("—".into(), |n| n.to_string());
797            let upnl = s
798                .unrealized_pnl()
799                .map_or("—".into(), |v| format!("{v:+.2}"));
800            out.lines.push(OutputLine::command(format!(
801                "engine: regime={regime}  confidence={conf}  equity={eq}  open={open}  upnl={upnl}"
802            )));
803            let today = &s.today;
804            if today.trades.is_some() || today.pnl.is_some() {
805                let trades = today.trades.map_or("—".into(), |n| n.to_string());
806                let wins = today.wins.map_or("—".into(), |n| n.to_string());
807                let pnl = today.pnl.map_or("—".into(), |v| format!("{v:+.2}"));
808                let streak = today.streak.map_or("—".into(), |n| format!("{n:+}"));
809                let sizing = today.sizing_mult.map_or("—".into(), |v| format!("{v:.2}x"));
810                out.lines.push(OutputLine::system(format!(
811                    "  today: trades={trades}  wins={wins}  pnl={pnl}  streak={streak}  sizing={sizing}"
812                )));
813            }
814            let market = &s.market;
815            if market.fear_greed.is_some() || market.health.is_some() {
816                let fg = market.fear_greed.map_or("—".into(), |n| n.to_string());
817                let health = market
818                    .health
819                    .map_or("—".into(), |v| format!("{:.0}%", v * 100.0));
820                let coins = market.coins_tradeable.map_or("—".into(), |n| n.to_string());
821                out.lines.push(OutputLine::system(format!(
822                    "  market: fear_greed={fg}  health={health}  coins_tradeable={coins}"
823                )));
824            }
825            if let Some(recovery) = &s.recovery {
826                let status = recovery.status.as_deref().unwrap_or("unknown");
827                let source = recovery.source.as_deref().unwrap_or("unknown");
828                let durable = if recovery.durable {
829                    "durable"
830                } else {
831                    "ephemeral"
832                };
833                let decisions = recovery
834                    .current_decisions
835                    .or(recovery.decisions_recovered)
836                    .map_or("—".into(), |n| n.to_string());
837                let fills = recovery
838                    .current_fills
839                    .or(recovery.fills_recovered)
840                    .map_or("—".into(), |n| n.to_string());
841                let positions = recovery
842                    .current_positions
843                    .or(recovery.positions_recovered)
844                    .map_or("—".into(), |n| n.to_string());
845                out.lines.push(OutputLine::system(format!(
846                    "  recovery: {status}  source={source}  journal={durable}  decisions={decisions}  fills={fills}  positions={positions}"
847                )));
848            }
849        }
850        Err(e) => out.lines.push(OutputLine::alert(format!("status: {e}"))),
851    }
852    out
853}
854
855async fn brief(ctx: &DispatchContext) -> DispatchOutput {
856    let mut out = DispatchOutput::default();
857    let Some(http) = require_http(ctx, &mut out) else {
858        return out;
859    };
860    match http.brief().await {
861        Ok(b) => {
862            if !b.has_content() {
863                out.lines
864                    .push(OutputLine::system("(engine has no briefing right now)"));
865                return out;
866            }
867            let open = b.open_positions.unwrap_or(0);
868            let fg = b
869                .fear_greed
870                .map_or("—".into(), |v| format!("{v} ({})", fg_sentiment(v)));
871            out.lines.push(OutputLine::command(format!(
872                "brief: open={open}  fear_greed={fg}  signals={}  approaching={}",
873                b.recent_signals.len(),
874                b.approaching.len(),
875            )));
876            for pos in b.positions.iter().take(8) {
877                let pnl = pos
878                    .unrealized_pnl
879                    .map_or_else(|| "—".into(), |v| format!("{v:+.2}"));
880                out.lines.push(OutputLine::system(format!(
881                    "  position {}  {}  size={:.4}  entry={:.2}  pnl={}",
882                    pos.symbol, pos.side, pos.size, pos.entry, pnl
883                )));
884            }
885            for sig in b.recent_signals.iter().take(5) {
886                if let Some(summary) = brief_line_summary(sig) {
887                    out.lines
888                        .push(OutputLine::system(format!("  signal  {summary}")));
889                }
890            }
891            for app in b.approaching.iter().take(5) {
892                if let Some(summary) = brief_line_summary(app) {
893                    out.lines
894                        .push(OutputLine::system(format!("  approaching  {summary}")));
895                }
896            }
897            if let Some(cycle) = b.last_cycle.as_object()
898                && !cycle.is_empty()
899            {
900                let parts: Vec<String> = cycle
901                    .iter()
902                    .take(5)
903                    .map(|(k, v)| format!("{k}={}", compact_json_value(v)))
904                    .collect();
905                out.lines.push(OutputLine::system(format!(
906                    "  last_cycle  {}",
907                    parts.join("  ")
908                )));
909            }
910        }
911        Err(e) => out.lines.push(OutputLine::alert(format!("brief: {e}"))),
912    }
913    out
914}
915
916async fn hl_status_cmd(ctx: &DispatchContext, symbol: Option<&str>) -> DispatchOutput {
917    let mut out = DispatchOutput::default();
918    let Some(http) = require_http(ctx, &mut out) else {
919        return out;
920    };
921    match http.hyperliquid_status(symbol).await {
922        Ok(s) if !s.enabled => {
923            let reason = s
924                .reason
925                .as_deref()
926                .unwrap_or("Hyperliquid read-only adapter disabled");
927            out.lines
928                .push(OutputLine::warn(format!("hl: disabled — {reason}")));
929        }
930        Ok(s) => {
931            let coins = s.coins.map_or("—".into(), |n| n.to_string());
932            let secrets = s
933                .secrets_required
934                .map_or("—".into(), |required| required.to_string());
935            out.lines.push(OutputLine::command(format!(
936                "hl: enabled  coins={coins}  secrets_required={secrets}"
937            )));
938            for (symbol, mid) in s.mids.iter().take(8) {
939                out.lines
940                    .push(OutputLine::system(format!("  {symbol}: mid={mid:.4}")));
941            }
942        }
943        Err(e) => out.lines.push(OutputLine::alert(format!("hl-status: {e}"))),
944    }
945    out
946}
947
948async fn hl_account_cmd(ctx: &DispatchContext) -> DispatchOutput {
949    let mut out = DispatchOutput::default();
950    let Some(http) = require_http(ctx, &mut out) else {
951        return out;
952    };
953    match http.hyperliquid_account().await {
954        Ok(account) => {
955            let equity = account
956                .account_value
957                .map_or("—".into(), |value| format!("${value:.2}"));
958            let margin = account
959                .margin_used
960                .map_or("—".into(), |value| format!("${value:.2}"));
961            out.lines.push(OutputLine::command(format!(
962                "hl-account: user={}  equity={equity}  margin={margin}  positions={}  open_orders={}",
963                account.user,
964                account.positions.len(),
965                account.open_orders.len()
966            )));
967            for position in account.positions.iter().take(8) {
968                out.lines.push(OutputLine::system(format!(
969                    "  {} {} qty={:.6} entry={:.4} value=${:.2} upnl=${:.2}",
970                    position.symbol,
971                    position.side,
972                    position.quantity.abs(),
973                    position.entry_price,
974                    position.position_value,
975                    position.unrealized_pnl
976                )));
977            }
978        }
979        Err(e) => out
980            .lines
981            .push(OutputLine::alert(format!("hl-account: {e}"))),
982    }
983    out
984}
985
986async fn hl_reconcile_cmd(ctx: &DispatchContext) -> DispatchOutput {
987    let mut out = DispatchOutput::default();
988    let Some(http) = require_http(ctx, &mut out) else {
989        return out;
990    };
991    match http.hyperliquid_reconciliation().await {
992        Ok(report) => {
993            out.lines.push(OutputLine::command(format!(
994                "hl-reconcile: status={}  risk_increasing_allowed={}  reason={}",
995                report.status, report.risk_increasing_allowed, report.reason
996            )));
997            for drift in report.drifts.iter().take(8) {
998                let symbol = drift.symbol.as_deref().unwrap_or("account");
999                out.lines.push(OutputLine::system(format!(
1000                    "  {symbol}: {} {} — {}",
1001                    drift.severity, drift.code, drift.reason
1002                )));
1003            }
1004        }
1005        Err(e) => out
1006            .lines
1007            .push(OutputLine::alert(format!("hl-reconcile: {e}"))),
1008    }
1009    out
1010}
1011
1012async fn live_certify_cmd(ctx: &DispatchContext) -> DispatchOutput {
1013    let mut out = DispatchOutput::default();
1014    let Some(http) = require_http(ctx, &mut out) else {
1015        return out;
1016    };
1017    match http.live_certification().await {
1018        Ok(report) => {
1019            let passed = report
1020                .summary
1021                .get("passed")
1022                .and_then(serde_json::Value::as_u64)
1023                .unwrap_or(0);
1024            let total = report
1025                .summary
1026                .get("total")
1027                .and_then(serde_json::Value::as_u64)
1028                .unwrap_or(report.drills.len() as u64);
1029            out.lines.push(OutputLine::command(format!(
1030                "live-certify: passed={}  live_start_certified={}  drills={passed}/{total}",
1031                report.passed, report.live_start_certified
1032            )));
1033            for drill in report
1034                .drills
1035                .iter()
1036                .filter(|drill| drill.status != "pass")
1037                .take(8)
1038            {
1039                out.lines.push(OutputLine::system(format!(
1040                    "  {}: {} — {}",
1041                    drill.name, drill.status, drill.note
1042                )));
1043            }
1044        }
1045        Err(e) => out
1046            .lines
1047            .push(OutputLine::alert(format!("live-certify: {e}"))),
1048    }
1049    out
1050}
1051
1052async fn live_cockpit_cmd(ctx: &DispatchContext) -> DispatchOutput {
1053    let mut out = DispatchOutput::default();
1054    let Some(http) = require_http(ctx, &mut out) else {
1055        return out;
1056    };
1057    match http.live_cockpit().await {
1058        Ok(cockpit) => {
1059            let preflight_total = json_u64(&cockpit.preflight.summary, "total");
1060            let preflight_passed = json_u64(&cockpit.preflight.summary, "passed");
1061            let preflight_failed = json_u64(&cockpit.preflight.summary, "failed");
1062            let immune_open = json_u64(&cockpit.immune.summary, "open");
1063            let immune_blocking = json_u64(&cockpit.immune.summary, "risk_blocking");
1064            let cert_total = json_u64(&cockpit.certification.summary, "total");
1065            let cert_passed = json_u64(&cockpit.certification.summary, "passed");
1066            let timeout = cockpit
1067                .heartbeat
1068                .timeout_s
1069                .map_or_else(|| "n/a".to_string(), |s| s.to_string());
1070
1071            out.lines.push(OutputLine::command(format!(
1072                "live-cockpit: live_mode={}  ready={}  risk_allowed={}  controls_ready={}",
1073                cockpit.live_mode,
1074                cockpit.ready,
1075                cockpit.risk_increasing_allowed,
1076                cockpit.controls_ready
1077            )));
1078            out.lines.push(OutputLine::system(format!(
1079                "  next: {}",
1080                cockpit.next_action
1081            )));
1082            out.lines.push(OutputLine::system(format!(
1083                "  operator: handle={} id={} role={} scope={}",
1084                cockpit.operator_context.handle,
1085                cockpit.operator_context.operator_id,
1086                cockpit.operator_context.role,
1087                cockpit.operator_context.scope
1088            )));
1089            out.lines.push(OutputLine::system(format!(
1090                "  preflight: passed={preflight_passed}/{preflight_total} failed={preflight_failed}"
1091            )));
1092            out.lines.push(OutputLine::system(format!(
1093                "  immune: open={immune_open} risk_blocking={immune_blocking}"
1094            )));
1095            out.lines.push(OutputLine::system(format!(
1096                "  reconcile: status={} risk_allowed={} drifts={} - {}",
1097                cockpit.reconciliation.status,
1098                cockpit.reconciliation.risk_increasing_allowed,
1099                cockpit.reconciliation.drifts,
1100                cockpit.reconciliation.reason
1101            )));
1102            out.lines.push(OutputLine::system(format!(
1103                "  certification: passed={} live_start_certified={} drills={cert_passed}/{cert_total}",
1104                cockpit.certification.passed, cockpit.certification.live_start_certified
1105            )));
1106            out.lines.push(OutputLine::system(format!(
1107                "  heartbeat: configured={} expired={} timeout_s={timeout}",
1108                cockpit.heartbeat.configured, cockpit.heartbeat.expired
1109            )));
1110            out.lines.push(OutputLine::system(format!(
1111                "  live-records: total={} accepted={} refused={} exchange_error={}",
1112                cockpit.live_records.total,
1113                cockpit.live_records.accepted,
1114                cockpit.live_records.refused,
1115                cockpit.live_records.exchange_error
1116            )));
1117            for check in cockpit.preflight.failed_checks.iter().take(4) {
1118                out.lines.push(OutputLine::system(format!(
1119                    "  preflight:{} {} - {}",
1120                    check.name, check.status, check.note
1121                )));
1122            }
1123            for breaker in cockpit.immune.open_breakers.iter().take(4) {
1124                out.lines.push(OutputLine::system(format!(
1125                    "  breaker:{} {} - {}",
1126                    breaker.name, breaker.status, breaker.reason
1127                )));
1128            }
1129            out.lines.push(OutputLine::system(
1130                "  actions: reduce=/pause-entries /kill /flatten-all  resume=/resume-entries",
1131            ));
1132        }
1133        Err(e) => out
1134            .lines
1135            .push(OutputLine::alert(format!("live-cockpit: {e}"))),
1136    }
1137    out
1138}
1139
1140async fn live_evidence_cmd(ctx: &DispatchContext) -> DispatchOutput {
1141    let mut out = DispatchOutput::default();
1142    let Some(http) = require_http(ctx, &mut out) else {
1143        return out;
1144    };
1145    match http.live_evidence().await {
1146        Ok(evidence) => {
1147            let signer = evidence
1148                .signature
1149                .get("signer")
1150                .and_then(serde_json::Value::as_str)
1151                .unwrap_or("unknown");
1152            let signature_status = evidence
1153                .signature
1154                .get("status")
1155                .and_then(serde_json::Value::as_str)
1156                .unwrap_or("unknown");
1157            let hash_short = evidence
1158                .evidence_hash
1159                .strip_prefix("sha256:")
1160                .map_or(evidence.evidence_hash.as_str(), |hash| hash)
1161                .chars()
1162                .take(12)
1163                .collect::<String>();
1164            out.lines.push(OutputLine::command(format!(
1165                "live-evidence: live_mode={}  ready={}  risk_allowed={}  artifacts={}  hash=sha256:{hash_short}...",
1166                evidence.live_mode,
1167                evidence.ready,
1168                evidence.risk_increasing_allowed,
1169                evidence.artifacts.len()
1170            )));
1171            out.lines.push(OutputLine::system(format!(
1172                "  signature: status={signature_status} signer={signer}"
1173            )));
1174            out.lines.push(OutputLine::system(format!(
1175                "  operator: handle={} id={} role={} scope={}",
1176                evidence.operator_context.handle,
1177                evidence.operator_context.operator_id,
1178                evidence.operator_context.role,
1179                evidence.operator_context.scope
1180            )));
1181            for artifact in evidence.artifacts.iter().take(8) {
1182                out.lines.push(OutputLine::system(format!(
1183                    "  {}: {} {} {}",
1184                    artifact.name, artifact.status, artifact.schema_version, artifact.hash
1185                )));
1186            }
1187        }
1188        Err(e) => out
1189            .lines
1190            .push(OutputLine::alert(format!("live-evidence: {e}"))),
1191    }
1192    out
1193}
1194
1195async fn live_receipts_cmd(ctx: &DispatchContext) -> DispatchOutput {
1196    let mut out = DispatchOutput::default();
1197    let Some(http) = require_http(ctx, &mut out) else {
1198        return out;
1199    };
1200    match http.live_receipts().await {
1201        Ok(receipts) => {
1202            let total = json_u64(&receipts.summary, "total");
1203            let accepted = json_u64(&receipts.summary, "accepted");
1204            let refused = json_u64(&receipts.summary, "refused");
1205            let exchange_error = json_u64(&receipts.summary, "exchange_error");
1206            let status = receipts
1207                .summary
1208                .get("status")
1209                .and_then(serde_json::Value::as_str)
1210                .unwrap_or("unknown");
1211            let hash_short = short_sha(&receipts.receipts_hash);
1212            out.lines.push(OutputLine::command(format!(
1213                "live-receipts: status={status}  total={total}  accepted={accepted}  refused={refused}  exchange_error={exchange_error}  hash=sha256:{hash_short}..."
1214            )));
1215            out.lines.push(OutputLine::system(format!(
1216                "  operator: handle={} id={} role={} scope={}",
1217                receipts.operator_context.handle,
1218                receipts.operator_context.operator_id,
1219                receipts.operator_context.role,
1220                receipts.operator_context.scope
1221            )));
1222            out.lines.push(OutputLine::system(format!(
1223                "  privacy: credentials={} wallet={} raw_ack={} trace_tokens={} idempotency_tokens={}",
1224                json_bool(&receipts.privacy, "contains_exchange_credentials"),
1225                json_bool(&receipts.privacy, "contains_wallet_material"),
1226                json_bool(&receipts.privacy, "contains_raw_venue_ack_payload"),
1227                json_bool(&receipts.privacy, "contains_trace_tokens"),
1228                json_bool(&receipts.privacy, "contains_idempotency_tokens")
1229            )));
1230            for receipt in receipts.receipts.iter().take(8) {
1231                out.lines.push(OutputLine::system(format!(
1232                    "  receipt: accepted={} status={} reason={} hash=sha256:{}...",
1233                    receipt.accepted,
1234                    receipt.status,
1235                    receipt.reason,
1236                    short_sha(&receipt.receipt_hash)
1237                )));
1238            }
1239        }
1240        Err(e) => out
1241            .lines
1242            .push(OutputLine::alert(format!("live-receipts: {e}"))),
1243    }
1244    out
1245}
1246
1247async fn live_canary_policy_cmd(ctx: &DispatchContext) -> DispatchOutput {
1248    let mut out = DispatchOutput::default();
1249    let Some(http) = require_http(ctx, &mut out) else {
1250        return out;
1251    };
1252    match http.live_canary_policy().await {
1253        Ok(policy) => {
1254            out.lines.push(OutputLine::command(format!(
1255                "live-canary: ready={}  armed={}  qualified={}  publishable={}  accepted_live={}",
1256                policy.summary.ready_for_canary,
1257                policy.summary.policy_armed,
1258                policy.summary.qualified,
1259                policy.summary.publishable_canary_evidence,
1260                policy.summary.live_order_accepted
1261            )));
1262            out.lines.push(OutputLine::system(format!(
1263                "  next: {} risk={} - {}",
1264                policy.recommendation.action,
1265                policy.recommendation.risk_direction,
1266                policy.recommendation.reason
1267            )));
1268            out.lines.push(OutputLine::system(format!(
1269                "  evidence: attempted={} receipts_accepted={} exchange_attached={} refusal_qualified={}",
1270                policy.summary.live_order_attempted,
1271                policy.summary.receipts_accepted,
1272                policy.summary.exchange_evidence_attached,
1273                policy.summary.refusal_evidence_qualified
1274            )));
1275            out.lines.push(OutputLine::system(format!(
1276                "  operator: handle={} id={} role={} scope={}",
1277                policy.operator_context.handle,
1278                policy.operator_context.operator_id,
1279                policy.operator_context.role,
1280                policy.operator_context.scope
1281            )));
1282            for phase in policy.phases.iter().take(8) {
1283                out.lines.push(OutputLine::system(format!(
1284                    "  phase:{} {} - {}",
1285                    phase.name, phase.status, phase.detail
1286                )));
1287            }
1288        }
1289        Err(e) => out
1290            .lines
1291            .push(OutputLine::alert(format!("live-canary: {e}"))),
1292    }
1293    out
1294}
1295
1296async fn runtime_parity_cmd(ctx: &DispatchContext) -> DispatchOutput {
1297    let mut out = DispatchOutput::default();
1298    let Some(http) = require_http(ctx, &mut out) else {
1299        return out;
1300    };
1301    match http.runtime_parity().await {
1302        Ok(report) => {
1303            let production_ooda = json_bool(&report.claim_boundary, "production_ooda_parity");
1304            let live_claimed = json_bool(&report.claim_boundary, "live_trading_claimed");
1305            let canary_required = json_bool(
1306                &report.claim_boundary,
1307                "operator_owned_canary_required_for_live_claim",
1308            );
1309            let protected_live_code = json_bool(
1310                &report.claim_boundary,
1311                "protected_live_code_evolution_allowed",
1312            );
1313            let top_reason = report
1314                .feedback
1315                .by_rejection_reason
1316                .iter()
1317                .max_by_key(|(_, count)| *count)
1318                .map_or("none", |(reason, _)| reason.as_str());
1319            out.lines.push(OutputLine::command(format!(
1320                "runtime-parity: ok={}  production_ooda={}  paper_only={}  live_trading_claimed={}",
1321                report.ok, production_ooda, report.paper_only, live_claimed
1322            )));
1323            out.lines.push(OutputLine::system(format!(
1324                "  paper: cycles={}/{} decisions={} fills={} rejections={} open_positions={}",
1325                report.cycles_run,
1326                report.cycles_requested,
1327                report.paper.decisions,
1328                report.paper.fills,
1329                report.paper.rejections,
1330                report.paper.open_positions
1331            )));
1332            out.lines.push(OutputLine::system(format!(
1333                "  live-shadow: mode={} refused={} accepted={} adapter_orders={} places_live_orders={}",
1334                report.live_shadow.mode,
1335                report.live_shadow.refused,
1336                report.live_shadow.accepted,
1337                report.live_shadow.adapter_orders_placed,
1338                report.places_live_orders
1339            )));
1340            out.lines.push(OutputLine::system(format!(
1341                "  feedback: rejection_rate={:.2}% sample={} top_rejection={}",
1342                report.feedback.rejection_rate * 100.0,
1343                report.feedback.sample_size,
1344                top_reason
1345            )));
1346            out.lines.push(OutputLine::system(format!(
1347                "  boundary: operator_owned_canary_required={canary_required} protected_live_code_evolution={protected_live_code}"
1348            )));
1349            out.lines.push(OutputLine::system(format!(
1350                "  certification: passed={} live_start_certified={} mode={}",
1351                report.certification.passed,
1352                report.certification.live_start_certified,
1353                report.certification.mode
1354            )));
1355        }
1356        Err(e) => out
1357            .lines
1358            .push(OutputLine::alert(format!("runtime-parity: {e}"))),
1359    }
1360    out
1361}
1362
1363async fn immune_cmd(ctx: &DispatchContext) -> DispatchOutput {
1364    let mut out = DispatchOutput::default();
1365    let Some(http) = require_http(ctx, &mut out) else {
1366        return out;
1367    };
1368    match http.immune().await {
1369        Ok(report) => {
1370            let open = report
1371                .summary
1372                .get("open")
1373                .and_then(serde_json::Value::as_u64)
1374                .unwrap_or_else(|| report.breakers.iter().filter(|b| b.blocks_risk).count() as u64);
1375            out.lines.push(OutputLine::command(format!(
1376                "immune: risk_increasing_allowed={}  open={}  mode={}",
1377                report.risk_increasing_allowed, open, report.mode
1378            )));
1379            for breaker in report
1380                .breakers
1381                .iter()
1382                .filter(|breaker| breaker.blocks_risk)
1383                .take(8)
1384            {
1385                out.lines.push(OutputLine::system(format!(
1386                    "  {}: {} - {}",
1387                    breaker.name, breaker.status, breaker.reason
1388                )));
1389            }
1390        }
1391        Err(e) => out.lines.push(OutputLine::alert(format!("immune: {e}"))),
1392    }
1393    out
1394}
1395
1396fn json_u64(map: &std::collections::BTreeMap<String, serde_json::Value>, key: &str) -> u64 {
1397    map.get(key)
1398        .and_then(serde_json::Value::as_u64)
1399        .unwrap_or(0)
1400}
1401
1402fn json_bool(map: &std::collections::BTreeMap<String, serde_json::Value>, key: &str) -> bool {
1403    map.get(key)
1404        .and_then(serde_json::Value::as_bool)
1405        .unwrap_or(false)
1406}
1407
1408fn short_sha(hash: &str) -> String {
1409    hash.strip_prefix("sha256:")
1410        .unwrap_or(hash)
1411        .chars()
1412        .take(12)
1413        .collect()
1414}
1415
1416async fn quote_cmd(ctx: &DispatchContext, symbol: Option<&str>) -> DispatchOutput {
1417    let mut out = DispatchOutput::default();
1418    let Some(symbol) = symbol else {
1419        out.lines.push(OutputLine::warn(
1420            "/quote <coin> — name the coin to inspect (e.g. /quote BTC)",
1421        ));
1422        return out;
1423    };
1424    let Some(http) = require_http(ctx, &mut out) else {
1425        return out;
1426    };
1427    match http.market_quote(symbol).await {
1428        Ok(q) => {
1429            let live = if q.live { "live" } else { "fixture" };
1430            out.lines.push(OutputLine::command(format!(
1431                "quote {}: {:.4}  source={}  mode={live}",
1432                q.symbol, q.price, q.source
1433            )));
1434            if let Some(as_of) = q.as_of {
1435                out.lines
1436                    .push(OutputLine::system(format!("  as_of={as_of}")));
1437            }
1438        }
1439        Err(e) => out.lines.push(OutputLine::alert(format!("quote: {e}"))),
1440    }
1441    out
1442}
1443
1444/// Map a fear-greed score (0..=100) to the conventional label
1445/// the engine uses on `/brief` and `/v2/status`. Centralized so
1446/// `/brief` and any future status readout agree on thresholds.
1447fn fg_sentiment(v: i64) -> &'static str {
1448    match v {
1449        i64::MIN..=24 => "extreme fear",
1450        25..=44 => "fear",
1451        45..=55 => "neutral",
1452        56..=74 => "greed",
1453        _ => "extreme greed",
1454    }
1455}
1456
1457/// Collapse a nested JSON value to a one-line "k=v  k=v" summary for
1458/// briefing lists. Strings and scalars pass through; objects render
1459/// their first few keys; arrays render their length. Prevents the
1460/// brief output from dumping multi-line JSON into the pane.
1461fn brief_line_summary(v: &serde_json::Value) -> Option<String> {
1462    match v {
1463        serde_json::Value::String(s) => Some(s.clone()),
1464        serde_json::Value::Object(map) if !map.is_empty() => {
1465            let parts: Vec<String> = map
1466                .iter()
1467                .take(4)
1468                .map(|(k, v)| format!("{k}={}", compact_json_value(v)))
1469                .collect();
1470            Some(parts.join("  "))
1471        }
1472        serde_json::Value::Array(items) if !items.is_empty() => {
1473            Some(format!("[{} items]", items.len()))
1474        }
1475        serde_json::Value::Null => None,
1476        other => Some(compact_json_value(other)),
1477    }
1478}
1479
1480/// Render a JSON scalar the way an operator expects at a glance:
1481/// numbers un-quoted, strings un-quoted, objects/arrays as their
1482/// compact count/shape marker.
1483fn compact_json_value(v: &serde_json::Value) -> String {
1484    match v {
1485        serde_json::Value::Null => "—".into(),
1486        serde_json::Value::Bool(b) => b.to_string(),
1487        serde_json::Value::Number(n) => n.to_string(),
1488        serde_json::Value::String(s) => s.clone(),
1489        serde_json::Value::Array(a) => format!("[{}]", a.len()),
1490        serde_json::Value::Object(m) => format!("{{{}}}", m.len()),
1491    }
1492}
1493
1494async fn risk_cmd(ctx: &DispatchContext) -> DispatchOutput {
1495    let mut out = DispatchOutput::default();
1496    let Some(http) = require_http(ctx, &mut out) else {
1497        return out;
1498    };
1499    match http.risk().await {
1500        Ok(r) => {
1501            let halted = r.is_halted();
1502            // Cross-field consistency probe: a peak-equity tracker
1503            // that advertises `equity > peak` is violating its own
1504            // definition — the number the engine is telling the
1505            // operator to plan against is older than the equity it
1506            // is simultaneously reporting. Render the mismatch
1507            // explicitly instead of passing through a confident
1508            // `dd=0.22%` that was computed against a stale peak.
1509            // Tolerance: 1% of peak covers float rounding + one
1510            // missed bus-flush tick without crying wolf.
1511            // Peak the engine actually derives `drawdown_pct` against
1512            // is the rolling 30d, not lifetime. Rendering lifetime
1513            // `peak_equity` next to `dd` produced a second kind of
1514            // apparent inconsistency — "peak=$613 dd=10%" only
1515            // reconciles against the 30d peak of $640. Prefer the
1516            // 30d anchor so the line's arithmetic is checkable by
1517            // eye; fall back to lifetime if 30d is missing.
1518            let peak_ref = r.peak_equity_30d.or(r.peak_equity);
1519            let equity_above_peak = match (r.account_value, peak_ref) {
1520                (Some(eq), Some(peak)) if peak > 0.0 => eq > peak * 1.01,
1521                _ => false,
1522            };
1523            let dd = if equity_above_peak {
1524                // Do not display a percent we know is wrong. The
1525                // warning line below tells the operator why.
1526                "—".to_string()
1527            } else {
1528                r.drawdown_pct.map_or("—".into(), |v| format!("{v:.2}%"))
1529            };
1530            let daily_loss = r
1531                .daily_loss_pct()
1532                .map(|v| format!("{v:.2}%"))
1533                .or_else(|| r.daily_loss_usd.map(|v| format!("${v:.2}")))
1534                .unwrap_or_else(|| "—".into());
1535            let daily_pnl = r.daily_pnl_usd.map_or("—".into(), |v| format!("{v:+.2}"));
1536            let eq = r.account_value.map_or("—".into(), |v| format!("${v:.2}"));
1537            let peak = peak_ref.map_or("—".into(), |v| format!("${v:.2}"));
1538            let open = r.open_count.map_or("—".into(), |n| n.to_string());
1539            let state = if halted { "HALTED" } else { "OK" };
1540            let line = format!(
1541                "risk: {state}  equity={eq}  peak={peak}  dd={dd}  daily-pnl={daily_pnl}  \
1542                 daily-loss={daily_loss}  open={open}"
1543            );
1544            if halted {
1545                out.lines.push(OutputLine::alert(line));
1546                if let Some(reason) = &r.halt_reason {
1547                    out.lines
1548                        .push(OutputLine::alert(format!("  halt reason: {reason}")));
1549                }
1550                if let Some(until) = &r.halt_until {
1551                    out.lines
1552                        .push(OutputLine::alert(format!("  halt until: {until}")));
1553                }
1554            } else {
1555                out.lines.push(OutputLine::command(line));
1556            }
1557            if equity_above_peak {
1558                // Warn (not alert) because this is a data-integrity
1559                // oddity, not an active risk event. The operator
1560                // just needs to know the numbers in this row do not
1561                // agree with themselves and the engine is the
1562                // source to audit, not the CLI.
1563                out.lines.push(OutputLine::warn(
1564                    "  inconsistent: equity > peak — engine peak-equity tracker is stale \
1565                     (see bus/risk.json vs bus/portfolio.json); dd suppressed",
1566                ));
1567            }
1568            if r.capital_floor_hit {
1569                out.lines
1570                    .push(OutputLine::alert("  capital floor hit".to_string()));
1571            }
1572        }
1573        Err(e) => out.lines.push(OutputLine::alert(format!("risk: {e}"))),
1574    }
1575    out
1576}
1577
1578/// Composite heat: a single-line "how hot am I?" readout that
1579/// folds every risk-proximity signal the engine exposes into one
1580/// number.
1581///
1582/// Formula:
1583/// - `heat` (0..=100) is the maximum of the three percent-scaled
1584///   risk metrics (drawdown / daily-loss / exposure). A percent
1585///   scale is honest because the engine already returns percent
1586///   values; we do not invent a denominator we cannot justify.
1587/// - If the kill switch is on or the circuit breaker is active,
1588///   heat is pinned to 100 — both conditions mean "no new risk
1589///   will clear regardless of what any single meter says."
1590/// - Positions (`n/max`) is appended verbatim rather than folded
1591///   into the score so operators can read "pos=2/3" without
1592///   having to invert it into a percent.
1593///
1594/// Styling:
1595/// - Alert (red/bold) when heat >= 80, kill, or breaker. An
1596///   operator glancing at the log should see those hits without
1597///   reading the whole line.
1598/// - Command (neutral) otherwise.
1599async fn heat_cmd(ctx: &DispatchContext) -> DispatchOutput {
1600    let mut out = DispatchOutput::default();
1601    let Some(http) = require_http(ctx, &mut out) else {
1602        return out;
1603    };
1604    match http.risk().await {
1605        Ok(r) => {
1606            let dd = r.drawdown_pct.unwrap_or(0.0);
1607            let daily = r.daily_loss_pct().unwrap_or(0.0);
1608            let score_pct = dd.max(daily).clamp(0.0, 100.0);
1609            let pinned = r.is_halted() || r.capital_floor_hit;
1610            let heat_pct = if pinned { 100.0 } else { score_pct };
1611            let halted = if r.is_halted() { "on" } else { "off" };
1612            let floor = if r.capital_floor_hit { "on" } else { "off" };
1613            let open = r.open_count.map_or("—".into(), |n| n.to_string());
1614            let level = if pinned {
1615                "CRITICAL"
1616            } else if heat_pct >= 80.0 {
1617                "HIGH"
1618            } else if heat_pct >= 50.0 {
1619                "WARM"
1620            } else {
1621                "COOL"
1622            };
1623            let line = format!(
1624                "heat: {level} {heat_pct:.0}%  dd={dd:.1}%  daily-loss={daily:.1}%  \
1625                 open={open}  halted={halted}  floor={floor}"
1626            );
1627            if pinned || heat_pct >= 80.0 {
1628                out.lines.push(OutputLine::alert(line));
1629            } else {
1630                out.lines.push(OutputLine::command(line));
1631            }
1632        }
1633        Err(e) => out.lines.push(OutputLine::alert(format!("heat: {e}"))),
1634    }
1635    out
1636}
1637
1638async fn regime_cmd(ctx: &DispatchContext, coin: Option<&str>) -> DispatchOutput {
1639    let mut out = DispatchOutput::default();
1640    let Some(http) = require_http(ctx, &mut out) else {
1641        return out;
1642    };
1643    let label = coin.unwrap_or("market");
1644    match http.regime(coin).await {
1645        Ok(r) => {
1646            // Some engine builds return a 200 with `{"error": "<msg>"}`
1647            // when a coin lookup misses — a valid envelope but not a
1648            // useful regime. Surface that as a real alert rather than
1649            // rendering a row of em-dashes that looks like data.
1650            if let Some(err) = r.extra.get("error").and_then(|v| v.as_str()) {
1651                out.lines
1652                    .push(OutputLine::alert(format!("regime[{label}]: {err}")));
1653                return out;
1654            }
1655            // Bare-`{}` path: the engine decoded cleanly but had
1656            // nothing to say (older builds expose `/regime` but
1657            // never populate it). An em-dash row here is worse
1658            // than useless — it masquerades as data. Tell the
1659            // operator plainly that the engine has no regime
1660            // reading right now.
1661            if r.regime.is_none() && r.confidence.is_none() {
1662                out.lines.push(OutputLine::alert(format!(
1663                    "regime[{label}]: engine has no regime reading (empty response)"
1664                )));
1665                return out;
1666            }
1667            let name = r.regime.as_deref().unwrap_or("—");
1668            let conf = r.confidence.map_or("—".into(), |v| format!("{v:.2}"));
1669            out.lines.push(OutputLine::command(format!(
1670                "regime[{label}]: {name}  confidence={conf}"
1671            )));
1672        }
1673        Err(e) => out.lines.push(OutputLine::alert(format!("regime: {e}"))),
1674    }
1675    out
1676}
1677
1678async fn evaluate_cmd(
1679    ctx: &DispatchContext,
1680    coin: Option<&str>,
1681    extras: &[String],
1682) -> DispatchOutput {
1683    let mut out = DispatchOutput::default();
1684    // Missing-argument path: resolve to a usage hint rather than
1685    // a silent warn so operators never wonder whether the command
1686    // was accepted. Keeps the picker entry ("/evaluate") and the
1687    // command consistent.
1688    let Some(raw) = coin else {
1689        out.lines.push(OutputLine::warn(
1690            "/evaluate <coin> — name the coin to evaluate (e.g. /evaluate BTC)",
1691        ));
1692        return out;
1693    };
1694    let coin = raw.trim();
1695    if coin.is_empty() {
1696        out.lines.push(OutputLine::warn(
1697            "/evaluate <coin> — name the coin to evaluate (e.g. /evaluate BTC)",
1698        ));
1699        return out;
1700    }
1701
1702    // Surface trailing tokens as a warning before the HTTP call so
1703    // operators who type `/evaluate sol short` (assuming they can
1704    // bias direction) are told plainly that the extras do nothing.
1705    // We still run the evaluate — the coin is unambiguous and
1706    // aborting would be punitive for a harmless typo.
1707    if !extras.is_empty() {
1708        out.lines.push(OutputLine::warn(format!(
1709            "/evaluate takes only a coin — ignoring extra args: {}",
1710            extras.join(" ")
1711        )));
1712    }
1713
1714    let Some(http) = require_http(ctx, &mut out) else {
1715        return out;
1716    };
1717    match http.evaluate(coin).await {
1718        Ok(mut eval) => {
1719            // Engines sometimes omit the coin on the response even
1720            // when we passed it in. Backfill so the overlay header
1721            // is never `?` when we literally just named the coin.
1722            if eval.coin.is_none() {
1723                eval.coin = Some(coin.to_string());
1724            }
1725            // Guard against a degenerate-but-HTTP-200 response:
1726            // no layers AND no direction means the verdict card
1727            // would render its "no verdict — `/evaluate <coin>`
1728            // to request one" placeholder, which is worse than
1729            // useless here — the operator *did* request one, and
1730            // showing the placeholder makes it look like the
1731            // request silently failed. Emit a real alert instead
1732            // and dismiss any prior (stale) overlay so the error
1733            // is visible, not hidden behind an older card.
1734            if eval.layers.is_empty() && eval.direction.is_none() {
1735                out.lines.push(OutputLine::alert(format!(
1736                    "evaluate {coin}: engine returned an empty verdict (no layers, no direction)"
1737                )));
1738                out.dismiss_overlay = true;
1739                return out;
1740            }
1741            out.show_overlay = Some(OverlayTarget::Verdict(Box::new(eval)));
1742            // No lines: the overlay is the output surface. A
1743            // parallel system line would duplicate everything the
1744            // card already renders, and once we ship session
1745            // logging the overlay's payload is what gets recorded.
1746        }
1747        Err(e) => {
1748            out.lines
1749                .push(OutputLine::alert(format!("evaluate {coin}: {e}")));
1750            // Dismiss any stale overlay from a prior `/evaluate`
1751            // so the alert is the visible result, not hidden
1752            // behind an unrelated card that still reads "verdict
1753            // · OTHER_COIN".
1754            out.dismiss_overlay = true;
1755        }
1756    }
1757    out
1758}
1759
1760async fn positions_cmd(ctx: &DispatchContext) -> DispatchOutput {
1761    let mut out = DispatchOutput::default();
1762    let Some(http) = require_http(ctx, &mut out) else {
1763        return out;
1764    };
1765    match http.positions().await {
1766        Ok(p) => {
1767            if p.items.is_empty() {
1768                out.lines
1769                    .push(OutputLine::system("flat — no open positions"));
1770                return out;
1771            }
1772            for pos in &p.items {
1773                let pnl = pos
1774                    .unrealized_pnl
1775                    .map_or_else(|| "—".into(), |v| format!("{v:+.2}"));
1776                out.lines.push(OutputLine::command(format!(
1777                    "{}  {}  size={:.4}  entry={:.2}  pnl={}",
1778                    pos.symbol, pos.side, pos.size, pos.entry, pnl
1779                )));
1780            }
1781        }
1782        Err(e) => out.lines.push(OutputLine::alert(format!("positions: {e}"))),
1783    }
1784    out
1785}
1786
1787async fn pulse_cmd(ctx: &DispatchContext, limit: Option<u32>) -> DispatchOutput {
1788    let mut out = DispatchOutput::default();
1789    let Some(http) = require_http(ctx, &mut out) else {
1790        return out;
1791    };
1792    let n = limit.unwrap_or_else(Command::default_pulse_limit);
1793    match http.pulse(n).await {
1794        Ok(p) => {
1795            if p.items.is_empty() {
1796                out.lines.push(OutputLine::system(
1797                    "(pulse idle — engine has no recent events)",
1798                ));
1799                return out;
1800            }
1801            for ev in &p.items {
1802                let ts = trim_ts(ev.ts.as_deref());
1803                let kind = ev.kind.as_deref().unwrap_or("event");
1804                let coin = ev.coin.as_deref().unwrap_or("—");
1805                let msg = ev.message.as_deref().unwrap_or("(no message)");
1806                let line = format!("{ts}  {kind:<10}  {coin:<6}  {msg}");
1807                // Route severity=warn/alert to the alert lane so the
1808                // TUI palette paints it red; everything else is
1809                // neutral command output.
1810                match ev.severity.as_deref() {
1811                    Some("warn" | "warning") => out.lines.push(OutputLine::warn(line)),
1812                    Some("alert" | "error" | "critical") => {
1813                        out.lines.push(OutputLine::alert(line));
1814                    }
1815                    _ => out.lines.push(OutputLine::command(line)),
1816                }
1817            }
1818        }
1819        Err(e) => out.lines.push(OutputLine::alert(format!("pulse: {e}"))),
1820    }
1821    out
1822}
1823
1824async fn approaching_cmd(ctx: &DispatchContext) -> DispatchOutput {
1825    let mut out = DispatchOutput::default();
1826    let Some(http) = require_http(ctx, &mut out) else {
1827        return out;
1828    };
1829    match http.approaching().await {
1830        Ok(feed) => {
1831            if feed.items.is_empty() {
1832                out.lines.push(OutputLine::system("(nothing approaching)"));
1833                return out;
1834            }
1835            // Sort by ascending distance so the first row is the
1836            // candidate the operator actually has to watch. `None`
1837            // distances sort last — we cannot rank them.
1838            let mut items = feed.items.clone();
1839            items.sort_by(|a, b| match (a.distance_to_gate, b.distance_to_gate) {
1840                (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
1841                (Some(_), None) => std::cmp::Ordering::Less,
1842                (None, Some(_)) => std::cmp::Ordering::Greater,
1843                (None, None) => std::cmp::Ordering::Equal,
1844            });
1845            for a in &items {
1846                let dir = a.direction.as_deref().unwrap_or("—");
1847                let gate = a.gate.as_deref().unwrap_or("—");
1848                let dist = a
1849                    .distance_to_gate
1850                    .map_or_else(|| "—".into(), |d| format!("{d:+.3}"));
1851                out.lines.push(OutputLine::command(format!(
1852                    "{coin:<6}  {dir:<5}  gate={gate:<10}  Δ={dist}",
1853                    coin = a.coin,
1854                )));
1855            }
1856        }
1857        Err(zero_engine_client::HttpError::NotFound { .. }) => {
1858            // Older engine builds don't expose `/approaching`.
1859            // The bare "not found: /approaching" we used to print
1860            // looked like a CLI bug; say plainly that this engine
1861            // just doesn't serve the endpoint so the operator
1862            // knows it's not something they can fix.
1863            out.lines.push(OutputLine::alert(
1864                "approaching: this engine build does not expose /approaching (endpoint missing)",
1865            ));
1866        }
1867        Err(e) => out
1868            .lines
1869            .push(OutputLine::alert(format!("approaching: {e}"))),
1870    }
1871    out
1872}
1873
1874async fn rejections_cmd(
1875    ctx: &DispatchContext,
1876    coin: Option<&str>,
1877    limit: Option<u32>,
1878) -> DispatchOutput {
1879    let mut out = DispatchOutput::default();
1880    let Some(http) = require_http(ctx, &mut out) else {
1881        return out;
1882    };
1883    let n = limit.unwrap_or_else(Command::default_rejections_limit);
1884    match http.rejections(n, coin).await {
1885        Ok(feed) => {
1886            if feed.items.is_empty() {
1887                let scope = coin.map_or_else(
1888                    || "(no rejections)".to_string(),
1889                    |c| format!("(no rejections for {c})"),
1890                );
1891                out.lines.push(OutputLine::system(scope));
1892                return out;
1893            }
1894            for r in &feed.items {
1895                let ts = trim_ts(r.ts.as_deref());
1896                let coin = r.coin.as_deref().unwrap_or("—");
1897                let dir = r.direction.as_deref().unwrap_or("—");
1898                let stage = r.stage.as_deref().unwrap_or("—");
1899                let reason = r.reason.as_deref().unwrap_or("(no reason)");
1900                out.lines.push(OutputLine::command(format!(
1901                    "{ts}  {coin:<6}  {dir:<5}  {stage:<8}  {reason}"
1902                )));
1903            }
1904        }
1905        Err(e) => out
1906            .lines
1907            .push(OutputLine::alert(format!("rejections: {e}"))),
1908    }
1909    out
1910}
1911
1912/// Truncate an ISO-8601 timestamp down to `HH:MM:SS` for compact
1913/// line rendering. Falls back to an em-dash when the engine did
1914/// not supply a timestamp or emitted something we cannot slice
1915/// (e.g. a short relative marker) — an honest placeholder beats
1916/// a silent misread.
1917fn trim_ts(raw: Option<&str>) -> String {
1918    let Some(s) = raw else {
1919        return "—       ".into();
1920    };
1921    // Extract HH:MM:SS if the string looks like `YYYY-MM-DDTHH:MM:SS…`.
1922    if let Some(rest) = s.split_once('T').map(|(_, r)| r) {
1923        let hms: String = rest.chars().take(8).collect();
1924        if hms.len() == 8 {
1925            return hms;
1926        }
1927    }
1928    // Short strings (`now`, `5m ago`, unknown formats) flow
1929    // through as-is — trimming them would mislead.
1930    if s.len() <= 8 {
1931        return format!("{s:<8}");
1932    }
1933    s.to_string()
1934}
1935
1936/// `/kill` — hard stop. Risk-reducer, friction-exempt (see the
1937/// 2 AM suite in `tests/two_am_scenarios.rs`). Posts to the live
1938/// executor when an engine client is attached and preserves the
1939/// compound local-supervisor tear-down behavior.
1940///
1941/// Compound contract: when [`DispatchContext::supervisor`] is
1942/// `Some` *and* the daemon is running, `/kill` tears down the
1943/// listener socket as part of the same call and tags the
1944/// confirmation line so the operator sees both effects in one
1945/// breadcrumb. When no engine client is attached, the command still
1946/// reports that the live kill was not posted instead of pretending
1947/// exchange state changed.
1948async fn kill_cmd(ctx: &DispatchContext) -> DispatchOutput {
1949    let mut lines = match &ctx.http {
1950        Some(http) => match http.post_live_kill().await {
1951            Ok(reply) => render_live_control("/kill", "live kill", &reply),
1952            Err(e) => vec![OutputLine::alert(format!("/kill — engine refused: {e}"))],
1953        },
1954        None => vec![OutputLine::alert(
1955            "/kill — engine client unavailable; live kill not posted.",
1956        )],
1957    };
1958    let Some(sup) = ctx.supervisor.as_ref() else {
1959        return DispatchOutput {
1960            lines,
1961            ..Default::default()
1962        };
1963    };
1964    match sup.tear_down_socket() {
1965        // `tear_down_socket` returns `true` only when the daemon
1966        // was running *and* this call shut it down. `false`
1967        // means the daemon was already stopped — `/kill` does not
1968        // need to tag a non-event.
1969        Ok(true) => lines.push(OutputLine::alert(
1970            "/kill — headless supervisor stopped and operator-local socket torn down.",
1971        )),
1972        Ok(false) => {}
1973        // A tear-down failure is an honesty bug if we silently
1974        // dropped it — the operator pressed `/kill` in part to
1975        // stop the daemon. Surface the error on its own line so
1976        // the primary `/kill` confirmation is not hidden behind a
1977        // multi-sentence alert.
1978        Err(e) => lines.push(OutputLine::alert(format!(
1979            "/kill — headless tear-down failed: {e}. Manual cleanup may be required."
1980        ))),
1981    }
1982    DispatchOutput {
1983        lines,
1984        ..Default::default()
1985    }
1986}
1987
1988async fn flatten_cmd(ctx: &DispatchContext) -> DispatchOutput {
1989    let Some(http) = &ctx.http else {
1990        return single_alert("/flatten-all — engine client unavailable; live flatten not posted.");
1991    };
1992    match http.post_live_flatten().await {
1993        Ok(reply) => DispatchOutput {
1994            lines: render_live_control("/flatten-all", "live flatten", &reply),
1995            ..Default::default()
1996        },
1997        Err(e) => single_alert(format!("/flatten-all — engine refused: {e}")),
1998    }
1999}
2000
2001async fn pause_cmd(ctx: &DispatchContext) -> DispatchOutput {
2002    let Some(http) = &ctx.http else {
2003        return single_alert("/pause-entries — engine client unavailable; live pause not posted.");
2004    };
2005    match http.post_live_pause().await {
2006        Ok(reply) => DispatchOutput {
2007            lines: render_live_control("/pause-entries", "live entries pause", &reply),
2008            ..Default::default()
2009        },
2010        Err(e) => single_alert(format!("/pause-entries — engine refused: {e}")),
2011    }
2012}
2013
2014async fn resume_entries_cmd(ctx: &DispatchContext) -> DispatchOutput {
2015    let Some(http) = &ctx.http else {
2016        return single_alert(
2017            "/resume-entries — engine client unavailable; live resume not posted.",
2018        );
2019    };
2020    match http.post_live_resume().await {
2021        Ok(reply) => DispatchOutput {
2022            lines: render_live_control("/resume-entries", "live entries resume", &reply),
2023            ..Default::default()
2024        },
2025        Err(e) => single_alert(format!("/resume-entries — engine refused: {e}")),
2026    }
2027}
2028
2029fn render_live_control(
2030    command: &str,
2031    action: &str,
2032    reply: &LiveControlResponse,
2033) -> Vec<OutputLine> {
2034    let reason = reply.reason.as_deref().unwrap_or("no reason supplied");
2035    if !reply.ok {
2036        return vec![OutputLine::alert(format!("{command} — refused: {reason}"))];
2037    }
2038    let mut parts = vec![format!("{command} — {action} accepted")];
2039    if let Some(state) = reply.state.as_deref() {
2040        parts.push(format!("state={state}"));
2041    }
2042    if !reply.orders.is_empty() {
2043        parts.push(format!("orders={}", reply.orders.len()));
2044    }
2045    if let Some(operator) = &reply.operator_context
2046        && !operator.handle.is_empty()
2047    {
2048        parts.push(format!("operator={}", operator.handle));
2049    }
2050    vec![OutputLine::alert(parts.join(" "))]
2051}
2052
2053fn execute_stub() -> DispatchOutput {
2054    DispatchOutput {
2055        lines: vec![OutputLine::warn(
2056            "/execute <coin> <buy|sell> <size> — example: /execute BTC buy 0.001",
2057        )],
2058        ..Default::default()
2059    }
2060}
2061
2062async fn execute_cmd(
2063    ctx: &DispatchContext,
2064    coin: Option<&str>,
2065    direction: Option<ExecuteSide>,
2066    quantity: Option<&str>,
2067    error: Option<&str>,
2068) -> DispatchOutput {
2069    if let Some(error) = error {
2070        return single_warn(format!(
2071            "/execute <coin> <buy|sell> <size> — {error} (example: /execute BTC buy 0.001)"
2072        ));
2073    }
2074    let (Some(coin), Some(direction), Some(quantity)) = (
2075        coin,
2076        direction,
2077        quantity.and_then(|value| value.parse::<f64>().ok()),
2078    ) else {
2079        return single_warn("/execute <coin> <buy|sell> <size> — example: /execute BTC buy 0.001");
2080    };
2081    let mut out = DispatchOutput::default();
2082    let Some(http) = require_http(ctx, &mut out) else {
2083        return out;
2084    };
2085    match http.post_execute(coin, direction, quantity).await {
2086        Ok(reply) => {
2087            let rendered_coin = reply.coin.as_deref().unwrap_or(coin);
2088            let rendered_direction = reply.side.unwrap_or(direction).as_wire();
2089            let rendered_quantity = reply.size.unwrap_or(quantity);
2090            let reason = reply.reason.as_deref().unwrap_or("no reason supplied");
2091            let mode_suffix = if reply.simulated {
2092                " (paper)"
2093            } else {
2094                " (live)"
2095            };
2096            let fill = reply.fill_id.as_deref().unwrap_or("none");
2097            let receipt = reply
2098                .extra
2099                .get("receipt_hash")
2100                .and_then(serde_json::Value::as_str)
2101                .map(short_hash);
2102            if reply.accepted {
2103                let mut parts = vec![format!(
2104                    "/execute — accepted{mode_suffix} {rendered_coin} {rendered_direction} {rendered_quantity} fill={fill}"
2105                )];
2106                if let Some(receipt) = receipt {
2107                    parts.push(format!("receipt={receipt}"));
2108                }
2109                out.lines.push(OutputLine::alert(parts.join(" ")));
2110            } else {
2111                let mut parts = vec![format!(
2112                    "/execute — refused{mode_suffix} {rendered_coin} {rendered_direction} {rendered_quantity}: {reason}"
2113                )];
2114                if let Some(receipt) = receipt {
2115                    parts.push(format!("receipt={receipt}"));
2116                }
2117                out.lines.push(OutputLine::alert(parts.join(" ")));
2118            }
2119        }
2120        Err(e) => out
2121            .lines
2122            .push(OutputLine::alert(format!("/execute — engine refused: {e}"))),
2123    }
2124    out
2125}
2126
2127fn short_hash(hash: &str) -> String {
2128    if let Some(rest) = hash.strip_prefix("sha256:")
2129        && rest.len() >= 12
2130    {
2131        return format!("sha256:{}...", &rest[..12]);
2132    }
2133    hash.to_string()
2134}
2135
2136/// `/auto on|off|status|<unknown>|<missing>` — toggle the
2137/// engine's Auto-mode switch via [`AutoSource`].
2138///
2139/// Reached only after the friction ladder has had its say — the
2140/// `on` action is risk-increasing and therefore gated exactly
2141/// like `/execute`, while `off` / `status` are Neutral and arrive
2142/// here unconditionally (see `Command::risk`).
2143///
2144/// When no [`AutoSource`] is attached the dispatcher surfaces a
2145/// single "unavailable" alert — identical honesty policy to
2146/// `/config`.
2147fn auto_cmd(ctx: &DispatchContext, action: &AutoAction) -> DispatchOutput {
2148    let request = match action {
2149        AutoAction::On => AutoRequest::On,
2150        AutoAction::Off => AutoRequest::Off,
2151        AutoAction::Status => AutoRequest::Status,
2152        AutoAction::Missing => {
2153            return single_system(
2154                "/auto — usage: /auto on | off | status. `on` is risk-increasing and friction-gated.",
2155            );
2156        }
2157        AutoAction::Unknown(tok) => {
2158            return single_warn(format!(
2159                "/auto — unknown action '{tok}'. usage: /auto on | off | status."
2160            ));
2161        }
2162    };
2163    let Some(source) = ctx.auto.as_ref() else {
2164        return single_alert(
2165            "/auto — unavailable (no engine auto-mode adapter on this invocation).",
2166        );
2167    };
2168    match source.act(request) {
2169        Ok(reply) => {
2170            let mode = reply.mode.as_str();
2171            let line = match (action, reply.changed) {
2172                // Status is read-only; keep the copy neutral.
2173                (AutoAction::Status, _) => format!("/auto status — mode={mode}"),
2174                (AutoAction::On | AutoAction::Off, true) => {
2175                    format!("/auto — mode={mode} (changed)")
2176                }
2177                (AutoAction::On | AutoAction::Off, false) => {
2178                    // Idempotent flip — same mode as before. No
2179                    // "changed" tag; silent acceptance here would be
2180                    // wrong (operator asked for a change) but a
2181                    // warn line beats an alert since nothing is
2182                    // broken.
2183                    return single_warn(format!("/auto — mode already {mode}; no change."));
2184                }
2185                (AutoAction::Missing | AutoAction::Unknown(_), _) => {
2186                    unreachable!("/auto missing/unknown resolve before reaching the source adapter",)
2187                }
2188            };
2189            DispatchOutput {
2190                lines: vec![OutputLine::command(line)],
2191                ..Default::default()
2192            }
2193        }
2194        Err(e) => single_alert(format!("/auto — {e}")),
2195    }
2196}
2197
2198/// `/headless start|stop|status|<unknown>|<missing>` — daemon
2199/// lifecycle surface.
2200///
2201/// Dispatches through [`SupervisorSource`]; when no adapter is
2202/// attached, surfaces a single "unavailable" alert. All three
2203/// verbs are Neutral (see `Command::risk`) — this handler is
2204/// reached without friction.
2205fn headless_cmd(ctx: &DispatchContext, action: &HeadlessAction) -> DispatchOutput {
2206    let request = match action {
2207        HeadlessAction::Start => SupervisorAction::Start,
2208        HeadlessAction::Stop => SupervisorAction::Stop,
2209        HeadlessAction::Status => SupervisorAction::Status,
2210        HeadlessAction::Missing => {
2211            return single_system(
2212                "/headless — usage: /headless start | stop | status. The daemon is the operator-local supervisor (ADR-006).",
2213            );
2214        }
2215        HeadlessAction::Unknown(tok) => {
2216            return single_warn(format!(
2217                "/headless — unknown action '{tok}'. usage: /headless start | stop | status."
2218            ));
2219        }
2220    };
2221    let Some(source) = ctx.supervisor.as_ref() else {
2222        return single_alert(
2223            "/headless — supervisor unavailable (no headless adapter on this invocation).",
2224        );
2225    };
2226    match source.act(request) {
2227        Ok(reply) => {
2228            let line = format_headless_reply(action, &reply);
2229            DispatchOutput {
2230                lines: vec![OutputLine::command(line)],
2231                ..Default::default()
2232            }
2233        }
2234        // A `Refused` reply is a warn, not an alert — the call
2235        // was understood, just not honored (e.g. stop while
2236        // already stopping). Everything else is an alert.
2237        Err(SupervisorError::Refused(msg)) => single_warn(format!("/headless — refused: {msg}")),
2238        Err(e) => single_alert(format!("/headless — {e}")),
2239    }
2240}
2241
2242fn format_headless_reply(action: &HeadlessAction, reply: &SupervisorReply) -> String {
2243    use crate::supervisor::SupervisorState;
2244    let state = match &reply.state {
2245        SupervisorState::Running => "running",
2246        SupervisorState::Stopped => "stopped",
2247        SupervisorState::Failed(reason) => {
2248            return format!("/headless {} — failed: {reason}", headless_verb(action),);
2249        }
2250    };
2251    let changed = if reply.changed { " (changed)" } else { "" };
2252    let socket = reply
2253        .socket
2254        .as_deref()
2255        .map(|s| format!(" socket={s}"))
2256        .unwrap_or_default();
2257    let pid = reply.pid.map(|p| format!(" pid={p}")).unwrap_or_default();
2258    let uptime = reply
2259        .uptime
2260        .map(|d| format!(" uptime={}s", d.as_secs()))
2261        .unwrap_or_default();
2262    format!(
2263        "/headless {} — state={state}{changed}{socket}{pid}{uptime}",
2264        headless_verb(action),
2265    )
2266}
2267
2268const fn headless_verb(action: &HeadlessAction) -> &'static str {
2269    match action {
2270        HeadlessAction::Start => "start",
2271        HeadlessAction::Stop => "stop",
2272        HeadlessAction::Status => "status",
2273        HeadlessAction::Missing | HeadlessAction::Unknown(_) => "(usage)",
2274    }
2275}
2276
2277/// `/sessions [limit]` — paint a newest-first list of sessions.
2278///
2279/// We clamp the caller's limit into `[1, max_sessions_limit]` so a
2280/// stray `/sessions 100000` cannot push the prompt off-screen on a
2281/// terminal with thousands of historical rows. Persistence-disabled
2282/// and IO-failure paths emit single-line alerts rather than leaving
2283/// the operator guessing — the same policy the engine-fetch
2284/// commands follow.
2285fn sessions_cmd(ctx: &DispatchContext, limit: Option<u32>) -> DispatchOutput {
2286    let Some(sessions) = ctx.sessions.as_ref() else {
2287        return single_alert("/sessions — persistence disabled (no session store).");
2288    };
2289    let effective = limit
2290        .unwrap_or_else(Command::default_sessions_limit)
2291        .clamp(1, Command::max_sessions_limit());
2292    let rows = match sessions.list(effective) {
2293        Ok(rows) => rows,
2294        Err(e) => return single_alert(format!("/sessions — {e}")),
2295    };
2296    if rows.is_empty() {
2297        return DispatchOutput {
2298            lines: vec![OutputLine::system(
2299                "/sessions — no prior sessions on record.",
2300            )],
2301            ..Default::default()
2302        };
2303    }
2304    let current = sessions.current_ulid();
2305    let mut lines = Vec::with_capacity(rows.len() + 1);
2306    lines.push(OutputLine::command(format!(
2307        "/sessions — {n} recent session(s)",
2308        n = rows.len()
2309    )));
2310    for row in rows {
2311        let marker = if Some(&row.ulid) == current.as_ref() {
2312            "*"
2313        } else {
2314            " "
2315        };
2316        let started = format_ms_short(row.started_at_ms);
2317        let state = if row.ended_at_ms.is_some() {
2318            "ended"
2319        } else {
2320            "live/interrupted"
2321        };
2322        let parent = row
2323            .parent_ulid
2324            .as_deref()
2325            .map(|p| format!(" parent:{p}"))
2326            .unwrap_or_default();
2327        let events = if row.n_events >= 0 {
2328            format!(" {n} evt", n = row.n_events)
2329        } else {
2330            String::new()
2331        };
2332        lines.push(OutputLine::system(format!(
2333            "{marker} {ulid} · {started} · {state}{events}{parent}",
2334            ulid = row.ulid,
2335        )));
2336    }
2337    DispatchOutput {
2338        lines,
2339        ..Default::default()
2340    }
2341}
2342
2343/// `/resume <ulid|label>` — replay a prior session into the log
2344/// **silently** (without re-persisting). The split between
2345/// `lines` and `replay_lines` is what keeps that invariant
2346/// honest: the "resuming …" banner is a new recorded line; every
2347/// rehydrated row goes onto `replay_lines` and `AppState` appends
2348/// those without writing to the current session's events table.
2349///
2350/// Missing argument is surfaced as a usage hint rather than
2351/// silently doing nothing — matches the `/evaluate` convention so
2352/// picker + help paths stay uniform.
2353fn resume_cmd(ctx: &DispatchContext, needle: Option<&str>) -> DispatchOutput {
2354    fetch_and_paint_session(ctx, needle, SessionVerb::Resume)
2355}
2356
2357/// `/replay <ulid|label>` — identical to `/resume` from the
2358/// dispatcher's perspective: look up the session, paint a banner,
2359/// emit `replay_lines`. The semantic difference — that `/replay`
2360/// does **not** switch the active session — lives in the caller
2361/// (the TUI): today the dispatcher never rotates the `sessions`
2362/// adapter's active ulid, so both commands are non-destructive
2363/// at this layer. The split is meaningful because the operator
2364/// model calls for distinct language ("resume this session" vs.
2365/// "replay this session"), and keeping the commands separate
2366/// now means we don't rename later if a `/resume` variant grows
2367/// a session-switch hook (it will, once we expose
2368/// `SessionSource::switch_to` for cross-session hopping).
2369fn replay_cmd(ctx: &DispatchContext, needle: Option<&str>) -> DispatchOutput {
2370    fetch_and_paint_session(ctx, needle, SessionVerb::Replay)
2371}
2372
2373/// Which verb is driving this call. Keeps every user-visible
2374/// string keyed to the command name so `/resume` never leaks
2375/// "replay" wording and vice versa — operator-facing copy that
2376/// drifts from the command invoked is a small honesty debt that
2377/// compounds quickly.
2378#[derive(Debug, Clone, Copy)]
2379enum SessionVerb {
2380    Resume,
2381    Replay,
2382}
2383
2384impl SessionVerb {
2385    const fn name(self) -> &'static str {
2386        match self {
2387            Self::Resume => "/resume",
2388            Self::Replay => "/replay",
2389        }
2390    }
2391
2392    const fn banner_prefix(self) -> &'static str {
2393        match self {
2394            Self::Resume => "resuming",
2395            Self::Replay => "replaying",
2396        }
2397    }
2398}
2399
2400fn fetch_and_paint_session(
2401    ctx: &DispatchContext,
2402    needle: Option<&str>,
2403    verb: SessionVerb,
2404) -> DispatchOutput {
2405    let name = verb.name();
2406    let Some(sessions) = ctx.sessions.as_ref() else {
2407        return single_alert(format!("{name} — persistence disabled (no session store)."));
2408    };
2409    let Some(needle) = needle else {
2410        return DispatchOutput {
2411            lines: vec![OutputLine::system(format!(
2412                "{name} <ulid|label> — try /sessions for a list of ids."
2413            ))],
2414            ..Default::default()
2415        };
2416    };
2417    let summary = match sessions.find(needle) {
2418        Ok(s) => s,
2419        Err(crate::session::SessionError::NotFound) => {
2420            return single_alert(format!(
2421                "{name} — no session matches '{needle}'. Try /sessions."
2422            ));
2423        }
2424        Err(e) => return single_alert(format!("{name} — {e}")),
2425    };
2426    // Cap at 200 to match the launch-time replay policy in
2427    // `main.rs::open_session_store`; a dramatically larger cap
2428    // would change the shape of the conversation pane on replay
2429    // and operators should see a consistent on-load experience
2430    // whether resumption is implicit (startup) or explicit here.
2431    let events = match sessions.list_events(&summary.ulid, 200) {
2432        Ok(e) => e,
2433        Err(e) => return single_alert(format!("{name} — {e}")),
2434    };
2435    let banner = format!(
2436        "{prefix} {ulid} · {started} · {n} event(s)",
2437        prefix = verb.banner_prefix(),
2438        ulid = summary.ulid,
2439        started = format_ms_short(summary.started_at_ms),
2440        n = events.len(),
2441    );
2442    let replay_lines: Vec<ReplayLine> = events
2443        .into_iter()
2444        .map(|e| ReplayLine {
2445            kind: e.kind,
2446            at_ms: e.at_ms,
2447            text: e.text,
2448        })
2449        .collect();
2450    DispatchOutput {
2451        lines: vec![OutputLine::command(banner)],
2452        replay_lines,
2453        ..Default::default()
2454    }
2455}
2456
2457/// `/fork` — start a new session with `parent_ulid = current`.
2458///
2459/// The session store is the authority for the new ulid; the
2460/// dispatcher only echoes what it got back. If the store reports
2461/// no current session (persistence disabled, or an impossibly
2462/// early invocation), we surface that honestly — forking off
2463/// nothing is a no-op and operators deserve to know.
2464fn fork_cmd(ctx: &DispatchContext) -> DispatchOutput {
2465    let Some(sessions) = ctx.sessions.as_ref() else {
2466        return single_alert("/fork — persistence disabled (no session store).");
2467    };
2468    match sessions.fork_from_current() {
2469        Ok(Some(child)) => DispatchOutput {
2470            lines: vec![OutputLine::command(format!(
2471                "/fork — new session {child}; parent carries over."
2472            ))],
2473            ..Default::default()
2474        },
2475        Ok(None) => single_alert("/fork — no current session to fork from."),
2476        Err(e) => single_alert(format!("/fork — {e}")),
2477    }
2478}
2479
2480/// `/save <label>` — attach a human label to the current session.
2481///
2482/// We resolve the current ulid up front (rather than letting the
2483/// store do it) so the saved line can echo "saved X → <ulid>",
2484/// giving the operator a confirming readout instead of a silent
2485/// success. A missing label prints a usage hint.
2486fn save_cmd(ctx: &DispatchContext, label: Option<&str>) -> DispatchOutput {
2487    let Some(sessions) = ctx.sessions.as_ref() else {
2488        return single_alert("/save — persistence disabled (no session store).");
2489    };
2490    let Some(label) = label else {
2491        return DispatchOutput {
2492            lines: vec![OutputLine::system(
2493                "/save <label> — pick a short name you'll recognise later.",
2494            )],
2495            ..Default::default()
2496        };
2497    };
2498    let Some(ulid) = sessions.current_ulid() else {
2499        return single_alert("/save — no active session to label.");
2500    };
2501    match sessions.save_label(&ulid, label) {
2502        Ok(()) => DispatchOutput {
2503            lines: vec![OutputLine::command(format!("/save — '{label}' → {ulid}"))],
2504            ..Default::default()
2505        },
2506        Err(e) => single_alert(format!("/save — {e}")),
2507    }
2508}
2509
2510/// `/share [ulid|label]` — render a session snapshot as a JSON
2511/// block inside the conversation log.
2512///
2513/// This is the minimal viable share primitive: the snapshot lives
2514/// in the pane the operator is already looking at, so they can
2515/// select-and-copy without a clipboard-API dep or a filesystem
2516/// policy. Writing to a file (and its twin concerns — default
2517/// paths, overwrite rules, mode-640 vs. 644) is a follow-up.
2518///
2519/// When `needle` is omitted we share the current session. That
2520/// makes the common case ("capture what just happened") a single
2521/// keystroke. Missing session store or missing needle both
2522/// surface honest alerts — no empty JSON, no partial success.
2523fn share_cmd(ctx: &DispatchContext, needle: Option<&str>) -> DispatchOutput {
2524    let Some(sessions) = ctx.sessions.as_ref() else {
2525        return single_alert("/share — persistence disabled (no session store).");
2526    };
2527    // Resolve the target: explicit needle wins, else current.
2528    let target = needle
2529        .map(ToOwned::to_owned)
2530        .or_else(|| sessions.current_ulid());
2531    let Some(needle) = target else {
2532        return single_alert("/share — no active session and no ulid/label given. Try /sessions.");
2533    };
2534    let summary = match sessions.find(&needle) {
2535        Ok(s) => s,
2536        Err(crate::session::SessionError::NotFound) => {
2537            return single_alert(format!(
2538                "/share — no session matches '{needle}'. Try /sessions."
2539            ));
2540        }
2541        Err(e) => return single_alert(format!("/share — {e}")),
2542    };
2543    let events = match sessions.list_events(&summary.ulid, 1000) {
2544        Ok(e) => e,
2545        Err(e) => return single_alert(format!("/share — {e}")),
2546    };
2547    let n = events.len();
2548    let json = render_share_json(&summary, &events);
2549    // Two lines: a one-line header so the operator can see
2550    // at-a-glance what's being shared, and the fenced JSON
2551    // block. Keeping them separate means `OutputLine::Command`
2552    // formatting (fixed-width) applies to the header and the
2553    // body renders with embedded newlines the TUI preserves.
2554    DispatchOutput {
2555        lines: vec![
2556            OutputLine::command(format!(
2557                "/share — {ulid} · {n} event(s) · copy the block below",
2558                ulid = summary.ulid,
2559            )),
2560            OutputLine::system(json),
2561        ],
2562        ..Default::default()
2563    }
2564}
2565
2566/// `/config` dispatcher. Pure routing over [`ConfigSource`];
2567/// the adapter (`zero/src/main.rs` in production) owns the
2568/// actual TOML + keychain reads, so the command crate can stay
2569/// filesystem-free and deterministic under tests.
2570///
2571/// Every outcome resolves to at least one line: missing
2572/// source, unknown action, missing action, and successful
2573/// readouts all emit a `DispatchOutput` with concrete lines so
2574/// the operator never sees a silent success.
2575fn config_cmd(ctx: &DispatchContext, action: &ConfigAction) -> DispatchOutput {
2576    match action {
2577        ConfigAction::Missing => single_warn(
2578            "/config <show|doctor> — show resolved values or diagnose config + secrets.",
2579        ),
2580        ConfigAction::Unknown(other) => single_warn(format!(
2581            "/config: unknown action '{other}'. Try /config show or /config doctor."
2582        )),
2583        ConfigAction::Show => {
2584            let Some(source) = ctx.config.as_ref() else {
2585                return single_alert("/config — config introspection unavailable.");
2586            };
2587            let rows = source.show();
2588            if rows.is_empty() {
2589                // Empty-state stays honest: the adapter said
2590                // "nothing to show" (fresh install, no config
2591                // on disk) so we mirror that rather than
2592                // pretending we printed something.
2593                return DispatchOutput {
2594                    lines: vec![OutputLine::system(
2595                        "/config show — no config loaded. Run `zero init`.",
2596                    )],
2597                    ..Default::default()
2598                };
2599            }
2600            let mut out = DispatchOutput::default();
2601            out.lines.push(OutputLine::command(format!(
2602                "/config show — {n} field(s)",
2603                n = rows.len()
2604            )));
2605            let label_width = rows.iter().map(|r| r.label.len()).max().unwrap_or(0);
2606            for row in rows {
2607                // Right-pad the label so columns line up —
2608                // cheaper than a table widget and survives
2609                // word-wrap in narrow terminals.
2610                out.lines.push(OutputLine::system(format!(
2611                    "  {label:<width$}  {value}",
2612                    label = row.label,
2613                    width = label_width,
2614                    value = row.value,
2615                )));
2616            }
2617            out
2618        }
2619        ConfigAction::Doctor => {
2620            let Some(source) = ctx.config.as_ref() else {
2621                return single_alert("/config — config introspection unavailable.");
2622            };
2623            let findings = source.doctor();
2624            if findings.is_empty() {
2625                // No findings at all is suspect — typically a
2626                // misconfigured adapter. Surface as System so
2627                // operators can tell the command ran but had
2628                // nothing to report.
2629                return DispatchOutput {
2630                    lines: vec![OutputLine::system(
2631                        "/config doctor — no findings (adapter returned empty list).",
2632                    )],
2633                    ..Default::default()
2634                };
2635            }
2636            let mut out = DispatchOutput::default();
2637            let n_err = findings
2638                .iter()
2639                .filter(|f| matches!(f.severity, DoctorSeverity::Error))
2640                .count();
2641            let n_warn = findings
2642                .iter()
2643                .filter(|f| matches!(f.severity, DoctorSeverity::Warn))
2644                .count();
2645            let header = format!(
2646                "/config doctor — {total} check(s)  errors={n_err}  warnings={n_warn}",
2647                total = findings.len(),
2648            );
2649            // Header promotes to Alert when any error is
2650            // present so operators glancing at the log see
2651            // failure immediately; Warn otherwise downgrades
2652            // to Command so a clean run is not visually
2653            // indistinguishable from an alerting one.
2654            if n_err > 0 {
2655                out.lines.push(OutputLine::alert(header));
2656            } else {
2657                out.lines.push(OutputLine::command(header));
2658            }
2659            for f in findings {
2660                let prefix = match f.severity {
2661                    DoctorSeverity::Ok => "  ok    ",
2662                    DoctorSeverity::Warn => "  warn  ",
2663                    DoctorSeverity::Error => "  ERROR ",
2664                };
2665                for emitted in wrap_doctor_row(prefix, &f.message, f.severity) {
2666                    out.lines.push(emitted);
2667                }
2668            }
2669            out
2670        }
2671    }
2672}
2673
2674/// Width budget used when wrapping doctor findings. Chosen as
2675/// the narrow-end standard terminal (80 cols) minus the 8-col
2676/// prefix slot (`"  ok    "` / `"  warn  "` / `"  ERROR "`).
2677/// Rows that fit under this budget render as a single line,
2678/// identical to the pre-wrap behavior.
2679///
2680/// Rationale for the specific number: the original failure mode
2681/// (screenshotted 2026-04-22) was the ERROR row `engine token
2682/// unset — pass --token, set ZERO_API_TOKEN, or run \`zero
2683/// init --force\`` — about 82 characters with prefix — getting
2684/// clipped at the backtick by ratatui's single-row
2685/// `Line::render`. A body budget of 70 cols wraps that string
2686/// onto a second line and preserves the remediation hint in
2687/// full.
2688const DOCTOR_ROW_WRAP_BODY_COLS: usize = 70;
2689
2690/// The fixed-width prefix column for doctor rows. All three
2691/// severities produce 8-character prefixes by construction:
2692/// `"  ok    "`, `"  warn  "`, `"  ERROR "`. Continuation lines
2693/// of a wrapped row indent by this many spaces so wrapped body
2694/// text aligns vertically with the first-line body. The
2695/// `debug_assert_eq!` in `wrap_doctor_row` pins the invariant
2696/// so a future prefix-copy change cannot silently break
2697/// alignment.
2698const DOCTOR_ROW_PREFIX_COLS: usize = 8;
2699
2700/// Wrap a doctor finding's message into one or more output lines
2701/// so the rightmost characters don't get eaten by terminal
2702/// clipping. Continuation lines use the same [`OutputLine`]
2703/// kind as the first so a wrapped ERROR reads as one semantic
2704/// unit (all-alert-styled) rather than fragmenting into
2705/// mixed-color chunks.
2706///
2707/// Wrapping is word-based via `str::split_whitespace`; a single
2708/// token longer than the body budget (e.g. a URL) is emitted on
2709/// its own line with the continuation indent, un-broken —
2710/// breaking a URL mid-character would lose information that the
2711/// operator may need to paste into a browser.
2712fn wrap_doctor_row(prefix: &str, message: &str, severity: DoctorSeverity) -> Vec<OutputLine> {
2713    debug_assert_eq!(
2714        prefix.len(),
2715        DOCTOR_ROW_PREFIX_COLS,
2716        "doctor row prefix must be exactly {DOCTOR_ROW_PREFIX_COLS} cols for continuation alignment"
2717    );
2718
2719    let make_line = |text: String| match severity {
2720        DoctorSeverity::Ok => OutputLine::system(text),
2721        DoctorSeverity::Warn => OutputLine::warn(text),
2722        DoctorSeverity::Error => OutputLine::alert(text),
2723    };
2724    let continuation_indent = " ".repeat(DOCTOR_ROW_PREFIX_COLS);
2725
2726    // Fast path: the full row fits in the budget. Emit as-is so
2727    // the common case (short `ok` rows) doesn't pay for the
2728    // wrapping dance.
2729    if message.chars().count() <= DOCTOR_ROW_WRAP_BODY_COLS {
2730        return vec![make_line(format!("{prefix}{message}"))];
2731    }
2732
2733    let mut lines = Vec::new();
2734    let mut current = String::with_capacity(DOCTOR_ROW_WRAP_BODY_COLS);
2735    let mut is_first = true;
2736
2737    for word in message.split_whitespace() {
2738        let word_len = word.chars().count();
2739        let current_len = current.chars().count();
2740        let needs_space = !current.is_empty();
2741        let prospective = current_len + usize::from(needs_space) + word_len;
2742
2743        if prospective > DOCTOR_ROW_WRAP_BODY_COLS && !current.is_empty() {
2744            let pfx = if is_first {
2745                prefix.to_owned()
2746            } else {
2747                continuation_indent.clone()
2748            };
2749            lines.push(make_line(format!("{pfx}{current}")));
2750            is_first = false;
2751            current.clear();
2752        }
2753
2754        if !current.is_empty() {
2755            current.push(' ');
2756        }
2757        current.push_str(word);
2758    }
2759
2760    if !current.is_empty() {
2761        let pfx = if is_first {
2762            prefix.to_owned()
2763        } else {
2764            continuation_indent
2765        };
2766        lines.push(make_line(format!("{pfx}{current}")));
2767    }
2768
2769    lines
2770}
2771
2772/// `/verbose` dispatcher.
2773///
2774/// Resolves `Toggle` against `ctx.verbose` so the
2775/// `DispatchOutput.verbose_toggle` channel always carries an
2776/// absolute target — the TUI never has to re-implement toggle
2777/// semantics on top of whatever the operator typed.
2778///
2779/// A no-op transition (e.g. `/verbose on` when already on) is
2780/// deliberately kept: we still emit the confirmation line so
2781/// the operator sees that the command landed.
2782fn verbose_cmd(ctx: &DispatchContext, action: &VerboseAction) -> DispatchOutput {
2783    let target = match action {
2784        VerboseAction::On => true,
2785        VerboseAction::Off => false,
2786        VerboseAction::Toggle => !ctx.verbose,
2787        VerboseAction::Unknown(other) => {
2788            return single_warn(format!(
2789                "/verbose — unknown '{other}'. Use on|off|toggle (or no argument to toggle)."
2790            ));
2791        }
2792    };
2793    let word = if target { "on" } else { "off" };
2794    DispatchOutput {
2795        lines: vec![OutputLine::system(format!("verbose {word}"))],
2796        verbose_toggle: Some(target),
2797        ..Default::default()
2798    }
2799}
2800
2801// ── Addendum A cohort ───────────────────────────────────────────
2802//
2803// Every handler below is a stub in the honest sense: the local
2804// acknowledgement / journaling is real, and the slot for the
2805// future engine-side wiring (`POST /operator/events`, positions
2806// model, disclosure store) is a single clearly-labelled line so
2807// a future contributor knows exactly where to solder in the real
2808// backend without changing the contract. We do NOT pretend to
2809// have done something when we have not — a silent success here
2810// would be the worst kind of failure for the operator.
2811
2812/// `/state-override <label>` — operator self-declared state.
2813/// Risk-increasing; the friction ladder has already gated this
2814/// by the time the handler runs (see `dispatch::run`'s
2815/// decision branch). Emits a single `Command` line in the
2816/// conversation pane naming the declared label so the override
2817/// is visible in the audit trail. The POST to
2818/// `/operator/events` will land once the engine endpoint
2819/// ships (see ADR-016 + ADDENDUM_A §2.1 table row 1) — a
2820/// placeholder comment is kept below rather than a silent
2821/// todo because the operator must not infer the claim already
2822/// reached the engine.
2823fn state_override_cmd(label: Option<StateOverrideLabel>) -> DispatchOutput {
2824    let Some(label) = label else {
2825        // Missing / unrecognized label — honest usage hint.
2826        // Listing the full set inline avoids operators having
2827        // to bounce through `/help`.
2828        return single_warn(
2829            "/state-override <label> — one of FRESH | STEADY | ELEVATED | TILT | FATIGUED | RECOVERY",
2830        );
2831    };
2832    DispatchOutput {
2833        lines: vec![OutputLine::command(format!(
2834            "/state-override — label declared: {name}  (engine POST /operator/events pending)",
2835            name = label.as_str(),
2836        ))],
2837        ..Default::default()
2838    }
2839}
2840
2841/// `/continue` — acknowledge the most recent coaching notice
2842/// and resume. Today's coaching buffer is not wired (no engine
2843/// coaching stream has landed) so the handler surfaces a
2844/// pending-infrastructure line rather than pretending to
2845/// dismiss something that was not there. Risk-neutral.
2846fn continue_cmd() -> DispatchOutput {
2847    DispatchOutput {
2848        lines: vec![OutputLine::system(
2849            "/continue — acknowledged  (coaching buffer pending; no notices queued right now)",
2850        )],
2851        ..Default::default()
2852    }
2853}
2854
2855/// `/close [coin]` — per-coin position close. Risk-reducing;
2856/// the friction-asymmetry invariant keeps this friction-exempt
2857/// at every label. Until the positions-model + engine POST
2858/// path land the handler reports the would-be effect and
2859/// clearly tags it as pending, following the same pattern as
2860/// `/execute` and `/kill`. A bare `/close` without a coin
2861/// surfaces a usage hint so the operator is never left
2862/// wondering whether a "close everything" (which is
2863/// `/flatten-all`) just happened.
2864fn close_cmd(coin: Option<&str>) -> DispatchOutput {
2865    let Some(raw) = coin else {
2866        return DispatchOutput {
2867            lines: vec![OutputLine::warn(
2868                "/close <coin> — name the coin (try /pos to see open symbols; /flatten-all closes all)",
2869            )],
2870            ..Default::default()
2871        };
2872    };
2873    let coin = raw.trim();
2874    if coin.is_empty() {
2875        return DispatchOutput {
2876            lines: vec![OutputLine::warn(
2877                "/close <coin> — name the coin (try /pos to see open symbols; /flatten-all closes all)",
2878            )],
2879            ..Default::default()
2880        };
2881    }
2882    DispatchOutput {
2883        lines: vec![OutputLine::system(format!(
2884            "/close {coin} — noted  (positions model + engine POST pending; no order was placed)"
2885        ))],
2886        ..Default::default()
2887    }
2888}
2889
2890/// `/wrap-off` — opt out of the daily-wrap generator for
2891/// *this session only*. The TUI honors the flag on session
2892/// exit; the dispatcher resolves an absolute `true` target so
2893/// downstream state assignment is trivial. A second
2894/// invocation is kept idempotent (still emits "already off"
2895/// to confirm the command landed) because silence would
2896/// make a follow-up `/wrap-off` look broken.
2897fn wrap_off_cmd() -> DispatchOutput {
2898    let body =
2899        "/wrap-off — daily wrap skipped for this session  (next session runs the wrap again)";
2900    DispatchOutput {
2901        lines: vec![OutputLine::system(body)],
2902        wrap_off_toggle: Some(true),
2903        ..Default::default()
2904    }
2905}
2906
2907/// `/coaching reset` — clear the rolling coaching notice
2908/// buffer. Emits the `coaching_reset` signal; the TUI wires
2909/// it to `AppState::clear_coaching_buffer` once the buffer
2910/// ships. Today the buffer does not exist, so the signal is
2911/// a noop on the receiving side — we still emit it here so
2912/// the contract is stable and the future TUI change is a
2913/// one-line addition.
2914fn coaching_reset_cmd() -> DispatchOutput {
2915    DispatchOutput {
2916        lines: vec![OutputLine::system(
2917            "/coaching reset — buffer cleared  (coaching stream pending; nothing was queued)",
2918        )],
2919        coaching_reset: true,
2920        ..Default::default()
2921    }
2922}
2923
2924/// `/disclosure-override --i-know-what-i-am-doing` — defeat
2925/// progressive disclosure. Risk-increasing; the friction
2926/// ladder has already gated this before the handler runs.
2927///
2928/// We additionally require the exact confirm phrase inside
2929/// the handler so an operator at STEADY (where the ladder
2930/// would Proceed) cannot bypass the guard by typing a bare
2931/// `/disclosure-override`. This is a hard, handler-level
2932/// guard separate from friction — the intent of the phrase
2933/// is operator acknowledgement, not rate-limiting.
2934fn disclosure_override_cmd(confirmed: bool) -> DispatchOutput {
2935    if !confirmed {
2936        let phrase = DISCLOSURE_OVERRIDE_CONFIRM;
2937        return DispatchOutput {
2938            lines: vec![OutputLine::alert(format!(
2939                "/disclosure-override — phrase required: `/disclosure-override {phrase}`",
2940            ))],
2941            ..Default::default()
2942        };
2943    }
2944    DispatchOutput {
2945        lines: vec![OutputLine::command(
2946            "/disclosure-override — progressive disclosure bypassed for this session  (disclosure store pending; no milestone was written)",
2947        )],
2948        ..Default::default()
2949    }
2950}
2951
2952/// `/rate <trade_id> <1..=10>` — attach a conviction rating
2953/// to a past trade. M1_PLAN §7a line 119 + Addendum A §10.
2954///
2955/// Argument-shape guard comes first: a missing `trade_id`,
2956/// a missing `rating`, or a `rating` outside `1..=10`
2957/// surfaces a usage hint naming the full range — silently
2958/// accepting out-of-range values would launder a typo into a
2959/// recorded conviction. The parser already filters non-u8
2960/// tokens, so by the time we get here `rating == Some(n)`
2961/// means `n ∈ 1..=10`; the bound re-check is a defence-in-
2962/// depth against future parser refactors (ADR-017 honesty
2963/// bar: handler must not assume upstream invariants).
2964///
2965/// On the happy path we emit a single `Command` line
2966/// acknowledging the local record and clearly tagging the
2967/// engine POST as pending. The classifier-side wiring —
2968/// feeding an `EventKind::Conviction` into the local
2969/// operator-state event stream so replay reconstructs the
2970/// rating deterministically — attaches to the same sink the
2971/// engine writes land on when ADR-016's `POST /operator/events`
2972/// ships. Today that sink is not exposed through
2973/// `DispatchContext` (no write-side trait alongside
2974/// `StateSource`), so the rating lives in the conversation log
2975/// alone. Adding the sink is a one-trait addition on the same
2976/// ADR; the honest thing to do now is show the operator exactly
2977/// where the rating ended up.
2978async fn rate_cmd(
2979    ctx: &DispatchContext,
2980    trade_id: Option<&str>,
2981    rating: Option<u8>,
2982) -> DispatchOutput {
2983    use chrono::Utc;
2984    use zero_operator_state::{Event, EventKind};
2985
2986    let trade_id = trade_id.map(str::trim).filter(|s| !s.is_empty());
2987    let Some(trade_id) = trade_id else {
2988        return DispatchOutput {
2989            lines: vec![OutputLine::warn(
2990                "/rate <trade_id> <1..=10> — name the trade and a conviction rating (1 low, 10 high)",
2991            )],
2992            ..Default::default()
2993        };
2994    };
2995    let Some(rating) = rating else {
2996        return DispatchOutput {
2997            lines: vec![OutputLine::warn(format!(
2998                "/rate {trade_id} <1..=10> — rating must be an integer in 1..=10 (1 low, 10 high)"
2999            ))],
3000            ..Default::default()
3001        };
3002    };
3003    // Defensive re-check: parser already filters to 1..=10,
3004    // but a future refactor that loosens the parser must not
3005    // silently let a 0 or 11 through.
3006    if !(1..=10).contains(&rating) {
3007        return DispatchOutput {
3008            lines: vec![OutputLine::warn(format!(
3009                "/rate {trade_id} {rating} — rating must be an integer in 1..=10 (1 low, 10 high)"
3010            ))],
3011            ..Default::default()
3012        };
3013    }
3014
3015    // Engine wire format: `zero_operator_state::EventKind::Conviction`.
3016    // The classifier treats this as append-only and idempotent at the
3017    // event-log layer, so a retry on transport failure cannot
3018    // double-count. `ts` is wall-clock now — the engine's classifier
3019    // sorts events by `ts` during replay, so skewed clocks produce
3020    // an out-of-order event, not a corrupt snapshot.
3021    let event = Event::new(
3022        Utc::now(),
3023        EventKind::Conviction {
3024            trade_id: trade_id.to_string(),
3025            rating,
3026        },
3027    );
3028
3029    let tail = post_operator_event_tail(ctx, &event).await;
3030    DispatchOutput {
3031        lines: vec![OutputLine::command(format!(
3032            "/rate {trade_id} {rating} — recorded{tail}"
3033        ))],
3034        ..Default::default()
3035    }
3036}
3037
3038/// Post an operator-state event and render a one-phrase tail the
3039/// caller appends to its acknowledgement line. Factored out so
3040/// every rewirable stub (`/rate`, `/break`, future `/break-end`)
3041/// emits the exact same vocabulary for the three outcomes:
3042///
3043/// * `, posted` — engine accepted the event (2xx).
3044/// * `, engine unreachable (kept locally)` — transport failed; the
3045///   command's effect still stands in the conversation log, the
3046///   operator knows the engine did not see it.
3047/// * ` (engine client unavailable)` — no `HttpClient` in the
3048///   context at all (e.g. `--no-engine` mode, tests). Signalled
3049///   with an em-dash-neutral wording so the operator never infers
3050///   a partial-success.
3051///
3052/// Keeping this vocabulary tight (one phrase per state) is
3053/// deliberate: a future audit that greps for `posted` or
3054/// `unreachable` in conversation logs should find a single shape,
3055/// not five near-synonyms.
3056async fn post_operator_event_tail(
3057    ctx: &DispatchContext,
3058    event: &zero_operator_state::Event,
3059) -> String {
3060    let Some(http) = &ctx.http else {
3061        return "  (engine client unavailable; not posted)".to_string();
3062    };
3063    match http.post_operator_event(event).await {
3064        Ok(_) => ", posted to engine".to_string(),
3065        Err(e) => {
3066            // Log the underlying reason so `/doctor` or the logfile
3067            // retains the real failure, but keep the operator-
3068            // facing wording stable — they do not need the reqwest
3069            // error taxonomy at the conversation pane.
3070            tracing::debug!(error = %e, "operator-event POST failed");
3071            ", engine unreachable (kept locally)".to_string()
3072        }
3073    }
3074}
3075
3076/// Tiny helper — a [`DispatchOutput`] that is only one Warn
3077/// line. Parallels [`single_alert`] for consistency.
3078fn single_warn(msg: impl Into<String>) -> DispatchOutput {
3079    DispatchOutput {
3080        lines: vec![OutputLine::warn(msg.into())],
3081        ..Default::default()
3082    }
3083}
3084
3085fn render_share_json(
3086    summary: &crate::session::SessionSummary,
3087    events: &[crate::session::ReplayEvent],
3088) -> String {
3089    // A hand-rolled serialization would avoid `serde_json`, but
3090    // `zero-commands` already pulls it in transitively and the
3091    // structured value buys us forward compatibility: a future
3092    // `/share --to file.json` can reuse the same shape. Keys are
3093    // explicit (not derived via Serialize on `SessionSummary`)
3094    // so the `/share` contract is visible in one place and not
3095    // coupled to field renames in the session-source types.
3096    use serde_json::{Value, json};
3097    let events: Vec<Value> = events
3098        .iter()
3099        .map(|e| {
3100            json!({
3101                "kind": replay_kind_str(e.kind),
3102                "at_ms": e.at_ms,
3103                "text": e.text,
3104            })
3105        })
3106        .collect();
3107    let body = json!({
3108        "ulid": summary.ulid,
3109        "started_at_ms": summary.started_at_ms,
3110        "ended_at_ms": summary.ended_at_ms,
3111        "engine_base_url": summary.engine_base_url,
3112        "cli_version": summary.cli_version,
3113        "parent_ulid": summary.parent_ulid,
3114        "n_events": summary.n_events,
3115        "events": events,
3116    });
3117    // Pretty-print: an operator staring at 3 KB of packed JSON
3118    // in a narrow terminal is not getting a share; they're
3119    // getting a wall. Indentation costs bytes — but share is an
3120    // explicit, operator-driven action, not ambient output.
3121    serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into())
3122}
3123
3124const fn replay_kind_str(k: ReplayKind) -> &'static str {
3125    match k {
3126        ReplayKind::Prompt => "prompt",
3127        ReplayKind::System => "system",
3128        ReplayKind::Command => "command",
3129        ReplayKind::Warn => "warn",
3130        ReplayKind::Alert => "alert",
3131    }
3132}
3133
3134/// Tiny helper — a [`DispatchOutput`] that is only one alert line.
3135fn single_alert(msg: impl Into<String>) -> DispatchOutput {
3136    DispatchOutput {
3137        lines: vec![OutputLine::alert(msg.into())],
3138        ..Default::default()
3139    }
3140}
3141
3142/// Tiny helper — a [`DispatchOutput`] that is only one System
3143/// line. Parallels [`single_alert`] / [`single_warn`] for the
3144/// usage-hint arms where the line is informational, not
3145/// alarming.
3146fn single_system(msg: impl Into<String>) -> DispatchOutput {
3147    DispatchOutput {
3148        lines: vec![OutputLine::system(msg.into())],
3149        ..Default::default()
3150    }
3151}
3152
3153/// Epoch-ms → `YYYY-MM-DD HH:MM UTC`. Chosen for parity with the
3154/// `summarize` helper in `zero-tui::app::session` so the
3155/// interactive resume banner and the `/sessions` list use the
3156/// same clock wording. A non-parseable value falls back to the
3157/// epoch string rather than crashing the render.
3158fn format_ms_short(ms: i64) -> String {
3159    use chrono::{DateTime, TimeZone, Utc};
3160    let secs = ms.div_euclid(1000);
3161    let nanos = u32::try_from(ms.rem_euclid(1000) * 1_000_000).unwrap_or(0);
3162    let dt: DateTime<Utc> = Utc
3163        .timestamp_opt(secs, nanos)
3164        .single()
3165        .unwrap_or_else(|| Utc.timestamp_opt(0, 0).single().unwrap_or_default());
3166    dt.format("%Y-%m-%d %H:%M UTC").to_string()
3167}
3168
3169async fn break_stub(ctx: &DispatchContext, minutes: Option<u32>) -> DispatchOutput {
3170    use chrono::Utc;
3171    use zero_operator_state::{Event, EventKind};
3172
3173    // Engine wire format: `zero_operator_state::EventKind::BreakStarted`.
3174    // `planned_ms` is carried in milliseconds because that is the
3175    // classifier's native unit — the `/break` CLI parser accepts
3176    // minutes for operator ergonomics, and the conversion lives
3177    // here so the wire contract is narrow. `u64::from` on a `u32`
3178    // minute count cannot overflow the 64 bit product with 60_000
3179    // so no saturating-mul is needed.
3180    let planned_ms = minutes.map(|m| u64::from(m) * 60_000);
3181    let event = Event::new(Utc::now(), EventKind::BreakStarted { planned_ms });
3182
3183    let tail = post_operator_event_tail(ctx, &event).await;
3184    let note = minutes.map_or_else(
3185        || format!("/break — noted{tail}"),
3186        |m| format!("/break {m}m — noted{tail}"),
3187    );
3188    DispatchOutput {
3189        lines: vec![OutputLine::system(note)],
3190        ..Default::default()
3191    }
3192}
3193
3194/// Placeholder error type — the dispatcher's public signature
3195/// reserves a Result<…> slot for future commands that need to
3196/// refuse execution rather than emit a warn line.
3197#[derive(Debug, thiserror::Error)]
3198pub enum Never {}
3199
3200#[cfg(test)]
3201mod tests {
3202    use std::sync::Arc;
3203
3204    use super::{DispatchContext, StaticLabel, dispatch};
3205    use crate::command::Command;
3206    use crate::friction::FrictionDecision;
3207    use crate::risk::RiskDirection;
3208    use zero_engine_client::EngineState;
3209    use zero_operator_state::friction::FrictionLevel;
3210    use zero_operator_state::label::Label;
3211
3212    fn ctx_with_label(l: Label) -> DispatchContext {
3213        DispatchContext::new(None, EngineState::shared()).with_state(Arc::new(StaticLabel(l)))
3214    }
3215
3216    #[tokio::test]
3217    async fn empty_input_returns_none() {
3218        let ctx = DispatchContext::new(None, EngineState::shared());
3219        let out = dispatch(&ctx, "").await.unwrap();
3220        assert!(out.is_none());
3221    }
3222
3223    #[tokio::test]
3224    async fn help_renders_many_lines() {
3225        let ctx = DispatchContext::new(None, EngineState::shared());
3226        let out = dispatch(&ctx, "/help").await.unwrap().unwrap();
3227        assert!(out.lines.len() >= 6);
3228        assert!(!out.quit);
3229        assert!(out.mode_change.is_none());
3230    }
3231
3232    // ---------- doctor-row wrap helper ----------
3233    //
3234    // These tests pin the fix for the 2026-04-22 paper cut
3235    // where the ERROR row `engine token unset — pass --token,
3236    // set ZERO_API_TOKEN, or run \`zero init --force\`` (84
3237    // cols) got clipped at the 80-col terminal edge, eating
3238    // the remediation hint. The operator saw `...or run \``
3239    // and had nothing to act on.
3240
3241    #[test]
3242    fn short_doctor_row_emits_single_line_unchanged() {
3243        use super::{OutputLine, wrap_doctor_row};
3244        use crate::config::DoctorSeverity;
3245
3246        let out = wrap_doctor_row("  ok    ", "keychain reachable", DoctorSeverity::Ok);
3247        assert_eq!(out.len(), 1);
3248        match &out[0] {
3249            OutputLine::System(s) => assert_eq!(s, "  ok    keychain reachable"),
3250            other => panic!("expected System, got {other:?}"),
3251        }
3252    }
3253
3254    #[test]
3255    fn long_doctor_row_wraps_preserving_all_text() {
3256        use super::{DOCTOR_ROW_PREFIX_COLS, OutputLine, wrap_doctor_row};
3257        use crate::config::DoctorSeverity;
3258
3259        // Literal string from main.rs::resolved_token_source,
3260        // the row that failed in the screenshot.
3261        let msg =
3262            "engine token unset — pass --token, set ZERO_API_TOKEN, or run `zero init --force`";
3263        let out = wrap_doctor_row("  ERROR ", msg, DoctorSeverity::Error);
3264
3265        // Must emit ≥2 lines — the whole point of the fix.
3266        assert!(
3267            out.len() >= 2,
3268            "expected wrap to produce ≥2 lines, got {} ({out:?})",
3269            out.len(),
3270        );
3271
3272        // Every emitted line must be Alert — the wrap preserves
3273        // severity so a wrapped ERROR does not visually
3274        // downgrade into a mixed-color paragraph.
3275        for line in &out {
3276            assert!(
3277                matches!(line, OutputLine::Alert(_)),
3278                "expected Alert for every line of a wrapped ERROR, got {line:?}",
3279            );
3280        }
3281
3282        // No character of the original message may be lost. We
3283        // strip the prefix / indent from each emitted line and
3284        // concatenate the whitespace-separated remainder; it
3285        // must equal the original message when collapsed.
3286        let joined: String = out
3287            .iter()
3288            .enumerate()
3289            .map(|(i, line)| {
3290                let OutputLine::Alert(s) = line else {
3291                    unreachable!()
3292                };
3293                let body = if i == 0 {
3294                    s.strip_prefix("  ERROR ").expect("first line keeps prefix")
3295                } else {
3296                    s.strip_prefix(&" ".repeat(DOCTOR_ROW_PREFIX_COLS))
3297                        .expect("continuation uses indent")
3298                };
3299                body.to_owned()
3300            })
3301            .collect::<Vec<_>>()
3302            .join(" ");
3303        let normalize = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
3304        assert_eq!(
3305            normalize(&joined),
3306            normalize(msg),
3307            "wrapped rows must preserve every word of the original message",
3308        );
3309    }
3310
3311    #[test]
3312    fn doctor_row_continuation_aligns_under_message_column() {
3313        use super::{DOCTOR_ROW_PREFIX_COLS, OutputLine, wrap_doctor_row};
3314        use crate::config::DoctorSeverity;
3315
3316        let msg = "config file missing at /Users/forge/Library/Application Support/zero/config.toml — run `zero init`";
3317        let out = wrap_doctor_row("  warn  ", msg, DoctorSeverity::Warn);
3318        assert!(out.len() >= 2);
3319
3320        for (i, line) in out.iter().enumerate() {
3321            let OutputLine::Warn(s) = line else {
3322                panic!("expected Warn, got {line:?}");
3323            };
3324            if i == 0 {
3325                assert!(
3326                    s.starts_with("  warn  "),
3327                    "first line must start with severity prefix, got {s:?}",
3328                );
3329            } else {
3330                let expected_indent = " ".repeat(DOCTOR_ROW_PREFIX_COLS);
3331                assert!(
3332                    s.starts_with(&expected_indent),
3333                    "continuation line {i} must align under message column (10 spaces), got {s:?}",
3334                );
3335                // The char at column 10 must be non-space (body
3336                // starts immediately) — otherwise we've stacked
3337                // indents or split a whitespace run.
3338                let at_col_10: Option<char> = s.chars().nth(DOCTOR_ROW_PREFIX_COLS);
3339                assert!(
3340                    at_col_10.is_some_and(|c| !c.is_whitespace()),
3341                    "continuation line {i} body must start at col 10, got {s:?}",
3342                );
3343            }
3344        }
3345    }
3346
3347    #[test]
3348    fn doctor_row_single_long_token_is_never_broken() {
3349        use super::{OutputLine, wrap_doctor_row};
3350        use crate::config::DoctorSeverity;
3351
3352        // A URL-like token that alone exceeds the body budget.
3353        // Breaking mid-character would destroy paste-ability —
3354        // the whole point of the URL is the operator copies it
3355        // into a browser. Emit it on its own line, un-split.
3356        let url = "https://docs.getzero.dev/runbook/reconnecting-forever-after-rotating-your-token-thoroughly";
3357        let out = wrap_doctor_row("  ERROR ", url, DoctorSeverity::Error);
3358
3359        // Concatenate every body and confirm the URL is intact.
3360        let joined: String = out
3361            .iter()
3362            .map(|line| {
3363                let OutputLine::Alert(s) = line else {
3364                    unreachable!()
3365                };
3366                s.trim_start().to_owned()
3367            })
3368            .collect::<String>();
3369        // First-line prefix "ERROR " may or may not be present
3370        // depending on where wrap fell; strip it robustly.
3371        let joined = joined.trim_start_matches("ERROR ").to_owned();
3372        assert!(
3373            joined.contains(url),
3374            "URL token must survive un-broken across wrap boundaries; joined={joined:?}",
3375        );
3376    }
3377
3378    #[tokio::test]
3379    async fn quit_sets_quit_flag() {
3380        let ctx = DispatchContext::new(None, EngineState::shared());
3381        let out = dispatch(&ctx, "/quit").await.unwrap().unwrap();
3382        assert!(out.quit);
3383    }
3384
3385    #[tokio::test]
3386    async fn state_sets_overlay_signal() {
3387        use crate::command::OverlayTarget;
3388        let ctx = DispatchContext::new(None, EngineState::shared());
3389        let out = dispatch(&ctx, "/state").await.unwrap().unwrap();
3390        assert_eq!(out.show_overlay, Some(OverlayTarget::State));
3391        assert!(!out.quit);
3392        assert!(out.lines.is_empty(), "overlay command emits no lines");
3393        assert_eq!(out.risk, Some(RiskDirection::Neutral));
3394    }
3395
3396    #[tokio::test]
3397    async fn state_under_tilt_still_opens_overlay() {
3398        // /state is Neutral — must never be gated.
3399        use crate::command::OverlayTarget;
3400        let ctx = ctx_with_label(Label::Tilt);
3401        let out = dispatch(&ctx, "/state").await.unwrap().unwrap();
3402        assert_eq!(out.show_overlay, Some(OverlayTarget::State));
3403        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3404    }
3405
3406    #[tokio::test]
3407    async fn clear_sets_clear_flag() {
3408        let ctx = DispatchContext::new(None, EngineState::shared());
3409        let out = dispatch(&ctx, "/clear").await.unwrap().unwrap();
3410        assert!(out.clear_log);
3411    }
3412
3413    #[tokio::test]
3414    async fn unknown_emits_warn() {
3415        let ctx = DispatchContext::new(None, EngineState::shared());
3416        let out = dispatch(&ctx, "/nope").await.unwrap().unwrap();
3417        assert_eq!(out.lines.len(), 1);
3418        matches!(out.lines[0], super::OutputLine::Warn(_));
3419    }
3420
3421    #[tokio::test]
3422    async fn status_without_http_emits_alert() {
3423        let ctx = DispatchContext::new(None, EngineState::shared());
3424        let out = dispatch(&ctx, "/status").await.unwrap().unwrap();
3425        assert!(
3426            matches!(&out.lines[0], super::OutputLine::Alert(s) if s.contains("engine client"))
3427        );
3428    }
3429
3430    // -------------------------------------------------------------
3431    // Friction ladder — dispatch-level enforcement
3432    // -------------------------------------------------------------
3433
3434    #[tokio::test]
3435    async fn execute_under_steady_proceeds() {
3436        let ctx = ctx_with_label(Label::Steady);
3437        let out = dispatch(&ctx, "/execute").await.unwrap().unwrap();
3438        assert_eq!(out.risk, Some(RiskDirection::Increases));
3439        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3440        // Bare /execute now emits usage; real orders require args.
3441        assert!(matches!(
3442            out.lines.first(),
3443            Some(super::OutputLine::Warn(_))
3444        ));
3445    }
3446
3447    #[tokio::test]
3448    async fn execute_under_elevated_pauses_without_running() {
3449        let ctx = ctx_with_label(Label::Elevated);
3450        let out = dispatch(&ctx, "/execute").await.unwrap().unwrap();
3451        assert_eq!(out.risk, Some(RiskDirection::Increases));
3452        assert!(matches!(
3453            out.friction,
3454            Some(FrictionDecision::Pause {
3455                level: FrictionLevel::L1,
3456                ..
3457            })
3458        ));
3459        // advisory line must indicate friction + NOT the "accepted" stub
3460        let joined = join_lines(&out);
3461        assert!(joined.contains("friction"), "{joined:?}");
3462        assert!(!joined.contains("accepted"), "{joined:?}");
3463        // pending_command carries the resolved Command so the TUI
3464        // can re-dispatch via run_bypass_friction after the pause.
3465        assert_eq!(out.pending_command, Some(Command::Execute));
3466    }
3467
3468    #[tokio::test]
3469    async fn execute_under_tilt_requires_typed_confirm() {
3470        let ctx = ctx_with_label(Label::Tilt);
3471        let out = dispatch(&ctx, "/execute").await.unwrap().unwrap();
3472        assert!(matches!(
3473            out.friction,
3474            Some(FrictionDecision::TypedConfirm {
3475                level: FrictionLevel::L2,
3476                ..
3477            })
3478        ));
3479        assert_eq!(
3480            out.friction
3481                .as_ref()
3482                .and_then(FrictionDecision::confirm_word)
3483                .as_deref(),
3484            Some("execute")
3485        );
3486        let joined = join_lines(&out);
3487        assert!(joined.contains("type 'execute'"), "{joined:?}");
3488        assert!(!joined.contains("accepted"), "{joined:?}");
3489        assert_eq!(out.pending_command, Some(Command::Execute));
3490    }
3491
3492    #[tokio::test]
3493    async fn proceed_path_leaves_pending_command_empty() {
3494        // When a command actually ran, there is no post-friction
3495        // work to do. `pending_command` must stay `None` so the
3496        // TUI does not open an overlay for commands that never
3497        // needed gating.
3498        let ctx = ctx_with_label(Label::Steady);
3499        let out = dispatch(&ctx, "/execute").await.unwrap().unwrap();
3500        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3501        assert!(
3502            out.pending_command.is_none(),
3503            "Proceed path must not carry pending_command"
3504        );
3505    }
3506
3507    #[tokio::test]
3508    async fn bypass_friction_runs_command_ignoring_label() {
3509        // Post-friction entrypoint: the TUI has honored the
3510        // pause + any typed confirmation; the command runs
3511        // straight through `run`. Even under TILT this must
3512        // proceed — the caller earned it by waiting.
3513        let ctx = ctx_with_label(Label::Tilt);
3514        let out = super::run_bypass_friction(&ctx, Command::Execute).await;
3515        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3516        assert_eq!(out.risk, Some(RiskDirection::Increases));
3517        let joined = join_lines(&out);
3518        assert!(
3519            joined.contains("/execute <coin>"),
3520            "expected usage: {joined}"
3521        );
3522    }
3523
3524    #[tokio::test]
3525    async fn bypass_friction_on_neutral_command_is_harmless() {
3526        // /help is Neutral — bypassing friction on it is a no-op
3527        // as far as gating goes. Asserts the invariant that the
3528        // bypass path does not lower the risk-asymmetry guarantee:
3529        // a Neutral command runs the same in both paths.
3530        let ctx = DispatchContext::new(None, EngineState::shared());
3531        let out = super::run_bypass_friction(&ctx, Command::Help).await;
3532        assert_eq!(out.risk, Some(RiskDirection::Neutral));
3533        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3534    }
3535
3536    /// The architectural tripwire: a Reduces command at TILT still
3537    /// runs, with a Proceed decision. If this test ever flips,
3538    /// someone has broken the risk-asymmetry invariant that makes
3539    /// the whole thing worth building.
3540    #[tokio::test]
3541    async fn kill_under_tilt_still_proceeds() {
3542        let ctx = ctx_with_label(Label::Tilt);
3543        let out = dispatch(&ctx, "/kill").await.unwrap().unwrap();
3544        assert_eq!(out.risk, Some(RiskDirection::Reduces));
3545        assert_eq!(
3546            out.friction,
3547            Some(FrictionDecision::Proceed),
3548            "Reduces commands MUST never be gated"
3549        );
3550    }
3551
3552    #[tokio::test]
3553    async fn flatten_under_tilt_still_proceeds() {
3554        let ctx = ctx_with_label(Label::Tilt);
3555        let out = dispatch(&ctx, "/flatten-all").await.unwrap().unwrap();
3556        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3557    }
3558
3559    #[tokio::test]
3560    async fn status_under_tilt_still_proceeds() {
3561        let ctx = ctx_with_label(Label::Tilt);
3562        let out = dispatch(&ctx, "/status").await.unwrap().unwrap();
3563        assert_eq!(out.risk, Some(RiskDirection::Neutral));
3564        assert_eq!(out.friction, Some(FrictionDecision::Proceed));
3565    }
3566
3567    fn join_lines(out: &super::DispatchOutput) -> String {
3568        out.lines
3569            .iter()
3570            .map(|l| match l {
3571                super::OutputLine::System(s)
3572                | super::OutputLine::Command(s)
3573                | super::OutputLine::Warn(s)
3574                | super::OutputLine::Alert(s) => s.as_str(),
3575            })
3576            .collect::<Vec<_>>()
3577            .join("\n")
3578    }
3579}