devist 0.26.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
//! Inbox audit — periodic quality check on unconfirmed advice.
//!
//! Companion to `verify` but with a different question:
//! - `verify` asks "did the user fix this?"
//! - `audit` asks "should this advice have been raised in the first place?"
//!
//! Triggers (whichever fires first):
//! - **30-minute tick** after last successful run.
//! - **Accumulation gate**: 5+ unconfirmed actionable items older than 5min.
//!
//! Process:
//! 1. Fetch unconfirmed `suggest|warn|block` advice older than `MIN_AGE_SECS`
//!    (so freshly-generated items have a chance to be acted on).
//! 2. Send to Claude with the same MUST/MUST NOT bar that gates advice
//!    generation, plus dedup logic against current Reso strong/constraint
//!    memories.
//! 3. Auto-confirm the rejects with `confirmed_by='audit'` and a reason logged.
//!
//! Cost: 1 Claude call per cycle.

use anyhow::{anyhow, Context, Result};
use chrono::Local;
use std::time::{Duration, Instant};

use crate::worker::claude::ClaudeCli;
use crate::worker::config::WorkerConfig;
use crate::worker::supabase::{AuditAdvice, SupabaseClient};

const TICK_INTERVAL_SECS: u64 = 30;
const HALF_HOUR: Duration = Duration::from_secs(30 * 60);
const NEW_ADVICE_TRIGGER: usize = 5;
const MIN_AGE_SECS: i64 = 5 * 60; // don't audit advice newer than 5min
const MAX_PER_CYCLE: usize = 30;

pub fn run(cfg: WorkerConfig) -> Result<()> {
    let supabase = match make_supabase(&cfg) {
        Some(s) => s,
        None => {
            log_line("[audit] Supabase not configured — thread idle");
            return Ok(());
        }
    };
    let claude = ClaudeCli::new(cfg.claude_bin.clone());

    log_line("[audit] thread up");

    let mut last_run = Instant::now() - HALF_HOUR;
    let mut last_seen_count = 0usize;
    let mut last_heartbeat = Instant::now() - Duration::from_secs(60);

    loop {
        std::thread::sleep(Duration::from_secs(TICK_INTERVAL_SECS));

        if last_heartbeat.elapsed() >= Duration::from_secs(30) {
            let _ = supabase.heartbeat("audit");
            last_heartbeat = Instant::now();
        }

        let pending = match supabase.list_pending_actionable_advice(MIN_AGE_SECS, MAX_PER_CYCLE) {
            Ok(p) => p,
            Err(e) => {
                log_line(&format!("[audit] count err: {}", e));
                continue;
            }
        };
        let new_since_last = pending.len().saturating_sub(last_seen_count);
        let elapsed = last_run.elapsed();
        let hourly_due = elapsed >= HALF_HOUR;
        let accumulation_due = new_since_last >= NEW_ADVICE_TRIGGER;

        if (!hourly_due && !accumulation_due) || pending.is_empty() {
            continue;
        }

        let trigger = if hourly_due { "30min" } else { "accumulation" };
        log_line(&format!(
            "[audit] {} trigger ({} pending older than {}s)",
            trigger,
            pending.len(),
            MIN_AGE_SECS
        ));

        match run_once(&supabase, &claude, &cfg.advice_locale, &pending) {
            Ok(summary) => log_line(&format!("[audit] {}", summary)),
            Err(e) => log_line(&format!("[audit] cycle err: {}", e)),
        }

        last_run = Instant::now();
        last_seen_count = supabase
            .list_pending_actionable_advice(MIN_AGE_SECS, MAX_PER_CYCLE)
            .map(|p| p.len())
            .unwrap_or(0);
    }
}

/// One-shot entry point: run a single audit cycle and return a summary.
/// Used by the `devist worker run-task audit` CLI subcommand and by
/// the `task_audit` worker_jobs handler. Same logic the daemon thread
/// invokes on its 30-min schedule, just decoupled from the loop.
pub fn run_task(cfg: &WorkerConfig) -> Result<String> {
    let supabase = make_supabase(cfg)
        .ok_or_else(|| anyhow!("Supabase not configured (supabase_url/supabase_key)"))?;
    let claude = ClaudeCli::new(cfg.claude_bin.clone());
    let pending = supabase.list_pending_actionable_advice(MIN_AGE_SECS, MAX_PER_CYCLE)?;
    if pending.is_empty() {
        return Ok("nothing to audit (no pending actionable advice older than 5min)".into());
    }
    run_once(&supabase, &claude, &cfg.advice_locale, &pending)
}

fn make_supabase(cfg: &WorkerConfig) -> Option<SupabaseClient> {
    let (url, key) = match (&cfg.supabase_url, &cfg.supabase_key) {
        (Some(u), Some(k)) if !u.is_empty() && !k.is_empty() => (u.as_str(), k.as_str()),
        _ => return None,
    };
    let client_id = cfg
        .client_id
        .as_deref()
        .filter(|s| !s.is_empty())
        .unwrap_or("unknown");
    SupabaseClient::new(url, key, client_id).ok()
}

