Skip to main content

borg/
review_fix.rs

1//! `borg review-fix`: the bugs a review step found and a fix step fixed, inside a session, before
2//! anything was committed. Most bugs are caught there (a build → review → verify → fix workflow), and a
3//! commit-based signal (regress links: a later commit from another session) never sees them.
4//!
5//! Source: each harness's runs of sub-agents (`Harness::sub_runs`: a Claude Code workflow run or a
6//! session's Agent-tool sub-agents, an omp session's task agents, an opencode session's subagents, a
7//! Codex session's spawned agents), each agent with its label, phase, kind,
8//! start, prompt, work and output. Workflow scripts and prompts are the engineer's own, so output
9//! shapes vary; this reads them tolerantly from each agent's output (Claude's last `StructuredOutput`,
10//! omp's yields, else a JSON object in its text, [`output_of`]):
11//! - **finding**: an object in a `findings` or `issues` array with a severity (or a `priority`) and a
12//!   text (`title`, `claim`, `problem`, `description`, `issue` or `body`), and no verdict of its own;
13//! - **verdict**: an output with a boolean `real`, or items of a `verdicts`/`results` array with `real`
14//!   or a `verdict` word (confirmed, refuted, …), linked to a finding of the same run by title (or, for
15//!   a one-finding verifier, by its label or prompt naming the title);
16//! - **fixed**: a fix agent of the run ([`is_fix`]), started after the finding, whose tool
17//!   calls or final text name the finding's file. Agents edit mostly through Bash, so tool inputs of
18//!   every kind count, not only Edit/Write.
19//!
20//! The builder (the episode's task and proposal) is the run's first agent that is none of these; a
21//! loose run (`Run::loose`: a plain session's own sub-agents) has none, since its session's own agent
22//! built.
23//!
24//! A finding is **strong** when it was fixed and no verifier rejected it. The negative control asks the
25//! same "fixed" question of a fix agent from another session's run: its rate is how often the file rule
26//! matches by chance.
27//!
28//! 0 tokens: nothing calls a model. Every finding is routed by its agent's cwd (`collective::route`); an
29//! off repo is only counted.
30
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33
34use serde::Serialize;
35use serde_json::Value;
36
37use crate::collective::{self, Config, Route};
38use crate::harness::{self, Harness, Run, SubAgent};
39use crate::learn::{self, Episode, regret};
40
41/// Its flags (completions.rs).
42pub const FLAGS: &[&str] = &["--home", "--since", "--json", "--apply", "--only"];
43
44const USAGE: &str = "usage: borg review-fix [--home DIR] [--since YYYY-MM-DD] [--json] [--apply [--only ID,…]]";
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Verdict {
49    Confirmed,
50    Rejected,
51    /// No verifier's verdict links to it.
52    Unchecked,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Fixed {
58    Yes,
59    /// The run had a fix step after the finding, and it never named the finding's file.
60    No,
61    /// The run had no fix step after the finding.
62    NoFixStep,
63    /// The finding names no file to look for.
64    Unlocated,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct Finding {
69    /// `<runId>#<n>`, n counting the run's findings in agent start order: stable across replays.
70    pub id: String,
71    pub session: String,
72    pub run: String,
73    pub workflow: String,
74    /// The reviewing agent's label (`review:rust`).
75    pub agent: String,
76    pub ms: i64,
77    pub cwd: String,
78    pub file: Option<String>,
79    pub title: String,
80    /// The reviewer's explanation (`detail`, `evidence` or `reason`), at most `DETAIL_CHARS`.
81    pub detail: String,
82    pub severity: String,
83    pub verdict: Verdict,
84    pub fixed: Fixed,
85    /// The prompt of the run's builder (its first agent that neither reviews, verifies nor fixes),
86    /// at most `DETAIL_CHARS` × 2; empty without one.
87    pub task: String,
88    /// The end of the builder's own account of its work, at most `DETAIL_CHARS` × 2.
89    pub built: String,
90}
91
92impl Finding {
93    /// Fixed, and no verifier said it was not real.
94    pub fn strong(&self) -> bool {
95        self.fixed == Fixed::Yes && self.verdict != Verdict::Rejected
96    }
97
98    /// The episode `borg learn` generalises with the review regret prompt (learn/regret.rs). Its id is
99    /// `V` + the finding's id, the dedupe key; its engineer is whoever works in the finding's repo.
100    /// Every text is redacted before it is written.
101    pub fn episode(&self, engineer: &str) -> Episode {
102        let red = |t: &str| crate::notes::redact(t);
103        let verdict = match self.verdict {
104            Verdict::Confirmed => "confirmed",
105            _ => "unchecked",
106        };
107        let detail = match &self.file {
108            Some(f) => format!("{f}: {}", self.detail),
109            None => self.detail.clone(),
110        };
111        let r = regret::Regret {
112            decision: self.run.clone(),
113            decision_session: self.session.clone(),
114            decision_task: red(&self.task),
115            decision_proposal: red(&self.built),
116            fix: self.id.clone(),
117            fix_subject: red(&self.title),
118            diff: red(&detail),
119            signals: vec![regret::REVIEW.into(), verdict.into()],
120            // The builder decided before the reviewer found it.
121            decision_ms: self.ms,
122            ..regret::Regret::default()
123        };
124        Episode {
125            id: format!("V{}", self.id),
126            ms: self.ms,
127            repo: borg_trace::repo_key(&self.cwd).rsplit('/').next().unwrap_or_default().to_string(),
128            session: self.session.clone(),
129            task: r.decision_task.clone(),
130            agent: r.decision_proposal.clone(),
131            rejected_tool: None,
132            correction: format!("Review finding ({}, {verdict}), fixed before any commit: {}\n{}", self.severity, r.fix_subject, r.diff),
133            engineer: engineer.to_string(),
134            kind: regret::KIND.into(),
135            regret: Some(r),
136            ..Episode::default()
137        }
138    }
139}
140
141/// The lowercase words of a label, phase or kind: split at non-alphanumerics and at CamelCase humps
142/// (`MappingValidityFix` → mapping, validity, fix).
143fn words(s: &str) -> Vec<String> {
144    let mut out: Vec<String> = Vec::new();
145    let mut prev_lower = false;
146    for c in s.chars() {
147        if !c.is_alphanumeric() {
148            prev_lower = false;
149            out.push(String::new());
150            continue;
151        }
152        if c.is_uppercase() && prev_lower || out.is_empty() {
153            out.push(String::new());
154        }
155        prev_lower = c.is_lowercase() || c.is_ascii_digit();
156        out.last_mut().expect("pushed").extend(c.to_lowercase());
157    }
158    out.retain(|w| !w.is_empty());
159    out
160}
161
162/// Says fix or repair as a whole word (`fix`, `harness-fix`, `DeriveFacetsRepairs`), and neither
163/// review, verify nor implement.
164fn says_fix(s: &str) -> bool {
165    let w = words(s);
166    w.iter().any(|w| ["fix", "fixes", "fixer", "fixing", "repair", "repairs", "repairer"].contains(&w.as_str()))
167        && !w.iter().any(|w| ["review", "verif", "impl"].iter().any(|n| w.starts_with(n)))
168}
169
170/// A fix step: its phase, its label's first segment (`fix:rust`, not `verify:fix-correctness`) or its
171/// kind says fix (or repair), it is in no review, verify or implement phase, and its output has no
172/// findings (not even an empty list) and no verdicts.
173fn is_fix(a: &SubAgent) -> bool {
174    let phase = words(&a.phase);
175    if phase.iter().any(|w| ["review", "verif", "impl"].iter().any(|n| w.starts_with(n))) {
176        return false;
177    }
178    let first = a.label.split([':', '#']).next().unwrap_or_default();
179    let listed = a.output.as_ref().is_some_and(|o| ["findings", "issues"].iter().any(|k| o.get(*k).is_some_and(Value::is_array)));
180    (says_fix(&a.phase) || says_fix(first) || says_fix(&a.kind)) && !listed && verdicts_of(a.output.as_ref()).is_empty()
181}
182
183/// An agent's output: its structured one, else the last JSON object in its final text that has a
184/// `findings` or `issues` array and starts a line (a fenced block's too). A harness without structured
185/// output (opencode) yields findings only from a reviewer told to answer in JSON; tool inputs are never
186/// read, so a fix agent repeating the findings stays a fix agent.
187fn output_of(a: &SubAgent) -> Option<Value> {
188    if a.output.is_some() {
189        return a.output.clone();
190    }
191    let w = a.final_text.as_str();
192    let listed = |v: &Value| ["findings", "issues"].iter().any(|k| v.get(*k).is_some_and(Value::is_array));
193    let starts = w.match_indices('{').map(|(i, _)| i).filter(|&i| w[w[..i].rfind('\n').map_or(0, |n| n + 1)..i].trim().is_empty());
194    starts.rev().find_map(|i| serde_json::Deserializer::from_str(&w[i..]).into_iter::<Value>().next()?.ok().filter(listed))
195}
196
197/// A finding's detail is cut to this many characters. Guard: bounds what one review finding adds to
198/// the episode and the learning prompt, whatever the reviewer wrote.
199pub const DETAIL_CHARS: usize = 800;
200
201const TEXT_KEYS: &[&str] = &["title", "claim", "problem", "description", "issue", "body"];
202const DETAIL_KEYS: &[&str] = &["detail", "evidence", "reason", "body"];
203const FILE_KEYS: &[&str] = &["file", "path", "file_path", "fileLines", "where"];
204
205fn field<'a>(o: &'a Value, keys: &[&str]) -> Option<&'a str> {
206    keys.iter().find_map(|k| o.get(*k).and_then(Value::as_str)).map(str::trim).filter(|s| !s.is_empty())
207}
208
209/// A verdict word or flag (`real`, `keep`, `refuted`), when `o` carries one.
210fn verdict_of(o: &Value) -> Option<Verdict> {
211    let flag = |k: &str| o.get(k).and_then(Value::as_bool);
212    if let Some(r) = flag("real").or_else(|| flag("keep")).or_else(|| flag("refuted").map(|r| !r)) {
213        return Some(if r { Verdict::Confirmed } else { Verdict::Rejected });
214    }
215    let w = o.get("verdict").and_then(Value::as_str)?.to_lowercase();
216    // A plausible finding survived the attempt to refute it.
217    if ["confirm", "real", "valid", "holds", "true", "plausible"].iter().any(|k| w.starts_with(k)) {
218        Some(Verdict::Confirmed)
219    } else if ["refut", "reject", "false", "invalid", "not"].iter().any(|k| w.starts_with(k)) {
220        Some(Verdict::Rejected)
221    } else {
222        None
223    }
224}
225
226/// A finding as a reviewer's output states it.
227#[derive(Debug, PartialEq)]
228struct Raw {
229    id: Option<String>,
230    title: String,
231    detail: String,
232    severity: String,
233    file: Option<String>,
234}
235
236/// The findings in an agent's output.
237fn findings_of(output: Option<&Value>) -> Vec<Raw> {
238    let Some(o) = output else { return Vec::new() };
239    ["findings", "issues"]
240        .iter()
241        .filter_map(|k| o.get(*k).and_then(Value::as_array))
242        .flatten()
243        .filter(|f| f.is_object() && verdict_of(f).is_none())
244        .filter_map(|f| {
245            let title = field(f, TEXT_KEYS)?;
246            let severity = severity_of(f)?;
247            let detail = field(f, DETAIL_KEYS).filter(|d| *d != title).unwrap_or_default().chars().take(DETAIL_CHARS).collect();
248            Some(Raw { id: field(f, &["id"]).map(String::from), title: title.to_string(), detail, severity, file: field(f, FILE_KEYS).and_then(path_in) })
249        })
250        .collect()
251}
252
253/// A finding's severity, lowercase: its `severity`, else its `priority` (0 to 3, `P1`) as `p0` to `p3`.
254fn severity_of(f: &Value) -> Option<String> {
255    if let Some(s) = field(f, &["severity"]) {
256        return Some(s.to_lowercase());
257    }
258    match f.get("priority")? {
259        Value::Number(n) => n.as_u64().map(|n| format!("p{n}")),
260        Value::String(s) => {
261            let s = s.trim().to_lowercase();
262            let n = s.strip_prefix('p').unwrap_or(&s);
263            Some(if n.parse::<u8>().is_ok() { format!("p{n}") } else { s }).filter(|s| !s.is_empty())
264        }
265        _ => None,
266    }
267}
268
269/// A verdict and what it names: a finding's id or title, or nothing (one verifier per finding).
270#[derive(Debug)]
271struct Said {
272    id: Option<String>,
273    title: Option<String>,
274    verdict: Verdict,
275}
276
277/// The verdicts in an agent's output.
278fn verdicts_of(output: Option<&Value>) -> Vec<Said> {
279    let Some(o) = output else { return Vec::new() };
280    if o.get("findings").is_none()
281        && let Some(verdict) = verdict_of(o)
282    {
283        return vec![Said { id: None, title: None, verdict }];
284    }
285    ["verdicts", "results"]
286        .iter()
287        .filter_map(|k| o.get(*k).and_then(Value::as_array))
288        .flatten()
289        .filter_map(|x| Some(Said { id: field(x, &["id"]).map(String::from), title: field(x, TEXT_KEYS).map(String::from), verdict: verdict_of(x)? }))
290        .collect()
291}
292
293/// The part of a label after its last `:` (`verify:rust` → `rust`), the dimension both sides share.
294fn dim(label: &str) -> &str {
295    label.rsplit(':').next().unwrap_or_default()
296}
297
298/// The first path-like token of `s` (`src/a.rs:12-14 (…)` → `src/a.rs`).
299fn path_in(s: &str) -> Option<String> {
300    s.split(|c: char| c.is_whitespace() || "`'\"(),;".contains(c))
301        .map(|t| t.split(':').next().unwrap_or(t).trim_end_matches(['.', '§']))
302        .find(|t| t.contains('.') && t.len() > 2 && !t.starts_with("http") && t.chars().any(char::is_alphabetic))
303        .map(String::from)
304}
305
306fn norm(s: &str) -> String {
307    s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
308}
309
310/// `a` and `b` name the same finding: one's normalised text starts with the other's (at least 12
311/// characters, so a label's truncated title still links).
312fn same_title(a: &str, b: &str) -> bool {
313    let (a, b) = (norm(a), norm(b));
314    let n = a.len().min(b.len());
315    n >= 12 && a[..n] == b[..n]
316}
317
318/// Does `work` name `file`? The path as written, or its last two components.
319fn names(work: &str, file: &str) -> bool {
320    let tail: Vec<&str> = file.rsplit('/').take(2).collect();
321    let short = tail.iter().rev().copied().collect::<Vec<_>>().join("/");
322    work.contains(file) || (short.len() > 3 && work.contains(&short))
323}
324
325/// The findings of one run, with their verdicts and whether a fix step fixed them.
326fn run_findings(run: &Run) -> Vec<Finding> {
327    let agents = &run.agents;
328    let verdicts: Vec<(&SubAgent, Said)> = agents.iter().flat_map(|a| verdicts_of(a.output.as_ref()).into_iter().map(move |v| (a, v))).collect();
329    let raws: Vec<(&SubAgent, Raw)> = agents.iter().flat_map(|a| findings_of(a.output.as_ref()).into_iter().map(move |r| (a, r))).collect();
330    // The builder: the first agent that produced neither findings nor verdicts and is no fix step; none
331    // in a loose run, where the session's own agent built and its sub-agents only searched or checked.
332    let builder =
333        agents.iter().filter(|_| !run.loose).find(|b| !is_fix(b) && findings_of(b.output.as_ref()).is_empty() && verdicts_of(b.output.as_ref()).is_empty());
334    let n = DETAIL_CHARS * 2;
335    let builder_repo = builder.map(|b| borg_trace::repo_key(&b.cwd));
336    let (task, built) = builder.map_or_else(Default::default, |b| {
337        let w: Vec<char> = b.work.chars().collect();
338        (b.prompt.chars().take(n).collect::<String>(), w[w.len().saturating_sub(n)..].iter().collect::<String>())
339    });
340    let mut out = Vec::new();
341    for (k, (a, Raw { id, title, detail, severity, file })) in raws.iter().enumerate() {
342        let head: String = title.chars().take(60).collect();
343        // An id links when it names one finding of the run, or one of the verifier's own dimension
344        // (reviewers number their findings F1, F2, … each); a title by its text; a verifier that
345        // names neither checks one finding, named by its label or prompt.
346        let same_id = |v: &str| raws.iter().filter(|(_, r)| r.id.as_deref() == Some(v)).count();
347        let linked = verdicts.iter().filter(|(va, said)| match (&said.id, &said.title) {
348            (Some(v), _) if id.as_deref() == Some(v.as_str()) => same_id(v) == 1 || dim(&va.label) == dim(&a.label),
349            (_, Some(t)) => same_title(t, title),
350            (Some(_), None) => false,
351            (None, None) => same_title(dim(&va.label), title) || va.prompt.contains(&head),
352        });
353        let verdict =
354            linked.fold(
355                Verdict::Unchecked,
356                |acc, (_, said)| if said.verdict == Verdict::Rejected || acc == Verdict::Rejected { Verdict::Rejected } else { said.verdict },
357            );
358        let fixers: Vec<&SubAgent> = agents.iter().filter(|f| is_fix(f) && f.started >= a.started).collect();
359        let fixed = match file {
360            _ if fixers.is_empty() => Fixed::NoFixStep,
361            None => Fixed::Unlocated,
362            Some(f) if fixers.iter().any(|x| names(&x.work, f)) => Fixed::Yes,
363            Some(_) => Fixed::No,
364        };
365        // The builder's text only when it worked in the reviewer's repo: the episode is routed by
366        // the reviewer's, and another repo's text must not land in its collective.
367        let same_repo = builder_repo.as_deref() == Some(borg_trace::repo_key(&a.cwd).as_str());
368        out.push(Finding {
369            // A loose run's agents finish in any order, so a finding there is named by its agent: a
370            // later finisher that started earlier must not renumber findings already recorded.
371            id: if run.loose {
372                format!("{}#{}.{}", run.id, a.id, raws[..k].iter().filter(|(b, _)| std::ptr::eq(*b, *a)).count())
373            } else {
374                format!("{}#{k}", run.id)
375            },
376            session: run.session.clone(),
377            run: run.id.clone(),
378            workflow: run.name.clone(),
379            agent: a.label.clone(),
380            ms: a.started,
381            cwd: a.cwd.clone(),
382            file: file.clone(),
383            title: title.clone(),
384            detail: detail.clone(),
385            severity: severity.clone(),
386            verdict,
387            fixed,
388            task: if same_repo { task.clone() } else { String::new() },
389            built: if same_repo { built.clone() } else { String::new() },
390        });
391    }
392    out
393}
394
395/// The runs of `harnesses` under `home` (of `session` only, when given), oldest first, each agent's
396/// output read from its text where it has no structured one ([`output_of`]).
397fn runs_of<'h>(harnesses: impl Iterator<Item = &'h dyn Harness>, home: &Path, since: Option<i64>, session: Option<&str>) -> Vec<Run> {
398    prepared(harnesses.flat_map(|h| h.sub_runs(home, session, since)).collect())
399}
400
401fn prepared(mut runs: Vec<Run>) -> Vec<Run> {
402    for a in runs.iter_mut().flat_map(|r| r.agents.iter_mut()) {
403        a.output = output_of(a);
404    }
405    runs.sort_by_key(|r| r.started);
406    runs
407}
408
409/// The findings of `runs`, and every run with a fix step, for the control.
410fn scan(runs: &[Run]) -> (Vec<Finding>, Vec<Fixers>) {
411    let mut findings = Vec::new();
412    let mut fixers = Vec::new();
413    for run in runs {
414        findings.extend(run_findings(run));
415        let fix: Vec<&SubAgent> = run.agents.iter().filter(|a| is_fix(a)).collect();
416        if let Some(first) = fix.first() {
417            let repo = borg_trace::repo_key(&first.cwd);
418            fixers.push(Fixers { session: run.session.clone(), run: run.id.clone(), repo, work: fix.iter().map(|a| a.work.clone()).collect() });
419        }
420    }
421    (findings, fixers)
422}
423
424/// A run's fix steps: what they did, and the repo they did it in.
425#[derive(Debug)]
426struct Fixers {
427    session: String,
428    run: String,
429    repo: String,
430    work: Vec<String>,
431}
432
433/// Counts for one collective, or for the off repos (only counted).
434#[derive(Debug, Default, Clone, Serialize)]
435pub struct Tally {
436    pub runs: usize,
437    pub findings: usize,
438    pub confirmed: usize,
439    pub rejected: usize,
440    pub unchecked: usize,
441    pub fixed: usize,
442    pub not_fixed: usize,
443    pub no_fix_step: usize,
444    pub unlocated: usize,
445    pub strong: usize,
446}
447
448impl Tally {
449    fn add(&mut self, f: &Finding) {
450        self.findings += 1;
451        match f.verdict {
452            Verdict::Confirmed => self.confirmed += 1,
453            Verdict::Rejected => self.rejected += 1,
454            Verdict::Unchecked => self.unchecked += 1,
455        }
456        match f.fixed {
457            Fixed::Yes => self.fixed += 1,
458            Fixed::No => self.not_fixed += 1,
459            Fixed::NoFixStep => self.no_fix_step += 1,
460            Fixed::Unlocated => self.unlocated += 1,
461        }
462        self.strong += usize::from(f.strong());
463    }
464}
465
466/// The negative control: of the located findings, how often the fix step of another session's run in
467/// the same repo names the finding's file (chance: the same files keep being worked on), against how
468/// often the run's own fix step does. Findings with no such other run are `unpaired`.
469#[derive(Debug, Default, Clone, Serialize)]
470pub struct Control {
471    pub unpaired: usize,
472    pub located: usize,
473    pub own_named: usize,
474    pub other_named: usize,
475}
476
477#[derive(Debug, Default, Serialize)]
478pub struct Report {
479    pub collectives: BTreeMap<String, Tally>,
480    pub off: Tally,
481    pub control: Control,
482    /// The findings in repos that route to a collective (never off ones).
483    pub findings: Vec<Finding>,
484}
485
486/// Route, count and control the findings of every harness's runs under `home`.
487pub fn run(home: &Path, since: Option<i64>, cfg: Option<&Config>, base: &Path) -> Report {
488    report(&runs_of(harness::ALL.iter().copied(), home, since, None), cfg, base)
489}
490
491/// Route, count and control the findings of `runs`.
492fn report(runs: &[Run], cfg: Option<&Config>, base: &Path) -> Report {
493    let (findings, fixers) = scan(runs);
494    let mut r = Report::default();
495    let mut runs: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
496    for f in findings {
497        let name = match collective::route(cfg, &f.cwd, base) {
498            Route::Off => None,
499            Route::Collective { name, .. } => Some(name),
500        };
501        let Some(name) = name else {
502            r.off.add(&f);
503            continue;
504        };
505        if let Some(file) = f.file.as_deref().filter(|_| f.fixed != Fixed::NoFixStep) {
506            // The control pairs each finding with the next run, in time order (wrapping), of another
507            // session in the same repo that has a fix step.
508            let repo = borg_trace::repo_key(&f.cwd);
509            let at = fixers.iter().position(|x| x.run == f.run).map_or(0, |i| i + 1);
510            match fixers.iter().cycle().skip(at).take(fixers.len()).find(|x| x.session != f.session && x.repo == repo) {
511                Some(other) => {
512                    r.control.located += 1;
513                    r.control.own_named += usize::from(f.fixed == Fixed::Yes);
514                    r.control.other_named += usize::from(other.work.iter().any(|w| names(w, file)));
515                }
516                None => r.control.unpaired += 1,
517            }
518        }
519        runs.entry(name.clone()).or_default().insert(f.run.clone());
520        r.collectives.entry(name).or_default().add(&f);
521        r.findings.push(f);
522    }
523    for (name, set) in runs {
524        r.collectives.entry(name).or_default().runs = set.len();
525    }
526    r
527}
528
529fn pct(n: usize, d: usize) -> String {
530    if d == 0 { "-".into() } else { format!("{:.0}%", 100.0 * n as f64 / d as f64) }
531}
532
533pub fn render(r: &Report) -> String {
534    let mut s = String::new();
535    for (name, t) in &r.collectives {
536        s.push_str(&format!(
537            "{name}: {} findings in {} workflow runs; verdict: {} confirmed, {} rejected, {} unchecked; fix: {} fixed, {} not named by the fix step, {} without a fix step, {} without a file; {} strong\n",
538            t.findings, t.runs, t.confirmed, t.rejected, t.unchecked, t.fixed, t.not_fixed, t.no_fix_step, t.unlocated, t.strong
539        ));
540    }
541    if r.collectives.is_empty() {
542        s.push_str("no workflow findings in repos of a collective\n");
543    }
544    let c = &r.control;
545    s.push_str(&format!(
546        "control: of {} located findings, the run's own fix step names the file {} ({}), another session's fix step in the same repo {} ({}); {} without such a session\n",
547        c.located,
548        c.own_named,
549        pct(c.own_named, c.located),
550        c.other_named,
551        pct(c.other_named, c.located),
552        c.unpaired
553    ));
554    s.push_str(&format!("off: {} findings skipped (not shown)\n", r.off.findings));
555    s
556}
557
558/// Record the strong findings (those in `only`, when given) as episodes in their collectives' stores,
559/// skipping known ones, then learn each store that got new episodes. Returns (recorded, learned
560/// lessons, merged).
561pub fn apply(report: &Report, only: Option<&[String]>, cfg: Option<&Config>, base: &Path, ask: &dyn Fn(&str) -> Option<Value>) -> (usize, usize, usize) {
562    let (recorded, homes) = record(report.findings.iter().filter(|f| only.is_none_or(|o| o.contains(&f.id))), cfg, base);
563    let (mut new, mut merged) = (0, 0);
564    for home in &homes {
565        let (n, m, _) = learn::learn_in_reporting(home, None, ask, learn::learning(), learn::now_ms(), &mut learn::print_step);
566        new += n;
567        merged += m;
568    }
569    (recorded, new, merged)
570}
571
572/// Record the strong ones of `findings` as episodes in the stores their repos route to, skipping
573/// known ones and off repos. Returns how many were recorded and the stores that got any.
574fn record<'a>(findings: impl Iterator<Item = &'a Finding>, cfg: Option<&Config>, base: &Path) -> (usize, Vec<PathBuf>) {
575    let mut homes: Vec<PathBuf> = Vec::new();
576    let mut recorded = 0;
577    let mut engineers: BTreeMap<String, String> = BTreeMap::new();
578    for f in findings.filter(|f| f.strong()) {
579        let Route::Collective { home, .. } = collective::route(cfg, &f.cwd, base) else { continue };
580        let engineer = engineers.entry(f.cwd.clone()).or_insert_with(|| learn::engineer(&f.cwd)).clone();
581        let e = f.episode(&engineer);
582        if crate::notes::regret::known(&home, &e.id) {
583            continue;
584        }
585        if learn::record_episode_in(&home, &e).is_ok() {
586            recorded += 1;
587            crate::stats::count(&home, crate::stats::REVIEW_FINDING, &f.session, |ev| {
588                ev.engineer = engineer.clone();
589                ev.repo = e.repo.clone();
590                ev.why = f.severity.clone();
591                ev.text = e.regret.as_ref().map(|r| r.fix_subject.clone()).unwrap_or_default();
592            });
593            if !homes.contains(&home) {
594                homes.push(home);
595            }
596        }
597    }
598    (recorded, homes)
599}
600
601/// Session end records the session's strong review findings as episodes before `borg learn`
602/// generalises them: on by default (the value eval, docs/evidence.md), `BORG_REVIEW_FIX=off` turns it off.
603pub fn on() -> bool {
604    std::env::var("BORG_REVIEW_FIX").as_deref() != Ok("off")
605}
606
607/// At the end of `session`: record the strong findings of its runs under `home`, as the drone's harness
608/// writes them (`BORG_HARNESS`; every harness when unset). Returns how many were recorded and the
609/// stores that got any, so the caller learns each.
610pub fn record_session(home: &Path, session: &str, cfg: Option<&Config>, base: &Path) -> (usize, Vec<PathBuf>) {
611    // A session id names a directory or a file, never a path.
612    if session.is_empty() || session.contains(['/', '\\']) || session.starts_with('.') {
613        return (0, Vec::new());
614    }
615    let runs = match harness::named_current() {
616        Some(h) => runs_of(std::iter::once(h), home, None, Some(session)),
617        None => runs_of(harness::ALL.iter().copied(), home, None, Some(session)),
618    };
619    record(scan(&runs).0.iter(), cfg, base)
620}
621
622/// The engineer's home, where every harness keeps its data.
623pub fn home_dir() -> PathBuf {
624    PathBuf::from(std::env::var("HOME").unwrap_or_default())
625}
626
627/// `borg review-fix [--home DIR] [--since YYYY-MM-DD] [--json] [--apply [--only ID,…]]`; the exit code.
628pub fn main(args: &[&str], ask: &dyn Fn(&str) -> Option<Value>) -> i32 {
629    let mut home = None;
630    let mut since = None;
631    let mut json = false;
632    let mut apply_it = false;
633    let mut only: Option<Vec<String>> = None;
634    let mut it = args.iter();
635    while let Some(a) = it.next() {
636        match *a {
637            "--json" => json = true,
638            "--apply" => apply_it = true,
639            "--only" => only = it.next().map(|v| v.split(',').map(str::to_string).collect()),
640            "--home" => home = it.next().map(PathBuf::from),
641            "--since" => match it.next().map(|d| borg_trace::parse_ts(&format!("{d}T00:00:00Z"))) {
642                Some(ms) if ms > 0 => since = Some(ms),
643                _ => {
644                    eprintln!("borg review-fix: --since wants YYYY-MM-DD\n{USAGE}");
645                    return 2;
646                }
647            },
648            _ => {
649                eprintln!("{USAGE}");
650                return 2;
651            }
652        }
653    }
654    let home = home.unwrap_or_else(home_dir);
655    // A broken config routes everything off, exactly as in the hook.
656    let cfg = match collective::load() {
657        Ok(c) => c,
658        Err(e) => {
659            eprintln!("borg review-fix: invalid collectives config ({e}): every repo is off");
660            return 1;
661        }
662    };
663    let report = run(&home, since, cfg.as_ref(), &learn::home());
664    if apply_it {
665        let (recorded, new, merged) = apply(&report, only.as_deref(), cfg.as_ref(), &learn::home(), ask);
666        println!("{recorded} review findings recorded as episodes; {new} new lessons, {merged} merged");
667        return 0;
668    }
669    if json {
670        println!("{}", serde_json::to_string(&report).unwrap_or_default());
671    } else {
672        print!("{}", render(&report));
673    }
674    0
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use serde_json::json;
681
682    /// The Claude Code workflow runs under `projects`, as `borg review-fix` reads them.
683    fn claude_runs(projects: &Path, session: Option<&str>) -> Vec<Run> {
684        prepared(harness::claude::workflow_runs(projects, session, None))
685    }
686
687    fn found(projects: &Path) -> Vec<Finding> {
688        scan(&claude_runs(projects, None)).0
689    }
690
691    fn temp(name: &str) -> PathBuf {
692        let d = std::env::temp_dir().join(format!("borg-review-fix-{name}-{}", std::process::id()));
693        let _ = std::fs::remove_dir_all(&d);
694        d
695    }
696
697    /// A transcript: the prompt, then tool calls (name, input), then the structured output, if any.
698    fn transcript(prompt: &str, tools: &[(&str, Value)], output: Option<Value>) -> String {
699        let mut lines = vec![json!({"type": "user", "cwd": "/w/app", "message": {"role": "user", "content": prompt}})];
700        for (name, input) in tools {
701            lines.push(json!({"type": "assistant", "message": {"content": [{"type": "tool_use", "name": name, "input": input}]}}));
702        }
703        if let Some(o) = output {
704            lines.push(json!({"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "StructuredOutput", "input": o}]}}));
705        }
706        lines.iter().map(Value::to_string).collect::<Vec<_>>().join("\n")
707    }
708
709    /// One run in `projects/<slug>/<session>` with agents (id, label, phase, started, transcript).
710    fn write_run(projects: &Path, session: &str, run: &str, agents: &[(&str, &str, &str, i64, String)]) {
711        let dir = projects.join("-w-app").join(session);
712        let ad = dir.join("subagents/workflows").join(run);
713        std::fs::create_dir_all(&ad).unwrap();
714        std::fs::create_dir_all(dir.join("workflows")).unwrap();
715        let progress: Vec<Value> = agents
716            .iter()
717            .map(|(id, label, phase, at, t)| {
718                std::fs::write(ad.join(format!("agent-{id}.jsonl")), t).unwrap();
719                json!({"type": "workflow_agent", "label": label, "phaseTitle": phase, "agentId": id, "startedAt": at.to_string()})
720            })
721            .collect();
722        let r = json!({"runId": run, "workflowName": "build", "startTime": agents[0].3, "workflowProgress": progress});
723        std::fs::write(dir.join("workflows").join(format!("{run}.json")), r.to_string()).unwrap();
724    }
725
726    const TITLE: &str = "Pre-aborted signal returns before the error listener is attached";
727
728    fn review() -> String {
729        transcript("review the diff", &[], Some(json!({"findings": [{"file": "adapters/omp/borg.ts", "line": 153, "severity": "Medium", "title": TITLE}]})))
730    }
731
732    #[test]
733    fn a_confirmed_finding_the_fix_step_touches_is_strong() {
734        let p = temp("strong");
735        let verify = transcript(&format!("Verify: {TITLE}"), &[], Some(json!({"real": true, "reason": "probe crashed"})));
736        let fix = transcript("apply the findings", &[("Bash", json!({"command": "sed -i 's/a/b/' adapters/omp/borg.ts"}))], None);
737        write_run(
738            &p,
739            "s1",
740            "wf_1",
741            &[
742                ("a1", "review:concurrency", "Review", 10, review()),
743                ("a2", "verify:concurrency:Pre-aborted signal ret", "Verify", 20, verify),
744                ("a3", "fix", "Fix", 30, fix),
745            ],
746        );
747        let f = found(&p);
748        assert_eq!(f.len(), 1);
749        assert_eq!((f[0].verdict, f[0].fixed, f[0].severity.as_str()), (Verdict::Confirmed, Fixed::Yes, "medium"));
750        assert!(f[0].strong());
751        assert_eq!(f[0].cwd, "/w/app");
752        let _ = std::fs::remove_dir_all(p);
753    }
754
755    #[test]
756    fn a_rejected_or_unfixed_finding_is_not_strong() {
757        let p = temp("weak");
758        let verify = transcript("check it", &[], Some(json!({"verdicts": [{"title": TITLE, "verdict": "refuted"}]})));
759        let fix = transcript("apply the findings", &[("Edit", json!({"file_path": "src/other.rs"}))], None);
760        write_run(&p, "s1", "wf_1", &[("a1", "review", "Review", 10, review()), ("a2", "verify", "Verify", 20, verify), ("a3", "fix", "Fix", 30, fix)]);
761        let f = found(&p);
762        assert_eq!((f[0].verdict, f[0].fixed), (Verdict::Rejected, Fixed::No));
763        assert!(!f[0].strong());
764        let _ = std::fs::remove_dir_all(p);
765    }
766
767    #[test]
768    fn a_review_without_a_later_fix_step_or_a_file_is_counted_apart() {
769        let p = temp("nofix");
770        write_run(&p, "s1", "wf_1", &[("a1", "review", "Review", 10, review())]);
771        let unlocated = transcript("review", &[], Some(json!({"issues": [{"severity": "high", "problem": "the budget is spent on noise"}]})));
772        write_run(&p, "s2", "wf_2", &[("b1", "review:queen", "Review", 10, unlocated), ("b2", "fix", "Fix", 20, transcript("fix", &[], None))]);
773        let f = found(&p);
774        assert_eq!(f.iter().map(|x| x.fixed).collect::<Vec<_>>(), [Fixed::NoFixStep, Fixed::Unlocated]);
775        assert_eq!(f[1].verdict, Verdict::Unchecked);
776        let _ = std::fs::remove_dir_all(p);
777    }
778
779    #[test]
780    fn string_findings_and_gate_results_are_not_findings() {
781        let out = json!({"findings": ["X1 the voice speaks the commentary 38/38 times"], "results": [{"command": "npm run check", "passed": true}]});
782        assert!(findings_of(Some(&out)).is_empty());
783        assert!(verdicts_of(Some(&out)).is_empty());
784        // A verify output's items carry a verdict, so they are verdicts, not new findings.
785        let v = json!({"findings": [{"title": TITLE, "severity": "low", "real": false}]});
786        assert!(findings_of(Some(&v)).is_empty());
787    }
788
789    #[test]
790    fn verdicts_link_by_id_within_the_verifiers_dimension() {
791        let p = temp("ids");
792        let f =
793            |id: &str, file: &str| json!({"id": id, "file": file, "severity": "minor", "claim": format!("{file} drops the queued lessons on a late answer")});
794        let rust = transcript("review", &[], Some(json!({"findings": [f("F1", "src/a.rs")]})));
795        let docs = transcript("review", &[], Some(json!({"findings": [f("F1", "docs/b.md")]})));
796        let verify = transcript("verify", &[], Some(json!({"verdicts": [{"id": "F1", "refuted": true}]})));
797        let keep = transcript("verify", &[], Some(json!({"results": [{"id": "F1", "keep": true}]})));
798        write_run(
799            &p,
800            "s1",
801            "wf_1",
802            &[
803                ("a1", "review:rust", "Review", 10, rust),
804                ("a2", "review:docs", "Review", 11, docs),
805                ("a3", "verify:rust", "Verify", 20, verify),
806                ("a4", "verify:docs", "Verify", 21, keep),
807            ],
808        );
809        let got = found(&p);
810        assert_eq!(
811            got.iter().map(|x| (x.agent.as_str(), x.verdict)).collect::<Vec<_>>(),
812            [("review:rust", Verdict::Rejected), ("review:docs", Verdict::Confirmed)]
813        );
814        assert_eq!(verdict_of(&json!({"verdict": "PLAUSIBLE"})), Some(Verdict::Confirmed));
815        assert_eq!(verdict_of(&json!({"verdict": "needs more info"})), None);
816        let _ = std::fs::remove_dir_all(p);
817    }
818
819    #[test]
820    fn a_strong_finding_becomes_a_review_regret_episode() {
821        let p = temp("episode");
822        let build = transcript("Port the voice lane to gpt-live.", &[], None);
823        let fix = transcript("apply", &[("Bash", json!({"command": "sed -i x adapters/omp/borg.ts"}))], None);
824        write_run(&p, "s1", "wf_1", &[("a0", "build", "Build", 5, build), ("a1", "review", "Review", 10, review()), ("a2", "fix", "Fix", 20, fix)]);
825        let f = found(&p);
826        assert_eq!((f[0].id.as_str(), f[0].task.as_str()), ("wf_1#0", "Port the voice lane to gpt-live."));
827        let e = f[0].episode("dev@x");
828        assert_eq!((e.id.as_str(), e.kind.as_str(), e.session.as_str(), e.engineer.as_str()), ("Vwf_1#0", regret::KIND, "s1", "dev@x"));
829        let r = e.regret.unwrap();
830        assert!(regret::is_review(&r) && r.signals == ["review", "unchecked"]);
831        assert_eq!(r.decision_ms, e.ms, "decided before the finding: asks up to it are blamed");
832        assert!(r.diff.starts_with("adapters/omp/borg.ts: ") && r.fix_subject == TITLE, "{r:?}");
833        let _ = std::fs::remove_dir_all(p);
834    }
835
836    #[test]
837    fn paths_come_out_of_location_text() {
838        assert_eq!(path_in("src/main/java/Tq.java:72-79 (resets at L74)").as_deref(), Some("src/main/java/Tq.java"));
839        assert_eq!(path_in("sliceA-spec.md §G.3/§G.8 (ii)").as_deref(), Some("sliceA-spec.md"));
840        assert_eq!(path_in("the whole design"), None);
841        assert!(names("cat > crates/borg/src/live.rs <<EOF", "/home/x/crates/borg/src/live.rs") || names("x src/live.rs", "crates/borg/src/live.rs"));
842        assert!(!names("edits main.rs", "src/live.rs"));
843    }
844
845    #[test]
846    fn the_control_pairs_a_finding_with_another_sessions_fix_step() {
847        let p = temp("control");
848        let fix = |file: &str| transcript("fix", &[("Bash", json!({"command": format!("vim {file}")}))], None);
849        write_run(&p, "s1", "wf_1", &[("a1", "review", "Review", 10, review()), ("a2", "fix", "Fix", 20, fix("adapters/omp/borg.ts"))]);
850        write_run(&p, "s2", "wf_2", &[("b1", "build", "Build", 30, transcript("build", &[], None)), ("b2", "fix", "Fix", 40, fix("src/other.rs"))]);
851        let r = report(&claude_runs(&p, None), None, &p.join("store"));
852        assert_eq!((r.control.located, r.control.own_named, r.control.other_named), (1, 1, 0));
853        assert_eq!(r.collectives.values().map(|t| (t.findings, t.strong, t.runs)).collect::<Vec<_>>(), [(1, 1, 1)]);
854        let _ = std::fs::remove_dir_all(p);
855    }
856
857    #[test]
858    fn session_end_records_only_that_sessions_strong_findings_once() {
859        let p = temp("session");
860        let fix = transcript("apply the findings", &[("Bash", json!({"command": "sed -i 's/a/b/' adapters/omp/borg.ts"}))], None);
861        for (s, run) in [("s1", "wf_1"), ("s2", "wf_2")] {
862            write_run(&p, s, run, &[("a1", "review:concurrency", "Review", 10, review()), ("a3", "fix", "Fix", 30, fix.clone())]);
863        }
864        let store = p.join("store");
865        let session = |s: &str| record(scan(&claude_runs(&p, Some(s))).0.iter(), None, &store);
866        let (n, homes) = session("s1");
867        assert_eq!((n, homes), (1, vec![store.clone()]));
868        let episodes = std::fs::read_to_string(store.join("episodes.jsonl")).unwrap();
869        assert!(episodes.contains("Vwf_1#") && !episodes.contains("wf_2"), "{episodes}");
870        // Each recorded finding is counted for the page (stats.rs), with its severity; a skipped one is not.
871        let evs = crate::stats::events(&store);
872        assert_eq!(
873            evs.iter().filter(|e| e.kind == crate::stats::REVIEW_FINDING).map(|e| (e.session.as_str(), e.why.as_str())).collect::<Vec<_>>(),
874            [("s1", "medium")]
875        );
876        // Known episodes are skipped; a session id is never a path.
877        assert_eq!(session("s1").0, 0);
878        assert_eq!(record_session(&p, "../-w-app/s2", None, &store).0, 0);
879        assert_eq!(record_session(&p, "", None, &store).0, 0);
880        let _ = std::fs::remove_dir_all(p);
881    }
882
883    fn fixture(p: &str) -> PathBuf {
884        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/review_fix").join(p)
885    }
886
887    /// (title, severity, fixed, strong) of each finding.
888    fn rows(f: &[Finding]) -> Vec<(&str, &str, Fixed, bool)> {
889        f.iter().map(|x| (x.title.as_str(), x.severity.as_str(), x.fixed, x.strong())).collect()
890    }
891
892    #[test]
893    fn a_claude_workflow_run_gives_its_strong_finding() {
894        let f = found(&fixture("claude/projects"));
895        assert_eq!(rows(&f), [("The budget place is reset by an arc instead of drained", "high", Fixed::Yes, true)]);
896        assert_eq!((f[0].session.as_str(), f[0].file.as_deref(), f[0].workflow.as_str()), ("s1", Some("src/net.rs"), "build"));
897    }
898
899    #[test]
900    fn omp_findings_yielded_one_by_one_and_a_fix_task_give_one_strong_finding() {
901        let runs = prepared(harness::omp::session_runs(&fixture("omp/sessions"), Some("01a0a00d-0000-7000-8000-000000000001"), None));
902        let f = scan(&runs).0;
903        // The array the schema rejected is not counted: only the accepted yields, one finding each.
904        assert_eq!(
905            rows(&f),
906            [
907                ("Facet mapping drops the unit of a numeric range", "p1", Fixed::Yes, true),
908                ("Empty facet list renders a stray separator", "p3", Fixed::No, false)
909            ]
910        );
911        let x = &f[0];
912        assert_eq!(
913            (x.agent.as_str(), x.session.as_str(), x.cwd.as_str(), x.workflow.as_str()),
914            ("MappingReview", "01a0a00d-0000-7000-8000-000000000001", "/w/app", "Map the facets")
915        );
916        assert_eq!(x.file.as_deref(), Some("/w/app/src/facets/mapping.ts"));
917        assert!(x.detail.starts_with("`mapRange` in src/facets/mapping.ts") && x.task == "Implement the facet mapping in src/facets/mapping.ts.", "{x:?}");
918    }
919
920    #[test]
921    fn omp_findings_in_one_yielded_object_are_all_read() {
922        let runs = prepared(harness::omp::session_runs(&fixture("omp/sessions"), Some("01a0a00d-0000-7000-8000-000000000002"), None));
923        let f = scan(&runs).0;
924        assert_eq!(
925            rows(&f),
926            [
927                ("Retry loop never backs off", "p0", Fixed::NoFixStep, false),
928                ("Timeout is read in seconds and used as milliseconds", "p2", Fixed::NoFixStep, false)
929            ]
930        );
931        // Every session, oldest first.
932        let all = harness::omp::session_runs(&fixture("omp/sessions"), None, None);
933        assert_eq!(all.iter().map(|r| r.agents.len()).collect::<Vec<_>>(), [3, 2]);
934    }
935
936    /// opencode exports by id from the fixture's v1 and v2 directories; the query lists both roots.
937    fn opencode_runs(session: Option<&str>) -> Vec<Run> {
938        let export = |id: &str, _: Option<&Path>| {
939            ["v1", "v2"]
940                .iter()
941                .find_map(|v| std::fs::read(fixture("opencode").join(v).join(format!("{id}.json"))).ok())
942                .and_then(|b| serde_json::from_slice(&b).ok())
943        };
944        let query = |_: &str| Some(vec![json!({"id": "ses_1root0000000000000000000"}), json!({"id": "ses_2root0000000000000000000"})]);
945        prepared(harness::opencode::session_runs(session, None, &export, &query))
946    }
947
948    #[test]
949    fn opencode_findings_come_from_a_reviewers_json_text_and_a_fix_child_fixes_them() {
950        let f = scan(&opencode_runs(Some("ses_1root0000000000000000000"))).0;
951        assert_eq!(rows(&f), [("Cache key ignores the tenant id", "high", Fixed::Yes, true)]);
952        assert_eq!((f[0].agent.as_str(), f[0].session.as_str(), f[0].cwd.as_str()), ("review cache", "ses_1root0000000000000000000", "/w/app"));
953        // 2.x: a bare object with `issues`; the repair child's export is missing, so its final text
954        // as the parent saw it names the file.
955        let all = scan(&opencode_runs(None)).0;
956        assert_eq!(rows(&all)[1], ("Queue bound is checked after the push", "medium", Fixed::Yes, true));
957        // A session id that is no opencode one asks nothing.
958        assert!(opencode_runs(Some("01a0a00d-0000-7000-8000-000000000001")).is_empty());
959    }
960
961    #[test]
962    fn harnesses_without_sub_agent_transcripts_give_no_runs() {
963        let home = fixture("omp");
964        let none: Vec<&str> = harness::ALL.iter().filter(|h| h.sub_runs(&home, None, None).is_empty()).map(|h| h.name()).collect();
965        assert!(none.contains(&"sim"), "{none:?}");
966    }
967
968    #[test]
969    fn severity_comes_from_a_priority_and_text_from_a_body() {
970        let f = |v: Value| findings_of(Some(&json!({"findings": [v]}))).into_iter().map(|r| (r.title, r.severity, r.detail, r.file)).next();
971        assert_eq!(f(json!({"body": "src/a.rs drops it", "priority": "P2"})), Some(("src/a.rs drops it".into(), "p2".into(), String::new(), None)));
972        assert_eq!(
973            f(json!({"title": "t", "body": "b", "priority": 0, "file_path": "src/a.rs"})).map(|x| (x.1, x.2, x.3)),
974            Some(("p0".into(), "b".into(), Some("src/a.rs".into())))
975        );
976        assert_eq!(f(json!({"title": "no severity"})), None);
977        let text = |w: &str| output_of(&SubAgent { final_text: w.into(), ..Default::default() });
978        assert_eq!(text("{\"command\": \"x\"}\nDone: {\"findings\": []} inline"), None, "only an object that starts a line");
979        assert!(text("Report\n```json\n{\"issues\": [{\"problem\": \"p\"}]}\n```").is_some());
980    }
981
982    #[test]
983    fn only_a_fix_step_is_a_fix_step_whatever_its_label_mentions() {
984        let a = |label: &str, phase: &str, kind: &str, output: Option<Value>| SubAgent {
985            label: label.into(),
986            phase: phase.into(),
987            kind: kind.into(),
988            output,
989            ..Default::default()
990        };
991        let fix = |label: &str, phase: &str| is_fix(&a(label, phase, "", None));
992        // Labels of real workflow runs: fix in a later segment is what is checked, built or reviewed.
993        assert!(!fix("verify:fix-correctness", "Verify"));
994        assert!(!fix("review:harness-fix#0", "Review"));
995        assert!(!fix("impl:harness-fix", "Implement"));
996        assert!(!fix("verify:fix-correctness", ""), "the label's first segment decides");
997        assert!(fix("fix", "Fix") && fix("fix:rust", "") && fix("fix#1", "") && fix("anything", "Repair"));
998        assert!(fix("MappingValidityFix", "") && fix("DeriveFacetsRepairs", "") && !fix("Prefixed", "") && !fix("MappingReviewFix", ""));
999        assert!(fix("fix", "") && !fix("fix", "Review"), "a review phase is never a fix step");
1000        assert!(is_fix(&a("ses", "", "fixer", None)), "an omp kind");
1001        // An output with a findings list, even an empty one, or a verdict is a reviewer's or a verifier's.
1002        assert!(!is_fix(&a("fix", "Fix", "", Some(json!({"findings": []})))));
1003        assert!(!is_fix(&a("fix", "Fix", "", Some(json!({"real": true})))));
1004        // The implementer is the builder, not a fix step.
1005        let p = temp("labels");
1006        let build = transcript("Build the harness fix.", &[], None);
1007        let fix_it = transcript("apply", &[("Bash", json!({"command": "sed -i x adapters/omp/borg.ts"}))], None);
1008        let verify = transcript("verify", &[("Bash", json!({"command": "cat adapters/omp/borg.ts"}))], Some(json!({"real": true})));
1009        write_run(
1010            &p,
1011            "s1",
1012            "wf_1",
1013            &[
1014                ("a0", "impl:harness-fix", "Implement", 5, build),
1015                ("a1", "review:harness-fix#0", "Review", 10, review()),
1016                ("a2", "verify:fix-correctness", "Verify", 20, verify),
1017            ],
1018        );
1019        let f = found(&p);
1020        assert_eq!((f[0].fixed, f[0].task.as_str()), (Fixed::NoFixStep, "Build the harness fix."));
1021        write_run(&p, "s2", "wf_2", &[("b1", "review:harness-fix#0", "Review", 10, review()), ("b2", "fix:rust", "", 30, fix_it)]);
1022        assert_eq!(found(&p)[1].fixed, Fixed::Yes);
1023        let _ = std::fs::remove_dir_all(p);
1024    }
1025
1026    #[test]
1027    fn findings_come_only_from_the_final_text_never_from_a_tool_input() {
1028        let echo = json!({"findings": [{"file": "src/a.rs", "severity": "high", "title": "the findings this fix step works through"}]});
1029        // A fix step that passes the findings to a tool stays a fix step, whatever its text says after.
1030        for last in ["", "Fixed src/a.rs."] {
1031            let a = SubAgent {
1032                label: "fix".into(),
1033                work: format!("{{\"command\":\"cargo test\"}}\n{echo}\n{last}\n"),
1034                final_text: last.into(),
1035                ..Default::default()
1036            };
1037            let a = SubAgent { output: output_of(&a), ..a };
1038            assert!(a.output.is_none() && is_fix(&a), "{last:?}");
1039        }
1040        // A reviewer's answer is read, fenced or compact (keys in any order).
1041        let fenced = format!("Done.\n```json\n{}\n```\n", serde_json::to_string_pretty(&echo).unwrap());
1042        assert_eq!(output_of(&SubAgent { final_text: fenced, ..Default::default() }), Some(echo.clone()));
1043        assert_eq!(output_of(&SubAgent { final_text: echo.to_string(), ..Default::default() }), Some(echo));
1044    }
1045
1046    #[test]
1047    fn a_builder_in_another_repo_gives_no_task_text() {
1048        let p = temp("builder-repo");
1049        let fix = transcript("apply the findings", &[("Bash", json!({"command": "sed -i 's/a/b/' adapters/omp/borg.ts"}))], None);
1050        let build = transcript("build the secret feature", &[], None).replace("/w/app", "/w/private");
1051        write_run(&p, "s1", "wf_1", &[("a0", "build", "Build", 5, build), ("a1", "review:concurrency", "Review", 10, review()), ("a3", "fix", "Fix", 30, fix)]);
1052        let f = found(&p);
1053        assert_eq!(f.len(), 1);
1054        assert!(f[0].task.is_empty() && f[0].built.is_empty(), "{:?}", f[0].task);
1055        let _ = std::fs::remove_dir_all(p);
1056    }
1057}