Skip to main content

agentstatedeveloper_cli/commands/
conclusions.rs

1//! `asd conclusions list [--class <class>] [--symbol <qname>]` — view ledger
2//! entries bucketed by the six Plan B conclusion classes (decisions,
3//! classifications, mappings, hazards, recipes, followups).
4//!
5//! This is the read surface for the conclusion layer. Write happens through
6//! `asd ledger append` with the appropriate --kind (and optional --role /
7//! --command flags). Export to JSONL is t-004; round-trip import is t-005.
8
9use anyhow::Result;
10use clap::{Args, Subcommand, ValueEnum};
11use serde_json::json;
12
13use agentstatedeveloper_core::{
14    AsgIndexStore, ConclusionClass, Engine, IndexStore, LedgerKind,
15    conclusions_export::{self},
16};
17
18use crate::config::Config;
19
20#[derive(Debug, Subcommand)]
21pub enum ConclusionsCmd {
22    /// List ledger entries grouped by conclusion class.
23    List(ListArgs),
24    /// Write all ledger conclusions to compact JSONL files under
25    /// `.asd/conclusions/` (one file per class). Byte-stable when no new
26    /// entries — safe to run from a pre-commit hook.
27    Export(ExportArgs),
28    /// Read `.asd/conclusions/*.jsonl` back into the local ledger.
29    /// Idempotent — entries are keyed by entry_id. Use after `git pull`
30    /// or on a fresh clone to populate ASG with the committed conclusions.
31    Import(ImportArgs),
32    /// Git merge driver for `.asd/conclusions/*.jsonl`. Not called
33    /// directly — `asd init` registers it as `merge=asdconclusions` so
34    /// git invokes `asd conclusions merge %O %A %B` to union the records
35    /// of two branches instead of raising a textual conflict. Operates
36    /// purely on the three files; never opens the database.
37    Merge(MergeArgs),
38}
39
40#[derive(Debug, Args)]
41pub struct ListArgs {
42    /// Restrict to one conclusion class. Omit to list all six.
43    #[arg(long, value_enum)]
44    pub class: Option<CliConclusionClass>,
45
46    /// Restrict to one symbol (qname). Omit to list across all symbols.
47    #[arg(long)]
48    pub symbol: Option<String>,
49}
50
51#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
52pub enum CliConclusionClass {
53    Decisions,
54    Classifications,
55    Mappings,
56    Hazards,
57    Recipes,
58    #[value(name = "followups")]
59    FollowUps,
60}
61
62impl From<CliConclusionClass> for ConclusionClass {
63    fn from(c: CliConclusionClass) -> Self {
64        match c {
65            CliConclusionClass::Decisions => ConclusionClass::Decisions,
66            CliConclusionClass::Classifications => ConclusionClass::Classifications,
67            CliConclusionClass::Mappings => ConclusionClass::Mappings,
68            CliConclusionClass::Hazards => ConclusionClass::Hazards,
69            CliConclusionClass::Recipes => ConclusionClass::Recipes,
70            CliConclusionClass::FollowUps => ConclusionClass::FollowUps,
71        }
72    }
73}
74
75#[derive(Debug, Args)]
76pub struct ExportArgs {
77    /// Output directory for the JSONL files. Defaults to `.asd/conclusions/`
78    /// relative to the database parent directory.
79    #[arg(long)]
80    pub out: Option<std::path::PathBuf>,
81
82    /// Emit a one-line summary instead of full per-class counts.
83    #[arg(long)]
84    pub quiet: bool,
85
86    /// Plan K t-008: after export, verify total + per-shard sizes
87    /// against `.asd/config.toml` budgets (defaults: 1 MiB total,
88    /// 200 KiB per shard). Exits non-zero on violation.
89    #[arg(long)]
90    pub check_budget: bool,
91
92    /// Pairs with `--check-budget`. Warn on violation instead of
93    /// exiting non-zero — for CI gates that should surface drift
94    /// without failing the build.
95    #[arg(long, requires = "check_budget")]
96    pub soft: bool,
97}
98
99#[derive(Debug, Args)]
100pub struct ImportArgs {
101    /// Input directory containing the `*.jsonl` files. Defaults to
102    /// `.asd/conclusions/` relative to the database parent directory.
103    #[arg(long, name = "in")]
104    pub in_dir: Option<std::path::PathBuf>,
105}
106
107#[derive(Debug, Args)]
108pub struct MergeArgs {
109    /// Common ancestor version (git `%O`). Accepted for the standard
110    /// merge-driver signature; not consulted for inclusion (union semantics).
111    pub base: std::path::PathBuf,
112    /// "Ours" version (git `%A`). The merged result is written back here —
113    /// the path git reads the resolution from.
114    pub ours: std::path::PathBuf,
115    /// "Theirs" version (git `%B`).
116    pub theirs: std::path::PathBuf,
117}
118
119pub fn run(cfg: &Config, cmd: ConclusionsCmd) -> Result<()> {
120    match cmd {
121        ConclusionsCmd::List(args) => list(cfg, args),
122        ConclusionsCmd::Export(args) => export(cfg, args),
123        ConclusionsCmd::Import(args) => import(cfg, args),
124        ConclusionsCmd::Merge(args) => merge(args),
125    }
126}
127
128/// Git merge driver. Pure file operation — must not open the database, since
129/// git invokes it mid-merge/rebase while ASG state may be in flux.
130fn merge(args: MergeArgs) -> Result<()> {
131    let n = conclusions_export::merge_jsonl(&args.ours, &args.theirs)?;
132    eprintln!(
133        "asd conclusions merge: unioned {} into {} ({} records)",
134        args.theirs.display(),
135        args.ours.display(),
136        n
137    );
138    Ok(())
139}
140
141fn import(cfg: &Config, args: ImportArgs) -> Result<()> {
142    let engine = Engine::open_sqlite(&cfg.db_path)?;
143    let in_dir = args.in_dir.unwrap_or_else(|| {
144        cfg.db_path
145            .parent()
146            .unwrap_or(std::path::Path::new("."))
147            .join(".asd")
148            .join("conclusions")
149    });
150    let results = conclusions_export::import_all(&engine, &in_dir, &cfg.agent_id)?;
151    let payload = json!({
152        "in_dir": in_dir.display().to_string(),
153        "files": results.iter().map(|r| json!({
154            "class": r.class,
155            "file": r.file,
156            "read": r.read,
157            "imported": r.imported,
158            "skipped_up_to_date": r.skipped_up_to_date,
159            "skipped_unknown_qname": r.skipped_unknown_qname,
160            "skipped_parse_error": r.skipped_parse_error,
161        })).collect::<Vec<_>>(),
162        "total_read": results.iter().map(|r| r.read).sum::<usize>(),
163        "total_imported": results.iter().map(|r| r.imported).sum::<usize>(),
164        "total_skipped_up_to_date": results.iter().map(|r| r.skipped_up_to_date).sum::<usize>(),
165        "total_skipped_unknown_qname": results.iter().map(|r| r.skipped_unknown_qname).sum::<usize>(),
166        "total_skipped_parse_error": results.iter().map(|r| r.skipped_parse_error).sum::<usize>(),
167    });
168    println!("{}", serde_json::to_string_pretty(&payload)?);
169    // Plan T t-007: skips are no longer silent — anything read but not
170    // imported gets a stderr warning with the per-class breakdown.
171    let dropped: Vec<String> = results
172        .iter()
173        .filter(|r| r.skipped_unknown_qname + r.skipped_parse_error > 0)
174        .map(|r| {
175            format!(
176                "{}: read {}, imported {}, skipped {} (unknown qname {}, parse error {})",
177                r.class,
178                r.read,
179                r.imported,
180                r.skipped_unknown_qname + r.skipped_parse_error,
181                r.skipped_unknown_qname,
182                r.skipped_parse_error
183            )
184        })
185        .collect();
186    if !dropped.is_empty() {
187        eprintln!(
188            "warning: conclusions import skipped entries:\n  {}",
189            dropped.join("\n  ")
190        );
191    }
192    Ok(())
193}
194
195fn list(cfg: &Config, args: ListArgs) -> Result<()> {
196    use std::collections::{BTreeMap, HashSet};
197    let engine = Engine::open_sqlite(&cfg.db_path)?;
198    let index = AsgIndexStore::from_engine(&engine);
199    let ref_name = engine.ref_name.clone();
200
201    let target_class = args.class.map(ConclusionClass::from);
202
203    // If a specific symbol was requested, resolve it up front so we can
204    // both validate it and restrict the ledger walk to its id.
205    let target_symbol_id: Option<String> = if let Some(qname) = args.symbol.as_deref() {
206        match index.get_symbol_by_qname(&ref_name, qname)? {
207            Some(sym) => Some(sym.symbol_id),
208            None => {
209                println!(
210                    "{}",
211                    serde_json::to_string_pretty(&json!({
212                        "class": target_class.map(|c| c.filename_stem()),
213                        "symbol": qname,
214                        "buckets": {},
215                        "warning": "symbol not found",
216                    }))?
217                );
218                return Ok(());
219            }
220        }
221    } else {
222        None
223    };
224
225    let mut buckets: BTreeMap<&'static str, Vec<serde_json::Value>> = BTreeMap::new();
226    for class in ConclusionClass::all() {
227        if target_class.is_none() || target_class == Some(*class) {
228            buckets.insert(class.filename_stem(), Vec::new());
229        }
230    }
231
232    // Drive the walk from the LEDGER tree, not the symbol index. The
233    // previous version resolved every indexed qname and called
234    // `list_entries` per symbol — for a symbol with no conclusions that
235    // falls through to an authoritative git `get_json`, so the cost
236    // scaled with total symbol count (97k+ git probes on a large repo,
237    // >12s and climbing). The ledger tree has one child per symbol that
238    // actually has an entry, so this is O(symbols_with_conclusions);
239    // qname/file resolve via the cached id map. Mirrors the
240    // `conclusions_export::gather_buckets` fix.
241    let id_map = index.build_id_map(&engine);
242    let ledger_tree = engine
243        .repo
244        .get_tree(&ref_name, "/asd/v1/ledger")
245        .unwrap_or(serde_json::Value::Null);
246    let by_symbol = match ledger_tree {
247        serde_json::Value::Object(map) => map,
248        _ => serde_json::Map::new(),
249    };
250
251    for (sym_id, per_symbol) in &by_symbol {
252        if let Some(ref target) = target_symbol_id {
253            if sym_id != target {
254                continue;
255            }
256        }
257        // Orphaned ledger entry (symbol no longer indexed): skip, same
258        // as the prior code which only surfaced entries for resolvable
259        // qnames.
260        let qname = match id_map.get(sym_id) {
261            Some(s) => s.qname.clone(),
262            None => continue,
263        };
264        let entries_map = match per_symbol {
265            serde_json::Value::Object(m) => m,
266            _ => continue,
267        };
268        // Parse + apply the same superseded filter and newest-first sort
269        // that `LedgerStore::list_entries` applied.
270        let mut entries: Vec<agentstatedeveloper_core::LedgerEntry> = entries_map
271            .values()
272            .filter_map(|v| serde_json::from_value(v.clone()).ok())
273            .collect();
274        let superseded: HashSet<String> = entries
275            .iter()
276            .flat_map(|e| e.supersedes.iter().cloned())
277            .collect();
278        entries.retain(|e| !superseded.contains(&e.entry_id));
279        entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
280        for entry in entries {
281            let class = entry.kind.conclusion_class();
282            if let Some(filter) = target_class {
283                if class != filter {
284                    continue;
285                }
286            }
287            let stem = class.filename_stem();
288            if let Some(bucket) = buckets.get_mut(stem) {
289                bucket.push(json!({
290                    "entry_id": entry.entry_id,
291                    "kind": kind_str(entry.kind),
292                    "qname": qname,
293                    "symbol_id": entry.symbol_id,
294                    "summary": entry.summary,
295                    "role": entry.role,
296                    "command": entry.command,
297                    "tags": entry.tags,
298                    "created_at": entry.created_at,
299                }));
300            }
301        }
302    }
303
304    let total: usize = buckets.values().map(|v| v.len()).sum();
305    let payload = json!({
306        "class": target_class.map(|c| c.filename_stem()),
307        "symbol": args.symbol,
308        "total": total,
309        "buckets": buckets,
310    });
311    println!("{}", serde_json::to_string_pretty(&payload)?);
312    Ok(())
313}
314
315fn kind_str(k: LedgerKind) -> &'static str {
316    k.as_str()
317}
318
319// -- Export ------------------------------------------------------------------
320//
321// The actual walk/serialize/write helpers live in
322// `agentstatedeveloper_core::conclusions_export` so the MCP `conclusions_export`
323// tool can call the same code without duplication.
324
325fn export(cfg: &Config, args: ExportArgs) -> Result<()> {
326    let engine = Engine::open_sqlite(&cfg.db_path)?;
327    let out_dir = args.out.unwrap_or_else(|| {
328        cfg.db_path
329            .parent()
330            .unwrap_or(std::path::Path::new("."))
331            .join(".asd")
332            .join("conclusions")
333    });
334
335    let counts = conclusions_export::export_all(&engine, &out_dir)?;
336
337    // Plan K t-008: optionally enforce the size budget after writing.
338    // Resolve the project root the same way export_all does (out_dir's
339    // grandparent) so the budget read picks up the same .asd/config.toml
340    // the layout choice did.
341    let budget_report = if args.check_budget {
342        let project_root = out_dir
343            .parent()
344            .and_then(|p| p.parent())
345            .unwrap_or_else(|| std::path::Path::new("."));
346        let cfg_sidecar =
347            agentstatedeveloper_core::sidecar_config::SidecarConfig::load_from_project(
348                project_root,
349            );
350        let report = conclusions_export::check_budget(&out_dir, &cfg_sidecar.budget)?;
351        Some(report)
352    } else {
353        None
354    };
355
356    if args.quiet {
357        let total_entries: usize = counts.iter().map(|(_, n, _)| n).sum();
358        let total_bytes: u64 = counts.iter().map(|(_, _, b)| b).sum();
359        println!(
360            "exported {} entries ({} bytes) to {}",
361            total_entries,
362            total_bytes,
363            out_dir.display()
364        );
365    } else {
366        let mut payload = json!({
367            "out_dir": out_dir.display().to_string(),
368            "files": counts.iter().map(|(stem, n, b)| json!({
369                "class": stem,
370                "file": format!("{stem}.jsonl"),
371                "entries": n,
372                "bytes": b,
373            })).collect::<Vec<_>>(),
374            "total_entries": counts.iter().map(|(_, n, _)| n).sum::<usize>(),
375            "total_bytes": counts.iter().map(|(_, _, b)| b).sum::<u64>(),
376        });
377        if let Some(ref r) = budget_report {
378            payload["budget"] = json!({
379                "ok": r.ok,
380                "total_bytes": r.total_bytes,
381                "total_budget": r.total_budget,
382                "over_total": r.over_total,
383                "per_shard_budget": r.per_shard_budget,
384                "shards": r.shards.iter().map(|s| json!({
385                    "path": s.path,
386                    "bytes": s.bytes,
387                    "over_per_shard": s.over_per_shard,
388                })).collect::<Vec<_>>(),
389            });
390        }
391        println!("{}", serde_json::to_string_pretty(&payload)?);
392    }
393
394    // Plan K t-008: enforce the budget. `--soft` downgrades a hard
395    // failure to a stderr warning so CI can surface drift without
396    // breaking the build.
397    if let Some(r) = budget_report {
398        if !r.ok {
399            let mut violations: Vec<String> = Vec::new();
400            if r.over_total {
401                violations.push(format!(
402                    "total {} bytes > budget {} bytes",
403                    r.total_bytes, r.total_budget
404                ));
405            }
406            for s in &r.shards {
407                if s.over_per_shard {
408                    violations.push(format!(
409                        "shard `{}` {} bytes > per-shard budget {} bytes",
410                        s.path, s.bytes, r.per_shard_budget
411                    ));
412                }
413            }
414            let msg = format!("sidecar budget exceeded:\n  {}", violations.join("\n  "));
415            if args.soft {
416                eprintln!("warning: {msg}");
417            } else {
418                anyhow::bail!(msg);
419            }
420        }
421    }
422    Ok(())
423}
424
425// (write_jsonl, gather_buckets, ExportRecord and their tests live in
426//  agentstatedeveloper_core::conclusions_export — single source of truth
427//  shared by CLI and MCP.)