devist 0.26.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
//! Tier 2 — meta-thinking pass.
//!
//! Reads recent advice + outcomes (from migration 0017's
//! `advice_outcomes` view) + current strong/constraint memories +
//! existing proposed memories (to skip dups). Asks Claude to surface
//! patterns and propose new strong/project or strong/tech memories
//! that would have changed how the past week's advice was raised.
//!
//! Output: 0..N rows inserted into `memories` with status='proposed'
//! and proposed_reason filled. The user reviews each in the dashboard
//! and approves (status='active') or rejects (status='rejected').
//!
//! Cost: 1 Claude call per cycle; never more than MAX_PROPOSALS rows
//! per cycle to keep the review surface manageable.

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

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

const MAX_PROPOSALS: usize = 5;
const LOOKBACK_DAYS: i64 = 7;
const MAX_ADVICE_SAMPLE: usize = 60;
const MAX_MEMORY_CONTEXT: usize = 50;

/// One-shot entry point. Same shape as audit::run_task /
/// consolidate::run_task — Phase 3's pattern.
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 advice_lines = fetch_recent_advice_with_outcomes(&supabase)?;
    if advice_lines.is_empty() {
        return Ok(format!(
            "no resolved advice in the last {LOOKBACK_DAYS} days — nothing to reflect on"
        ));
    }
    let active_memories = supabase.list_all_memories().unwrap_or_default();
    let proposed_memories = supabase.list_proposed_memories().unwrap_or_default();

    let prompt = build_prompt(
        &advice_lines,
        &active_memories,
        &proposed_memories,
        &cfg.advice_locale,
    );
    let raw = claude
        .ask_json(&prompt, Duration::from_secs(120))
        .context("claude reflect call")?;
    let proposals = raw
        .get("proposals")
        .and_then(|v| v.as_array())
        .ok_or_else(|| anyhow!("no proposals[] in response"))?;

    let mut inserted = 0usize;
    let mut errors = 0usize;
    for p in proposals.iter().take(MAX_PROPOSALS) {
        let text = p.get("text").and_then(|x| x.as_str()).unwrap_or("").trim();
        if text.is_empty() {
            continue;
        }
        let scope = p.get("scope").and_then(|x| x.as_str()).unwrap_or("project");
        let priority = p
            .get("priority")
            .and_then(|x| x.as_str())
            .unwrap_or("strong");
        let project_raw = p.get("project").and_then(|x| x.as_str());
        let project_canonical = project_raw.map(|raw| {
            cfg.project_aliases
                .get(raw)
                .map(|a| a.as_str())
                .unwrap_or(raw)
        });
        let tech: Vec<String> = p
            .get("tech")
            .and_then(|x| x.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|t| t.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default();
        let reason = p
            .get("reason")
            .and_then(|x| x.as_str())
            .unwrap_or("(no reason)");

        match supabase.insert_memory_with_status(
            text,
            scope,
            priority,
            "claude",
            project_canonical,
            &tech,
            "proposed",
            Some(reason),
        ) {
            Ok(id) => {
                inserted += 1;
                log_line(&format!("[reflect] proposed memory #{}: {}", id, reason));
            }
            Err(e) => {
                errors += 1;
                log_line(&format!("[reflect] insert err: {}", e));
            }
        }
    }

    Ok(format!(
        "{} advice samples reviewed → {} proposals inserted (errors {})",
        advice_lines.len(),
        inserted,
        errors,
    ))
}

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

/// Pull the last LOOKBACK_DAYS of resolved advice from the
/// advice_outcomes view, formatted as compact lines for the prompt.
fn fetch_recent_advice_with_outcomes(supabase: &SupabaseClient) -> Result<Vec<String>> {
    let cutoff = chrono::Utc::now() - chrono::Duration::days(LOOKBACK_DAYS);
    let url = format!(
        "{}/rest/v1/advice_outcomes?created_at=gte.{}&outcome=neq.pending&select=id,project,severity,confirmed_by,outcome,created_at&order=created_at.desc&limit={}",
        supabase.base_url(),
        urlencoding::encode(&cutoff.to_rfc3339()),
        MAX_ADVICE_SAMPLE,
    );
    let raw = supabase.get_raw(&url)?;
    let arr: Vec<Value> = serde_json::from_str(&raw).context("parse advice_outcomes")?;

    // For each row, also fetch the payload.text via a separate query
    // to keep the lines self-contained. To avoid N+1 we batch-fetch
    // worker_events.payload by id.
    let ids: Vec<i64> = arr
        .iter()
        .filter_map(|v| v.get("id").and_then(|x| x.as_i64()))
        .collect();
    if ids.is_empty() {
        return Ok(Vec::new());
    }
    let id_csv = ids
        .iter()
        .map(|i| i.to_string())
        .collect::<Vec<_>>()
        .join(",");
    let detail_url = format!(
        "{}/rest/v1/worker_events?id=in.({})&select=id,payload",
        supabase.base_url(),
        id_csv
    );
    let detail_raw = supabase.get_raw(&detail_url)?;
    let details: Vec<Value> = serde_json::from_str(&detail_raw).context("parse details")?;

    let text_for_id: std::collections::HashMap<i64, String> = details
        .iter()
        .filter_map(|d| {
            let id = d.get("id").and_then(|x| x.as_i64())?;
            let txt = d
                .get("payload")
                .and_then(|p| p.get("text"))
                .and_then(|t| t.as_str())
                .unwrap_or("")
                .chars()
                .take(180)
                .collect::<String>();
            Some((id, txt))
        })
        .collect();

    Ok(arr
        .into_iter()
        .filter_map(|v| {
            let id = v.get("id").and_then(|x| x.as_i64())?;
            let project = v.get("project").and_then(|x| x.as_str()).unwrap_or("-");
            let severity = v.get("severity").and_then(|x| x.as_str()).unwrap_or("?");
            let outcome = v.get("outcome").and_then(|x| x.as_str()).unwrap_or("?");
            let confirmed_by = v.get("confirmed_by").and_then(|x| x.as_str()).unwrap_or("");
            let txt = text_for_id.get(&id).cloned().unwrap_or_default();
            Some(format!(
                "#{id} [{severity}/{outcome}/by={confirmed_by}] {project}: {txt}"
            ))
        })
        .collect())
}

