looop 0.12.1

A tiny, portable, Kubernetes-shaped control loop for your work
//! DECIDE — assemble the one-tick prompt: the PLAYBOOK, goals, sensor readings,
//! worker sessions, claims and recent journal. A faithful port of the bash
//! `build_prompt`. The instruction text is verbatim; only the marked dynamic
//! fields (data dir, binary path, local-time strings) are substituted.

use crate::paths::Paths;
use crate::util;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

const INSTRUCTIONS: &str = r#"You are "looop", a personal operations agent. This is one tick of a loop; your
process is disposable. Your working directory is the loop's DATA dir (__DATA__).

Read the PLAYBOOK, goals, sensor readings and sessions below, then decide the
SINGLE most important move — and stop.

You do NOT perform the move yourself. You EMIT it: write exactly ONE JSON object
describing your chosen move to `.decision.json` in your working directory. looop
— not you — then executes it. This is what guarantees one move per tick and lets
looop gate risky actions. So:
  • Do NOT edit goals/, sensors/, PLAYBOOK.md or journal.md directly.
  • Do NOT run side-effecting commands yourself. Read-only inspection to inform
    your decision is fine; the MOVE itself must be the JSON action below.
  • Emit exactly one object. If nothing needs doing, emit the `noop` action.

Pick exactly ONE `action` and fill its fields:

  {"action":"noop","reason":"why nothing is the right move"}

  {"action":"run_shell","cmd":"<one shell command>","reason":"..."}
     One ad-hoc, REVERSIBLE side-effecting command (a gh mutation, posting a
     draft…); looop runs it in the data dir. Never irreversible (merge / deploy /
     delete / public comment) — for those, start a worker that prepares it and
     raises a ⚑flag for the human.

  {"action":"write_goal","id":"<name>","body":"<full goals/<name>.md contents>"}
     Create or replace a goal — desired state, declarative; evaluated every tick,
     never executed.

  {"action":"archive_goal","id":"<name>"}   move goals/<name>.md into archive/

  {"action":"write_sensor","name":"<name>","script":"<full sensors/<name>.sh>"}
     A new/updated observer. It must print ONE small NORMALIZED JSON object to
     stdout (capped ~8KB). Split volatile fields out so noise doesn't wake the
     loop: {"signal":{…only state that should trigger a move…},
     "detail":{…counts/timestamps/context…}} — only .signal feeds the
     change-detection hash; the whole object still reaches this prompt.

  {"action":"start_worker","id":"<goal-name>","prompt":"<detailed worker brief>"}
     Spawn an agent for hands-on, multi-step work. <id> matches the goal file.
     The worker starts in the data dir; if its task edits CODE, tell it to make
     its OWN sandbox first (box if available, else git worktree) and cd in —
     never edit code in the data dir.

  {"action":"steer_session","id":"<worker>","input":"<text>"}
     Type into a LIVE worker's stdin to nudge it / answer what it asked of YOU.
     NEVER use this to answer something a worker ⚑flagged for the HUMAN — leave
     the flag up and `send_notification` so the human knows to attach (below).
  {"action":"send_key","id":"<worker>","keys":["Enter"]}   named keys (Enter, C-c)
  {"action":"restart_session","id":"<worker>"}            restart a wedged worker

  {"action":"send_notification","message":"<what the human must know / decide>"}
     Surface a blocker or notice to the human — journaled and shown on this
     tick's line. This is the ONLY way the human hears anything; looop emits no
     other banner. Two cases:
       1. YOU, the pulse, are blocked on a human editing the world (a goal, the
          PLAYBOOK, creds, a priority call) — which the next tick observes.
       2. A worker is ⚑flagged and waiting for the human: relay its note and tell
          them how to answer, e.g. "fix-pr-2143 waiting: <note> → looop attach
          fix-pr-2143". The flag stays up in WORKER SESSIONS until they answer,
          so notify ONCE — don't re-notify the same flag every tick.
     There is NO reply channel and NO state kept on the notice itself: for a
     question whose answer must flow back INTO running work, the worker's own
     ⚑flag (above) is the session-backed channel — the human attaches to reply.

  {"action":"write_playbook","body":"<full PLAYBOOK.md contents>"}
     Change your own judgment / guardrails. Deliberate — only harden a drift into
     a rule once it actually hurts.

