Skip to main content

agentstatedeveloper_cli/commands/
task_close.rs

1//! `asd task-close` — capture proof, validation result, and affected symbols
2//! and write them atomically to the ledger when closing a task.
3//!
4//! Reads CTXONE_PLAN / CTXONE_TASK env vars for provenance.
5//! Changed files are resolved from git HEAD..HEAD~1 by default or passed
6//! via --symbols.
7
8use anyhow::Result;
9use clap::Args;
10use serde_json::json;
11
12use agentstatedeveloper_core::{
13    AsgIndexStore, AsgLedgerStore, Engine, IndexStore, LedgerKind, LedgerStore, Symbol,
14    WorkflowSummary, append_workflow_session, compute_trust_score, detect_workflow,
15    schema::{Author, AuthorKind, LedgerEntry},
16    score_evidence_quality,
17};
18
19use crate::config::Config;
20
21#[derive(Debug, Args)]
22pub struct TaskCloseArgs {
23    /// Free-text description of what was completed (written as Proof entry).
24    /// Omit to default to "task completed".
25    pub proof: Option<String>,
26
27    /// Comma-separated fully-qualified symbol names to annotate.
28    /// If omitted, symbols are resolved from files changed in HEAD.
29    #[arg(long)]
30    pub symbols: Option<String>,
31
32    /// Mark the task as validated (writes a ValidationScenario entry too).
33    #[arg(long)]
34    pub validated: bool,
35
36    /// Optional validation note when --validated is set.
37    #[arg(long)]
38    pub validation_note: Option<String>,
39
40    /// Reference to validation evidence (file path, URL, or test name).
41    /// Appended to the Proof entry summary and written as a KnownBug tag if pointing to a failure.
42    #[arg(long)]
43    pub evidence: Option<String>,
44
45    /// CTX plan ID (overrides CTXONE_PLAN env var).
46    #[arg(long)]
47    pub plan: Option<String>,
48
49    /// CTX task ID (overrides CTXONE_TASK env var).
50    #[arg(long)]
51    pub task: Option<String>,
52
53    /// Author id written into ledger entries.
54    #[arg(long, default_value = "asd-task-close")]
55    pub author: String,
56
57    /// Suppress informational output.
58    #[arg(long)]
59    pub quiet: bool,
60
61    /// Emit JSON closure summary (default: true; set --no-json to suppress).
62    #[arg(long, default_value_t = true)]
63    pub json: bool,
64}
65
66pub fn run(cfg: &Config, args: TaskCloseArgs) -> Result<()> {
67    let engine = Engine::open_sqlite(&cfg.db_path)?;
68    let index_store = AsgIndexStore::from_engine(&engine);
69    let ledger_store = AsgLedgerStore::from_engine(&engine);
70
71    // Resolve CTX plan/task from args or env vars (t-001).
72    let plan_id = args
73        .plan
74        .clone()
75        .or_else(|| std::env::var("CTXONE_PLAN").ok())
76        .unwrap_or_default();
77    let task_id = args
78        .task
79        .clone()
80        .or_else(|| std::env::var("CTXONE_TASK").ok())
81        .unwrap_or_default();
82
83    // Build provenance tags.
84    let mut ctx_tags: Vec<String> = Vec::new();
85    if !plan_id.is_empty() {
86        ctx_tags.push(format!("ctx:plan:{}", plan_id));
87    }
88    if !task_id.is_empty() {
89        ctx_tags.push(format!("ctx:task:{}", task_id));
90    }
91
92    // Resolve affected symbols.
93    let target_symbols: Vec<Symbol> = if let Some(ref sym_list) = args.symbols {
94        sym_list
95            .split(',')
96            .map(|s| s.trim())
97            .filter(|s| !s.is_empty())
98            .filter_map(|q| {
99                index_store
100                    .get_symbol_by_qname(&engine.ref_name, q)
101                    .ok()
102                    .flatten()
103            })
104            .collect()
105    } else {
106        // Auto-detect from git HEAD changed files.
107        let out = std::process::Command::new("git")
108            .args(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
109            .output()
110            .unwrap_or_else(|_| std::process::Output {
111                status: std::process::ExitStatus::default(),
112                stdout: vec![],
113                stderr: vec![],
114            });
115        let changed: Vec<String> = String::from_utf8_lossy(&out.stdout)
116            .lines()
117            .map(|l| l.trim().to_string())
118            .filter(|l| !l.is_empty())
119            .collect();
120
121        let tree = engine
122            .repo
123            .get_tree(&engine.ref_name, "/asd/v1/index/by-qname")
124            .unwrap_or(serde_json::Value::Object(Default::default()));
125        let mut syms: Vec<Symbol> = tree
126            .as_object()
127            .map(|m| {
128                m.values()
129                    .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
130                    .filter(|s| {
131                        changed
132                            .iter()
133                            .any(|f| s.file.ends_with(f.as_str()) || s.file == *f)
134                    })
135                    .collect()
136            })
137            .unwrap_or_default();
138        syms.truncate(20);
139        syms
140    };
141
142    if target_symbols.is_empty() {
143        eprintln!("asd: no symbols resolved — pass --symbols or ensure HEAD has changed files");
144        println!(
145            "{}",
146            json!({"written": [], "ctx": {"plan": plan_id, "task": task_id}})
147        );
148        return Ok(());
149    }
150
151    // Build the proof text, incorporating evidence reference if provided.
152    let proof_base = args
153        .proof
154        .clone()
155        .unwrap_or_else(|| "task completed".to_string());
156    let proof_text = if let Some(ref ev) = args.evidence {
157        format!("{} [evidence: {}]", proof_base, ev)
158    } else {
159        proof_base.clone()
160    };
161
162    let author = Author {
163        kind: AuthorKind::Human,
164        id: args.author.clone(),
165    };
166    let mut written: Vec<serde_json::Value> = Vec::new();
167    let closed_at = chrono::Utc::now().to_rfc3339();
168
169    for sym in &target_symbols {
170        // Write Proof entry.
171        let mut proof_entry = LedgerEntry::new(
172            &sym.symbol_id,
173            LedgerKind::Proof,
174            &proof_text,
175            author.clone(),
176        );
177        proof_entry.tags.extend(ctx_tags.iter().cloned());
178        if let Some(ref ev) = args.evidence {
179            proof_entry.tags.push(format!("evidence:{}", ev));
180        }
181        ledger_store.append_entry(&engine.ref_name, &proof_entry, &args.author)?;
182        written.push(json!({"symbol": sym.qname, "kind": "proof", "summary": proof_text}));
183
184        // Write ValidationScenario if --validated.
185        if args.validated {
186            let validation_text = args
187                .validation_note
188                .clone()
189                .unwrap_or_else(|| format!("validated: {}", proof_base));
190            let mut vs_entry = LedgerEntry::new(
191                &sym.symbol_id,
192                LedgerKind::ValidationScenario,
193                &validation_text,
194                author.clone(),
195            );
196            vs_entry.tags.extend(ctx_tags.iter().cloned());
197            ledger_store.append_entry(&engine.ref_name, &vs_entry, &args.author)?;
198            written.push(json!({"symbol": sym.qname, "kind": "validation_scenario", "summary": validation_text}));
199        }
200    }
201
202    if !args.quiet {
203        eprintln!(
204            "asd: wrote {} ledger entries across {} symbols",
205            written.len(),
206            target_symbols.len()
207        );
208        if !ctx_tags.is_empty() {
209            eprintln!("asd: provenance: {}", ctx_tags.join(", "));
210        }
211    }
212
213    // ── Workflow Integration: evidence quality + recipe detection ──────────
214    // Gather all pre-existing ledger entries for touched symbols (exclude the
215    // entries we just wrote so they don't inflate the detection signals).
216    let pre_existing: Vec<LedgerEntry> = target_symbols
217        .iter()
218        .flat_map(|sym| {
219            ledger_store
220                .list_entries(&engine.ref_name, &sym.symbol_id)
221                .unwrap_or_default()
222        })
223        .filter(|e| {
224            // Exclude entries whose summary matches what we just wrote.
225            !written.iter().any(|w| {
226                w.get("summary").and_then(|s| s.as_str()) == Some(e.summary.as_str())
227                    && w.get("kind")
228                        .and_then(|k| k.as_str())
229                        .map(|k| k == format!("{:?}", e.kind).to_lowercase())
230                        .unwrap_or(false)
231            })
232        })
233        .collect();
234
235    let proof_was_explicit = args.proof.is_some();
236    let eq = score_evidence_quality(
237        &pre_existing,
238        args.validated,
239        args.evidence.as_deref(),
240        proof_was_explicit,
241        target_symbols.len(),
242        written.len(),
243    );
244
245    // Check whether any touched symbols have existing Invariant entries.
246    let has_invariants = pre_existing.iter().any(|e| e.kind == LedgerKind::Invariant);
247    let (wf_type, steps_detected, missing_steps) =
248        detect_workflow(&pre_existing, &eq, has_invariants);
249
250    // Capture db_state for context — helps agents understand low evidence scores
251    // on fresh/unannotated workspaces.
252    let trust = compute_trust_score(&cfg.db_path);
253    let db_state = trust.data_quality.state.clone();
254    let db_state_note = match db_state.as_str() {
255        "clean_room" => "fresh workspace — low evidence score is expected before annotations are written".to_string(),
256        "unannotated" => "index built but no prior annotations — low evidence score is expected; run `asd annotate-commit` to enrich".to_string(),
257        "degraded" => "warning: sparse ledger despite prior activity — possible state loss or DB reset".to_string(),
258        _ => String::new(),
259    };
260
261    let workflow_summary = WorkflowSummary {
262        workflow_type: wf_type,
263        steps_detected,
264        missing_recommended_steps: missing_steps,
265        evidence_quality: eq,
266        task_id: task_id.clone(),
267        plan_id: plan_id.clone(),
268        closed_at: closed_at.clone(),
269        symbols_annotated: target_symbols.len(),
270        ledger_entries_written: written.len(),
271        db_state,
272        db_state_note,
273    };
274
275    // Persist to .asd/workflow-sessions.jsonl.
276    append_workflow_session(&cfg.db_path, &workflow_summary);
277
278    let closure_summary = json!({
279        "status": "closed",
280        "closed_at": closed_at,
281        "proof": proof_text,
282        "validated": args.validated,
283        "evidence": args.evidence,
284        "symbols_annotated": target_symbols.len(),
285        "ledger_entries_written": written.len(),
286        "ctx": {
287            "plan": if plan_id.is_empty() { serde_json::Value::Null } else { json!(plan_id) },
288            "task": if task_id.is_empty() { serde_json::Value::Null } else { json!(task_id) },
289        },
290        "workflow": workflow_summary.to_json(),
291        "written": written,
292    });
293
294    if args.json {
295        println!("{}", serde_json::to_string_pretty(&closure_summary)?);
296    }
297    Ok(())
298}