fn build_prompt(
    advice_lines: &[String],
    active: &[MemoryRow],
    proposed: &[MemoryRow],
    locale: &str,
) -> String {
    let mut active_block = String::new();
    for m in active.iter().take(MAX_MEMORY_CONTEXT) {
        let proj = m.project.as_deref().unwrap_or("-");
        active_block.push_str(&format!(
            "- [{}/{}/{}] {}\n",
            m.priority, m.scope, proj, m.text
        ));
    }
    if active_block.is_empty() {
        active_block.push_str("(none)\n");
    }

    let mut proposed_block = String::new();
    for m in proposed {
        let proj = m.project.as_deref().unwrap_or("-");
        proposed_block.push_str(&format!(
            "- [{}/{}/{}] {}\n",
            m.priority, m.scope, proj, m.text
        ));
    }
    if proposed_block.is_empty() {
        proposed_block.push_str("(none)\n");
    }

    let advice_block = advice_lines.join("\n");

    format!(
        r#"You are the reflection pass for the Devist Reso memory system.
Your job: look at the last {days} days of advice + how it was resolved,
and propose NEW strong-priority memories that would make future advice
better. Most reflections produce zero or one proposals — the bar is high.

============================================================
LAST {days} DAYS — ADVICE OUTCOMES
============================================================
Each line: #id [severity/outcome/by=confirmed_by] project: text…

Outcomes mean:
  win     — user trusted the advice (verify auto-confirmed it as fixed,
            or user clicked Apply with AI)
  loss    — advice was wrong (user said "intentional" or audit thread
            confirmed it as noise)
  neutral — user manually confirmed (no signal about advice quality)
  ignored — sat unconfirmed for >7 days

{advice_block}

============================================================
CURRENT STRONG / CONSTRAINT MEMORIES (already active)
============================================================
{active_block}
============================================================
ALREADY-PROPOSED MEMORIES (still under user review — DO NOT re-propose)
============================================================
{proposed_block}
============================================================
TASK
============================================================
Find PATTERNS in the outcomes, not individual fixes. For example:
- "all 3 'add tests' suggest items were marked intentional" → propose a
  user-scope memory: "Tests are added on demand, not opportunistically".
- "every advice mentioning React useEffect cleanup got Apply'd" → propose
  a tech-scope memory: "useEffect callbacks must always return a cleanup
  function when subscribing".
- "block-severity advice on auth.* paths gets handled within an hour;
  suggest-severity ignored" → propose a project-scope memory: "auth
  changes are high-priority; suggest-level on auth.* should be promoted
  to warn".

DO NOT propose:
- Restatements of an existing active memory (would just duplicate).
- Anything already in the proposed list above.
- Vague observations ("user is sometimes inconsistent") — must be
  actionable, falsifiable, and specific enough to inject into a future
  advice prompt.
- Per-incident memories ("issue #42 should not be raised again") — the
  audit thread handles those.

OUTPUT — STRICT JSON, no markdown fences:
{{
  "proposals": [
    {{
      "text": "<the memory content, in {locale}>",
      "scope": "project|tech|user",
      "priority": "strong|constraint",
      "project": "<canonical project name, only if scope=project>",
      "tech": ["..."],
      "reason": "<one sentence in {locale} citing which advice IDs led to this proposal>"
    }}
  ]
}}

Empty `proposals: []` is the correct, expected answer most of the time.
Cap: at most {max_proposals} proposals per cycle.
"#,
        days = LOOKBACK_DAYS,
        advice_block = advice_block,
        active_block = active_block,
        proposed_block = proposed_block,
        locale = locale,
        max_proposals = MAX_PROPOSALS,
    )
}

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