agentstatedeveloper_core/workflow.rs
1//! Workflow Recipe Detection + Task Close Evidence Quality.
2//!
3//! Answers three questions at task-close time:
4//!
5//! 1. **What workflow actually happened?** — `WorkflowSummary::workflow_type`
6//! (full / annotate-and-close / test-and-close / close-only)
7//!
8//! 2. **Which recommended steps were skipped?** — `WorkflowSummary::missing_recommended_steps`
9//!
10//! 3. **How strong is the closure evidence?** — `EvidenceQuality::evidence_quality_score`
11//!
12//! # Workflow types
13//!
14//! | Type | annotate_commit | tested |
15//! |---------------------|-----------------|--------|
16//! | `full` | ✓ | ✓ |
17//! | `annotate-and-close`| ✓ | — |
18//! | `test-and-close` | — | ✓ |
19//! | `close-only` | — | — |
20//!
21//! # Detection signals
22//!
23//! Steps are detected from ledger entries on the touched symbols **before** the
24//! new proof entry is written, so task-close itself does not inflate the score.
25//!
26//! - `annotate_commit` — any existing entry has a `commit:*` tag
27//! - `invariant_checked` — any existing Invariant entry on a touched symbol
28//! - `tested` — `--validated` flag OR any existing ValidationScenario
29//! - `task_closed` — always true (we are running task-close right now)
30
31use std::io::Write as IoWrite;
32use std::path::Path;
33
34use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36
37use crate::schema::{LedgerEntry, LedgerKind};
38
39// ---------------------------------------------------------------------------
40// Public types
41// ---------------------------------------------------------------------------
42
43/// Per-field evidence quality breakdown for the task closure.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct EvidenceQuality {
46 /// At least one existing ledger entry carries a `commit:` tag (from annotate-commit).
47 pub proof_has_commit: bool,
48 /// `--validated` was passed OR evidence text mentions "test" / "spec" / "spec".
49 pub proof_has_tests: bool,
50 /// Changed files were resolved (touched_symbols_count > 0).
51 pub proof_has_files: bool,
52 /// `--validated` flag was explicitly set.
53 pub proof_has_manual_validation: bool,
54 /// `--proof` was supplied with non-default text.
55 pub closure_summary_present: bool,
56 /// At least one symbol was annotated (entries written > 0).
57 pub touched_symbols_annotated: bool,
58 /// Fraction of the six boolean fields that are true, rounded to 2 d.p.
59 pub evidence_quality_score: f64,
60}
61
62impl EvidenceQuality {
63 pub fn to_json(&self) -> Value {
64 serde_json::to_value(self).unwrap_or(Value::Null)
65 }
66}
67
68/// Rolled-up workflow summary emitted in task-close JSON and persisted to
69/// `.asd/workflow-sessions.jsonl`.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct WorkflowSummary {
72 /// One of: "full" | "annotate-and-close" | "test-and-close" | "close-only".
73 pub workflow_type: String,
74 /// Steps that were detected from ledger evidence.
75 pub steps_detected: Vec<String>,
76 /// Recommended steps that were not detected.
77 pub missing_recommended_steps: Vec<String>,
78 /// Per-field evidence quality breakdown.
79 pub evidence_quality: EvidenceQuality,
80 /// CTX task id (empty string when absent).
81 pub task_id: String,
82 /// CTX plan id (empty string when absent).
83 pub plan_id: String,
84 /// RFC-3339 timestamp of this task-close invocation.
85 pub closed_at: String,
86 /// Number of symbols that received ledger entries.
87 pub symbols_annotated: usize,
88 /// Total ledger entries written in this task-close call.
89 pub ledger_entries_written: usize,
90 /// Data-quality state of the workspace at task-close time.
91 ///
92 /// Mirrors `DataQuality::state` from the trust score:
93 /// `"clean_room"` | `"unannotated"` | `"sparse_but_active"` | `"populated"` | `"degraded"` | `"empty"`.
94 ///
95 /// Helps agents understand why `evidence_quality_score` is low on a fresh
96 /// workspace: a score of 0.17 on an `unannotated` DB is expected, not a bug.
97 #[serde(default)]
98 pub db_state: String,
99 /// Human-readable clarification when `db_state` may need explanation.
100 ///
101 /// Set for `clean_room` and `unannotated` (low evidence expected),
102 /// and for `degraded` (possible state loss). Empty for healthy states.
103 #[serde(default, skip_serializing_if = "String::is_empty")]
104 pub db_state_note: String,
105}
106
107impl WorkflowSummary {
108 pub fn to_json(&self) -> Value {
109 serde_json::to_value(self).unwrap_or(Value::Null)
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Evidence quality scoring
115// ---------------------------------------------------------------------------
116
117/// Score task-closure evidence quality from available signals.
118///
119/// # Parameters
120/// - `pre_existing_entries` — all ledger entries for touched symbols **before**
121/// this task-close call (used to detect annotate_commit / tested).
122/// - `was_validated` — `--validated` CLI flag.
123/// - `evidence_text` — optional `--evidence` value.
124/// - `proof_text` — the resolved proof text (may be default "task completed").
125/// - `proof_was_explicit` — true when the caller supplied `--proof` explicitly.
126/// - `touched_count` — number of resolved symbols.
127/// - `entries_written` — number of entries written in this call.
128pub fn score_evidence_quality(
129 pre_existing_entries: &[LedgerEntry],
130 was_validated: bool,
131 evidence_text: Option<&str>,
132 proof_was_explicit: bool,
133 touched_count: usize,
134 entries_written: usize,
135) -> EvidenceQuality {
136 let proof_has_commit = pre_existing_entries
137 .iter()
138 .any(|e| e.tags.iter().any(|t| t.starts_with("commit:")));
139
140 let evidence_mentions_test = evidence_text
141 .map(|ev| {
142 let low = ev.to_lowercase();
143 low.contains("test") || low.contains("spec") || low.contains("assert")
144 })
145 .unwrap_or(false);
146
147 let has_existing_validation = pre_existing_entries
148 .iter()
149 .any(|e| e.kind == LedgerKind::ValidationScenario);
150
151 let proof_has_tests = was_validated || evidence_mentions_test || has_existing_validation;
152 let proof_has_files = touched_count > 0;
153 let proof_has_manual_validation = was_validated;
154 let closure_summary_present = proof_was_explicit;
155 let touched_symbols_annotated = entries_written > 0;
156
157 let bool_count = [
158 proof_has_commit,
159 proof_has_tests,
160 proof_has_files,
161 proof_has_manual_validation,
162 closure_summary_present,
163 touched_symbols_annotated,
164 ]
165 .iter()
166 .filter(|&&b| b)
167 .count();
168
169 let evidence_quality_score = (bool_count as f64 / 6.0 * 100.0).round() / 100.0;
170
171 EvidenceQuality {
172 proof_has_commit,
173 proof_has_tests,
174 proof_has_files,
175 proof_has_manual_validation,
176 closure_summary_present,
177 touched_symbols_annotated,
178 evidence_quality_score,
179 }
180}
181
182// ---------------------------------------------------------------------------
183// Workflow recipe detection
184// ---------------------------------------------------------------------------
185
186/// Detect which workflow recipe was followed and which steps were skipped.
187///
188/// Returns `(workflow_type, steps_detected, missing_recommended_steps)`.
189pub fn detect_workflow(
190 pre_existing_entries: &[LedgerEntry],
191 eq: &EvidenceQuality,
192 has_invariants: bool,
193) -> (String, Vec<String>, Vec<String>) {
194 let mut steps: Vec<String> = Vec::new();
195
196 // annotate_commit: any existing entry has a commit: tag.
197 let did_annotate = eq.proof_has_commit;
198 if did_annotate {
199 steps.push("annotate_commit".to_string());
200 }
201
202 // invariant_checked: any existing Invariant entry on a touched symbol.
203 let did_invariant = pre_existing_entries
204 .iter()
205 .any(|e| e.kind == LedgerKind::Invariant);
206 if did_invariant {
207 steps.push("invariant_checked".to_string());
208 }
209
210 // tested: validated flag OR existing ValidationScenario.
211 let did_test = eq.proof_has_tests
212 && (eq.proof_has_manual_validation
213 || pre_existing_entries
214 .iter()
215 .any(|e| e.kind == LedgerKind::ValidationScenario));
216 if did_test {
217 steps.push("tested".to_string());
218 }
219
220 // task_closed is always true (we're here).
221 steps.push("task_closed".to_string());
222
223 // Classify workflow type.
224 let workflow_type = match (did_annotate, did_test) {
225 (true, true) => "full",
226 (true, false) => "annotate-and-close",
227 (false, true) => "test-and-close",
228 (false, false) => "close-only",
229 };
230
231 // Missing recommended steps.
232 let mut missing: Vec<String> = Vec::new();
233 if !did_annotate {
234 missing.push("annotate_commit".to_string());
235 }
236 if !did_test {
237 missing.push("test_or_validate".to_string());
238 }
239 if has_invariants && !did_invariant {
240 missing.push("check_invariants".to_string());
241 }
242
243 (workflow_type.to_string(), steps, missing)
244}
245
246// ---------------------------------------------------------------------------
247// Workflow session persistence
248// ---------------------------------------------------------------------------
249
250/// Append a compact workflow session record to `.asd/workflow-sessions.jsonl`.
251/// Silently skips on any I/O error so task-close never fails due to logging.
252/// Caps the file at 500 lines (drops the oldest when over limit).
253pub fn append_workflow_session(db_path: &Path, summary: &WorkflowSummary) {
254 let dot_asd = match db_path.parent() {
255 Some(p) if !p.as_os_str().is_empty() => p.join(".asd"),
256 _ => return,
257 };
258 let _ = std::fs::create_dir_all(&dot_asd);
259 let sessions_path = dot_asd.join("workflow-sessions.jsonl");
260
261 let line = serde_json::to_string(&json!({
262 "closed_at": summary.closed_at,
263 "task_id": if summary.task_id.is_empty() { Value::Null } else { json!(summary.task_id) },
264 "plan_id": if summary.plan_id.is_empty() { Value::Null } else { json!(summary.plan_id) },
265 "workflow_type": summary.workflow_type,
266 "steps_detected": summary.steps_detected,
267 "missing_steps": summary.missing_recommended_steps,
268 "evidence_score": summary.evidence_quality.evidence_quality_score,
269 "symbols_annotated": summary.symbols_annotated,
270 "entries_written": summary.ledger_entries_written,
271 }));
272 let line = match line {
273 Ok(l) => format!("{l}\n"),
274 Err(_) => return,
275 };
276
277 // Read existing lines, append new one, cap at 500.
278 let existing: Vec<String> = std::fs::read_to_string(&sessions_path)
279 .unwrap_or_default()
280 .lines()
281 .filter(|l| !l.trim().is_empty())
282 .map(|l| l.to_string())
283 .collect();
284
285 let mut combined: Vec<String> = existing;
286 combined.push(line.trim_end_matches('\n').to_string());
287 if combined.len() > 500 {
288 let excess = combined.len() - 500;
289 combined.drain(0..excess);
290 }
291
292 if let Ok(mut f) = std::fs::OpenOptions::new()
293 .write(true)
294 .create(true)
295 .truncate(true)
296 .open(&sessions_path)
297 {
298 let _ = f.write_all(combined.join("\n").as_bytes());
299 let _ = f.write_all(b"\n");
300 }
301}