kaizen-cli 0.1.43

Distributable agent observability: real-time-tailable sessions, agile-style retros, and repo-level improvement (Cursor, Claude Code, Codex). SQLite, redact before any sync you enable.
Documentation
use super::*;

impl Store {
    pub fn upsert_guidance_candidate(&self, c: &crate::guidance::GuidanceCandidate) -> Result<()> {
        let action_json = serde_json::to_string(&c.action)?;
        let evidence_json = serde_json::to_string(&c.evidence)?;
        self.conn.execute(
            "INSERT OR REPLACE INTO guidance_candidates
             (id, artifact_kind, artifact_id, action_json, status, rationale,
              evidence_json, created_at_ms, applied_at_ms, treatment_fingerprint,
              experiment_id, backup_path)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
            params![
                c.id,
                c.artifact.kind.as_str(),
                c.artifact.slug,
                action_json,
                c.status.as_str(),
                c.rationale,
                evidence_json,
                c.created_at_ms as i64,
                c.applied_at_ms.map(|v| v as i64),
                c.treatment_fingerprint.as_deref(),
                c.experiment_id.as_deref(),
                c.backup_path.as_deref(),
            ],
        )?;
        Ok(())
    }

    pub fn list_guidance_candidates(&self) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
        let mut stmt = self.conn.prepare(GUIDANCE_CANDIDATE_SELECT)?;
        let rows = stmt.query_map([], guidance_candidate_row)?;
        rows.map(|r| r.map_err(anyhow::Error::from)).collect()
    }

    pub fn get_guidance_candidate(
        &self,
        id: &str,
    ) -> Result<Option<crate::guidance::GuidanceCandidate>> {
        self.conn
            .query_row(
                &format!("{GUIDANCE_CANDIDATE_SELECT} WHERE id = ?1"),
                params![id],
                guidance_candidate_row,
            )
            .optional()
            .map_err(Into::into)
    }

    pub fn rejected_guidance_candidates(
        &self,
        artifact: &crate::guidance::ArtifactRef,
        limit: usize,
    ) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
        let sql = format!(
            "{GUIDANCE_CANDIDATE_SELECT} WHERE artifact_kind = ?1 AND artifact_id = ?2
             AND status = 'rejected' ORDER BY created_at_ms DESC LIMIT ?3"
        );
        let mut stmt = self.conn.prepare(&sql)?;
        let rows = stmt.query_map(
            params![artifact.kind.as_str(), artifact.slug.as_str(), limit as i64],
            guidance_candidate_row,
        )?;
        rows.map(|r| r.map_err(anyhow::Error::from)).collect()
    }

    pub fn set_guidance_candidate_status(
        &self,
        id: &str,
        status: crate::guidance::CandidateStatus,
    ) -> Result<()> {
        self.conn.execute(
            "UPDATE guidance_candidates SET status = ?2 WHERE id = ?1",
            params![id, status.as_str()],
        )?;
        Ok(())
    }
}

const GUIDANCE_CANDIDATE_SELECT: &str = "SELECT id, artifact_kind, artifact_id, action_json,
    status, rationale, evidence_json, created_at_ms, applied_at_ms,
    treatment_fingerprint, experiment_id, backup_path FROM guidance_candidates";

fn guidance_candidate_row(
    r: &rusqlite::Row<'_>,
) -> rusqlite::Result<crate::guidance::GuidanceCandidate> {
    let kind: String = r.get(1)?;
    let status: String = r.get(4)?;
    Ok(crate::guidance::GuidanceCandidate {
        id: r.get(0)?,
        artifact: crate::guidance::ArtifactRef {
            kind: crate::guidance::ArtifactKind::parse(&kind)
                .unwrap_or(crate::guidance::ArtifactKind::Skill),
            slug: r.get(2)?,
        },
        action: serde_json::from_str(&r.get::<_, String>(3)?)
            .unwrap_or(crate::guidance::CandidateAction::ReviewOnly),
        status: crate::guidance::CandidateStatus::parse(&status)
            .unwrap_or(crate::guidance::CandidateStatus::Proposed),
        rationale: r.get(5)?,
        evidence: serde_json::from_str(&r.get::<_, String>(6)?).unwrap_or_default(),
        created_at_ms: r.get::<_, i64>(7)? as u64,
        applied_at_ms: r.get::<_, Option<i64>>(8)?.map(|v| v as u64),
        treatment_fingerprint: r.get(9)?,
        experiment_id: r.get(10)?,
        backup_path: r.get(11)?,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::guidance::{ArtifactKind, ArtifactRef, CandidateAction, CandidateStatus};

    #[test]
    fn candidate_round_trip_and_status() -> anyhow::Result<()> {
        let dir = tempfile::tempdir()?;
        let store = Store::open(&dir.path().join("k.db"))?;
        let c = candidate();
        store.upsert_guidance_candidate(&c)?;
        store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
        let got = store.get_guidance_candidate("c1")?.unwrap();
        assert_eq!(got.status, CandidateStatus::Rejected);
        Ok(())
    }

    #[test]
    fn rejected_candidates_filter_by_artifact() -> anyhow::Result<()> {
        let dir = tempfile::tempdir()?;
        let store = Store::open(&dir.path().join("k.db"))?;
        store.upsert_guidance_candidate(&candidate())?;
        store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
        let got = store.rejected_guidance_candidates(&candidate().artifact, 10)?;
        assert_eq!(got.len(), 1);
        Ok(())
    }

    fn candidate() -> crate::guidance::GuidanceCandidate {
        crate::guidance::GuidanceCandidate {
            id: "c1".into(),
            artifact: ArtifactRef {
                kind: ArtifactKind::Skill,
                slug: "tdd".into(),
            },
            action: CandidateAction::ReviewOnly,
            status: CandidateStatus::Proposed,
            rationale: "inspect".into(),
            evidence: vec!["e".into()],
            created_at_ms: 1,
            applied_at_ms: None,
            treatment_fingerprint: None,
            experiment_id: None,
            backup_path: None,
        }
    }
}