1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum OutputLine {
31 System(String),
33 Command(String),
35 Warn(String),
37 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#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ReplayLine {
72 pub kind: ReplayKind,
73 pub at_ms: i64,
74 pub text: String,
75}
76
77#[allow(clippy::struct_excessive_bools)]
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub struct DispatchOutput {
98 pub lines: Vec<OutputLine>,
99 pub replay_lines: Vec<ReplayLine>,
108 pub mode_change: Option<ModeTarget>,
109 pub show_overlay: Option<OverlayTarget>,
113 pub quit: bool,
114 pub clear_log: bool,
115 pub risk: Option<RiskDirection>,
116 pub friction: Option<FrictionDecision>,
123 pub pending_command: Option<Command>,
130 pub verbose_toggle: Option<bool>,
138 pub wrap_off_toggle: Option<bool>,
144 pub coaching_reset: bool,
149 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
171pub trait StateSource: Send + Sync + 'static {
184 fn label(&self) -> Label;
185}
186
187#[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#[derive(Clone)]
222pub struct DispatchContext {
223 pub http: Option<HttpClient>,
224 pub engine: Arc<RwLock<EngineState>>,
225 pub state: Arc<dyn StateSource>,
228 pub sessions: Option<Arc<dyn SessionSource>>,
234 pub config: Option<Arc<dyn ConfigSource>>,
239 pub verbose: bool,
245 pub wrap_off: bool,
250 pub auto: Option<Arc<dyn AutoSource>>,
256 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 #[must_use]
295 pub fn with_state(mut self, src: Arc<dyn StateSource>) -> Self {
296 self.state = src;
297 self
298 }
299
300 #[must_use]
303 pub fn with_sessions(mut self, src: Arc<dyn SessionSource>) -> Self {
304 self.sessions = Some(src);
305 self
306 }
307
308 #[must_use]
312 pub fn with_config(mut self, src: Arc<dyn ConfigSource>) -> Self {
313 self.config = Some(src);
314 self
315 }
316
317 #[must_use]
321 pub const fn with_verbose(mut self, on: bool) -> Self {
322 self.verbose = on;
323 self
324 }
325
326 #[must_use]
330 pub const fn with_wrap_off(mut self, on: bool) -> Self {
331 self.wrap_off = on;
332 self
333 }
334
335 #[must_use]
338 pub fn with_auto(mut self, src: Arc<dyn AutoSource>) -> Self {
339 self.auto = Some(src);
340 self
341 }
342
343 #[must_use]
349 pub fn with_supervisor(mut self, src: Arc<dyn SupervisorSource>) -> Self {
350 self.supervisor = Some(src);
351 self
352 }
353}
354
355pub 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 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 let mut out = if matches!(decision, FrictionDecision::Proceed) {
424 run(ctx, &cmd).await
425 } else {
426 friction_advisory(&cmd, label, &decision)
427 };
428 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
446fn 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 Some(
467 risk.halt_reason
468 .clone()
469 .unwrap_or_else(|| "halted".to_string()),
470 )
471 } else {
472 None
473 }
474}
475
476fn 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
492pub 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 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 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 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 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
726fn 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
1444fn 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
1457fn 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
1480fn 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 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 "—".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 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
1578async 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 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 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 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 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 if eval.coin.is_none() {
1723 eval.coin = Some(coin.to_string());
1724 }
1725 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 }
1747 Err(e) => {
1748 out.lines
1749 .push(OutputLine::alert(format!("evaluate {coin}: {e}")));
1750 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 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 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 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
1912fn trim_ts(raw: Option<&str>) -> String {
1918 let Some(s) = raw else {
1919 return "— ".into();
1920 };
1921 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 if s.len() <= 8 {
1931 return format!("{s:<8}");
1932 }
1933 s.to_string()
1934}
1935
1936async 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 Ok(true) => lines.push(OutputLine::alert(
1970 "/kill — headless supervisor stopped and operator-local socket torn down.",
1971 )),
1972 Ok(false) => {}
1973 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
2136fn 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 (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 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
2198fn 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 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
2277fn 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
2343fn resume_cmd(ctx: &DispatchContext, needle: Option<&str>) -> DispatchOutput {
2354 fetch_and_paint_session(ctx, needle, SessionVerb::Resume)
2355}
2356
2357fn replay_cmd(ctx: &DispatchContext, needle: Option<&str>) -> DispatchOutput {
2370 fetch_and_paint_session(ctx, needle, SessionVerb::Replay)
2371}
2372
2373#[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 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
2457fn 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
2480fn 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
2510fn 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 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 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
2566fn 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 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 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 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 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
2674const DOCTOR_ROW_WRAP_BODY_COLS: usize = 70;
2689
2690const DOCTOR_ROW_PREFIX_COLS: usize = 8;
2699
2700fn 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 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
2772fn 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
2801fn state_override_cmd(label: Option<StateOverrideLabel>) -> DispatchOutput {
2824 let Some(label) = label else {
2825 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
2841fn 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
2855fn 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
2890fn 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
2907fn 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
2924fn 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
2952async 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 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 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
3038async 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 tracing::debug!(error = %e, "operator-event POST failed");
3071 ", engine unreachable (kept locally)".to_string()
3072 }
3073 }
3074}
3075
3076fn 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 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 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
3134fn single_alert(msg: impl Into<String>) -> DispatchOutput {
3136 DispatchOutput {
3137 lines: vec![OutputLine::alert(msg.into())],
3138 ..Default::default()
3139 }
3140}
3141
3142fn single_system(msg: impl Into<String>) -> DispatchOutput {
3147 DispatchOutput {
3148 lines: vec![OutputLine::system(msg.into())],
3149 ..Default::default()
3150 }
3151}
3152
3153fn 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 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#[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 #[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 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 assert!(
3267 out.len() >= 2,
3268 "expected wrap to produce ≥2 lines, got {} ({out:?})",
3269 out.len(),
3270 );
3271
3272 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 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 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 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 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 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 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 #[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 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 let joined = join_lines(&out);
3461 assert!(joined.contains("friction"), "{joined:?}");
3462 assert!(!joined.contains("accepted"), "{joined:?}");
3463 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 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 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 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 #[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}