1use 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
15pub 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
25pub 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
81pub 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}