Skip to main content

cosh_tools/subagent/
severity.rs

1//! Severity contract for sub-agent code-review reports.
2//!
3//! A code-review sub-agent task (`code_review: true` on
4//! [`SubAgentCallInput`](crate::subagent::types::SubAgentCallInput)) carries
5//! a prompt contract: the FINAL REPORT — the sub-agent's last message, the
6//! one written after its final tool call and the only text returned to the
7//! caller (see [`crate::subagent::closure`]) — must START with an HTML
8//! comment header declaring the outcome: `<!-- severity: green -->`,
9//! `<!-- severity: yellow -->` (minor issues / bad practice at most) or
10//! `<!-- severity: red -->` (something critical was found).
11//!
12//! The marker lives on the final report — not on the turn's first message —
13//! for a logical reason: the agent can only declare the review outcome once
14//! it has DONE the analysis. Earlier narration (progress notes between tool
15//! calls) belongs to the live TUI timeline and is never shown to the caller,
16//! so coloring the turn by the first line would tint the box from a message
17//! written before any work happened.
18//!
19//! An HTML comment was chosen deliberately: it is a shape every model
20//! already knows how to produce, it is inert in markdown rendering, and it
21//! survives copy-through without being reformatted.
22//!
23//! The header is CONSUMED by the client: it is stripped from the rendered
24//! report and only drives the sub-agent box color (green/orange/red — the
25//! wire's middle value stays named `yellow`; only the rendered color is
26//! orange). A report without a header (or a non-review task) leaves the
27//! box's neutral per-agent color untouched.
28
29use serde::{Deserialize, Serialize};
30
31/// The review outcome declared by the severity header.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Severity {
35    /// All good — at most cosmetic details.
36    Green,
37    /// Minor issues / bad practice at most.
38    Yellow,
39    /// Something critical was found.
40    Red,
41}
42
43impl Severity {
44    /// Parse the DSL keyword (case-insensitive). Returns `None` for any
45    /// other word — an unknown severity is treated as "no header", leaving
46    /// the box color untouched, rather than guessing.
47    pub fn parse(word: &str) -> Option<Self> {
48        match word.trim().to_ascii_lowercase().as_str() {
49            "green" => Some(Self::Green),
50            "yellow" => Some(Self::Yellow),
51            "red" => Some(Self::Red),
52            _ => None,
53        }
54    }
55}
56
57/// The prompt contract appended to the input when `code_review` is set. The
58/// marker must open the FINAL REPORT — the sub-agent's last message, written
59/// after its final tool call, which is the only text returned to the caller
60/// ([`crate::subagent::closure`]). Earlier narration is not the report and
61/// carries no marker.
62pub(crate) const SEVERITY_CONTRACT: &str = "\n\n---\nREPORT FORMAT CONTRACT (mandatory): your final report — your LAST message, the one you write AFTER your final tool calls, which is the only text returned to the caller — must START with an HTML comment header declaring the review outcome, on its own first line, exactly one of:\n<!-- severity: green -->   (all good — at most cosmetic details)\n<!-- severity: yellow --> (minor issues or bad practice found, nothing critical)\n<!-- severity: red -->    (something critical was found)\nDo NOT put the header on earlier progress messages: intermediate narration between tool calls is shown live but is not your report. The header is consumed by the client tooling and never shown; everything after it is the report itself. Do not put anything before the header.";
63
64/// Extract the severity from the first line of a report and return
65/// `(severity, report_without_the_header)`.
66///
67/// The header must be the report's first non-empty line and must match
68/// `<!-- severity: WORD -->` (whitespace inside the comment is tolerated;
69/// the match is case-insensitive). Anything else — no comment, wrong word,
70/// header not on the first line — means "no severity": the report is
71/// returned untouched with `None`.
72pub fn extract_severity(report: &str) -> (Option<Severity>, &str) {
73    // Tolerate leading blank lines before the header.
74    let trimmed = report.trim_start_matches(['\n', '\r', ' ', '\t']);
75    let Some(first_line_end) = trimmed.find('\n') else {
76        // Single-line report: the whole thing is the candidate line. With a
77        // header the body is empty by definition; without one the report is
78        // returned UNTOUCHED (dropping the only line would lose the text).
79        return match parse_header_line(trimmed) {
80            Some(severity) => (Some(severity), ""),
81            None => (None, report),
82        };
83    };
84    let (first_line, after) = trimmed.split_at(first_line_end);
85    match parse_header_line(first_line) {
86        Some(severity) => (
87            Some(severity),
88            // CRLF outputs leave a leading `\r` after splitting on `\n`
89            // (and Windows bodies may pad blank lines with `\r`): strip
90            // both so the body never starts with stray carriage returns.
91            after.trim_start_matches(['\n', '\r']),
92        ),
93        None => (None, report),
94    }
95}
96
97/// `<!-- severity: WORD -->` on a single line, case-insensitive, whitespace
98/// tolerant. Returns `None` for anything else.
99fn parse_header_line(line: &str) -> Option<Severity> {
100    let line = line.trim();
101    let inner = line.strip_prefix("<!--")?.strip_suffix("-->")?;
102    let inner = inner.trim();
103    let word = inner.strip_prefix("severity:")?;
104    Severity::parse(word)
105}
106
107/// Append the severity contract to a task input (review tasks only).
108///
109/// The explicit `code_review` flag wins, but it is NOT trusted blindly:
110/// session evidence showed the orchestrating model routinely forgets to set
111/// it on review dispatches, so the contract was never appended and the
112/// sub-agent never produced the `<!-- severity: ... -->` header (the box
113/// stayed untinted — the reported "agent ignores the DSL"). When the flag
114/// is absent, a conservative heuristic infers a review task from the input
115/// itself and appends the contract anyway; the header is consumed (stripped
116/// and never shown) so a false positive costs the task nothing but a
117/// harmless first line, and the extraction treats a report without one as
118/// "no severity".
119pub fn with_severity_contract(input: &str, code_review: bool) -> String {
120    if is_review_task(input, code_review) {
121        format!("{input}{SEVERITY_CONTRACT}")
122    } else {
123        input.to_string()
124    }
125}
126
127/// Whether this dispatch is a code-review task: the explicit `code_review`
128/// flag, or the same conservative heuristic [`with_severity_contract`]
129/// applies when the caller forgot the flag. Both the contract append (input
130/// side) and the header enforcement (report side) must ask THIS so a task
131/// that got the contract is also the task whose report is enforced.
132pub fn is_review_task(input: &str, code_review: bool) -> bool {
133    code_review || looks_like_code_review(input)
134}
135
136/// Enforce the severity header on a FINISHED review report.
137///
138/// The prompt contract asks the sub-agent to open its final report with the
139/// `<!-- severity: ... -->` header, but prompting is not enforcement — a
140/// model can end its report without one (or bury it after narration, which
141/// `extract_severity` then ignores). On a completed review turn the header
142/// is MANDATORY: it is the only color signal the report box gets. When the
143/// report lacks a valid first-line header, the client INJECTS the header
144/// that best matches the report's content ([`infer_severity`]) — the DSL is
145/// honored even when the sub-agent ignored it. Reports that already carry
146/// the header pass through untouched, and non-review tasks are never
147/// touched. The header is never RENDERED (the visible report strips it);
148/// on the external path the enforced text is what the orchestrator's tool
149/// call returns, but as an inert HTML comment it is inert transcript
150/// metadata, not displayable text.
151pub fn enforce_severity_header(report: &str, is_review: bool) -> String {
152    if !is_review || report.trim().is_empty() || extract_severity(report).0.is_some() {
153        return report.to_string();
154    }
155    let word = match infer_severity(report) {
156        Severity::Green => "green",
157        Severity::Yellow => "yellow",
158        Severity::Red => "red",
159    };
160    format!("<!-- severity: {word} -->\n{report}")
161}
162
163/// Words that flip a NEARBY finding keyword into "absent": a review saying
164/// "no critical findings" is CLEAN, not red. Only the three tokens before
165/// the keyword are considered — enough to reach a negation separated by
166/// short filler ("nothing is a major concern"), short enough that ordinary
167/// prose does not extend the shadow across sentences.
168const NEGATIONS: [&str; 6] = ["no", "not", "never", "none", "without", "nothing"];
169
170/// Best-effort severity inference from a review report's own findings, used
171/// only when the sub-agent failed to declare the header. Scans for the
172/// finding vocabulary the review protocol prescribes — CRITICAL findings are
173/// red, MAJOR findings (or an explicit "changes required" verdict) are
174/// yellow, anything else reads as green. The scan is token-based (word
175/// boundaries), so "majority" does not read as "major", and a negation right
176/// before a keyword ("no critical findings", "no changes required") marks
177/// the finding ABSENT instead of present: a false RED on a clean report is
178/// the most misleading failure this inference could produce, so cleanliness
179/// outranks coverage. The header is consumed by the client either way, so a
180/// mis-inference costs a tint shade, never text.
181fn infer_severity(report: &str) -> Severity {
182    let tokens: Vec<String> = report
183        .split(|c: char| !c.is_alphanumeric())
184        .filter(|t| !t.is_empty())
185        .map(|t| t.to_ascii_lowercase())
186        .collect();
187    let negated = |i: usize| {
188        tokens[..i]
189            .iter()
190            .rev()
191            .take(3)
192            .any(|t| NEGATIONS.contains(&t.as_str()))
193    };
194    if tokens
195        .iter()
196        .enumerate()
197        .any(|(i, t)| t == "critical" && !negated(i))
198    {
199        return Severity::Red;
200    }
201    if tokens.iter().enumerate().any(|(i, t)| {
202        (t == "major"
203            || (t == "changes" && tokens.get(i + 1).is_some_and(|next| next == "required")))
204            && !negated(i)
205    }) {
206        return Severity::Yellow;
207    }
208    Severity::Green
209}
210
211/// Conservative heuristic for review tasks the caller forgot to flag.
212///
213/// Matches only unmistakable review phrasing ("code review", the VERDICT
214/// protocol used by this repo's own review dispatches, and
215/// review-only/re-review wording). It deliberately does NOT match generic
216/// "review the changes" prose — a false positive is cheap (the header is
217/// stripped from the rendered report) but appending a report-format
218/// contract to an unrelated implementation task would still be noise.
219fn looks_like_code_review(input: &str) -> bool {
220    let lower = input.to_ascii_lowercase();
221    lower.contains("code review")
222        || lower.contains("re-review")
223        || lower.contains("review only")
224        || lower.contains("review-only")
225        || lower.contains("verdict:")
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn parse_accepts_the_three_keywords_case_insensitive() {
234        assert_eq!(Severity::parse("green"), Some(Severity::Green));
235        assert_eq!(Severity::parse("YELLOW"), Some(Severity::Yellow));
236        assert_eq!(Severity::parse("  Red "), Some(Severity::Red));
237        assert_eq!(Severity::parse("orange"), None);
238        assert_eq!(Severity::parse(""), None);
239    }
240
241    #[test]
242    fn extract_reads_the_header_and_strips_it() {
243        let report = "<!-- severity: red -->\n\n## Critical\n\nBug found.";
244        let (severity, rest) = extract_severity(report);
245        assert_eq!(severity, Some(Severity::Red));
246        assert_eq!(rest, "## Critical\n\nBug found.");
247    }
248
249    #[test]
250    fn extract_tolerates_leading_blank_lines_and_whitespace() {
251        let report = "\n\n  <!--severity: GREEN-->  \nbody";
252        let (severity, rest) = extract_severity(report);
253        assert_eq!(severity, Some(Severity::Green));
254        assert_eq!(rest, "body");
255    }
256
257    /// CRLF reports: the header line's trailing `\r` is already trimmed by
258    /// `parse_header_line`, but the split leaves `"\r\n"` before the body —
259    /// the body must not start with stray carriage returns.
260    #[test]
261    fn extract_strips_crlf_blank_lines_after_the_header() {
262        let report = "<!-- severity: red -->\r\n\r\n## Critical\r\nBug found.";
263        let (severity, rest) = extract_severity(report);
264        assert_eq!(severity, Some(Severity::Red));
265        assert!(rest.starts_with("## Critical"));
266        assert!(!rest.starts_with('\r'));
267    }
268
269    #[test]
270    fn extract_without_header_returns_the_report_untouched() {
271        let report = "## Findings\n\nnothing wrong";
272        let (severity, rest) = extract_severity(report);
273        assert_eq!(severity, None);
274        // Bit-identical round trip: no header means no mutation.
275        assert_eq!(rest, report);
276    }
277
278    #[test]
279    fn extract_ignores_headers_not_on_the_first_line() {
280        let report = "## Report\n<!-- severity: red -->\nbody";
281        let (severity, rest) = extract_severity(report);
282        assert_eq!(severity, None);
283        assert_eq!(rest, report);
284    }
285
286    #[test]
287    fn extract_ignores_unknown_severity_words() {
288        let report = "<!-- severity: purple -->\nbody";
289        let (severity, rest) = extract_severity(report);
290        assert_eq!(severity, None);
291        assert_eq!(rest, report);
292    }
293
294    #[test]
295    fn extract_handles_single_line_header_only_report() {
296        let report = "<!-- severity: yellow -->";
297        let (severity, rest) = extract_severity(report);
298        assert_eq!(severity, Some(Severity::Yellow));
299        assert_eq!(rest, "");
300    }
301
302    /// Regression: a single-line report WITHOUT a header must come back
303    /// untouched — an earlier version returned an empty body, dropping the
304    /// only line of the report.
305    #[test]
306    fn extract_keeps_a_single_line_report_without_header() {
307        let report = "just one line of findings";
308        let (severity, rest) = extract_severity(report);
309        assert_eq!(severity, None);
310        assert_eq!(rest, report);
311    }
312
313    #[test]
314    fn contract_is_appended_only_for_review_tasks() {
315        let plain = with_severity_contract("do the task", false);
316        assert_eq!(plain, "do the task");
317        assert!(!plain.contains("severity"));
318
319        let review = with_severity_contract("review this PR", true);
320        assert!(review.starts_with("review this PR"));
321        assert!(review.contains("<!-- severity: green -->"));
322        assert!(review.contains("<!-- severity: yellow -->"));
323        assert!(review.contains("<!-- severity: red -->"));
324    }
325
326    /// Regression (live session evidence): the orchestrating model routinely
327    /// dispatches code reviews WITHOUT setting `code_review: true`, so the
328    /// contract was never appended and the sub-agent never emitted the
329    /// `<!-- severity: ... -->` header — the box stayed untinted ("agent
330    /// ignores the DSL"). The heuristic must catch those unflagged review
331    /// dispatches while leaving ordinary implementation tasks untouched.
332    #[test]
333    fn unflagged_review_dispatches_get_the_contract_via_heuristic() {
334        // Real unflagged dispatch shapes from the live session / review flow.
335        for input in [
336            "Faça um CODE REVIEW do último commit deste repositório. Rode git show e leia o código.",
337            "Faça um RE-REVIEW do commit mais recente deste repositório.",
338            "Code review task (review only — do NOT modify anything).",
339            "You are a code reviewer. Review ONLY; modify nothing. End with VERDICT: ACCEPT.",
340        ] {
341            let out = with_severity_contract(input, false);
342            assert!(
343                out.starts_with(input),
344                "the input text itself must stay untouched"
345            );
346            assert!(
347                out.contains("<!-- severity: red -->"),
348                "unflagged review dispatch must get the contract: {input:?}"
349            );
350        }
351
352        // Ordinary implementation tasks stay contract-free.
353        for input in [
354            "Fix the failing test in src/lib.rs and run the suite.",
355            "review the changes and apply the refactor to the module",
356            "Add a caching layer to the renderer.",
357        ] {
358            let out = with_severity_contract(input, false);
359            assert_eq!(out, input, "non-review task must not get the contract");
360        }
361    }
362
363    /// The explicit flag wins even when the input carries no review wording
364    /// (the caller knows the task kind better than the heuristic).
365    #[test]
366    fn the_explicit_flag_always_appends_the_contract() {
367        let out = with_severity_contract("audit the crate thoroughly", true);
368        assert!(out.contains("<!-- severity: green -->"));
369    }
370
371    /// `is_review_task` must answer exactly what `with_severity_contract`
372    /// asked when it decided to append: the same task that got the contract
373    /// is the task whose report gets the header enforced.
374    #[test]
375    fn is_review_task_agrees_with_the_contract_append() {
376        assert!(is_review_task("plain task", true));
377        assert!(is_review_task(
378            "Faça um CODE REVIEW do último commit.",
379            false
380        ));
381        assert!(!is_review_task("plain task", false));
382        assert!(!is_review_task(
383            "review the changes and apply the refactor",
384            false
385        ));
386    }
387
388    /// Enforcement (the point of the fix): a review report WITHOUT the
389    /// header gets one injected — inferred from its own findings — so the
390    /// box tint works even when the sub-agent ignored the DSL.
391    #[test]
392    fn a_headerless_review_report_gets_the_header_injected() {
393        // The report's text is untouched: the injected header is PREPENDED,
394        // never merged into the body.
395        let report = "## Findings\n\n- MAJOR: the parser drops comments.\n";
396        let out = enforce_severity_header(report, true);
397        assert!(out.starts_with("<!-- severity: yellow -->\n"));
398        assert!(out.ends_with(report));
399        assert_eq!(extract_severity(&out).0, Some(Severity::Yellow));
400    }
401
402    /// The inference maps the report vocabulary to the three severities.
403    #[test]
404    fn the_inference_reads_the_findings_vocabulary() {
405        let word = |report: &str| {
406            let out = enforce_severity_header(report, true);
407            extract_severity(&out).0
408        };
409        assert_eq!(
410            word("CRITICAL: use-after-free in the render loop."),
411            Some(Severity::Red)
412        );
413        assert_eq!(
414            word("MAJOR: the lock is held across the await point."),
415            Some(Severity::Yellow)
416        );
417        assert_eq!(
418            word("VERDICT: CHANGES REQUIRED (naming only)."),
419            Some(Severity::Yellow)
420        );
421        assert_eq!(
422            word("All checks pass; only cosmetic notes remain."),
423            Some(Severity::Green)
424        );
425    }
426
427    /// Regression (review round 2): the scan is token-based and
428    /// negation-aware. A CLEAN report must never be tinted red/yellow —
429    /// that is the loudest mis-color this inference could produce.
430    #[test]
431    fn the_inference_never_colors_a_clean_report_by_substring_or_negation() {
432        let word = |report: &str| {
433            let out = enforce_severity_header(report, true);
434            extract_severity(&out).0
435        };
436        // "majority" is not a MAJOR finding (substring false positive).
437        assert_eq!(
438            word("For the majority of the changes the code follows the conventions."),
439            Some(Severity::Green)
440        );
441        // Negated findings are ABSENT, not present.
442        assert_eq!(
443            word("No critical findings; no major issues were raised."),
444            Some(Severity::Green)
445        );
446        assert_eq!(
447            word("Nothing is a major concern; VERDICT: no changes required."),
448            Some(Severity::Green)
449        );
450        assert_eq!(
451            word("There is without any critical defect in the patch."),
452            Some(Severity::Green)
453        );
454        // A real finding still wins even after earlier negated prose.
455        assert_eq!(
456            word("No critical findings in module A. CRITICAL: data loss in module B."),
457            Some(Severity::Red)
458        );
459        // The negation shadow does not extend across intervening prose.
460        assert_eq!(
461            word("No blockers this round. MAJOR: the parser drops comments."),
462            Some(Severity::Yellow)
463        );
464    }
465
466    /// An empty (or whitespace-only) report is never minted into a
467    /// header-only report by a hypothetical caller that skipped the
468    /// call-site guard.
469    #[test]
470    fn an_empty_report_is_never_enforced() {
471        assert_eq!(enforce_severity_header("", true), "");
472        assert_eq!(enforce_severity_header("   \n  ", true), "   \n  ");
473    }
474
475    /// A report that ALREADY carries a valid header passes through
476    /// untouched — enforcement never overrides the sub-agent's own verdict.
477    #[test]
478    fn a_report_with_a_header_passes_through_untouched() {
479        let report = "<!-- severity: red -->\nCRITICAL: data loss.\n";
480        assert_eq!(enforce_severity_header(report, true), report);
481    }
482
483    /// Non-review tasks are never touched, even when their text mentions
484    /// findings-like vocabulary (a build log can contain "CRITICAL").
485    #[test]
486    fn a_non_review_report_is_never_touched() {
487        let report = "CRITICAL: the build log shows a warning.\n";
488        assert_eq!(enforce_severity_header(report, false), report);
489    }
490}