Every action ALSO takes:
  "journal": "<one line: what you did and why>"  — looop appends it, timestamped.
  "next_interval_s": <int>  — OPTIONAL one-shot cadence nudge (clamped 5..3600):
     tighten when a backlog is piling up, widen when it's been quiet a long while.

Two of the SENSOR READINGS are looop's OWN state (system sensors, not
sensors/*.sh):
  • sys-sessions — the live worker fleet. An entry with a `note` means that worker
    raised a ⚑flag and is WAITING for the human: relay it via send_notification;
    do NOT answer a human-flag yourself. Prefer steering an existing worker over
    spawning a SECOND one for the same goal.
  • sys-claims — live worker leases. A name listed here is OWNED by the worker
    reconciling it; do NOT act on it yourself.
Current local time: __NOW__.

Write your single JSON object to `.decision.json` now, then stop.

"#;

fn sorted_glob(dir: &Path, ext: &str) -> Vec<PathBuf> {
    let mut v: Vec<PathBuf> = fs::read_dir(dir)
        .into_iter()
        .flatten()
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().map(|e| e == ext).unwrap_or(false))
        .collect();
    v.sort();
    v
}

fn tail_lines(text: &str, n: usize) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let start = lines.len().saturating_sub(n);
    lines[start..].join("\n")
}

pub fn build_prompt(paths: &Paths, snap_dir: &Path) -> String {
    let mut out = String::new();

    let instr = INSTRUCTIONS
        .replace("__DATA__", &paths.data_dir.to_string_lossy())
        .replace("__NOW__", &util::date_fmt("%Y-%m-%d %H:%M %Z"));
    out.push_str(&instr);

    // PLAYBOOK.
    out.push_str("=== PLAYBOOK ===\n");
    out.push_str(&fs::read_to_string(paths.playbook()).unwrap_or_default());
    out.push('\n');

    // GOALS.
    out.push_str("\n=== GOALS ===\n");
    let goals = sorted_glob(&paths.goals_dir(), "md");
    if goals.is_empty() {
        out.push_str("(no goals yet)\n");
    } else {
        for g in goals {
            let name = g.file_name().unwrap_or_default().to_string_lossy();
            let _ = writeln!(out, "--- {name}");
            out.push_str(&fs::read_to_string(&g).unwrap_or_default());
            out.push('\n');
        }
    }

    // SENSOR READINGS.
    out.push_str("\n=== SENSOR READINGS ===\n");
    for o in sorted_glob(snap_dir, "json") {
        let fname = o
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        // User sensors (`sensor-*`) and the virtual system sensors (`sys-*`) are
        // one uniform stream here — the fleet and leases arrive as `sys-sessions`
        // / `sys-claims`, no bespoke per-kind sections.
        if !(fname.starts_with("sensor-") || fname.starts_with("sys-")) {
            continue;
        }
        let _ = writeln!(out, "--- {fname}");
        out.push_str(&fs::read_to_string(&o).unwrap_or_default());
        out.push('\n');
    }

    // RECENT JOURNAL.
    out.push_str("\n=== RECENT JOURNAL ===\n");
    match fs::read_to_string(paths.journal()) {
        Ok(j) if !j.is_empty() => {
            out.push_str(&tail_lines(&j, 20));
            out.push('\n');
        }
        _ => out.push_str("(empty)\n"),
    }

    out
}

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

    fn fixture() -> Paths {
        let p = Paths::temp();
        fs::create_dir_all(p.goals_dir()).unwrap();
        fs::create_dir_all(p.claims_dir()).unwrap();
        fs::write(p.playbook(), b"PB RULES\n").unwrap();
        fs::write(p.goals_dir().join("triage.md"), b"triage the inbox\n").unwrap();
        p
    }

    #[test]
    fn build_prompt_has_all_sections() {
        let p = fixture();
        let out = build_prompt(&p, &p.snapshots_dir());
        for marker in [
            "=== PLAYBOOK ===",
            "=== GOALS ===",
            "=== SENSOR READINGS ===",
            "=== RECENT JOURNAL ===",
        ] {
            assert!(out.contains(marker), "missing section: {marker}");
        }
        assert!(out.contains("PB RULES"), "playbook body inlined");
        assert!(out.contains("triage the inbox"), "goal body inlined");
    }
}