Skip to main content

agent_doc_element_backlog/
guard_policy.rs

1//! Pure backlog guard policy and reference projection.
2//!
3//! This module owns the tracked-work decisions/messages that do not require
4//! filesystem, git, or session adapters. Orchestration supplies document content
5//! and any already-resolved id sets, then translates [`BacklogGuardOutcome`] at
6//! its boundary.
7
8use crate::backlog::{
9    DroppedBacklogReport, MalformedTrackedItemRef, ShadowPendingReport,
10    detect_dropped_from_history_with_extra_current_ids, detect_shadow_open_items,
11    format_dropped_refs, format_shadow_refs, malformed_tracked_item_interruption_message,
12    malformed_tracked_item_refs_in_components,
13};
14use agent_doc_element::element;
15use anyhow::Result;
16use std::collections::HashSet;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum BacklogGuardOutcome {
20    Pass,
21    Warn(Vec<String>),
22    Interrupt(String),
23}
24
25pub fn shadow_backlog_guard(doc: &str) -> Result<BacklogGuardOutcome> {
26    Ok(shadow_backlog_report_guard(&detect_shadow_open_items(doc)?))
27}
28
29pub fn shadow_backlog_report_guard(report: &ShadowPendingReport) -> BacklogGuardOutcome {
30    if !report.shadow_only.is_empty() {
31        return BacklogGuardOutcome::Interrupt(format!(
32            "[session-check] INTERRUPTED: open backlog item(s) exist only outside live agent:backlog: {}. Re-run preflight/repair after restoring them to the live backlog or marking them complete",
33            format_shadow_refs(&report.shadow_only)
34        ));
35    }
36    if !report.duplicated_in_live_backlog.is_empty() {
37        return BacklogGuardOutcome::Warn(vec![format!(
38            "[session-check] warning: open backlog item(s) also appear outside live agent:backlog: {}",
39            format_shadow_refs(&report.duplicated_in_live_backlog)
40        )]);
41    }
42    BacklogGuardOutcome::Pass
43}
44
45pub fn malformed_tracked_item_guard(
46    content: &str,
47    components: &[element::Component],
48) -> BacklogGuardOutcome {
49    let refs = malformed_tracked_item_reference_strings(malformed_tracked_item_refs_in_components(
50        content, components,
51    ));
52    if refs.is_empty() {
53        return BacklogGuardOutcome::Pass;
54    }
55    BacklogGuardOutcome::Interrupt(malformed_tracked_item_interruption_message(&refs))
56}
57
58pub fn malformed_tracked_item_reference_strings(
59    refs: impl IntoIterator<Item = MalformedTrackedItemRef>,
60) -> Vec<String> {
61    refs.into_iter().map(|item| item.reference()).collect()
62}
63
64pub fn dropped_from_history_guard(
65    current_doc: &str,
66    baseline_doc: &str,
67    done_ids: &HashSet<String>,
68    extra_current_ids: &HashSet<String>,
69) -> Result<BacklogGuardOutcome> {
70    Ok(dropped_from_history_report_guard(
71        &detect_dropped_from_history_with_extra_current_ids(
72            current_doc,
73            baseline_doc,
74            done_ids,
75            extra_current_ids,
76        )?,
77    ))
78}
79
80pub fn dropped_from_history_report_guard(report: &DroppedBacklogReport) -> BacklogGuardOutcome {
81    if report.dropped.is_empty() {
82        return BacklogGuardOutcome::Pass;
83    }
84    BacklogGuardOutcome::Interrupt(format!(
85        "[session-check] INTERRUPTED: open backlog item(s) from recent history are completely absent from the document: {}. Restore them to the live backlog, move them to icebox, or mark them done",
86        format_dropped_refs(&report.dropped)
87    ))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::backlog::{
94        ShadowPendingItem, detect_dropped_from_history, format_dropped_refs, format_shadow_refs,
95    };
96
97    #[test]
98    fn format_shadow_refs_joins_references() {
99        let refs = format_shadow_refs(&[
100            ShadowPendingItem {
101                id: "one1".to_string(),
102                text: "first".to_string(),
103                line: 7,
104            },
105            ShadowPendingItem {
106                id: "two2".to_string(),
107                text: "second".to_string(),
108                line: 9,
109            },
110        ]);
111
112        assert_eq!(refs, "#one1 (line 7), #two2 (line 9)");
113    }
114
115    #[test]
116    fn shadow_guard_interrupts_shadow_only_items() {
117        let doc = concat!(
118            "<!-- agent:backlog -->\n",
119            "- [ ] [#live1] Keep in backlog\n",
120            "<!-- /agent:backlog -->\n\n",
121            "<!-- parked copy\n",
122            "- [ ] [#lost1] Drifted out of backlog\n",
123            "-->\n"
124        );
125
126        let outcome = shadow_backlog_guard(doc).unwrap();
127
128        assert!(matches!(outcome, BacklogGuardOutcome::Interrupt(message)
129            if message.contains("#lost1") && message.contains("only outside live agent:backlog")));
130    }
131
132    #[test]
133    fn shadow_guard_warns_duplicate_shadow_items() {
134        let doc = concat!(
135            "<!-- agent:backlog -->\n",
136            "- [ ] [#live1] Keep in backlog\n",
137            "<!-- /agent:backlog -->\n\n",
138            "<!-- parked copy\n",
139            "- [ ] [#live1] Keep in backlog\n",
140            "-->\n"
141        );
142
143        let outcome = shadow_backlog_guard(doc).unwrap();
144
145        assert!(matches!(outcome, BacklogGuardOutcome::Warn(lines)
146            if lines.len() == 1 && lines[0].contains("#live1") && lines[0].contains("also appear")));
147    }
148
149    #[test]
150    fn malformed_guard_interrupts_with_projected_refs() {
151        let doc = concat!(
152            "<!-- agent:backlog -->\n",
153            "_- [ ] [#bad1] damaged backlog\n",
154            "<!-- /agent:backlog -->\n"
155        );
156        let components = element::parse(doc).unwrap();
157
158        let outcome = malformed_tracked_item_guard(doc, &components);
159
160        assert!(matches!(outcome, BacklogGuardOutcome::Interrupt(message)
161            if message.contains("backlog #bad1 (line 1)") && message.contains("malformed tracked checklist item")));
162    }
163
164    #[test]
165    fn dropped_history_guard_interrupts_with_missing_ids() {
166        let baseline = concat!(
167            "<!-- agent:backlog -->\n",
168            "- [ ] [#gone1] Was open in baseline\n",
169            "<!-- /agent:backlog -->\n"
170        );
171        let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
172
173        let outcome =
174            dropped_from_history_guard(current, baseline, &HashSet::new(), &HashSet::new())
175                .unwrap();
176
177        assert!(matches!(outcome, BacklogGuardOutcome::Interrupt(message)
178            if message.contains("#gone1") && message.contains("recent history")));
179    }
180
181    #[test]
182    fn dropped_refs_match_backlog_detection_projection() {
183        let baseline = concat!(
184            "<!-- agent:backlog -->\n",
185            "- [ ] [#gone1] Was open in baseline\n",
186            "- [ ] [#gone2] Also open in baseline\n",
187            "<!-- /agent:backlog -->\n"
188        );
189        let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
190        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
191
192        assert_eq!(format_dropped_refs(&report.dropped), "#gone1, #gone2");
193    }
194}