Skip to main content

agentstatedeveloper_cli/commands/
checklist.rs

1//! `asd checklist <query>` — structured pre-edit checklist.
2//!
3//! Builds on `investigate` entry-point search and ledger invariants to produce
4//! an action-oriented list: files to inspect, invariants to preserve, tests to
5//! run, known hazards, and effects to verify.
6//!
7//! Default output is Markdown; pass `--json` for machine-readable JSON.
8
9use std::collections::{HashSet, VecDeque};
10
11use anyhow::Result;
12use clap::Args;
13use serde_json::{Value, json};
14
15use agentstatedeveloper_core::{
16    AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine,
17    FeedbackStore, FtsFilters, IndexStore, LedgerKind, LedgerStore, apply_feedback_adjustments,
18    classify_layer_sym, derive_cold_hints, estimate_tokens, find_candidates, git_dirty_files,
19    intent_focus, load_layer_overrides, parse_intent, parse_query, propose_test_path,
20    resolve_scope, stale_warning, symbol_tier, trim_for_agent,
21};
22
23use crate::commands::graph::build_id_map;
24use crate::config::Config;
25
26#[derive(Debug, Args)]
27pub struct ChecklistArgs {
28    /// Natural-language or keyword query (same as `asd investigate`).
29    pub query: String,
30
31    /// Inject active task context to enrich the search query.
32    /// When absent, CTXONE_PLAN / CTXONE_TASK env vars are read automatically.
33    #[arg(long)]
34    pub task_context: Option<String>,
35
36    /// Number of top entry-point symbols to analyse (default: 10).
37    #[arg(long, default_value = "10")]
38    pub depth: usize,
39
40    /// Filter by symbol kind.
41    #[arg(long)]
42    pub kind: Option<String>,
43
44    /// Filter by language.
45    #[arg(long)]
46    pub language: Option<String>,
47
48    /// Include test-file symbols as entry-point candidates.
49    #[arg(long)]
50    pub include_tests: bool,
51
52    /// Suppress the stale-index warning.
53    #[arg(long)]
54    pub quiet: bool,
55
56    /// Emit JSON instead of Markdown.
57    #[arg(long)]
58    pub json: bool,
59
60    /// Adjust checklist framing for a specific intent.
61    /// Values: bugfix, feature, refactor, test, architecture, ui.
62    #[arg(long)]
63    pub intent: Option<String>,
64
65    /// Caller BFS depth for finding affected tests (default: 2).
66    #[arg(long, default_value = "2")]
67    pub test_depth: usize,
68
69    /// Emit token-budgeted JSON for LLM consumption (implies --json).
70    #[arg(long)]
71    pub agent: bool,
72
73    /// Token budget when --agent is set (default: 8000).
74    #[arg(long, default_value = "8000")]
75    pub agent_budget: usize,
76
77    /// Comma-separated terms to exclude. Also supports inline minus-prefix
78    /// syntax in the query, e.g. "drift playhead -sample -waveform".
79    #[arg(long)]
80    pub exclude: Option<String>,
81
82    /// Comma-separated glob patterns to restrict results to specific paths.
83    #[arg(long)]
84    pub paths: Option<String>,
85
86    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
87    #[arg(long)]
88    pub scope: Option<String>,
89}
90
91pub fn run(cfg: &Config, args: ChecklistArgs) -> Result<()> {
92    if !args.quiet {
93        if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
94            eprintln!("{warn}");
95        }
96    }
97    let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
98    let layer_overrides = load_layer_overrides(&cfg.db_path);
99    let engine = Engine::open_sqlite(&cfg.db_path)?;
100    let index_store = AsgIndexStore::from_engine(&engine);
101    let ledger_store = AsgLedgerStore::from_engine(&engine);
102    let effect_store = AsgEffectStore::from_engine(&engine);
103    let id_map = build_id_map(&engine);
104
105    let (mut tokens, mut exclusions) = parse_query(&args.query);
106    if let Some(ref excl) = args.exclude {
107        for term in excl
108            .split(',')
109            .map(|t| t.trim().to_lowercase())
110            .filter(|t| !t.is_empty())
111        {
112            exclusions.push(term);
113        }
114    }
115    // Auto-load CTX context from env vars if --task-context not provided.
116    let auto_ctx_plan = std::env::var("CTXONE_PLAN").ok().filter(|s| !s.is_empty());
117    let auto_ctx_task = std::env::var("CTXONE_TASK").ok().filter(|s| !s.is_empty());
118    let ctx_text = args.task_context.clone().or_else(|| {
119        let parts: Vec<&str> = [auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()]
120            .iter()
121            .filter_map(|x| *x)
122            .collect();
123        if parts.is_empty() {
124            None
125        } else {
126            Some(parts.join(" "))
127        }
128    });
129    if let Some(ref ctx) = ctx_text {
130        let (ctx_tokens, _) = parse_query(ctx);
131        for t in ctx_tokens {
132            if !tokens.contains(&t) {
133                tokens.push(t);
134            }
135        }
136    }
137    if tokens.is_empty() {
138        if args.json {
139            println!("{}", json!({"query": args.query, "items": {}}));
140        } else {
141            println!(
142                "# Pre-edit checklist: {}\n\nNo matching symbols.",
143                args.query
144            );
145        }
146        return Ok(());
147    }
148
149    let mut paths_filter: Vec<String> = Vec::new();
150    if let Some(ref scope) = args.scope {
151        paths_filter.extend(resolve_scope(scope, &cfg.db_path));
152    }
153    if let Some(ref paths) = args.paths {
154        paths_filter.extend(
155            paths
156                .split(',')
157                .map(|p| p.trim().to_string())
158                .filter(|p| !p.is_empty()),
159        );
160    }
161    let filters = FtsFilters {
162        kind: args.kind.as_deref().map(|k| k.to_lowercase()),
163        language: args.language.as_deref().map(|l| l.to_lowercase()),
164        include_tests: args.include_tests,
165        tests_only: false,
166        exclude_terms: exclusions,
167        paths_filter,
168        exclude_paths: Vec::new(),
169        exclude_languages: Vec::new(),
170    };
171
172    let mut candidates = find_candidates(
173        &engine,
174        &args.query,
175        &tokens,
176        &filters,
177        &ledger_store,
178        &index_store,
179        args.depth,
180    );
181
182    // Apply durable feedback adjustments (Useful/Noisy/WrongLayer verdicts).
183    let feedback_store = AsgFeedbackStore::from_engine(&engine);
184    let feedback_verdicts = feedback_store
185        .flat_verdicts(&engine.ref_name)
186        .unwrap_or_default();
187    apply_feedback_adjustments(
188        &engine,
189        &index_store,
190        &args.query,
191        &mut candidates,
192        &feedback_verdicts,
193    );
194
195    // --- Files to inspect -------------------------------------------------
196    let mut files_to_inspect: Vec<Value> = Vec::new();
197    let mut seen_files: HashSet<String> = HashSet::new();
198
199    // --- Invariants, hazards, effects, notes ------------------------------
200    let mut invariants: Vec<Value> = Vec::new();
201    let mut hazards: Vec<Value> = Vec::new();
202    let mut effects_list: Vec<Value> = Vec::new();
203    let mut seen_inv: HashSet<String> = HashSet::new();
204
205    // --- Affected tests (BFS from each entry point) -----------------------
206    let mut test_rows: Vec<Value> = Vec::new();
207    let mut seen_tests: HashSet<String> = HashSet::new();
208
209    for (_score, qname) in &candidates {
210        let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
211            Ok(Some(s)) => s,
212            _ => continue,
213        };
214        let tier = symbol_tier(&sym.file);
215        let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
216
217        // Files to inspect.
218        if seen_files.insert(sym.file.clone()) {
219            files_to_inspect.push(json!({
220                "file": sym.file,
221                "qname": sym.qname,
222                "layer": layer,
223                "line": sym.start.line,
224            }));
225        }
226
227        // Ledger entries.
228        let entries = ledger_store
229            .list_entries(&engine.ref_name, &sym.symbol_id)
230            .unwrap_or_default();
231        for entry in &entries {
232            let key = entry.summary.clone();
233            match entry.kind {
234                LedgerKind::Invariant => {
235                    if seen_inv.insert(key) {
236                        invariants.push(json!({
237                            "summary": entry.summary,
238                            "source": sym.qname,
239                            "body": entry.body,
240                        }));
241                    }
242                }
243                LedgerKind::Hazard => {
244                    hazards.push(json!({
245                        "summary": entry.summary,
246                        "source": sym.qname,
247                        "body": entry.body,
248                    }));
249                }
250                _ => {}
251            }
252        }
253
254        // Effects.
255        if let Ok(Some(decl)) = effect_store.get_effects(&engine.ref_name, &sym.symbol_id) {
256            for eff in &decl.declared {
257                let key = format!("{:?}", eff.effect);
258                effects_list.push(json!({
259                    "category": key,
260                    "source": sym.qname,
261                }));
262            }
263        }
264
265        // Affected tests via BFS.
266        let mut visited: HashSet<String> = HashSet::new();
267        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
268        visited.insert(sym.symbol_id.clone());
269        queue.push_back((sym.symbol_id.clone(), 0));
270        while let Some((sid, depth)) = queue.pop_front() {
271            if depth >= args.test_depth {
272                continue;
273            }
274            let callers = index_store
275                .get_callers(&engine.ref_name, &sid)
276                .unwrap_or_default();
277            for caller_id in callers {
278                if visited.contains(&caller_id) {
279                    continue;
280                }
281                visited.insert(caller_id.clone());
282                if let Some(s) = id_map.get(&caller_id) {
283                    if symbol_tier(&s.file) == 2 && seen_tests.insert(s.qname.clone()) {
284                        test_rows.push(json!({
285                            "qname": s.qname,
286                            "file": s.file,
287                            "line": s.start.line,
288                        }));
289                    }
290                    if depth + 1 < args.test_depth {
291                        queue.push_back((caller_id, depth + 1));
292                    }
293                }
294            }
295        }
296    }
297
298    // Deduplicate effects by category+source.
299    effects_list.dedup_by(|a, b| {
300        a.get("category").and_then(Value::as_str) == b.get("category").and_then(Value::as_str)
301            && a.get("source").and_then(Value::as_str) == b.get("source").and_then(Value::as_str)
302    });
303
304    let focus = intent_focus(intent);
305
306    // --- Staleness + test-gap -------------------------------------------
307    let dirty = git_dirty_files();
308    let stale_symbols: Vec<&str> = files_to_inspect
309        .iter()
310        .filter_map(|v| v.get("file").and_then(Value::as_str))
311        .filter(|f| dirty.contains(*f))
312        .collect();
313    let test_gap = test_rows.is_empty();
314    let proposed_test_path = test_gap
315        .then(|| {
316            files_to_inspect
317                .first()
318                .and_then(|v| v.get("file").and_then(Value::as_str))
319                .map(propose_test_path)
320        })
321        .flatten();
322    let suggested_test_coverage: Vec<String> = if test_gap {
323        let mut hints: Vec<String> = invariants
324            .iter()
325            .filter_map(|inv| inv.get("summary").and_then(Value::as_str))
326            .map(|s| s.to_string())
327            .collect();
328        for eff in &effects_list {
329            if let Some(cat) = eff.get("category").and_then(Value::as_str) {
330                let hint = format!("verify {} after change", cat.to_lowercase());
331                if !hints.contains(&hint) {
332                    hints.push(hint);
333                }
334            }
335        }
336        // Cold-start fallback: if no recorded invariants, derive hints from
337        // the top candidate symbol's own name, signature, and doc comment.
338        if invariants.is_empty() {
339            if let Some((_, qname)) = candidates.first() {
340                if let Ok(Some(sym)) = index_store.get_symbol_by_qname(&engine.ref_name, qname) {
341                    for h in
342                        derive_cold_hints(&sym.qname, sym.signature.as_deref(), sym.doc.as_deref())
343                    {
344                        if !hints.contains(&h) {
345                            hints.push(h);
346                        }
347                    }
348                }
349            }
350        }
351        hints
352    } else {
353        vec![]
354    };
355
356    // Scenario tests: invariants with constraint language are directly actionable
357    // test scenarios and are emitted regardless of test_gap.
358    const CONSTRAINT_WORDS: &[&str] = &[
359        "must",
360        "never",
361        "shall",
362        "always",
363        "only",
364        "cannot",
365        "no ",
366        "not ",
367        "require",
368        "ensure",
369        "prevent",
370        "guarantee",
371        "invariant",
372        "forbidden",
373    ];
374    let scenario_tests: Vec<String> = invariants
375        .iter()
376        .filter_map(|inv| inv.get("summary").and_then(Value::as_str))
377        .filter(|s| {
378            let sl = s.to_lowercase();
379            CONSTRAINT_WORDS.iter().any(|w| sl.contains(w))
380        })
381        .map(|s| s.to_string())
382        .collect();
383
384    // T-004: task-close proof suggestions — what to record in the ledger before
385    // closing the task that produced this checklist.
386    let task_close_suggestions: Vec<Value> = {
387        let mut suggestions: Vec<Value> = Vec::new();
388        // For each invariant: suggest confirming it still holds — phrased naturally.
389        for inv in invariants.iter().take(4) {
390            let source = inv.get("source").and_then(Value::as_str).unwrap_or("");
391            let summary = inv.get("summary").and_then(Value::as_str).unwrap_or("");
392            if !source.is_empty() && !summary.is_empty() {
393                // Trim leading constraint words for a more natural read.
394                let core = summary.trim_start_matches(|c: char| !c.is_alphabetic());
395                let core = ["must ", "never ", "shall ", "always ", "cannot ", "only "]
396                    .iter()
397                    .fold(core, |s, prefix| s.strip_prefix(prefix).unwrap_or(s));
398                suggestions.push(json!({
399                    "action": "ledger_append",
400                    "kind": "proof",
401                    "symbol": source,
402                    "suggested_summary": format!("confirm {} still holds after this change", core.trim()),
403                }));
404            }
405        }
406        // For hazards: note whether the hazard was avoided.
407        for h in hazards.iter().take(2) {
408            let source = h.get("source").and_then(Value::as_str).unwrap_or("");
409            let summary = h.get("summary").and_then(Value::as_str).unwrap_or("");
410            if !source.is_empty() && !summary.is_empty() {
411                suggestions.push(json!({
412                    "action": "ledger_append",
413                    "kind": "validation_scenario",
414                    "symbol": source,
415                    "suggested_summary": format!("check that '{}' was not introduced by this change", summary),
416                }));
417            }
418        }
419        // For effects: confirm the side-effect behaviour is still correct.
420        for eff in effects_list.iter().take(2) {
421            let source = eff.get("source").and_then(Value::as_str).unwrap_or("");
422            let cat = eff.get("category").and_then(Value::as_str).unwrap_or("");
423            if !source.is_empty() && !cat.is_empty() {
424                suggestions.push(json!({
425                    "action": "ledger_append",
426                    "kind": "proof",
427                    "symbol": source,
428                    "suggested_summary": format!("{} behaviour is still correct after this change", cat.to_lowercase()),
429                }));
430            }
431        }
432        suggestions
433    };
434
435    if args.agent || args.json {
436        let ctx_context_val = match (auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()) {
437            (None, None) => Value::Null,
438            _ => json!({ "plan": auto_ctx_plan, "task": auto_ctx_task }),
439        };
440        let out = json!({
441            "query": args.query,
442            "ctx_context": ctx_context_val,
443            "intent": if intent.is_empty() { Value::Null } else { json!(intent) },
444            "focus": if focus.is_empty() { Value::Null } else { json!(focus) },
445            "files_to_inspect": files_to_inspect,
446            "invariants_to_preserve": invariants,
447            "tests_to_run": test_rows,
448            "test_gap": test_gap,
449            "proposed_test_path": proposed_test_path,
450            "suggested_test_coverage": suggested_test_coverage,
451            "scenario_tests": scenario_tests,
452            "stale_symbols": stale_symbols,
453            "known_hazards": hazards,
454            "effects_to_verify": effects_list,
455            "task_close_suggestions": task_close_suggestions,
456        });
457        let out = if args.agent {
458            let max_list = (args.agent_budget / 500).max(3).min(20);
459            let trimmed = trim_for_agent(&out, max_list);
460            let json_str = serde_json::to_string_pretty(&trimmed)?;
461            let token_est = estimate_tokens(&json_str);
462            let mut v = trimmed;
463            if let Some(obj) = v.as_object_mut() {
464                obj.insert("token_estimate".into(), json!(token_est));
465            }
466            v
467        } else {
468            out
469        };
470        println!("{}", serde_json::to_string_pretty(&out)?);
471    } else {
472        print_markdown(
473            &args.query,
474            intent,
475            focus,
476            &files_to_inspect,
477            &invariants,
478            &test_rows,
479            test_gap,
480            proposed_test_path.as_deref(),
481            &suggested_test_coverage,
482            &stale_symbols,
483            &hazards,
484            &effects_list,
485            &task_close_suggestions,
486        );
487    }
488    Ok(())
489}
490
491fn print_markdown(
492    query: &str,
493    intent: &str,
494    focus: &str,
495    files: &[Value],
496    invariants: &[Value],
497    tests: &[Value],
498    test_gap: bool,
499    proposed_test_path: Option<&str>,
500    suggested_coverage: &[String],
501    stale_symbols: &[&str],
502    hazards: &[Value],
503    effects: &[Value],
504    task_close_suggestions: &[Value],
505) {
506    println!("# Pre-edit checklist: {query}");
507    if !intent.is_empty() {
508        println!("\n**Intent:** {intent}  ");
509        println!("**Focus:** {focus}");
510    }
511
512    println!("\n## Files to inspect");
513    if files.is_empty() {
514        println!("_(no entry points found)_");
515    } else {
516        for f in files {
517            let file = f.get("file").and_then(Value::as_str).unwrap_or("");
518            let qname = f.get("qname").and_then(Value::as_str).unwrap_or("");
519            let layer = f.get("layer").and_then(Value::as_str).unwrap_or("");
520            let line = f.get("line").and_then(Value::as_u64).unwrap_or(0);
521            println!("- [ ] `{file}:{line}` — `{qname}` [{layer}]");
522        }
523    }
524
525    println!("\n## Invariants to preserve");
526    if invariants.is_empty() {
527        println!("_(no invariants recorded — add with `asd ledger append --kind invariant`)_");
528    } else {
529        for inv in invariants {
530            let summary = inv.get("summary").and_then(Value::as_str).unwrap_or("");
531            let source = inv.get("source").and_then(Value::as_str).unwrap_or("");
532            println!("- [ ] {summary} _(from `{source}`)_");
533        }
534    }
535
536    println!("\n## Tests to run");
537    if tests.is_empty() {
538        if test_gap {
539            println!("_(no test callers found — test gap detected)_");
540            if let Some(path) = proposed_test_path {
541                println!("_Suggested test file: `{path}`_");
542            }
543            if !suggested_coverage.is_empty() {
544                println!("\n_Suggested behaviours to cover:_");
545                for hint in suggested_coverage {
546                    println!("- [ ] {hint}");
547                }
548            }
549        } else {
550            println!("_(no test callers found within BFS depth)_");
551        }
552    } else {
553        for t in tests {
554            let qname = t.get("qname").and_then(Value::as_str).unwrap_or("");
555            let file = t.get("file").and_then(Value::as_str).unwrap_or("");
556            println!("- [ ] `{qname}` — {file}");
557        }
558    }
559
560    if !stale_symbols.is_empty() {
561        println!("\n## Staleness warning");
562        println!(
563            "_The following files are modified since the last index — symbol ranges may be stale:_"
564        );
565        for f in stale_symbols {
566            println!("- `{f}`");
567        }
568    }
569
570    if !hazards.is_empty() {
571        println!("\n## Known hazards");
572        for h in hazards {
573            let summary = h.get("summary").and_then(Value::as_str).unwrap_or("");
574            let source = h.get("source").and_then(Value::as_str).unwrap_or("");
575            println!("- [ ] {summary} _(from `{source}`)_");
576        }
577    }
578
579    if !effects.is_empty() {
580        println!("\n## Effects to verify");
581        let mut seen: HashSet<String> = HashSet::new();
582        for e in effects {
583            let cat = e.get("category").and_then(Value::as_str).unwrap_or("");
584            let src = e.get("source").and_then(Value::as_str).unwrap_or("");
585            let key = format!("{cat}:{src}");
586            if seen.insert(key) {
587                println!("- [ ] `{cat}` — declared by `{src}`");
588            }
589        }
590    }
591
592    if !task_close_suggestions.is_empty() {
593        println!("\n## Before closing this task — record in ledger");
594        println!("_Run `asd task-close` or `asd ledger append` to capture these:_");
595        for s in task_close_suggestions {
596            let kind = s.get("kind").and_then(Value::as_str).unwrap_or("");
597            let symbol = s.get("symbol").and_then(Value::as_str).unwrap_or("");
598            let summary = s
599                .get("suggested_summary")
600                .and_then(Value::as_str)
601                .unwrap_or("");
602            println!("- [ ] [{kind}] `{symbol}` — {summary}");
603        }
604    }
605}