omk 0.5.0

A Rust runtime for Kimi CLI. Turns prompts into proof-backed engineering runs with gates, worktrees, and replay.
Documentation
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing::info;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Metrics {
    pub version: u32,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    #[serde(default, alias = "total_spawns")]
    pub total_team_runs: u64,
    pub total_shutdowns: u64,
    pub total_tasks_created: u64,
    pub total_tasks_completed: u64,
    pub total_tasks_failed: u64,
    pub total_ask_calls: u64,
    pub total_ask_errors: u64,
    pub total_autopilot_runs: u64,
    pub total_ralph_runs: u64,
}

impl Metrics {
    pub fn new() -> Self {
        let now = Utc::now();
        Self {
            version: 1,
            created_at: now,
            updated_at: now,
            ..Default::default()
        }
    }

    pub async fn load_or_default(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::new());
        }
        let raw = tokio::fs::read_to_string(path).await?;
        let m: Metrics = serde_json::from_str(&raw)
            .with_context(|| format!("parse metrics {}", path.display()))?;
        Ok(m)
    }

    pub async fn save(&self, path: &Path) -> Result<()> {
        let out = serde_json::to_vec_pretty(&self)?;
        crate::runtime::atomic::atomic_write(path, &out).await?;
        Ok(())
    }
}

pub async fn record(metrics_path: &Path, op: impl FnOnce(&mut Metrics)) -> Result<()> {
    let mut m = Metrics::load_or_default(metrics_path).await?;
    op(&mut m);
    m.updated_at = Utc::now();
    m.save(metrics_path).await?;
    info!(path = %metrics_path.display(), "Metrics updated");
    Ok(())
}

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

    #[tokio::test]
    async fn test_metrics_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("metrics.json");
        let mut m = Metrics::new();
        m.total_team_runs = 5;
        m.save(&path).await.unwrap();
        let loaded = Metrics::load_or_default(&path).await.unwrap();
        assert_eq!(loaded.total_team_runs, 5);
    }

    #[tokio::test]
    async fn test_metrics_record() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("metrics.json");
        record(&path, |m| m.total_team_runs += 1).await.unwrap();
        let m = Metrics::load_or_default(&path).await.unwrap();
        assert_eq!(m.total_team_runs, 1);
    }

    #[tokio::test]
    async fn test_metrics_loads_legacy_total_spawns() {
        let legacy = serde_json::json!({
            "version": 1,
            "created_at": "2026-05-11T00:00:00Z",
            "updated_at": "2026-05-11T00:00:00Z",
            "total_spawns": 7,
            "total_shutdowns": 0,
            "total_tasks_created": 0,
            "total_tasks_completed": 0,
            "total_tasks_failed": 0,
            "total_ask_calls": 0,
            "total_ask_errors": 0,
            "total_autopilot_runs": 0,
            "total_ralph_runs": 0
        });

        let loaded: Metrics = serde_json::from_value(legacy).unwrap();

        assert_eq!(loaded.total_team_runs, 7);
    }
}