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}