Skip to main content

agentstatedeveloper_cli/commands/
status.rs

1//! `asd status` — show index health, age, modified files, and sidecar lifecycle.
2//!
3//! With `--json`, emits a machine-readable object that includes a `trust` block
4//! (State Trust Score) and appends a compact snapshot to `.asd/trust-history.jsonl`
5//! for drift tracking.
6
7use 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    /// Show source files modified since the last index run (requires git).
23    #[arg(long)]
24    pub show_dirty: bool,
25
26    /// Emit machine-readable JSON instead of the default human text.
27    #[arg(long)]
28    pub json: bool,
29
30    /// Subcommand: history (show trust-history snapshots).
31    #[command(subcommand)]
32    pub command: Option<StatusSubcommand>,
33}
34
35#[derive(Debug, Subcommand)]
36pub enum StatusSubcommand {
37    /// Show recent trust-score snapshots from .asd/trust-history.jsonl.
38    History(StatusHistoryArgs),
39}
40
41#[derive(Debug, Args)]
42pub struct StatusHistoryArgs {
43    /// Number of recent entries to show (default: 20).
44    #[arg(long, default_value = "20")]
45    pub last: usize,
46    /// Emit raw JSONL instead of a formatted table.
47    #[arg(long)]
48    pub json: bool,
49}
50
51pub fn run(cfg: &Config, args: StatusArgs) -> Result<()> {
52    // Dispatch subcommands first.
53    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    // Concept-gap detection: symbols with Ownership but no Concept entry.
103    // Plan J t-005: while we have the by-qname tree open, also tally
104    // its length so we can emit `index_consistency` alongside the
105    // FTS-derived `symbols` count. The two diverge after a failed FTS
106    // rebuild or an out-of-band ledger migration; surfacing the delta
107    // turns a silent "why does asd status say 412 but asd health say
108    // 408?" puzzle into a one-line `advice` field.
109    let engine = Engine::open_sqlite(&cfg.db_path).ok();
110
111    // Ledger entries in the cache vs the store vs the sidecar. "hydrated"
112    // only ever meant a marker file exists; on its own it said nothing about
113    // whether the sidecar holds what the store acknowledged. Before ASG
114    // compare-and-swapped its refs, concurrent writers lost ~14% of one
115    // store's ledger while this line kept reading "sidecar is current".
116    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        // State Trust Score rollup.
173        let trust = compute_trust_score(&cfg.db_path);
174
175        // Plan J t-005: emit `index_consistency` when we have an
176        // ASG count to compare against. Null when the engine couldn't
177        // open (concept_gaps would also be empty in that case).
178        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 compact snapshot to trust-history.jsonl.
200        // Use the pre-strip dirty_files / concept_gaps lengths for
201        // historical accuracy (the snapshot wants 0/0 explicitly,
202        // not absence).
203        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        // Token economy (1.0.80): drop top-level null/[]/{} fields.
215        // status is mixed-use (human + machine); pretty-print stays
216        // for terminal readability, but the empty arrays and the
217        // null `index_consistency` (when consistent) drop out — both
218        // are signal-free for any consumer.
219        let out = agentstatedeveloper_core::drop_empty_top_level(out);
220
221        println!("{}", serde_json::to_string_pretty(&out)?);
222        return Ok(());
223    }
224
225    // Human-readable output.
226    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
291// ---------------------------------------------------------------------------
292// Trust-history JSONL
293// ---------------------------------------------------------------------------
294
295fn 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    // Read existing lines, cap at 500 before appending.
336    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    // Trim old entries so total stays at MAX_LINES after append.
342    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
357// ---------------------------------------------------------------------------
358// Trust-history reader (`asd status history`)
359// ---------------------------------------------------------------------------
360
361fn 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    // Newest first, then take last N.
371    records.reverse();
372    records.truncate(args.last);
373    records.reverse(); // restore chronological for display
374
375    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    // Human-readable table.
390    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        // Trim to 23 chars (ISO without offset) for table width.
398        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
424// ---------------------------------------------------------------------------
425// Helpers
426// ---------------------------------------------------------------------------
427
428fn 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}