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, open_workspace_read_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 store = open_workspace_read_store(&ws, refresh || source != DataSource::Local)?;
49    let ws_str = ws.to_string_lossy().to_string();
50    let cfg = config::load(&ws)?;
51    maybe_telemetry_pull(&ws, &store, &cfg, source, refresh)?;
52    maybe_refresh_store(&ws, &store, refresh)?;
53    let mut report = build_guidance_report(&store, &ws, &ws_str, days)?;
54    if source != DataSource::Local
55        && let Ok(Some(agg)) = crate::shell::remote_observe::try_remote_event_agg(&store, &cfg, &ws)
56    {
57        report =
58            crate::shell::remote_observe::merge_guidance_sessions_in_window(report, &agg, source);
59    }
60    if json_out {
61        return Ok(serde_json::to_string_pretty(&report)?);
62    }
63    Ok(format_human(&report, days))
64}
65
66pub fn cmd_guidance(
67    workspace: Option<&Path>,
68    days: u32,
69    json_out: bool,
70    refresh: bool,
71    source: DataSource,
72) -> Result<()> {
73    print!(
74        "{}",
75        guidance_text(workspace, days, json_out, refresh, source)?
76    );
77    Ok(())
78}
79
80/// Short block for `kaizen insights` (top observed skills/rules by session count).
81pub fn format_guidance_teaser(
82    store: &Store,
83    workspace_root: &Path,
84    workspace_key: &str,
85    days: u32,
86) -> Result<String> {
87    let report = build_guidance_report(store, workspace_root, workspace_key, days)?;
88    let mut s = String::new();
89    let _ = writeln!(
90        &mut s,
91        "Guidance (observed .cursor/skills + .cursor/rules path refs, last {days}d)"
92    );
93    let _ = writeln!(
94        &mut s,
95        "  Sessions in window: {} · workspace avg $/session: {}",
96        report.sessions_in_window,
97        report
98            .workspace_avg_cost_per_session_usd
99            .map(|v| format!("{v:.4}"))
100            .unwrap_or_else(|| "n/a".into())
101    );
102    let mut active: Vec<_> = report.rows.iter().filter(|r| r.sessions > 0).collect();
103    active.sort_by_key(|r| std::cmp::Reverse(r.sessions));
104    if active.is_empty() {
105        let _ = writeln!(
106            &mut s,
107            "  (no skill/rule path references in payloads — run agents that read SKILL.md / .mdc)"
108        );
109    } else {
110        let _ = writeln!(&mut s, "  Top by sessions:");
111        for r in active.iter().take(3) {
112            let kind = match r.kind {
113                GuidanceKind::Skill => "skill",
114                GuidanceKind::Rule => "rule",
115            };
116            let _ = writeln!(
117                &mut s,
118                "    · {} `{}` — {} sessions ({:.1}% of window)",
119                kind, r.id, r.sessions, r.sessions_pct
120            );
121        }
122    }
123    let _ = writeln!(&mut s, "  Full table: `kaizen guidance --days {days}`");
124    Ok(s)
125}
126
127fn format_human(report: &GuidanceReport, days: u32) -> String {
128    let mut s = String::new();
129    let _ = writeln!(
130        &mut s,
131        "kaizen guidance — {} (last {}d, observed payload refs only)",
132        report.workspace, days
133    );
134    let _ = writeln!(&mut s);
135    let _ = writeln!(&mut s, "Sessions in window: {}", report.sessions_in_window);
136    let _ = writeln!(
137        &mut s,
138        "Workspace avg $/session: {}",
139        report
140            .workspace_avg_cost_per_session_usd
141            .map(|v| format!("{v:.4}"))
142            .unwrap_or_else(|| "n/a".into())
143    );
144    let _ = writeln!(&mut s);
145    let _ = writeln!(
146        &mut s,
147        "{:<6} {:<24} {:>9} {:>8} {:>10} {:>10}  note",
148        "kind", "id", "sessions", "%window", "avg$/sess", "vs avg"
149    );
150    for r in &report.rows {
151        let kind = match r.kind {
152            GuidanceKind::Skill => "skill",
153            GuidanceKind::Rule => "rule",
154        };
155        let avg = r
156            .avg_cost_per_session_usd
157            .map(|v| format!("{v:.4}"))
158            .unwrap_or_else(|| "n/a".into());
159        let vs = r
160            .vs_workspace_avg_cost_per_session_usd
161            .map(|v| format!("{:+.4}", v))
162            .unwrap_or_else(|| "n/a".into());
163        let note = if r.sessions == 0 && r.on_disk {
164            "unused on disk"
165        } else if !r.on_disk && r.sessions > 0 {
166            "not in workspace inventory"
167        } else {
168            ""
169        };
170        let _ = writeln!(
171            &mut s,
172            "{:<6} {:<24} {:>9} {:>7.1}% {:>10} {:>10}  {}",
173            kind, r.id, r.sessions, r.sessions_pct, avg, vs, note
174        );
175    }
176    let _ = writeln!(&mut s);
177    let _ = writeln!(
178        &mut s,
179        "Counts reflect path strings in ingested tool payloads, not silent Cursor rule injection."
180    );
181    s
182}