Skip to main content

kaizen/shell/
guidance.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen guidance` — skill/rule adoption and cost proxy from observed payload references.
3
4use crate::core::config;
5use crate::core::data_source::DataSource;
6use crate::retro::inputs::{scan_rule_files, scan_skill_files};
7use crate::shell::cli::{maybe_refresh_store, workspace_path};
8use crate::shell::remote_pull::maybe_telemetry_pull;
9use crate::store::{GuidanceKind, GuidanceReport, Store};
10use anyhow::Result;
11use std::collections::HashSet;
12use std::fmt::Write;
13use std::path::Path;
14
15/// `(start_ms, end_ms)` for a trailing `days` window from now.
16pub fn trailing_window_ms(days: u32) -> (u64, u64) {
17    let end_ms = std::time::SystemTime::now()
18        .duration_since(std::time::UNIX_EPOCH)
19        .unwrap_or_default()
20        .as_millis() as u64;
21    let start_ms = end_ms.saturating_sub((days as u64).saturating_mul(86_400_000));
22    (start_ms, end_ms)
23}
24
25/// Build guidance report after optional agent rescan.
26pub fn build_guidance_report(
27    store: &Store,
28    workspace_root: &Path,
29    workspace_key: &str,
30    days: u32,
31) -> Result<GuidanceReport> {
32    let (start_ms, end_ms) = trailing_window_ms(days);
33    let skill_files = scan_skill_files(workspace_root, end_ms)?;
34    let rule_files = scan_rule_files(workspace_root, end_ms)?;
35    let skill_slugs: HashSet<String> = skill_files.into_iter().map(|s| s.slug).collect();
36    let rule_slugs: HashSet<String> = rule_files.into_iter().map(|s| s.slug).collect();
37    store.guidance_report(workspace_key, start_ms, end_ms, &skill_slugs, &rule_slugs)
38}
39
40pub fn guidance_text(
41    workspace: Option<&Path>,
42    days: u32,
43    json_out: bool,
44    refresh: bool,
45    source: DataSource,
46) -> Result<String> {
47    let ws = workspace_path(workspace)?;
48    let db_path = ws.join(".kaizen/kaizen.db");
49    let store = Store::open(&db_path)?;
50    let ws_str = ws.to_string_lossy().to_string();
51    let cfg = config::load(&ws)?;
52    maybe_telemetry_pull(&ws, &store, &cfg, source, refresh)?;
53    maybe_refresh_store(&ws, &store, refresh)?;
54    let mut report = build_guidance_report(&store, &ws, &ws_str, days)?;
55    if source != DataSource::Local
56        && let Ok(Some(agg)) = crate::shell::remote_observe::try_remote_event_agg(&store, &cfg, &ws)
57    {
58        report =
59            crate::shell::remote_observe::merge_guidance_sessions_in_window(report, &agg, source);
60    }
61    if json_out {
62        return Ok(serde_json::to_string_pretty(&report)?);
63    }
64    Ok(format_human(&report, days))
65}
66
67pub fn cmd_guidance(
68    workspace: Option<&Path>,
69    days: u32,
70    json_out: bool,
71    refresh: bool,
72    source: DataSource,
73) -> Result<()> {
74    print!(
75        "{}",
76        guidance_text(workspace, days, json_out, refresh, source)?
77    );
78    Ok(())
79}
80
81/// Short block for `kaizen insights` (top observed skills/rules by session count).
82pub fn format_guidance_teaser(
83    store: &Store,
84    workspace_root: &Path,
85    workspace_key: &str,
86    days: u32,
87) -> Result<String> {
88    let report = build_guidance_report(store, workspace_root, workspace_key, days)?;
89    let mut s = String::new();
90    let _ = writeln!(
91        &mut s,
92        "Guidance (observed .cursor/skills + .cursor/rules path refs, last {days}d)"
93    );
94    let _ = writeln!(
95        &mut s,
96        "  Sessions in window: {} · workspace avg $/session: {}",
97        report.sessions_in_window,
98        report
99            .workspace_avg_cost_per_session_usd
100            .map(|v| format!("{v:.4}"))
101            .unwrap_or_else(|| "n/a".into())
102    );
103    let mut active: Vec<_> = report.rows.iter().filter(|r| r.sessions > 0).collect();
104    active.sort_by_key(|r| std::cmp::Reverse(r.sessions));
105    if active.is_empty() {
106        let _ = writeln!(
107            &mut s,
108            "  (no skill/rule path references in payloads — run agents that read SKILL.md / .mdc)"
109        );
110    } else {
111        let _ = writeln!(&mut s, "  Top by sessions:");
112        for r in active.iter().take(3) {
113            let kind = match r.kind {
114                GuidanceKind::Skill => "skill",
115                GuidanceKind::Rule => "rule",
116            };
117            let _ = writeln!(
118                &mut s,
119                "    · {} `{}` — {} sessions ({:.1}% of window)",
120                kind, r.id, r.sessions, r.sessions_pct
121            );
122        }
123    }
124    let _ = writeln!(&mut s, "  Full table: `kaizen guidance --days {days}`");
125    Ok(s)
126}
127
128fn format_human(report: &GuidanceReport, days: u32) -> String {
129    let mut s = String::new();
130    let _ = writeln!(
131        &mut s,
132        "kaizen guidance — {} (last {}d, observed payload refs only)",
133        report.workspace, days
134    );
135    let _ = writeln!(&mut s);
136    let _ = writeln!(&mut s, "Sessions in window: {}", report.sessions_in_window);
137    let _ = writeln!(
138        &mut s,
139        "Workspace avg $/session: {}",
140        report
141            .workspace_avg_cost_per_session_usd
142            .map(|v| format!("{v:.4}"))
143            .unwrap_or_else(|| "n/a".into())
144    );
145    let _ = writeln!(&mut s);
146    let _ = writeln!(
147        &mut s,
148        "{:<6} {:<24} {:>9} {:>8} {:>10} {:>10}  note",
149        "kind", "id", "sessions", "%window", "avg$/sess", "vs avg"
150    );
151    for r in &report.rows {
152        let kind = match r.kind {
153            GuidanceKind::Skill => "skill",
154            GuidanceKind::Rule => "rule",
155        };
156        let avg = r
157            .avg_cost_per_session_usd
158            .map(|v| format!("{v:.4}"))
159            .unwrap_or_else(|| "n/a".into());
160        let vs = r
161            .vs_workspace_avg_cost_per_session_usd
162            .map(|v| format!("{:+.4}", v))
163            .unwrap_or_else(|| "n/a".into());
164        let note = if r.sessions == 0 && r.on_disk {
165            "unused on disk"
166        } else if !r.on_disk && r.sessions > 0 {
167            "not in workspace inventory"
168        } else {
169            ""
170        };
171        let _ = writeln!(
172            &mut s,
173            "{:<6} {:<24} {:>9} {:>7.1}% {:>10} {:>10}  {}",
174            kind, r.id, r.sessions, r.sessions_pct, avg, vs, note
175        );
176    }
177    let _ = writeln!(&mut s);
178    let _ = writeln!(
179        &mut s,
180        "Counts reflect path strings in ingested tool payloads, not silent Cursor rule injection."
181    );
182    s
183}