fn run_once(
    supabase: &SupabaseClient,
    claude: &ClaudeCli,
    locale: &str,
    items: &[AuditAdvice],
) -> Result<String> {
    // Pull current strong/constraint memories so the auditor can detect
    // "this advice contradicts a user-declared invariant".
    let mut memory_lines = String::new();
    if let Ok(mems) = supabase.list_memories(&["constraint", "strong"], None, None, &[]) {
        for m in mems.iter().take(40) {
            let proj = m.project.as_deref().unwrap_or("-");
            memory_lines.push_str(&format!("- [{}/{}] {}\n", m.priority, proj, m.text));
        }
    }
    if memory_lines.is_empty() {
        memory_lines.push_str("(no strong memories registered)\n");
    }

    let prompt = build_prompt(items, &memory_lines, locale);
    let raw = claude
        .ask_json(&prompt, Duration::from_secs(120))
        .context("claude audit call")?;
    let verdicts = raw
        .get("verdicts")
        .and_then(|v| v.as_array())
        .ok_or_else(|| anyhow!("no verdicts[] in response"))?;

    let mut kept = 0usize;
    let mut confirmed = 0usize;
    let mut errors = 0usize;

    for v in verdicts {
        let id = match v.get("id").and_then(|x| x.as_i64()) {
            Some(id) => id,
            None => {
                errors += 1;
                continue;
            }
        };
        let action = v.get("action").and_then(|x| x.as_str()).unwrap_or("keep");
        match action {
            "keep" => kept += 1,
            "confirm" => {
                if let Err(e) = supabase.ack_event(id, "audit") {
                    log_line(&format!("[audit] confirm #{} err: {}", id, e));
                    errors += 1;
                } else {
                    confirmed += 1;
                    if let Some(reason) = v.get("reason").and_then(|x| x.as_str()) {
                        log_line(&format!("[audit] confirmed #{}: {}", id, reason));
                    }
                }
            }
            _ => kept += 1,
        }
    }

    Ok(format!(
        "{} reviewed → keep {}, confirm(noise) {} (errors {})",
        items.len(),
        kept,
        confirmed,
        errors,
    ))
}

fn build_prompt(items: &[AuditAdvice], memory_lines: &str, locale: &str) -> String {
    let mut listing = String::new();
    for it in items {
        listing.push_str(&format!(
            "#{} [{}] project={}\n  paths: {}\n  {}\n\n",
            it.id,
            it.severity,
            it.project,
            it.paths.join(", "),
            it.text
        ));
    }

    format!(
        r#"You are the audit pass over the Devist advice inbox.
Each item below is an unconfirmed piece of advice older than {min_age}s.
Decide for each: was it valuable enough to raise, given the
current strong/constraint user memories? Auto-confirm the noise.

============================================================
USER STRONG / CONSTRAINT MEMORIES
============================================================
{memory_lines}
============================================================
THE BAR — what counts as valuable advice
============================================================
A piece of advice is valuable IFF acting on it would meaningfully
improve the code. The user already sees the diff; they don't need it
narrated. Apply the same MUST / MUST NOT used at generation time:

MUST (keep):
1. Concrete bug / risk with a specific file location + line/symbol
2. Code-doc or code-code drift with both sides cited
3. Cross-cutting invariant violated (security, RLS, secrets)
4. Concrete refactor opportunity with a clear, measurable win

MUST NOT (confirm as noise):
- Anything about temp / swap / build artifacts
  (*.tmp.*, *~, 4913, *.timestamp-*.mjs, tsbuildinfo, SMOKE_*.md)
- ".gitignore should include X" — noise unless X is secret-bearing
- "Consider adding tests" — generic, not a concrete invariant gap
- "Documentation might be stale" — only valid with both sides cited
- Restating what the user just changed
- Anything contradicting a strong/constraint memory above
  (those are explicit user decisions; don't fight them)
- Duplicates / near-duplicates of other items in the same batch
  (keep the most specific one, confirm the rest)

============================================================
INPUT — pending advice
============================================================
{listing}
============================================================
OUTPUT — STRICT JSON, no markdown fences
============================================================
{{
  "verdicts": [
    {{"id": <int>, "action": "keep|confirm", "reason": "<short, in {locale}>"}}
  ]
}}

Every item in the input MUST appear exactly once in `verdicts`.
`reason` is required for `action=confirm` (briefly explain which MUST NOT
category it violated, or which memory it duplicates). `reason` is
optional for `action=keep`.
"#,
        min_age = MIN_AGE_SECS,
        memory_lines = memory_lines,
        listing = listing,
        locale = locale,
    )
}

fn log_line(msg: &str) {
    let now = Local::now().format("%Y-%m-%d %H:%M:%S");
    println!("{} {}", now, msg);
}