netsky 0.1.6

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky morning` — overnight briefing: doctor verdict + git log +
//! brief iteration tail + loop-resume + watchdog log tail.

use std::fs;
use std::path::Path;
use std::process::Command;

use netsky_core::consts::{NETSKY_BIN, TICKER_LOG_PATH};
use crate::observability;
use netsky_core::paths::{
    agent0_hang_marker, agentinit_escalation_marker, loop_resume_file, state_dir,
};

const LEGACY_LOOP_RESUME: &str = "/tmp/netsky-loop-resume.txt";
const BRIEF_PATH: &str = "briefs/overnight-resilience-adversarial-review.md";
const MORNING_MARKER_PREFIX: &str = "morning-brief-";
const MORNING_MARKER_RETENTION_DAYS: i64 = 14;

pub fn run(send: bool) -> netsky_core::Result<()> {
    reap_morning_markers()?;

    section("health");
    let out = Command::new(NETSKY_BIN)
        .args(["doctor", "--brief"])
        .output()?;
    print!("{}", String::from_utf8_lossy(&out.stdout));
    if !out.status.success() {
        println!();
        println!(
            "doctor exited {} — full output:",
            out.status.code().unwrap_or(-1)
        );
        let full = Command::new(NETSKY_BIN).arg("doctor").output()?;
        let text = String::from_utf8_lossy(&full.stdout);
        for l in text
            .lines()
            .rev()
            .take(30)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
        {
            println!("{l}");
        }
    }

    let hang = agent0_hang_marker();
    let esc = agentinit_escalation_marker();
    let markers: Vec<_> = [&hang, &esc].into_iter().filter(|p| p.exists()).collect();
    if !markers.is_empty() {
        section("ACTIVE MARKERS (owner action needed)");
        for m in markers {
            println!("{}", m.display());
            if let Ok(body) = fs::read_to_string(m) {
                for l in body.lines() {
                    println!("  {l}");
                }
            }
        }
    }

    section("overnight commits (last 12h)");
    let log = Command::new("git")
        .args(["log", "--since=12 hours ago", "--pretty=format:%h %s"])
        .output()?;
    for l in String::from_utf8_lossy(&log.stdout).lines().take(30) {
        println!("{l}");
    }
    println!();

    if Path::new(BRIEF_PATH).exists() {
        section("brief: latest iterations");
        if let Ok(content) = fs::read_to_string(BRIEF_PATH) {
            let it_lines: Vec<_> = content
                .lines()
                .filter(|l| l.starts_with("### iteration"))
                .collect();
            for l in it_lines
                .iter()
                .rev()
                .take(8)
                .collect::<Vec<_>>()
                .into_iter()
                .rev()
            {
                println!("{l}");
            }
        }
    }

    for resume in [
        loop_resume_file(),
        Path::new(LEGACY_LOOP_RESUME).to_path_buf(),
    ] {
        if resume.exists() {
            let name = resume
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("loop-resume");
            section(&format!("loop-resume PENDING ({name})"));
            if let Ok(body) = fs::read_to_string(&resume) {
                let mut flag = false;
                let mut printed = 0;
                for l in body.lines() {
                    if l.starts_with("PENDING") {
                        flag = true;
                        continue;
                    }
                    if l.starts_with("Stop conditions:") {
                        flag = false;
                    }
                    if flag && printed < 20 {
                        println!("{l}");
                        printed += 1;
                    }
                }
            }
            break;
        }
    }

    section("watchdog (last 5 lines)");
    match fs::read_to_string(TICKER_LOG_PATH) {
        Ok(body) => {
            let lines: Vec<_> = body.lines().collect();
            let start = lines.len().saturating_sub(5);
            for l in &lines[start..] {
                println!("{l}");
            }
        }
        Err(_) => println!("(log not present)"),
    }

    if send {
        observability::record_directive(
            "morning",
            None,
            "netsky morning --send",
            Some("morning_brief"),
            Some(netsky_core::consts::AGENT0_NAME),
            Some("requested"),
            serde_json::json!({}),
        );
        println!();
        println!("--send requested; agent0 should pipe this into a reply tool call.");
    }

    Ok(())
}

fn reap_morning_markers() -> netsky_core::Result<()> {
    let today = chrono::Utc::now().date_naive();
    let cutoff = today - chrono::Duration::days(MORNING_MARKER_RETENTION_DAYS);
    let reaped = reap_morning_markers_in(&state_dir(), cutoff)?;
    if reaped > 0 {
        section("morning marker reaper");
        println!("reaped {reaped} stale marker(s)");
    }
    Ok(())
}

fn reap_morning_markers_in(dir: &Path, cutoff: chrono::NaiveDate) -> std::io::Result<usize> {
    let Ok(entries) = fs::read_dir(dir) else {
        return Ok(0);
    };

    let mut reaped = 0;
    for entry in entries.filter_map(|e| e.ok()) {
        let path = entry.path();
        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        if !is_stale_morning_marker(name, cutoff) {
            continue;
        }
        fs::remove_file(path)?;
        reaped += 1;
    }
    Ok(reaped)
}

fn is_stale_morning_marker(name: &str, cutoff: chrono::NaiveDate) -> bool {
    let Some(suffix) = name.strip_prefix(MORNING_MARKER_PREFIX) else {
        return false;
    };
    chrono::NaiveDate::parse_from_str(suffix, "%Y-%m-%d").is_ok_and(|date| date < cutoff)
}

fn section(label: &str) {
    println!();
    println!("── {label} ──");
}

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

    #[test]
    fn morning_marker_reaper_removes_only_old_dated_markers() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("morning-brief-2026-03-31"), "").unwrap();
        fs::write(dir.path().join("morning-brief-2026-04-15"), "").unwrap();
        fs::write(dir.path().join("morning-brief-not-a-date"), "").unwrap();
        fs::write(dir.path().join("other-2026-03-31"), "").unwrap();

        let cutoff = chrono::NaiveDate::from_ymd_opt(2026, 4, 2).unwrap();
        let reaped = reap_morning_markers_in(dir.path(), cutoff).unwrap();

        assert_eq!(reaped, 1);
        assert!(!dir.path().join("morning-brief-2026-03-31").exists());
        assert!(dir.path().join("morning-brief-2026-04-15").exists());
        assert!(dir.path().join("morning-brief-not-a-date").exists());
        assert!(dir.path().join("other-2026-03-31").exists());
    }
}