agentstatedeveloper_cli/commands/
status.rs1use anyhow::Result;
8use clap::{Args, Subcommand};
9use serde_json::json;
10
11use agentstatedeveloper_core::{
12 AsgLedgerStore, Engine, LedgerStore, SearchFtsDb, SidecarState, compute_index_consistency,
13 compute_trust_score, format_age, ledger_counts,
14 schema::{LedgerKind, Symbol},
15 sidecar_lifecycle_state,
16};
17
18use crate::config::Config;
19
20#[derive(Debug, Args)]
21pub struct StatusArgs {
22 #[arg(long)]
24 pub show_dirty: bool,
25
26 #[arg(long)]
28 pub json: bool,
29
30 #[command(subcommand)]
32 pub command: Option<StatusSubcommand>,
33}
34
35#[derive(Debug, Subcommand)]
36pub enum StatusSubcommand {
37 History(StatusHistoryArgs),
39}
40
41#[derive(Debug, Args)]
42pub struct StatusHistoryArgs {
43 #[arg(long, default_value = "20")]
45 pub last: usize,
46 #[arg(long)]
48 pub json: bool,
49}
50
51pub fn run(cfg: &Config, args: StatusArgs) -> Result<()> {
52 if let Some(sub) = args.command {
54 match sub {
55 StatusSubcommand::History(h) => return show_history(cfg, &h),
56 }
57 }
58
59 let fts = SearchFtsDb::open(&cfg.db_path)?;
60
61 let project_root = cfg.db_path.parent().unwrap_or(std::path::Path::new("."));
62 let sidecar_state = sidecar_lifecycle_state(project_root);
63
64 if !fts.has_data() {
65 if args.json {
66 println!(
67 "{}",
68 json!({
69 "state": "empty",
70 "note": "run 'asd index <dir>' to build",
71 "sidecar": sidecar_state_key(&sidecar_state),
72 })
73 );
74 } else {
75 println!("ASD index status");
76 println!(" db: {}", cfg.db_path.display());
77 println!(" state: empty — run 'asd index <dir>' to build");
78 }
79 return Ok(());
80 }
81
82 let count = fts.symbol_count();
83 let now = std::time::SystemTime::now()
84 .duration_since(std::time::UNIX_EPOCH)
85 .map(|d| d.as_secs() as i64)
86 .unwrap_or(0);
87
88 let (indexed_at, age_hours, fresh) = match fts.last_indexed_at() {
89 Some(ts) => {
90 let age_h = (now - ts).max(0) / 3600;
91 (Some(ts), Some(age_h), age_h == 0)
92 }
93 None => (None, None, false),
94 };
95
96 let dirty_files = if args.show_dirty || args.json {
97 collect_dirty_files(cfg)
98 } else {
99 vec![]
100 };
101
102 let engine = Engine::open_sqlite(&cfg.db_path).ok();
110
111 let ledger = engine
117 .as_ref()
118 .and_then(|e| ledger_counts(&e.repo, &e.ref_name, e.fts.as_ref(), Some(project_root)).ok());
119 let ledger_warning = ledger.as_ref().and_then(|l| l.warning());
120
121 let (concept_gaps, asg_symbol_count): (Vec<serde_json::Value>, Option<usize>) = if args.json {
122 if let Some(engine) = engine.as_ref() {
123 let ledger_store = AsgLedgerStore::from_engine(engine);
124 let tree = engine
125 .repo
126 .get_tree(&engine.ref_name, "/asd/v1/index/by-qname")
127 .unwrap_or(serde_json::Value::Object(Default::default()));
128 let asg_count = tree.as_object().map(|m| m.len());
129 let gaps = tree
130 .as_object()
131 .map(|m| {
132 m.values()
133 .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
134 .filter_map(|sym| {
135 let entries = ledger_store
136 .list_entries(&engine.ref_name, &sym.symbol_id)
137 .unwrap_or_default();
138 let has_ownership =
139 entries.iter().any(|e| e.kind == LedgerKind::Ownership);
140 let has_concept = entries.iter().any(|e| e.kind == LedgerKind::Concept);
141 if has_ownership && !has_concept {
142 Some(json!({"qname": sym.qname, "file": sym.file}))
143 } else {
144 None
145 }
146 })
147 .collect()
148 })
149 .unwrap_or_default();
150 (gaps, asg_count)
151 } else {
152 (vec![], None)
153 }
154 } else {
155 (vec![], None)
156 };
157
158 let sidecar_key = sidecar_state_key(&sidecar_state);
159 let sidecar_action = ledger_warning
160 .clone()
161 .unwrap_or_else(|| sidecar_action_hint(&sidecar_state).to_string());
162
163 if args.json {
164 let index_state = if fresh {
165 "fresh"
166 } else if age_hours.unwrap_or(0) >= 1 {
167 "stale"
168 } else {
169 "ok"
170 };
171
172 let trust = compute_trust_score(&cfg.db_path);
174
175 let index_consistency = asg_symbol_count
179 .map(|asg| compute_index_consistency(asg, count as usize))
180 .unwrap_or(serde_json::Value::Null);
181
182 let out = json!({
183 "db": cfg.db_path.display().to_string(),
184 "symbols": count,
185 "indexed_at_unix": indexed_at,
186 "age_hours": age_hours,
187 "state": index_state,
188 "sidecar": sidecar_key,
189 "sidecar_action": sidecar_action,
190 "ledger": ledger,
191 "ledger_warning": ledger_warning,
192 "hooks_warning": agentstatedeveloper_core::outdated_hooks(project_root),
193 "dirty_files": dirty_files,
194 "concept_gaps": concept_gaps,
195 "index_consistency": index_consistency,
196 "trust": trust.to_json(),
197 });
198
199 append_trust_history(
204 cfg,
205 &trust,
206 indexed_at,
207 age_hours,
208 count as u64,
209 sidecar_key,
210 dirty_files.len(),
211 concept_gaps.len(),
212 );
213
214 let out = agentstatedeveloper_core::drop_empty_top_level(out);
220
221 println!("{}", serde_json::to_string_pretty(&out)?);
222 return Ok(());
223 }
224
225 println!("ASD index status");
227 println!(" db: {}", cfg.db_path.display());
228 println!(" symbols: {count}");
229
230 match indexed_at {
231 Some(ts) => {
232 println!(" indexed: {} (unix {})", format_age(ts), ts);
233 if age_hours.unwrap_or(0) >= 1 {
234 println!(
235 " warning: index is {}h old — consider re-running 'asd index'",
236 age_hours.unwrap_or(0)
237 );
238 } else {
239 println!(" state: fresh");
240 }
241 }
242 None => println!(" indexed: unknown"),
243 }
244
245 let sidecar_label = match sidecar_state {
246 SidecarState::Missing => "missing — run 'asd sync' to create",
247 SidecarState::Present => "present — run 'asd hydrate' to load into ASG",
248 SidecarState::Hydrated if ledger_warning.is_some() => {
249 "hydrated — but ledger counts disagree (see warning below)"
250 }
251 SidecarState::Hydrated => "hydrated",
252 SidecarState::FreshReset => {
253 "fresh-reset (deliberate reset — re-run 'asd index' + 'asd sync')"
254 }
255 };
256 println!(" sidecar: {sidecar_label}");
257 if let Some(l) = &ledger {
258 let fmt = |n: Option<usize>| n.map_or_else(|| "—".to_string(), |n| n.to_string());
259 println!(
260 " ledger: cache {} · store {} · sidecar {}",
261 fmt(l.cache),
262 l.asg,
263 fmt(l.sidecar)
264 );
265 }
266 if let Some(w) = &ledger_warning {
267 println!(" warning: {w}");
268 }
269 if let Some(w) = agentstatedeveloper_core::outdated_hooks(project_root) {
270 println!(" warning: {w}");
271 }
272
273 if args.show_dirty {
274 let files = dirty_files;
275 if files.is_empty() {
276 println!(" dirty: none (all tracked source files match index)");
277 } else {
278 println!(
279 " dirty: {} modified source file(s) since last commit:",
280 files.len()
281 );
282 for f in &files {
283 println!(" {}", f);
284 }
285 }
286 }
287
288 Ok(())
289}
290
291fn trust_history_path(cfg: &Config) -> std::path::PathBuf {
296 cfg.db_path
297 .parent()
298 .unwrap_or(std::path::Path::new("."))
299 .join(".asd")
300 .join("trust-history.jsonl")
301}
302
303fn append_trust_history(
304 cfg: &Config,
305 trust: &agentstatedeveloper_core::TrustScore,
306 indexed_at: Option<i64>,
307 age_hours: Option<i64>,
308 symbol_count: u64,
309 sidecar_state: &str,
310 dirty_file_count: usize,
311 concept_gap_count: usize,
312) {
313 let now = chrono::Utc::now().to_rfc3339();
314 let record = json!({
315 "timestamp": now,
316 "indexed_at_unix": indexed_at,
317 "age_hours": age_hours,
318 "db_state": if age_hours.map(|h| h >= 1).unwrap_or(false) { "stale" } else { "fresh" },
319 "symbol_count": symbol_count,
320 "sidecar_state": sidecar_state,
321 "dirty_file_count": dirty_file_count,
322 "concept_gap_count": concept_gap_count,
323 "ledger_density": trust.signals.ledger_density,
324 "schema_version": trust.signals.schema_version,
325 "asd_version": env!("CARGO_PKG_VERSION"),
326 "trust_score": trust.score,
327 "trust_level": trust.level,
328 });
329
330 let path = trust_history_path(cfg);
331 if let Some(parent) = path.parent() {
332 let _ = std::fs::create_dir_all(parent);
333 }
334
335 const MAX_LINES: usize = 500;
337 let existing = std::fs::read_to_string(&path).unwrap_or_default();
338 let mut lines: Vec<&str> = existing.lines().collect();
339 let new_line = serde_json::to_string(&record).unwrap_or_default();
340
341 if lines.len() >= MAX_LINES {
343 let keep = MAX_LINES - 1;
344 lines = lines[lines.len() - keep..].to_vec();
345 }
346
347 let mut content = lines.join("\n");
348 if !content.is_empty() {
349 content.push('\n');
350 }
351 content.push_str(&new_line);
352 content.push('\n');
353
354 let _ = std::fs::write(&path, content);
355}
356
357fn show_history(cfg: &Config, args: &StatusHistoryArgs) -> Result<()> {
362 let path = trust_history_path(cfg);
363 let content = std::fs::read_to_string(&path).unwrap_or_default();
364 let mut records: Vec<serde_json::Value> = content
365 .lines()
366 .filter(|l| !l.trim().is_empty())
367 .filter_map(|l| serde_json::from_str(l).ok())
368 .collect();
369
370 records.reverse();
372 records.truncate(args.last);
373 records.reverse(); if records.is_empty() {
376 if args.json {
377 println!("[]");
378 } else {
379 println!("No trust-history snapshots yet. Run `asd status --json` to record one.");
380 }
381 return Ok(());
382 }
383
384 if args.json {
385 println!("{}", serde_json::to_string_pretty(&records)?);
386 return Ok(());
387 }
388
389 println!(
391 "{:<25} {:>6} {:>7} {:>8} {:>10} {:>8} {:>5}",
392 "timestamp", "score", "level", "symbols", "sidecar", "age_hrs", "dirty"
393 );
394 println!("{}", "-".repeat(75));
395 for r in &records {
396 let ts = r.get("timestamp").and_then(|v| v.as_str()).unwrap_or("?");
397 let ts_short = &ts[..ts.len().min(23)];
399 let score = r.get("trust_score").and_then(|v| v.as_f64()).unwrap_or(0.0);
400 let level = r.get("trust_level").and_then(|v| v.as_str()).unwrap_or("?");
401 let syms = r.get("symbol_count").and_then(|v| v.as_u64()).unwrap_or(0);
402 let sidecar = r
403 .get("sidecar_state")
404 .and_then(|v| v.as_str())
405 .unwrap_or("?");
406 let age = r
407 .get("age_hours")
408 .and_then(|v| v.as_i64())
409 .map(|h| h.to_string())
410 .unwrap_or_else(|| "?".to_string());
411 let dirty = r
412 .get("dirty_file_count")
413 .and_then(|v| v.as_u64())
414 .unwrap_or(0);
415 println!(
416 "{:<25} {:>6.2} {:>7} {:>8} {:>10} {:>8} {:>5}",
417 ts_short, score, level, syms, sidecar, age, dirty
418 );
419 }
420
421 Ok(())
422}
423
424fn sidecar_state_key(s: &SidecarState) -> &'static str {
429 match s {
430 SidecarState::Missing => "missing",
431 SidecarState::Present => "present",
432 SidecarState::Hydrated => "hydrated",
433 SidecarState::FreshReset => "fresh-reset",
434 }
435}
436
437fn sidecar_action_hint(s: &SidecarState) -> &'static str {
438 match s {
439 SidecarState::Missing => "run 'asd sync' to create sidecar",
440 SidecarState::Present => "run 'asd hydrate' to load sidecar into ASG",
441 SidecarState::Hydrated => "sidecar is current",
442 SidecarState::FreshReset => "re-run 'asd index' then 'asd sync'",
443 }
444}
445
446fn collect_dirty_files(cfg: &Config) -> Vec<String> {
447 let workspace = cfg.db_path.parent().unwrap_or(std::path::Path::new("."));
448 let output = std::process::Command::new("git")
449 .args(["status", "--short", "--untracked-files=no"])
450 .current_dir(workspace)
451 .output();
452
453 let Ok(out) = output else {
454 return vec![];
455 };
456 if !out.status.success() {
457 return vec![];
458 }
459
460 let text = String::from_utf8_lossy(&out.stdout);
461 let source_exts = [
462 ".swift", ".py", ".ts", ".tsx", ".js", ".rs", ".go", ".kt", ".java", ".rb", ".cs",
463 ];
464 text.lines()
465 .filter(|l| source_exts.iter().any(|ext| l.ends_with(ext)))
466 .map(|l| l.trim().to_string())
467 .collect()
468}