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 send this with `netsky imessage send --owner`.");
}
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());
}
}