Skip to main content

agent_doc_element_backlog/
ops_proof.rs

1//! Pure ops-proof completion classification for tracked-work items.
2//!
3//! This module is I/O-free. Callers own snapshots, cycle-state, ops-log writes,
4//! and document mutation.
5
6use std::collections::HashSet;
7
8use agent_doc_element::element;
9
10use crate::backlog::{self, PendingItem, PendingState};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct OpsProofCompletion {
14    pub id: String,
15    pub evidence: String,
16}
17
18/// Pending item ids present in `surface` within `content`.
19///
20/// This lets effectful callers detect brand-new same-cycle adds that were
21/// absent from a cycle-start snapshot before accepting ops-proof auto-complete.
22pub fn surface_pending_ids(content: &str, surface: &str) -> HashSet<String> {
23    element::parse(content)
24        .ok()
25        .and_then(|comps| {
26            comps
27                .into_iter()
28                .find(|c| backlog::component_matches_tracked_surface(&c.name, surface))
29        })
30        .map(|comp| {
31            let (_, items, _) = backlog::parse_items(comp.content(content));
32            items
33                .into_iter()
34                .map(|item| item.id)
35                .filter(|id| !id.is_empty())
36                .collect()
37        })
38        .unwrap_or_default()
39}
40
41pub fn ops_proof_completion_candidates(body: &str) -> Vec<OpsProofCompletion> {
42    let (_, items, _) = backlog::parse_items(body);
43    items
44        .iter()
45        .filter(|item| !matches!(item.state, PendingState::Done))
46        .filter_map(|item| {
47            classify_ops_proof_completion(item).map(|evidence| OpsProofCompletion {
48                id: item.id.clone(),
49                evidence,
50            })
51        })
52        .collect()
53}
54
55pub fn classify_ops_proof_completion(item: &PendingItem) -> Option<String> {
56    if item.id.is_empty() {
57        return None;
58    }
59    let text = format!("{} {}", item.text, item.continuation);
60    let upper = text.to_ascii_uppercase();
61    if !has_ops_completion_marker(&upper) || has_ops_completion_blocker(&upper) {
62        return None;
63    }
64
65    // #opsproofgate: a live-verify / operator-drive gate must NEVER be
66    // auto-completed on `evidence=commit`. A shipped commit is not proof for
67    // these items — only an anchored ops.log marker driven live by the operator
68    // is. The log-arbiter path closes them on a genuine structured emission;
69    // this commit/CI prose scan must stay out of its way.
70    if is_live_verify_gate(&upper) {
71        return None;
72    }
73
74    // #opsproof-falsepos: an open actionable item must NOT be reaped just
75    // because its prose cites already-landed dependency work. The completion
76    // marker must be the item's own leading status verb. Gated items were
77    // deliberately code-completed by the agent, so a proven marker anywhere in
78    // their text legitimately closes them.
79    let is_gated = matches!(item.state, PendingState::Gated);
80    if !is_gated && !marker_is_leading_status(&upper) {
81        return None;
82    }
83
84    let has_commit = contains_commit_hash(&text);
85    let has_ci = contains_successful_ci_proof(&upper);
86    if !has_commit && !has_ci {
87        return None;
88    }
89
90    Some(
91        match (has_commit, has_ci) {
92            (true, true) => "commit+ci",
93            (true, false) => "commit",
94            (false, true) => "ci",
95            (false, false) => unreachable!(),
96        }
97        .to_string(),
98    )
99}
100
101pub fn has_ops_completion_marker(upper: &str) -> bool {
102    ["DONE", "SHIPPED", "IMPLEMENTED", "COMPLETE", "COMPLETED"]
103        .iter()
104        .any(|marker| contains_ascii_word(upper, marker))
105}
106
107/// Max number of leading words (after skipping `#hashtag` tokens) that count as
108/// the item's status prefix for ops-proof auto-completion.
109pub const LEADING_STATUS_WORDS: usize = 4;
110
111/// True when an ops-completion marker is the item's leading status verb rather
112/// than a marker buried in a cited dependency clause. The leading status segment
113/// is the prefix before the first clause break (`: ` or `. `), further capped to
114/// the first [`LEADING_STATUS_WORDS`] words after skipping leading `#hashtag`
115/// tokens. `upper` must already be ASCII-uppercased.
116pub fn marker_is_leading_status(upper: &str) -> bool {
117    has_ops_completion_marker(&leading_status_segment(upper))
118}
119
120pub fn leading_status_segment(upper: &str) -> String {
121    let mut cut = upper.len();
122    for sep in [": ", ". "] {
123        if let Some(idx) = upper.find(sep) {
124            cut = cut.min(idx);
125        }
126    }
127    upper[..cut]
128        .split_whitespace()
129        .filter(|word| !word.starts_with('#'))
130        .take(LEADING_STATUS_WORDS)
131        .collect::<Vec<_>>()
132        .join(" ")
133}
134
135pub fn has_ops_completion_blocker(upper: &str) -> bool {
136    const BLOCKER_PHRASES: &[&str] = &[
137        "COULD NOT",
138        "CAN NOT",
139        "CANNOT",
140        "CAN'T",
141        "FALSE CLOSEOUT",
142        "FOLLOW-UP",
143        "FOLLOW UP",
144        "FOLLOWUPS",
145        "NOT DONE",
146        "NOT SHIPPED",
147        "NOT IMPLEMENTED",
148        "SUB-PART",
149        "SUBPART",
150    ];
151    const BLOCKER_WORDS: &[&str] = &[
152        "PARTIAL",
153        "REMAINING",
154        "REOPENED",
155        "DEFERRED",
156        "BLOCKED",
157        "BLOCKER",
158        "TODO",
159        "WIP",
160        "PARTLY",
161        "FAILING",
162        "FAILED",
163    ];
164
165    BLOCKER_PHRASES.iter().any(|phrase| upper.contains(phrase))
166        || BLOCKER_WORDS
167            .iter()
168            .any(|word| contains_ascii_word(upper, word))
169}
170
171/// True when an item is a live-verify / operator-drive gate whose only valid
172/// completion proof is an anchored structured ops.log marker driven live by the
173/// operator — never a cited commit/CI reference. `upper` must already be
174/// ASCII-uppercased.
175pub fn is_live_verify_gate(upper: &str) -> bool {
176    const LIVE_VERIFY_PHRASES: &[&str] = &[
177        "LIVE-VERIFY GATE",
178        "LIVE-VERIFY ONLY",
179        "LIVE VERIFY GATE",
180        "LIVE VERIFY ONLY",
181        "OPERATOR-DRIVE",
182        "OPERATOR DRIVE",
183        "OPERATOR DRIVES",
184        "OPERATOR LIVE-VERIFY",
185        "OPERATOR LIVE VERIFY",
186    ];
187    LIVE_VERIFY_PHRASES
188        .iter()
189        .any(|phrase| upper.contains(phrase))
190}
191
192pub fn contains_successful_ci_proof(upper: &str) -> bool {
193    contains_ascii_word(upper, "CI")
194        && ["GREEN", "PASSED", "PASSING", "SUCCESS", "SUCCEEDED"]
195            .iter()
196            .any(|word| contains_ascii_word(upper, word))
197}
198
199pub fn contains_commit_hash(text: &str) -> bool {
200    text.split(|c: char| !c.is_ascii_alphanumeric())
201        .any(|token| {
202            (7..=40).contains(&token.len())
203                && token.chars().all(|c| c.is_ascii_hexdigit())
204                && token.chars().any(|c| matches!(c, 'a'..='f' | 'A'..='F'))
205        })
206}
207
208pub fn contains_ascii_word(haystack: &str, needle: &str) -> bool {
209    haystack.match_indices(needle).any(|(idx, _)| {
210        let before = idx
211            .checked_sub(1)
212            .and_then(|pos| haystack.as_bytes().get(pos).copied());
213        let after = haystack.as_bytes().get(idx + needle.len()).copied();
214        before.is_none_or(|b| !b.is_ascii_alphanumeric())
215            && after.is_none_or(|b| !b.is_ascii_alphanumeric())
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn item(id: &str, state: PendingState, text: &str) -> PendingItem {
224        PendingItem {
225            marker: backlog::PendingListMarker::Bullet,
226            id: id.to_string(),
227            state,
228            gate_type: None,
229            in_progress: false,
230            text: text.to_string(),
231            continuation: String::new(),
232        }
233    }
234
235    #[test]
236    fn classifies_leading_status_with_commit_and_ci() {
237        let result = classify_ops_proof_completion(&item(
238            "doneci",
239            PendingState::Open,
240            "#agent-doc-bug DONE 7b60fcdc (CI 27075841879 green): fixed it",
241        ));
242        assert_eq!(result.as_deref(), Some("commit+ci"));
243    }
244
245    #[test]
246    fn rejects_blocked_partial_and_missing_proof_items() {
247        assert_eq!(
248            classify_ops_proof_completion(&item(
249                "partial",
250                PendingState::Open,
251                "PARTIAL SHIPPED 9df1244f: REMAINING live proof gate",
252            )),
253            None
254        );
255        assert_eq!(
256            classify_ops_proof_completion(&item(
257                "noproof",
258                PendingState::Open,
259                "DONE: lacks deterministic proof",
260            )),
261            None
262        );
263    }
264
265    #[test]
266    fn rejects_cited_dependency_marker_for_open_items() {
267        let result = classify_ops_proof_completion(&item(
268            "citeddep",
269            PendingState::Open,
270            "wire the predicate into dispatch. The predicate already shipped in 600797b3",
271        ));
272        assert_eq!(result, None);
273    }
274
275    #[test]
276    fn accepts_gated_completion_marker_anywhere() {
277        let result = classify_ops_proof_completion(&item(
278            "reviewdone",
279            PendingState::Gated,
280            "review-gated path SHIPPED abcdef1 (CI 2 passed)",
281        ));
282        assert_eq!(result.as_deref(), Some("commit+ci"));
283    }
284
285    #[test]
286    fn rejects_live_verify_gate_on_commit_hash() {
287        let result = classify_ops_proof_completion(&item(
288            "ktw8",
289            PendingState::Gated,
290            "[live-verify gate] destructive path. Code SHIPPED 1edb20d2; operator drives proof.",
291        ));
292        assert_eq!(result, None);
293    }
294
295    #[test]
296    fn surface_pending_ids_reads_backlog_aliases_and_review_surface() {
297        let content = concat!(
298            "<!-- agent:pending -->\n",
299            "- [ ] [#a] one\n",
300            "<!-- /agent:pending -->\n\n",
301            "<!-- agent:review -->\n",
302            "- [/] [#r] review\n",
303            "<!-- /agent:review -->\n",
304        );
305        assert_eq!(
306            surface_pending_ids(content, "backlog"),
307            HashSet::from(["a".to_string()])
308        );
309        assert_eq!(
310            surface_pending_ids(content, "review"),
311            HashSet::from(["r".to_string()])
312        );
313    }
314}