netsky 0.1.7

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 crate::observability;
use netsky_core::consts::{NETSKY_BIN, TICKER_LOG_PATH};
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, json: bool) -> netsky_core::Result<()> {
    reap_morning_markers()?;
    let snap = gather()?;

    if json {
        print_json(&snap)?;
        return Ok(());
    }

    print_text(&snap);

    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(())
}

struct Snapshot {
    doctor_brief: String,
    doctor_exit: i32,
    doctor_full_tail: Option<Vec<String>>,
    markers: Vec<MarkerRec>,
    commits: Vec<CommitRec>,
    brief_iterations: Vec<String>,
    loop_resume: Option<LoopResumeRec>,
    watchdog_tail: Vec<String>,
    watchdog_present: bool,
}

struct MarkerRec {
    path: String,
    body: String,
}

struct CommitRec {
    sha: String,
    subject: String,
}

struct LoopResumeRec {
    path: String,
    pending_lines: Vec<String>,
}

fn gather() -> netsky_core::Result<Snapshot> {
    let out = Command::new(NETSKY_BIN)
        .args(["doctor", "--brief"])
        .output()?;
    let doctor_brief = String::from_utf8_lossy(&out.stdout).to_string();
    let doctor_exit = out.status.code().unwrap_or(-1);
    let doctor_full_tail = if !out.status.success() {
        let full = Command::new(NETSKY_BIN).arg("doctor").output()?;
        let text = String::from_utf8_lossy(&full.stdout).to_string();
        let tail: Vec<String> = text
            .lines()
            .rev()
            .take(30)
            .map(|s| s.to_string())
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect();
        Some(tail)
    } else {
        None
    };

    let hang = agent0_hang_marker();
    let esc = agentinit_escalation_marker();
    let markers = [&hang, &esc]
        .into_iter()
        .filter(|p| p.exists())
        .map(|p| MarkerRec {
            path: p.display().to_string(),
            body: fs::read_to_string(p).unwrap_or_default(),
        })
        .collect();

    let log = Command::new("git")
        .args(["log", "--since=12 hours ago", "--pretty=format:%h %s"])
        .output()?;
    let commits: Vec<CommitRec> = String::from_utf8_lossy(&log.stdout)
        .lines()
        .take(30)
        .filter_map(|l| {
            let (sha, subject) = l.split_once(' ')?;
            Some(CommitRec {
                sha: sha.to_string(),
                subject: subject.to_string(),
            })
        })
        .collect();

    let brief_iterations = if Path::new(BRIEF_PATH).exists() {
        let content = fs::read_to_string(BRIEF_PATH).unwrap_or_default();
        let it_lines: Vec<String> = content
            .lines()
            .filter(|l| l.starts_with("### iteration"))
            .map(|s| s.to_string())
            .collect();
        it_lines
            .iter()
            .rev()
            .take(8)
            .cloned()
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect()
    } else {
        Vec::new()
    };

    let loop_resume = [
        loop_resume_file(),
        Path::new(LEGACY_LOOP_RESUME).to_path_buf(),
    ]
    .into_iter()
    .find(|p| p.exists())
    .map(|resume| {
        let body = fs::read_to_string(&resume).unwrap_or_default();
        let pending_lines = extract_pending_lines(&body);
        LoopResumeRec {
            path: resume.display().to_string(),
            pending_lines,
        }
    });

    let (watchdog_tail, watchdog_present) = match fs::read_to_string(TICKER_LOG_PATH) {
        Ok(body) => {
            let lines: Vec<String> = body.lines().map(|s| s.to_string()).collect();
            let start = lines.len().saturating_sub(5);
            (lines[start..].to_vec(), true)
        }
        Err(_) => (Vec::new(), false),
    };

    Ok(Snapshot {
        doctor_brief,
        doctor_exit,
        doctor_full_tail,
        markers,
        commits,
        brief_iterations,
        loop_resume,
        watchdog_tail,
        watchdog_present,
    })
}

fn extract_pending_lines(body: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut flag = false;
    for l in body.lines() {
        if l.starts_with("PENDING") {
            flag = true;
            continue;
        }
        if l.starts_with("Stop conditions:") {
            flag = false;
        }
        if flag && out.len() < 20 {
            out.push(l.to_string());
        }
    }
    out
}

fn print_text(snap: &Snapshot) {
    section("health");
    print!("{}", snap.doctor_brief);
    if let Some(tail) = &snap.doctor_full_tail {
        println!();
        println!("doctor exited {} — full output:", snap.doctor_exit);
        for l in tail {
            println!("{l}");
        }
    }

    if !snap.markers.is_empty() {
        section("ACTIVE MARKERS (owner action needed)");
        for m in &snap.markers {
            println!("{}", m.path);
            for l in m.body.lines() {
                println!("  {l}");
            }
        }
    }

    section("overnight commits (last 12h)");
    for c in &snap.commits {
        println!("{} {}", c.sha, c.subject);
    }
    println!();

    if !snap.brief_iterations.is_empty() {
        section("brief: latest iterations");
        for l in &snap.brief_iterations {
            println!("{l}");
        }
    }

    if let Some(lr) = &snap.loop_resume {
        let name = Path::new(&lr.path)
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("loop-resume");
        section(&format!("loop-resume PENDING ({name})"));
        for l in &lr.pending_lines {
            println!("{l}");
        }
    }

    section("watchdog (last 5 lines)");
    if snap.watchdog_present {
        for l in &snap.watchdog_tail {
            println!("{l}");
        }
    } else {
        println!("(log not present)");
    }
}

fn print_json(snap: &Snapshot) -> netsky_core::Result<()> {
    let doctor_status = if snap.doctor_exit == 0 {
        "green"
    } else {
        "red"
    };
    let overall = if !snap.markers.is_empty() || snap.doctor_exit != 0 {
        "red"
    } else if snap.loop_resume.is_some() || snap.watchdog_tail.is_empty() {
        "yellow"
    } else {
        "green"
    };
    let summary = if !snap.markers.is_empty() {
        format!(
            "{} active marker(s) — owner action needed",
            snap.markers.len()
        )
    } else if snap.doctor_exit != 0 {
        format!("doctor red (exit {})", snap.doctor_exit)
    } else {
        format!(
            "{} overnight commit(s); doctor {doctor_status}",
            snap.commits.len()
        )
    };

    let envelope = serde_json::json!({
        "command": "morning",
        "status": overall,
        "summary": summary,
        "generated_at": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
        "data": {
            "doctor": {
                "status": doctor_status,
                "exit": snap.doctor_exit,
                "brief": snap.doctor_brief.trim(),
                "full_tail": snap.doctor_full_tail,
            },
            "markers": snap.markers.iter().map(|m| serde_json::json!({
                "path": m.path,
                "body": m.body,
            })).collect::<Vec<_>>(),
            "overnight_commits": snap.commits.iter().map(|c| serde_json::json!({
                "sha": c.sha,
                "subject": c.subject,
            })).collect::<Vec<_>>(),
            "brief_iterations": snap.brief_iterations,
            "loop_resume": snap.loop_resume.as_ref().map(|lr| serde_json::json!({
                "path": lr.path,
                "pending_lines": lr.pending_lines,
            })),
            "watchdog_tail": snap.watchdog_tail,
            "watchdog_log_present": snap.watchdog_present,
        },
    });
    println!("{}", serde_json::to_string_pretty(&envelope)?);
    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());
    }
}