agentstatedeveloper_cli/commands/
task_close.rs1use 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 pub proof: Option<String>,
26
27 #[arg(long)]
30 pub symbols: Option<String>,
31
32 #[arg(long)]
34 pub validated: bool,
35
36 #[arg(long)]
38 pub validation_note: Option<String>,
39
40 #[arg(long)]
43 pub evidence: Option<String>,
44
45 #[arg(long)]
47 pub plan: Option<String>,
48
49 #[arg(long)]
51 pub task: Option<String>,
52
53 #[arg(long, default_value = "asd-task-close")]
55 pub author: String,
56
57 #[arg(long)]
59 pub quiet: bool,
60
61 #[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 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 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 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 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 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 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 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 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 !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 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 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 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}