Skip to main content

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}