Skip to main content

borg/
assimilate.rs

1//! `borg assimilate`: bootstrap the collectives from the engineer's existing history, so Borg is
2//! useful on day one instead of after weeks of live capture.
3//!
4//! Sources are Claude Code transcripts (`$CLAUDE_CONFIG_DIR|~/.claude` `/projects/<slug>/<session>.jsonl`),
5//! omp sessions (`$PI_CODING_AGENT_DIR|~/.omp/agent` `/sessions/<slug>/<ts>_<session>.jsonl`), Codex rollouts
6//! (`$CODEX_HOME|~/.codex` `/sessions/YYYY/MM/DD/rollout-*.jsonl`) and opencode 1.x and 2.x sessions
7//! (exported through the `opencode` CLI, see `opencode_session` and `cli_sessions`); main sessions only
8//! (sub-agents never talk to the engineer, and borg's own model calls are not the engineer either). Each
9//! is replayed the way the live hooks would have seen it:
10//! - a human prompt that `learn::is_correction` flags becomes an episode; any other prompt becomes the task;
11//! - an `is_error` tool result that *starts* as the harness's rejection notice and carries the engineer's
12//!   words (`learn::rejection`) becomes an episode; the same words inside ordinary tool output are echoed
13//!   history (a script printing an old transcript), not a correction;
14//! - a wordless denial (`learn::denied`) waits for the next prompt, which is the engineer's reason; a later
15//!   successful tool call ends the wait, as in the hook;
16//! - an answer to the agent's own question (Claude Code's `AskUserQuestion`, omp's `ask`, opencode's
17//!   `question`, Codex's `request_user_input`) becomes an episode with `via` `answer`: the questions and
18//!   their options are the proposal, the picked options, typed words and notes the correction. No hook
19//!   ever saw these. Codex rollouts give answers only: Codex records no rejection, and its hooks see
20//!   every prompt live.
21//!
22//! Every episode names its harness and the harness's version, and an episode about a tool call is
23//! named by the harness's id for that call (learn.rs `transcript_id`).
24//!
25//! Every episode is routed by its own cwd (`collective::route`): an off repo is only counted, never
26//! stored; each collective gets its own episodes. The task, the drone's text and a denied tool call
27//! last across prompts, and a session can move between repos, so each remembers the cwd it was seen
28//! in and is dropped from an episode routed to another collective: the live hook keeps that state per
29//! collective and writes nothing in an off repo, so text from an off repo never reaches a store. Anything mentioning a word of `BORG_ASSIMILATE_SKIP`
30//! (comma-separated, case-insensitive, default none; for knowledge the team abandoned) is dropped. The default is a dry run that writes nothing; `--apply` appends the new episodes and runs
31//! `borg learn` on each collective. Re-running adds nothing: an episode the store already holds (learn.rs
32//! `Known`: the same id, the same words in the same session, or a prompt within `learn::SAME_MS`, since
33//! the live hook stamps capture time and the transcript message time) is a duplicate. What a store holds
34//! is read again under its lock when appending (learn.rs `record_new_in`), so a turn-end sweep that
35//! appended an episode while another collective was learning leaves nothing to append twice.
36
37use std::borrow::Cow;
38use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
39use std::io::{BufRead, BufReader};
40use std::path::{Path, PathBuf};
41
42use serde_json::Value;
43
44use crate::collective::{self, Config, Route};
45use crate::harness::{self, Harness as _, codex, opencode};
46use crate::learn::{self, Episode, Lesson};
47use crate::runner;
48
49/// Its flags and `--from-dir`'s (completions.rs).
50pub const FLAGS: &[&str] = &[
51    "--apply",
52    "--since",
53    "--limit",
54    "--claude",
55    "--omp",
56    "--opencode",
57    "--codex",
58    "--session",
59    "--from-dir",
60    "--plan",
61    "--for",
62    "--collective",
63    "--knowledge",
64    "--retros",
65    "--families",
66    "--decisions",
67];
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Source {
71    Claude,
72    Omp,
73    Opencode,
74    Codex,
75}
76
77/// Where opencode sessions come from.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum OpencodeFrom {
80    /// The installed `opencode` CLI (the default when it is on PATH).
81    Cli,
82    /// A directory of session exports of either version (`--opencode DIR`: tests, offline copies).
83    Dir(PathBuf),
84}
85
86/// An episode found in a transcript, with the cwd it happened in (for routing) and the cwds its
87/// task, drone text and rejected tool call were seen in (they may come from an earlier repo).
88#[derive(Debug, Clone)]
89pub struct Found {
90    pub cwd: String,
91    pub task_cwd: String,
92    pub agent_cwd: String,
93    pub tool_cwd: String,
94    pub episode: Episode,
95}
96
97impl AsMut<Episode> for Found {
98    fn as_mut(&mut self) -> &mut Episode {
99        &mut self.episode
100    }
101}
102
103pub struct Options {
104    pub claude: Option<PathBuf>,
105    pub omp: Option<PathBuf>,
106    /// None: opencode is not installed.
107    pub opencode: Option<OpencodeFrom>,
108    /// Codex's sessions directory (`$CODEX_HOME/sessions`).
109    pub codex: Option<PathBuf>,
110    /// Only this session's episodes (`--session`: checking one session).
111    pub session: Option<String>,
112    /// Only episodes at or after this epoch ms.
113    pub since: Option<i64>,
114    /// At most this many new episodes (the newest not yet stored, across collectives), so repeated
115    /// runs work through the history in batches.
116    pub limit: Option<usize>,
117    pub apply: bool,
118    /// Episodes mentioning any of these (lowercase) are dropped (`skip_words`).
119    pub skip: Vec<String>,
120}
121
122/// What assimilation found (dry run) or did (`--apply`) for one collective.
123#[derive(Debug, Default, Clone, PartialEq, Eq)]
124pub struct Tally {
125    pub home: PathBuf,
126    pub may_sync: bool,
127    pub sessions: usize,
128    /// Episodes not yet in the store.
129    pub episodes: usize,
130    /// Episodes already in the store (from an earlier run or the live hook).
131    pub known: usize,
132    /// Model calls `borg learn` needs: the new episodes plus those already waiting in the store.
133    pub calls: usize,
134    pub new_lessons: usize,
135    pub merged: usize,
136    /// Episodes `borg learn` could not get a verdict on (model unavailable); a re-run retries them.
137    pub pending: usize,
138}
139
140/// What one harness's transcripts gave, across collectives (off repos aside).
141#[derive(Debug, Default, Clone, PartialEq, Eq)]
142pub struct Kinds {
143    /// New episodes: rejected tool calls (with words, or a denial and its reason), answers to the
144    /// agent's questions, and prompts (pushback and vetoes).
145    pub rejections: usize,
146    pub answers: usize,
147    pub prompts: usize,
148    /// Episodes the stores already hold (the live hook's, an earlier run's).
149    pub known: usize,
150    /// The harness version of the newest episode, where its transcript names one.
151    pub version: String,
152}
153
154#[derive(Debug, Default)]
155pub struct Report {
156    pub scanned: usize,
157    /// Main sessions read per source; `opencode` is None when it is not installed.
158    pub claude: usize,
159    pub omp: usize,
160    pub opencode: Option<usize>,
161    pub codex: usize,
162    pub collectives: BTreeMap<String, Tally>,
163    pub off_sessions: usize,
164    pub off_episodes: usize,
165    /// Per harness (`Harness::name`).
166    pub harnesses: BTreeMap<String, Kinds>,
167    /// What one learn call cost on this machine, on average (`learn_cost`), and over how many calls;
168    /// None: no learn call with a cost recorded yet.
169    pub usd_per_call: Option<(f64, usize)>,
170}
171
172const NOTICE: &str = "want to proceed with this tool use";
173/// The characters of a failed tool call's first line searched for `NOTICE`. Method, not a display:
174/// the harness writes its notice at the very start of that line, so past this many characters the same
175/// words are the tool's own output (a long first line of a command's output), not a rejection.
176const NOTICE_CHARS: usize = 200;
177
178/// The engineer's words in a failed tool call's `text` when it is the harness's worded rejection; None
179/// otherwise. Only the harness's own notice, on the first line, makes a rejection: the same words
180/// further down are tool output (e.g. "Exit code 1" and then an old transcript). The replay and the
181/// doctor's count share it, so the two cannot drift.
182fn worded_rejection(text: &str) -> Option<String> {
183    let first: String = take(text.trim_start().lines().next().unwrap_or_default(), NOTICE_CHARS);
184    learn::rejection(text).filter(|_| first.contains(NOTICE))
185}
186
187fn human(t: &str) -> bool {
188    t.len() > 3 && !t.starts_with('<') && !t.contains("[Request interrupted")
189}
190
191fn take(s: &str, n: usize) -> String {
192    s.chars().take(n).collect()
193}
194
195fn tail(s: &str, n: usize) -> String {
196    let c: Vec<char> = s.chars().collect();
197    c[c.len().saturating_sub(n)..].iter().collect()
198}
199
200fn text_of(v: &Value) -> String {
201    match v {
202        Value::String(s) => s.clone(),
203        Value::Array(a) => a
204            .iter()
205            .filter(|b| b.get("type").is_none_or(|t| t == "text"))
206            .filter_map(|b| b.get("text").and_then(Value::as_str).or(b.as_str()))
207            .collect::<Vec<_>>()
208            .join("\n"),
209        _ => String::new(),
210    }
211}
212
213/// A refused tool call waiting for its reason: the call (`tool_text`), the cwd it was refused in, and
214/// the harness's own id for it.
215type Refused = (String, String, String);
216
217/// What a reader knows at the end of the lines it read, so a later read of the lines after them goes on
218/// as one read of the whole transcript would (the turn-end sweep, sweep.rs, keeps it between turns): the
219/// cwd, the harness version, the task and the drone's last text (each with the cwd it was seen in), a
220/// denial waiting for its reason and the tool calls still waiting for their result.
221#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
222pub struct Carry {
223    #[serde(default)]
224    pub cwd: String,
225    #[serde(default)]
226    pub version: String,
227    #[serde(default)]
228    pub task: (String, String),
229    #[serde(default)]
230    pub said: (String, String),
231    #[serde(default)]
232    pub denied: Option<Refused>,
233    /// Each open call by the harness's id, oldest first: Claude Code `{name, input}`, omp the call's
234    /// text, Codex a question call's arguments.
235    #[serde(default)]
236    pub calls: Vec<(String, Value)>,
237}
238
239/// Guard: the open tool calls a [`Carry`] keeps, newest. A call is dropped once its result is read, so
240/// this many are open only after interrupted turns left calls without a result; it bounds the state a
241/// sweep writes per turn, never what is captured (a result whose call fell out keeps its id).
242const OPEN_CALLS: usize = 64;
243
244/// Replays one session's events with the live hooks' capture rules.
245#[derive(Default)]
246struct Replay {
247    session: String,
248    cwd: String,
249    /// The harness whose transcript this is (`Harness::name`) and its version where the transcript names
250    /// one; both go on every episode.
251    harness: &'static str,
252    version: String,
253    /// Prompts only set the task, and nothing but answers becomes an episode (Codex: its hooks capture
254    /// every prompt live, and its rollouts record no rejection at all).
255    answers_only: bool,
256    /// Each piece of state with the cwd it was seen in.
257    task: (String, String),
258    said: (String, String),
259    /// The tool call a wordless denial refused, waiting for the engineer's next prompt.
260    denied: Option<Refused>,
261    out: Vec<Found>,
262    /// Every cwd the session worked in: where opencode 2's sessions are listed (`cli_sessions`).
263    cwds: BTreeSet<String>,
264    /// The tool calls without their result yet ([`Carry::calls`]).
265    calls: Vec<(String, Value)>,
266}
267
268impl Replay {
269    /// A replay of `harness`'s session `session` that goes on from `c`.
270    fn resume(session: &str, harness: &'static str, c: Carry) -> Replay {
271        let mut r = Replay {
272            session: session.to_string(),
273            harness,
274            version: c.version,
275            task: c.task,
276            said: c.said,
277            denied: c.denied,
278            calls: c.calls,
279            ..Default::default()
280        };
281        if !c.cwd.is_empty() {
282            r.at(&c.cwd);
283        }
284        r
285    }
286
287    /// Where this replay stands, for a later read of the lines after these.
288    fn carry(&self) -> Carry {
289        let skip = self.calls.len().saturating_sub(OPEN_CALLS);
290        Carry {
291            cwd: self.cwd.clone(),
292            version: self.version.clone(),
293            task: self.task.clone(),
294            said: self.said.clone(),
295            denied: self.denied.clone(),
296            calls: self.calls[skip..].to_vec(),
297        }
298    }
299
300    /// Tool call `id` started: `what` is remembered until its result (`called`).
301    fn call(&mut self, id: &str, what: Value) {
302        self.calls.retain(|(c, _)| c != id);
303        self.calls.push((id.to_string(), what));
304    }
305
306    /// The call `id`'s result came: what `call` remembered of it (Null when its call is not known).
307    fn called(&mut self, id: &str) -> Value {
308        match self.calls.iter().position(|(c, _)| c == id) {
309            Some(i) => self.calls.remove(i).1,
310            None => Value::Null,
311        }
312    }
313
314    fn at(&mut self, cwd: &str) {
315        if self.cwd != cwd {
316            self.cwd = cwd.to_string();
317            self.cwds.insert(self.cwd.clone());
318        }
319    }
320
321    /// `call` is the harness's id of the tool call it is about (empty for a prompt: learn.rs
322    /// `transcript_id`), `rejected_tool` the refused call and the cwd it was refused in.
323    fn episode(&mut self, ms: i64, call: &str, rejected_tool: Option<(String, String)>, correction: String) -> &mut Found {
324        let (rejected_tool, tool_cwd) = match rejected_tool {
325            Some((tool, cwd)) => (Some(tool), cwd),
326            None => (None, self.cwd.clone()),
327        };
328        let episode = Episode {
329            id: learn::transcript_id(&self.session, call, ms),
330            ms,
331            repo: String::new(),
332            session: self.session.clone(),
333            task: self.task.0.clone(),
334            agent: self.said.0.clone(),
335            rejected_tool,
336            correction,
337            harness: self.harness.into(),
338            harness_version: self.version.clone(),
339            ..Episode::default()
340        };
341        self.out.push(Found { cwd: self.cwd.clone(), task_cwd: self.task.1.clone(), agent_cwd: self.said.1.clone(), tool_cwd, episode });
342        self.out.last_mut().expect("just pushed")
343    }
344
345    fn assistant_text(&mut self, t: &str) {
346        if !t.trim().is_empty() {
347            self.said = (tail(t.trim(), learn::PROPOSAL_CHARS), self.cwd.clone());
348        }
349    }
350
351    fn prompt(&mut self, ms: i64, t: &str) {
352        let t = t.trim();
353        if !human(t) || t.starts_with(AGENTS_MD) {
354            return;
355        }
356        if self.answers_only {
357            if !learn::is_correction(t) || self.task.0.is_empty() {
358                self.task = (take(t, learn::TASK_CHARS), self.cwd.clone());
359            }
360            return;
361        }
362        // "borg no <reason>", as the prompt hook reads it (veto.rs): a veto episode that answers a pending
363        // wordless denial, never the session's task nor a correction.
364        if let Some(reason) = crate::veto::enabled().then(|| crate::veto::reason_in(t)).flatten() {
365            self.denied = None;
366            let f = self.episode(ms, "", None, reason);
367            f.episode.id = format!("no-{}", f.episode.id);
368            f.episode.kind = crate::veto::KIND.into();
369            return;
370        }
371        if let Some((tool, cwd, call)) = self.denied.take() {
372            self.episode(ms, &call, Some((tool, cwd)), take(t, learn::EXCERPT_CHARS));
373            return;
374        }
375        if learn::is_correction(t) && !self.task.0.is_empty() {
376            self.episode(ms, "", None, take(t, learn::EXCERPT_CHARS));
377        } else {
378            self.task = (take(t, learn::TASK_CHARS), self.cwd.clone());
379        }
380    }
381
382    /// A finished tool call `call` (the harness's id for it).
383    fn tool_result(&mut self, ms: i64, is_error: bool, text: &str, tool: String, call: &str) {
384        if !is_error {
385            self.denied = None; // the drone moved on: the next prompt is no longer the reason
386            return;
387        }
388        if self.answers_only {
389            return;
390        }
391        if let Some(said) = worded_rejection(text) {
392            let cwd = self.cwd.clone();
393            self.episode(ms, call, Some((tool, cwd)), said);
394        } else if learn::denied(text) {
395            self.denied = Some((tool, self.cwd.clone(), call.to_string()));
396        }
397    }
398
399    /// The engineer's answer to the agent's own questions, given through tool call `call`: an episode
400    /// whose proposal is the questions with their options (`asked_text`) and whose correction is the
401    /// answer (`answer_text`); nothing when nothing was answered. Like any finished call, it ends the wait
402    /// for a denial's reason.
403    fn answer(&mut self, ms: i64, call: &str, asked: &[Asked]) {
404        self.denied = None;
405        let Some(said) = answer_text(asked) else { return };
406        let (proposal, cwd) = (asked_text(asked), self.cwd.clone());
407        let f = self.episode(ms, call, None, said);
408        f.episode.via = learn::ANSWER.into();
409        f.episode.agent = proposal;
410        f.agent_cwd = cwd;
411    }
412}
413
414// ---- answers to the agent's own questions (every harness)
415
416/// One question the agent asked the engineer, and the answer.
417#[derive(Debug, Default, Clone, PartialEq, Eq)]
418struct Asked {
419    /// Its short name (a header or an id); may be empty.
420    label: String,
421    question: String,
422    options: Vec<String>,
423    /// The offered options the engineer picked.
424    chosen: Vec<String>,
425    /// What the engineer typed instead of (or beside) an option.
426    typed: String,
427    /// The engineer's notes on the choice (Claude Code).
428    notes: String,
429}
430
431impl Asked {
432    /// An answer as the harness wrote it: the options it names are picks (Claude Code joins several
433    /// with ", "), anything else is typed.
434    fn pick(&mut self, answer: &str) {
435        let a = answer.trim();
436        let offered = |x: &str| self.options.iter().any(|o| o == x);
437        let parts: Vec<&str> = a.split(", ").collect();
438        if offered(a) {
439            self.chosen.push(a.into());
440        } else if parts.len() > 1 && parts.iter().all(|p| offered(p)) {
441            self.chosen.extend(parts.iter().map(|p| p.to_string()));
442        } else if !a.is_empty() {
443            self.typed = if self.typed.is_empty() { a.to_string() } else { format!("{}\n{a}", self.typed) };
444        }
445    }
446
447    fn answer(&self) -> String {
448        let notes = (!self.notes.trim().is_empty()).then(|| format!("notes: {}", self.notes.trim()));
449        [Some(self.chosen.join(", ")), Some(self.typed.trim().to_string()), notes]
450            .into_iter()
451            .flatten()
452            .filter(|s| !s.is_empty())
453            .collect::<Vec<_>>()
454            .join("; ")
455    }
456}
457
458/// The opening of an answer's text, each harness's wording (Claude Code's current and older, opencode's,
459/// omp's three): what is cut off when only the text is left.
460const ANSWER_OPENINGS: &[&str] = &[
461    "Your questions have been answered:",
462    "The user answered:",
463    "User has answered your questions:",
464    "User selected:",
465    "User provided custom input:",
466    "User answers:",
467];
468/// Where the harness's own words after an answer begin: Claude Code's and opencode's "continue", and
469/// Claude Code's "read carefully" (after "The user answered:").
470const ANSWER_CLOSINGS: &[&str] = &[". You can now continue", ". Read the answers carefully"];
471/// Codex prepends the repo's instructions as a user message; nobody typed it.
472const AGENTS_MD: &str = "# AGENTS.md";
473
474/// The engineer's words in an answer's text: without the harness's opening and closing.
475fn answer_words(text: &str) -> String {
476    let t = text.trim();
477    let t = ANSWER_OPENINGS.iter().find_map(|o| t.strip_prefix(o)).unwrap_or(t);
478    let end = ANSWER_CLOSINGS.iter().filter_map(|c| t.find(c)).min().unwrap_or(t.len());
479    t[..end].trim().to_string()
480}
481
482/// The offered options' labels: objects with a `label` (Claude Code, opencode, Codex, omp's call) or strings.
483fn labels(options: &Value) -> Vec<String> {
484    options.as_array().into_iter().flatten().filter_map(|o| o["label"].as_str().or(o.as_str())).map(String::from).collect()
485}
486
487fn s(v: &Value) -> String {
488    v.as_str().unwrap_or_default().to_string()
489}
490
491/// A question as asked (Claude Code, opencode and Codex share the shape): `header` (else `id`),
492/// `question`, `options`.
493fn question(q: &Value) -> Asked {
494    let label = q["header"].as_str().or(q["id"].as_str()).unwrap_or_default().to_string();
495    Asked { label, question: s(&q["question"]), options: labels(&q["options"]), ..Asked::default() }
496}
497
498/// `asked`, or, when nothing in it carries an answer, `asked` plus the answer's whole `text`.
499fn or_text(mut asked: Vec<Asked>, text: &str) -> Vec<Asked> {
500    if asked.iter().all(|a| a.answer().is_empty()) {
501        asked.push(Asked { typed: answer_words(text), ..Asked::default() });
502    }
503    asked
504}
505
506/// What the agent proposed when it asked: each question with the options it offered.
507fn asked_text(asked: &[Asked]) -> String {
508    let one = |a: &Asked| match a.options.is_empty() {
509        true => a.question.clone(),
510        false => format!("{}\n  options: {}", a.question, a.options.join(" | ")),
511    };
512    take(&asked.iter().filter(|a| !a.question.is_empty()).map(one).collect::<Vec<_>>().join("\n"), learn::PROPOSAL_CHARS)
513}
514
515/// The engineer's answer: one question's answer alone, several each after its label (else its
516/// question); None when nothing was answered.
517fn answer_text(asked: &[Asked]) -> Option<String> {
518    let answered: Vec<&Asked> = asked.iter().filter(|a| !a.answer().is_empty()).collect();
519    let text = match answered.as_slice() {
520        [] => return None,
521        [one] => one.answer(),
522        many => many.iter().map(|a| format!("{}: {}", if a.label.is_empty() { &a.question } else { &a.label }, a.answer())).collect::<Vec<_>>().join("\n"),
523    };
524    Some(take(&text, learn::EXCERPT_CHARS))
525}
526
527/// Claude Code's `AskUserQuestion`.
528const CLAUDE_ASK: &str = "AskUserQuestion";
529
530/// Claude Code's answer: the questions from its call (`input`), the answers and notes from the result
531/// line's `toolUseResult` (`answers` {question: answer, or a list of answers for a multi-select or a
532/// typed one}, `annotations` {question: {notes}}); a result without them leaves its text as the answer.
533fn claude_asked(input: &Value, result: &Value, text: &str) -> Vec<Asked> {
534    let questions = input["questions"].as_array().or(result["questions"].as_array());
535    let asked = questions
536        .into_iter()
537        .flatten()
538        .map(|q| {
539            let mut a = question(q);
540            match &result["answers"][&a.question] {
541                Value::String(x) => a.pick(x),
542                Value::Array(xs) => xs.iter().filter_map(Value::as_str).for_each(|x| a.pick(x)),
543                _ => {}
544            }
545            a.notes = s(&result["annotations"][&a.question]["notes"]);
546            a
547        })
548        .collect();
549    or_text(asked, text)
550}
551
552/// omp's `ask` tool.
553const OMP_ASK: &str = "ask";
554
555/// omp's answer, from the result's `details`: one question (`question`, `options`, `selectedOptions`,
556/// `customInput`), or several in `results` (each also with its `id`); without them its text is the answer.
557fn omp_asked(details: &Value, text: &str) -> Vec<Asked> {
558    let one = |d: &Value| {
559        let mut a = Asked { label: s(&d["id"]), question: s(&d["question"]), options: labels(&d["options"]), ..Asked::default() };
560        d["selectedOptions"].as_array().into_iter().flatten().filter_map(Value::as_str).for_each(|o| a.pick(o));
561        a.typed = [a.typed.clone(), s(&d["customInput"])].iter().filter(|t| !t.trim().is_empty()).cloned().collect::<Vec<_>>().join("\n");
562        a
563    };
564    let asked = match details["results"].as_array() {
565        Some(rs) => rs.iter().map(one).collect(),
566        None if details["question"].is_string() => vec![one(details)],
567        None => Vec::new(),
568    };
569    or_text(asked, text)
570}
571
572/// opencode's `question` tool.
573const OC_QUESTION: &str = "question";
574
575/// opencode's answer (1.x and 2.x): the questions from the call's `input`, the answers from
576/// `metadata.answers` (one list of labels per question, in order); without them the output's text.
577fn opencode_asked(state: &Value) -> Vec<Asked> {
578    let answers = state["metadata"]["answers"].as_array().map(Vec::as_slice).unwrap_or_default();
579    let asked = state["input"]["questions"]
580        .as_array()
581        .into_iter()
582        .flatten()
583        .enumerate()
584        .map(|(i, q)| {
585            let mut a = question(q);
586            answers.get(i).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).for_each(|x| a.pick(x));
587            a
588        })
589        .collect();
590    let output = state["output"].as_str().map_or_else(|| text_of(&state["content"]), String::from);
591    or_text(asked, &output)
592}
593
594/// Codex's `request_user_input` function.
595const CODEX_ASK: &str = "request_user_input";
596
597/// Codex's answer: the questions from the call's `arguments` (`id`, `header`, `question`, `options`), the
598/// answers from its output, `{"answers": {<id>: {"answers": [label…]}}}`. The output is JSON, not words:
599/// with no answer to any question it is nobody's answer (no fallback to its text), like opencode's
600/// dismissed question.
601fn codex_asked(arguments: &Value, output: &Value) -> Vec<Asked> {
602    arguments["questions"]
603        .as_array()
604        .into_iter()
605        .flatten()
606        .map(|q| {
607            let mut a = question(q);
608            let id = q["id"].as_str().unwrap_or_default();
609            output["answers"][id]["answers"].as_array().into_iter().flatten().filter_map(Value::as_str).for_each(|x| a.pick(x));
610            a
611        })
612        .collect()
613}
614
615/// A JSON value Codex may hand over as a string of JSON.
616fn json_in(v: &Value) -> Value {
617    v.as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or_else(|| v.clone())
618}
619
620fn tool_text(name: &str, input: &Value) -> String {
621    format!("{name} {}", take(&input.to_string(), learn::EXCERPT_CHARS))
622}
623
624fn lines(path: &Path) -> impl Iterator<Item = Value> {
625    let file = std::fs::File::open(path).ok();
626    file.into_iter().flat_map(|f| BufReader::new(f).lines()).map_while(Result::ok).filter_map(|l| serde_json::from_str::<Value>(&l).ok())
627}
628
629fn ts(v: &Value) -> i64 {
630    v.get("timestamp").and_then(Value::as_str).map_or(0, borg_trace::parse_ts)
631}
632
633/// Episodes of one Claude Code transcript (none for a sub-agent).
634pub fn claude_session(path: &Path) -> Vec<Found> {
635    claude_replay(path).out
636}
637
638/// The episodes in `lines` of Claude Code session `session`'s transcript (a tail of it, say): a result
639/// whose call is not among them has no tool text, and an answer is then known by its opening.
640pub fn claude_lines(session: &str, lines: impl IntoIterator<Item = Value>) -> Vec<Found> {
641    claude_from(session, Carry::default(), lines).out
642}
643
644fn claude_replay(path: &Path) -> Replay {
645    claude_from(&path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), Carry::default(), lines(path))
646}
647
648fn claude_from(session: &str, carry: Carry, lines: impl IntoIterator<Item = Value>) -> Replay {
649    let mut r = Replay::resume(session, harness::claude::Claude.name(), carry);
650    for v in lines {
651        if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
652            return Replay::default();
653        }
654        if let Some(c) = v.get("cwd").and_then(Value::as_str) {
655            r.at(c);
656        }
657        if let Some(version) = v.get("version").and_then(Value::as_str) {
658            r.version = version.to_string();
659        }
660        if v.get("isMeta") == Some(&Value::Bool(true)) || v.get("isCompactSummary") == Some(&Value::Bool(true)) {
661            continue;
662        }
663        let Some(msg) = v.get("message") else { continue };
664        let content = msg.get("content").unwrap_or(&Value::Null);
665        match v.get("type").and_then(Value::as_str) {
666            Some("assistant") => {
667                for b in content.as_array().into_iter().flatten() {
668                    match b.get("type").and_then(Value::as_str) {
669                        Some("text") => r.assistant_text(b.get("text").and_then(Value::as_str).unwrap_or_default()),
670                        Some("tool_use") => r.call(&s(&b["id"]), serde_json::json!({"name": b["name"], "input": b["input"]})),
671                        _ => {}
672                    }
673                }
674            }
675            Some("user") => {
676                let results: Vec<&Value> =
677                    content.as_array().into_iter().flatten().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")).collect();
678                if results.is_empty() {
679                    r.prompt(ts(&v), &text_of(content));
680                }
681                for b in results {
682                    claude_result(&mut r, &v, b);
683                }
684            }
685            _ => {}
686        }
687    }
688    r
689}
690
691/// One `tool_result` block `b` of Claude Code line `v`: an answer to `AskUserQuestion`, else a
692/// finished tool call (its call's name and input as `Replay::call` remembered them).
693fn claude_result(r: &mut Replay, v: &Value, b: &Value) {
694    let id = b.get("tool_use_id").and_then(Value::as_str).unwrap_or_default();
695    let call = r.called(id);
696    let (name, input) = (s(&call["name"]), call["input"].clone());
697    let is_error = b.get("is_error").and_then(Value::as_bool) == Some(true);
698    let text = text_of(b.get("content").unwrap_or(&Value::Null));
699    // A call not in this transcript (before a compaction) is known by its answer's opening.
700    let asking = name == CLAUDE_ASK || (name.is_empty() && ANSWER_OPENINGS.iter().any(|o| text.starts_with(o)));
701    if !is_error && asking {
702        r.answer(ts(v), id, &claude_asked(&input, &v["toolUseResult"], &text));
703    } else {
704        let tool = if name.is_empty() { String::new() } else { tool_text(&name, &input) };
705        r.tool_result(ts(v), is_error, &text, tool, id);
706    }
707}
708
709/// Episodes of one omp session (none for a sub-agent session).
710pub fn omp_session(path: &Path) -> Vec<Found> {
711    omp_replay(path).out
712}
713
714/// The episodes in `lines` of omp session `session`'s file (a tail of it, say: the session record, which
715/// names the id and cwd, may be absent).
716pub fn omp_lines(session: &str, lines: impl IntoIterator<Item = Value>) -> Vec<Found> {
717    omp_from(session, Carry::default(), lines).out
718}
719
720/// omp's tool call id, without the provider state some providers append after a `|`.
721fn omp_call(id: &str) -> &str {
722    id.split('|').next().unwrap_or(id)
723}
724
725fn omp_replay(path: &Path) -> Replay {
726    omp_from(&path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), Carry::default(), lines(path))
727}
728
729fn omp_from(session: &str, carry: Carry, lines: impl IntoIterator<Item = Value>) -> Replay {
730    let mut r = Replay::resume(session, harness::omp::Omp.name(), carry);
731    for v in lines {
732        if let Some(version) = v["runtime"]["version"].as_str() {
733            r.version = version.to_string();
734        }
735        match v.get("type").and_then(Value::as_str) {
736            Some("session") => {
737                if v.get("parentSession").is_some_and(|p| !p.is_null()) {
738                    return Replay::default();
739                }
740                if let Some(c) = v.get("cwd").and_then(Value::as_str) {
741                    r.at(c);
742                }
743                if let Some(id) = v.get("id").and_then(Value::as_str) {
744                    r.session = id.to_string();
745                }
746            }
747            Some("message") => {
748                let Some(msg) = v.get("message") else { continue };
749                let content = msg.get("content").unwrap_or(&Value::Null);
750                match msg.get("role").and_then(Value::as_str) {
751                    Some("user") => r.prompt(ts(&v), &text_of(content)),
752                    Some("assistant") => {
753                        let blocks = content.as_array().into_iter().flatten();
754                        for b in blocks {
755                            match b.get("type").and_then(Value::as_str) {
756                                Some("text") => r.assistant_text(b.get("text").and_then(Value::as_str).unwrap_or_default()),
757                                Some("toolCall") => {
758                                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
759                                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
760                                    r.call(omp_call(id), Value::String(tool_text(name, b.get("arguments").unwrap_or(&Value::Null))));
761                                }
762                                _ => {}
763                            }
764                        }
765                    }
766                    Some("toolResult") => {
767                        let id = omp_call(msg.get("toolCallId").and_then(Value::as_str).unwrap_or_default());
768                        let tool = s(&r.called(id));
769                        let is_error = msg.get("isError").and_then(Value::as_bool) == Some(true);
770                        if !is_error && msg["toolName"] == OMP_ASK {
771                            r.answer(ts(&v), id, &omp_asked(&msg["details"], &text_of(content)));
772                        } else {
773                            r.tool_result(ts(&v), is_error, &text_of(content), tool, id);
774                        }
775                    }
776                    _ => {}
777                }
778            }
779            _ => {}
780        }
781    }
782    r
783}
784
785/// Episodes of one Codex rollout: answers to the agent's questions (`request_user_input`) only. Codex
786/// records no rejection: it has no failure event, and a refused call leaves nothing but an aborted
787/// turn. None for a spawned agent's thread or a `codex exec` run (`codex::main_session`).
788pub fn codex_session(path: &Path) -> Vec<Found> {
789    codex_replay(path).out
790}
791
792/// The episodes in `records` (rollout lines, a tail say) of the thread whose `session_meta` payload
793/// (`codex::first_meta`) is `meta`; none for a thread that is no main session.
794pub fn codex_lines(meta: &Value, records: impl IntoIterator<Item = Value>) -> Vec<Found> {
795    codex_from(meta, Carry::default(), records).out
796}
797
798fn codex_replay(path: &Path) -> Replay {
799    codex::first_meta(path).map(|meta| codex_from(&meta, Carry::default(), codex::records(path))).unwrap_or_default()
800}
801
802fn codex_from(meta: &Value, carry: Carry, records: impl IntoIterator<Item = Value>) -> Replay {
803    if !codex::main_session(meta) {
804        return Replay::default();
805    }
806    let started = carry.cwd.is_empty();
807    let mut r = Replay::resume(&s(&meta["id"]), harness::codex::Codex.name(), carry);
808    (r.version, r.answers_only) = (s(&meta["cli_version"]), true);
809    if started {
810        r.at(meta["cwd"].as_str().unwrap_or_default());
811    }
812    // Each question call's arguments wait for its output in `calls`.
813    for v in records {
814        let p = &v["payload"];
815        let call = s(&p["call_id"]);
816        match (v["type"].as_str(), p["type"].as_str()) {
817            (Some("turn_context"), _) => {
818                if let Some(c) = p["cwd"].as_str() {
819                    r.at(c);
820                }
821            }
822            (Some("response_item"), Some("message")) => {
823                let text = p["content"].as_array().into_iter().flatten().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n");
824                match p["role"].as_str() {
825                    Some("user") => r.prompt(ts(&v), &text),
826                    Some("assistant") => r.assistant_text(&text),
827                    _ => {}
828                }
829            }
830            (Some("response_item"), Some("function_call")) if p["name"] == CODEX_ASK => r.call(&call, json_in(&p["arguments"])),
831            (Some("response_item"), Some("function_call_output")) => {
832                let arguments = r.called(&call);
833                if !arguments.is_null() {
834                    r.answer(ts(&v), &call, &codex_asked(&arguments, &json_in(&p["output"])));
835                }
836            }
837            _ => {}
838        }
839    }
840    r
841}
842
843// ---- one harness's own transcript, as the sweep reads it (sweep.rs): its reader, where it is, its marks
844
845/// The reader of harness `h`'s transcripts. harness/*.rs knows where they are, this module how to read
846/// them (harness/ never depends on this module).
847fn source_of(h: &dyn harness::Harness) -> Option<Source> {
848    let readers = [
849        (harness::claude::Claude.name(), Source::Claude),
850        (harness::omp::Omp.name(), Source::Omp),
851        (harness::codex::Codex.name(), Source::Codex),
852        (harness::opencode::Opencode.name(), Source::Opencode),
853    ];
854    readers.into_iter().find(|(name, _)| *name == h.name()).map(|(_, s)| s)
855}
856
857/// The episodes in `lines` of harness `h`'s transcript file `path`, which records session `session`,
858/// read on from `carry` (where an earlier read of the lines before them stopped), and where this read
859/// stops. None: `h` keeps no transcript file borg reads (opencode: [`exported`]).
860pub fn log_lines(h: &dyn harness::Harness, path: &Path, session: &str, carry: Carry, lines: Vec<Value>) -> Option<(Vec<Found>, Carry)> {
861    let r = match source_of(h)? {
862        Source::Claude => claude_from(session, carry, lines),
863        Source::Omp => {
864            // A read past the session record (a tail, the first turn after an upgrade) knows no cwd yet:
865            // the record, the file's first line, names it and says whether this is a sub-agent's session
866            // (as Codex reseeds from its `session_meta`).
867            let unseated = carry.cwd.is_empty() && lines.first().is_none_or(|v| v["type"] != "session");
868            let record = unseated.then(|| harness::omp::first_line(path)).flatten();
869            omp_from(session, carry, record.into_iter().chain(lines))
870        }
871        Source::Codex => codex_from(&codex::first_meta(path)?, carry, lines),
872        Source::Opencode => return None,
873    };
874    let carry = r.carry();
875    Some((r.out, carry))
876}
877
878/// Session `id`'s episodes from harness `h`'s CLI export, run in `dir`: opencode, whose sessions are
879/// read through its CLI only, and only where a few seconds do not matter (a session's end, never a
880/// hook). None for a harness whose transcript is a file ([`log_lines`]), an id that is no opencode id,
881/// or an export that failed.
882pub fn exported(h: &dyn harness::Harness, id: &str, dir: &Path) -> Option<Vec<Found>> {
883    let ok_id = id.starts_with("ses_") && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
884    if source_of(h)? != Source::Opencode || !ok_id {
885        return None;
886    }
887    let v2 = runner::opencode() == runner::Opencode::V2;
888    let export: Value = opencode::cli(v2, None, &opencode::export_args(v2, id), dir).and_then(|o| serde_json::from_slice(&o).ok())?;
889    Some(opencode_session(&export))
890}
891
892/// Every main-session transcript file of harness `h` under `home` (`$HOME`; each harness's own
893/// variable honoured, harness/*.rs). None for a harness without transcript files (opencode).
894pub fn logs(h: &dyn harness::Harness, home: &Path) -> Option<Vec<PathBuf>> {
895    match source_of(h)? {
896        Source::Claude => Some(session_files(&harness::claude::projects(home))),
897        Source::Omp => Some(session_files(&harness::omp::sessions(home))),
898        Source::Codex => Some(codex::rollouts(&codex::sessions(home))),
899        Source::Opencode => None,
900    }
901}
902
903/// What one transcript says the engineer did, from its raw lines and without the replay, so a reader
904/// that broke cannot hide it (sweep.rs `doctor`): each worded rejection and each answer to the agent's
905/// question, with its time and the harness version that wrote it.
906#[derive(Debug, Default, Clone, PartialEq, Eq)]
907pub struct Marks {
908    pub session: String,
909    pub cwd: String,
910    pub rejections: Vec<(i64, String)>,
911    pub answers: Vec<(i64, String)>,
912    /// The harness version of the newest line read that names one (the newest mark's, when there is one).
913    pub version: String,
914}
915
916/// The words that put a raw line in question for [`marks`] (it is parsed only then): a worded
917/// rejection, an answer's opening in each harness's wording, Codex's question call and its output.
918const MARK_WORDS: &[&str] =
919    &["the user said:", "answered", "User selected:", "User provided custom input:", "User answers:", CODEX_ASK, "function_call_output"];
920
921/// The [`Marks`] of transcript `path` of harness `h`, its lines streamed; None for no main session
922/// (a sub-agent's, a spawned thread, `codex exec`) or a harness without transcript files.
923pub fn marks(h: &dyn harness::Harness, path: &Path) -> Option<Marks> {
924    let source = source_of(h)?;
925    let file = std::fs::File::open(path).ok()?;
926    let mut m = Marks { session: path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), ..Marks::default() };
927    let mut version = String::new();
928    // Codex's question calls waiting for their output.
929    let mut asking: HashSet<String> = HashSet::new();
930    for (i, line) in BufReader::new(file).lines().map_while(Result::ok).enumerate() {
931        // The first line names the session (omp, Codex); a line is parsed beyond it only while the cwd is
932        // unknown or when it may be a mark.
933        if i > 0 && !m.cwd.is_empty() && !MARK_WORDS.iter().any(|w| line.contains(w)) {
934            continue;
935        }
936        let Ok(v) = serde_json::from_str::<Value>(&line) else { continue };
937        let main = match source {
938            Source::Claude => claude_marks(&v, &mut m, &mut version),
939            Source::Omp => omp_marks(&v, &mut m, &mut version),
940            Source::Codex => codex_marks(&v, &mut m, &mut version, &mut asking),
941            Source::Opencode => false,
942        };
943        if !main {
944            return None;
945        }
946    }
947    m.version = version;
948    Some(m)
949}
950
951/// One Claude Code line's marks; false for a sub-agent's transcript.
952fn claude_marks(v: &Value, m: &mut Marks, version: &mut String) -> bool {
953    if v["isSidechain"] == true {
954        return false;
955    }
956    if m.cwd.is_empty() {
957        m.cwd = s(&v["cwd"]);
958    }
959    if let Some(x) = v["version"].as_str() {
960        *version = x.to_string();
961    }
962    let results = v["message"]["content"].as_array().into_iter().flatten().filter(|b| b["type"] == "tool_result");
963    for b in results {
964        let text = text_of(&b["content"]);
965        let at = (ts(v), version.clone());
966        if b["is_error"] == true && worded_rejection(&text).is_some() {
967            m.rejections.push(at);
968        } else if b["is_error"] != true && ANSWER_OPENINGS.iter().any(|o| text.trim_start().starts_with(o)) {
969            m.answers.push(at);
970        }
971    }
972    true
973}
974
975/// One omp line's marks (answers only: omp's denials carry no words); false for a sub-agent's session.
976fn omp_marks(v: &Value, m: &mut Marks, version: &mut String) -> bool {
977    if v["type"] == "session" {
978        if v.get("parentSession").is_some_and(|p| !p.is_null()) {
979            return false;
980        }
981        (m.session, m.cwd) = (s(&v["id"]), s(&v["cwd"]));
982    }
983    if let Some(x) = v["runtime"]["version"].as_str() {
984        *version = x.to_string();
985    }
986    let msg = &v["message"];
987    let text = text_of(&msg["content"]);
988    if msg["role"] == "toolResult" && msg["toolName"] == OMP_ASK && msg["isError"] != true && ANSWER_OPENINGS.iter().any(|o| text.starts_with(o)) {
989        m.answers.push((ts(v), version.clone()));
990    }
991    true
992}
993
994/// One Codex rollout line's marks (answers only: Codex records no rejection); false for a thread that
995/// is no main session.
996fn codex_marks(v: &Value, m: &mut Marks, version: &mut String, asking: &mut HashSet<String>) -> bool {
997    let p = &v["payload"];
998    if v["type"] == "session_meta" {
999        if !codex::main_session(p) {
1000            return false;
1001        }
1002        (m.session, m.cwd, *version) = (s(&p["id"]), s(&p["cwd"]), s(&p["cli_version"]));
1003    }
1004    match p["type"].as_str() {
1005        Some("function_call") if p["name"] == CODEX_ASK => {
1006            asking.insert(s(&p["call_id"]));
1007        }
1008        Some("function_call_output") if asking.remove(p["call_id"].as_str().unwrap_or_default()) => m.answers.push((ts(v), version.clone())),
1009        _ => {}
1010    }
1011    true
1012}
1013
1014// ---- opencode: one parser for both export shapes, normalised into `Step`s at one point.
1015//
1016// 1.x `opencode export`: {info: {id, directory, parentID?, title}, messages: [{info: {role, time}, parts}]}.
1017// 2.x `opencode session export`: {info: {id, location: {directory}, parentID?, title}, messages:
1018// [{type: user|synthetic|assistant|idle, time, text | content}]}.
1019
1020/// opencode 1.x: a permission the engineer rejected without words (it halts the turn).
1021const OC_REJECTED: &str = "The user rejected permission to use this specific tool call.";
1022/// opencode 1.x: a rejection with the engineer's words after it.
1023const OC_FEEDBACK: &str = "The user rejected permission to use this specific tool call with the following feedback:";
1024/// opencode 2.x (`aborted`): a call the engineer declined without words.
1025const OC_DECLINED: &str = "The user declined this tool call";
1026/// opencode 2.x (`permission.rejected`): `opencode run` cannot ask, so it rejects every ask by itself.
1027/// It shares its type with the engineer's feedback but is nobody's correction.
1028const OC_AUTO_REJECTED: &str = "This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action.";
1029/// Claude's wordless rejection, which `learn::denied` matches.
1030const DENIED: &str = "The user doesn't want to proceed with this tool use.";
1031
1032/// An opencode tool error in Claude's wording, so the replay (`learn::rejection`, `learn::denied`)
1033/// reads it as the hook reads Claude's; the adapter's `canonicalError` does the same live. `kind` is
1034/// 2.x's `error.type` (1.x errors are bare strings). Every other error comes back as it was and is no
1035/// correction: a rule that denied the call, a dismissed question, an abort, a failed tool.
1036fn canonical(kind: Option<&str>, message: &str) -> String {
1037    let m = message.trim();
1038    let said = |words: &str| {
1039        format!("The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:\n{words}")
1040    };
1041    match kind {
1042        None if m == OC_REJECTED => DENIED.into(),
1043        None => m.strip_prefix(OC_FEEDBACK).map_or_else(|| m.to_string(), |w| said(w.trim())),
1044        Some("aborted") if m == OC_DECLINED => DENIED.into(),
1045        Some("permission.rejected") if m == OC_AUTO_REJECTED => m.into(),
1046        Some("permission.rejected") if m.is_empty() => DENIED.into(),
1047        Some("permission.rejected") => said(m),
1048        _ => m.into(),
1049    }
1050}
1051
1052/// `opencode run "fix it"` stores one argument that holds whitespace wrapped in literal double
1053/// quotes, its inner quotes backslash-escaped (1.x and 2.x); the engineer's prompt is inside. A text
1054/// with an unescaped quote inside is not such an argument and stays as it is.
1055fn unquote(t: &str) -> Cow<'_, str> {
1056    let Some(inner) = t.trim().strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return t.into() };
1057    if inner.match_indices('"').any(|(i, _)| !inner[..i].ends_with('\\')) {
1058        return t.into();
1059    }
1060    inner.replace("\\\"", "\"").into()
1061}
1062
1063/// What the replay needs of one opencode message, either version.
1064#[derive(Debug, PartialEq)]
1065enum Step {
1066    Prompt(i64, String),
1067    Said(String),
1068    /// A finished tool call: its time, its error in Claude's wording (None: it completed), the call, its id.
1069    Tool(i64, Option<String>, String, String),
1070    /// An answered `question` call: its time, its id, the questions and answers.
1071    Answer(i64, String, Vec<Asked>),
1072}
1073
1074/// A finished tool call: 1.x names it `tool`, 2.x `name`; 1.x errors are strings, 2.x `{type, message}`.
1075/// None while it is still pending or running. `ms` is when it ended (1.x `state.time.end`, 2.x the
1076/// item's `time.completed`), else its message's time: a rejection happens when the engineer answers,
1077/// often long after the message began. `id` is the part's (1.x) or the item's (2.x) id. A completed
1078/// `question` call is the engineer's answer; a dismissed one is an error that is nobody's correction.
1079fn tool_step(ms: i64, id: &Value, name: &Value, state: &Value) -> Option<Step> {
1080    let (name, id) = (name.as_str().unwrap_or_default(), s(id));
1081    let tool = tool_text(name, &state["input"]);
1082    let (kind, message) = match &state["error"] {
1083        Value::String(s) => (None, s.as_str()),
1084        e => (e["type"].as_str(), e["message"].as_str().unwrap_or_default()),
1085    };
1086    match state["status"].as_str() {
1087        Some("completed") if name == OC_QUESTION => Some(Step::Answer(ms, id, opencode_asked(state))),
1088        Some("completed") => Some(Step::Tool(ms, None, tool, id)),
1089        Some("error") => Some(Step::Tool(ms, Some(canonical(kind, message)), tool, id)),
1090        _ => None,
1091    }
1092}
1093
1094/// The steps of one message: the one place the two export shapes differ.
1095/// - 1.x: the engineer's words are the text parts not marked `synthetic` (borg's prompt context, a
1096///   compaction's "Continue", an attached file's contents, editor context) or `ignored`; a message
1097///   with a part carrying `metadata.borg` is borg's own question coming back. When the engineer
1098///   rejects a call with words, 1.x rejects every other pending call of the message with the plain
1099///   `OC_REJECTED`: that cascade is nobody's denial and is dropped (the adapter does the same live).
1100/// - 2.x: a `user` message's `text`, unless it carries `metadata.borg`; `synthetic` (borg's context,
1101///   hidden inputs) and `idle` messages are nobody speaking.
1102fn opencode_steps(m: &Value) -> Vec<Step> {
1103    let spoken = |p: &&Value| p["type"] == "text" && p["synthetic"] != true && p["ignored"] != true;
1104    let text = |p: &Value| p["text"].as_str().unwrap_or_default().to_string();
1105    if let Some(info) = m.get("info") {
1106        let ms = info["time"]["created"].as_i64().unwrap_or(0);
1107        let parts = m["parts"].as_array().map(Vec::as_slice).unwrap_or_default();
1108        let error = |p: &Value| p["state"]["error"].as_str().unwrap_or_default().trim().to_string();
1109        let worded = parts.iter().any(|p| error(p).starts_with(OC_FEEDBACK));
1110        return match info["role"].as_str() {
1111            Some("user") if !parts.iter().any(|p| p["metadata"]["borg"] == true) => {
1112                vec![Step::Prompt(ms, parts.iter().filter(spoken).map(text).collect::<Vec<_>>().join("\n"))]
1113            }
1114            Some("assistant") => by_completion(
1115                parts
1116                    .iter()
1117                    .filter_map(|p| match p["type"].as_str() {
1118                        Some("tool") if worded && error(p) == OC_REJECTED => None,
1119                        Some("tool") => tool_step(p["state"]["time"]["end"].as_i64().unwrap_or(ms), &p["id"], &p["tool"], &p["state"]),
1120                        _ => spoken(&p).then(|| Step::Said(text(p))),
1121                    })
1122                    .collect(),
1123            ),
1124            _ => Vec::new(),
1125        };
1126    }
1127    let ms = m["time"]["created"].as_i64().unwrap_or(0);
1128    match m["type"].as_str() {
1129        Some("user") if m["metadata"]["borg"] != true => vec![Step::Prompt(ms, text(m))],
1130        Some("assistant") => by_completion(
1131            m["content"]
1132                .as_array()
1133                .into_iter()
1134                .flatten()
1135                .filter_map(|c| match c["type"].as_str() {
1136                    Some("text") => Some(Step::Said(text(c))),
1137                    Some("tool") => tool_step(c["time"]["completed"].as_i64().unwrap_or(ms), &c["id"], &c["name"], &c["state"]),
1138                    _ => None,
1139                })
1140                .collect(),
1141        ),
1142        _ => Vec::new(),
1143    }
1144}
1145
1146/// Each run of tool calls in the order they finished, text keeping its place. A message lists
1147/// parallel calls in the order they were made, and one that waits for the engineer's permission
1148/// often finishes last; the live hook hears them finish, so a sibling that completed first does
1149/// not end the wait for a reason that a later wordless rejection starts.
1150fn by_completion(mut steps: Vec<Step>) -> Vec<Step> {
1151    for run in steps.split_mut(|s| matches!(s, Step::Said(_))) {
1152        run.sort_by_key(|s| match s {
1153            Step::Tool(ms, ..) | Step::Answer(ms, ..) => *ms,
1154            _ => 0,
1155        });
1156    }
1157    steps
1158}
1159
1160/// A session the engineer talked in: not a sub-agent's (`parentID`; its first prompt is the parent
1161/// drone's task) and not one of borg's own model calls (`runner::OPENCODE_TITLE`).
1162fn engineers_session(export: &Value) -> bool {
1163    let info = &export["info"];
1164    info.is_object() && info.get("parentID").is_none_or(Value::is_null) && info["title"] != runner::OPENCODE_TITLE
1165}
1166
1167/// Episodes of one opencode session export, 1.x or 2.x (none for a sub-agent or a model call of borg's).
1168/// Like the live hook, a wordless 1.x rejection cannot be told from 1.x `opencode run`'s own
1169/// auto-reject (the same `OC_REJECTED`): a session continued with `run -c` takes its next prompt
1170/// for the reason of a refusal nobody made.
1171pub fn opencode_session(export: &Value) -> Vec<Found> {
1172    if !engineers_session(export) {
1173        return Vec::new();
1174    }
1175    let info = &export["info"];
1176    // 1.x names its version on the session; 2.x's export names none.
1177    let mut r = Replay { session: s(&info["id"]), harness: harness::opencode::Opencode.name(), version: s(&info["version"]), ..Default::default() };
1178    r.at(info["directory"].as_str().or(info["location"]["directory"].as_str()).unwrap_or_default());
1179    for step in export["messages"].as_array().into_iter().flatten().flat_map(opencode_steps) {
1180        match step {
1181            Step::Prompt(ms, t) => r.prompt(ms, &unquote(&t)),
1182            Step::Said(t) => r.assistant_text(&t),
1183            Step::Tool(ms, error, tool, id) => r.tool_result(ms, error.is_some(), error.as_deref().unwrap_or_default(), tool, &id),
1184            Step::Answer(ms, id, asked) => r.answer(ms, &id, &asked),
1185        }
1186    }
1187    r.out
1188}
1189
1190/// One opencode CLI call: (arguments, the directory it runs in) -> stdout. `opencode::cli` in production.
1191pub type Exec<'a> = &'a dyn Fn(&[String], &Path) -> Option<Vec<u8>>;
1192
1193fn strings(xs: &[&str]) -> Vec<String> {
1194    xs.iter().map(|x| x.to_string()).collect()
1195}
1196
1197/// opencode 1.x's read-only query for every main session that is not one of borg's model calls.
1198fn db_args(since: Option<i64>) -> Vec<String> {
1199    let since = since.map(|ms| format!(" AND time_updated >= {ms}")).unwrap_or_default();
1200    let title = runner::OPENCODE_TITLE.replace('\'', "''");
1201    let sql = format!("SELECT id FROM session WHERE parent_id IS NULL AND title != '{title}'{since} ORDER BY time_created");
1202    let mut a = strings(&["db", "--pure", "--format", "json"]);
1203    a.push(sql);
1204    a
1205}
1206
1207/// The main sessions of the project the call runs in. 2.x always `--standalone`: a private server,
1208/// so the engineer's background service is never started or asked.
1209fn list_args(v2: bool) -> Vec<String> {
1210    strings(&["session", "list", if v2 { "--standalone" } else { "--pure" }, "--format", "json", "-n", "100000"])
1211}
1212
1213/// The session ids in a JSON array of rows (`opencode db`, `opencode session list`), without borg's
1214/// model calls and sessions last updated before `since`. None: no such array (the call failed).
1215fn ids_of(out: &[u8], since: Option<i64>) -> Option<Vec<String>> {
1216    let rows: Vec<Value> = serde_json::from_slice(out).ok()?;
1217    let recent = |r: &Value| since.is_none_or(|s| r["updated"].as_i64().is_none_or(|u| u >= s));
1218    Some(rows.iter().filter(|r| r["title"] != runner::OPENCODE_TITLE && recent(r)).filter_map(|r| Some(r["id"].as_str()?.to_string())).collect())
1219}
1220
1221/// Where the collectives config says the engineer works: each collective path and the git repos up
1222/// to two levels inside it. A path is a prefix, often a folder of repos (`~/work-repos`), and
1223/// opencode 2 lists sessions per project (a folder outside git is a project of its own), so the
1224/// folder alone would list none of its repos' sessions.
1225fn collective_dirs(cfg: Option<&Config>) -> BTreeSet<PathBuf> {
1226    cfg.into_iter().flat_map(|c| &c.collectives).flat_map(|c| &c.paths).flat_map(|p| with_repos(collective::expand(p))).collect()
1227}
1228
1229fn with_repos(dir: PathBuf) -> Vec<PathBuf> {
1230    let repo = |d: &Path| d.join(".git").exists();
1231    let subdirs = |d: &Path| -> Vec<PathBuf> { std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect() };
1232    let mut out = Vec::new();
1233    if !dir.ancestors().any(repo) {
1234        for child in subdirs(&dir) {
1235            match repo(&child) {
1236                true => out.push(child),
1237                false => out.extend(subdirs(&child).into_iter().filter(|g| repo(g))),
1238            }
1239        }
1240    }
1241    out.push(dir);
1242    out
1243}
1244
1245/// One directory per opencode project among `dirs` (a project is a git repo; a dir outside any repo
1246/// stands for itself), existing ones only.
1247fn projects(dirs: &BTreeSet<PathBuf>) -> Vec<PathBuf> {
1248    let root = |d: &Path| d.ancestors().find(|a| a.join(".git").exists()).unwrap_or(d).to_path_buf();
1249    dirs.iter().filter(|d| d.is_dir()).map(|d| root(d)).collect::<BTreeSet<_>>().into_iter().collect()
1250}
1251
1252/// The exports of the engineer's opencode sessions, through opencode's own CLI: read-only, never its
1253/// database file, never its HTTP API. 1.x lists every main session with one `opencode db` query.
1254/// 2.x has no such query and lists per project, so it sees the projects in `known` only (the cwds of
1255/// this run's Claude Code and omp sessions and `collective_dirs`); so does 1.x when its query fails
1256/// (an older 1.x without `db`, a changed schema). Each session is exported in the directory it was
1257/// listed in and handed to `each`; a call that fails or answers no JSON is skipped.
1258///
1259/// 2.x does not list the sessions it migrated from 1.x: it files them under the project `global`,
1260/// and a listing in their own directory (now a project of its own) does not show them; no
1261/// directory is known whose listing does. 2.x keeps the 1.x tables, so a 1.x `opencode` still
1262/// lists and exports them; their exports go in through `--opencode DIR`.
1263/// `only`: export that session alone (`--session`).
1264fn cli_sessions(v2: bool, since: Option<i64>, only: Option<&str>, known: &BTreeSet<PathBuf>, exec: Exec, each: &mut dyn FnMut(&Value)) {
1265    let tmp = std::env::temp_dir();
1266    if v2 {
1267        eprintln!(
1268            "borg assimilate: opencode 2 lists sessions per known project only, and never the ones it migrated from 1.x (export those with a 1.x opencode into a directory and pass --opencode DIR)"
1269        );
1270    }
1271    let queried = if v2 { None } else { exec(&db_args(since), &tmp).and_then(|o| ids_of(&o, since)) };
1272    let mut listed: Vec<(String, PathBuf)> = match queried {
1273        Some(ids) => ids.into_iter().map(|id| (id, tmp.clone())).collect(),
1274        None => {
1275            if !v2 {
1276                eprintln!("borg assimilate: `opencode db` failed; reading the opencode sessions of known projects only");
1277            }
1278            let mut seen = HashSet::new();
1279            let mut out = Vec::new();
1280            for dir in projects(known) {
1281                for id in exec(&list_args(v2), &dir).and_then(|o| ids_of(&o, since)).unwrap_or_default() {
1282                    if seen.insert(id.clone()) {
1283                        out.push((id, dir.clone()));
1284                    }
1285                }
1286            }
1287            out
1288        }
1289    };
1290    listed.retain(|(id, _)| only.is_none_or(|s| s == id));
1291    if !listed.is_empty() {
1292        eprintln!("borg assimilate: exporting {} opencode sessions", listed.len());
1293    }
1294    for (id, dir) in listed {
1295        if let Some(v) = exec(&opencode::export_args(v2, &id), &dir).and_then(|o| serde_json::from_slice::<Value>(&o).ok()) {
1296            each(&v);
1297        }
1298    }
1299}
1300
1301/// The `*.json` exports in `dir`, in name order; a file that is no JSON is skipped.
1302fn dir_sessions(dir: &Path, each: &mut dyn FnMut(&Value)) {
1303    let mut files: Vec<PathBuf> =
1304        std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect();
1305    files.sort();
1306    for f in files {
1307        if let Some(v) = std::fs::read(&f).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) {
1308            each(&v);
1309        }
1310    }
1311}
1312
1313/// `<root>/<slug>/<session>.jsonl`, one level deep: deeper files are sub-agents.
1314fn session_files(root: &Path) -> Vec<PathBuf> {
1315    let mut out = Vec::new();
1316    for slug in std::fs::read_dir(root).into_iter().flatten().flatten() {
1317        if !slug.file_type().is_ok_and(|t| t.is_dir()) {
1318            continue;
1319        }
1320        for f in std::fs::read_dir(slug.path()).into_iter().flatten().flatten() {
1321            let p = f.path();
1322            if f.file_type().is_ok_and(|t| t.is_file()) && p.extension().is_some_and(|x| x == "jsonl") {
1323                out.push(p);
1324            }
1325        }
1326    }
1327    out.sort();
1328    out
1329}
1330
1331/// Words whose mention keeps a session's text or a document out of assimilation, from
1332/// `BORG_ASSIMILATE_SKIP` (comma-separated, case-insensitive; default none): knowledge the team abandoned.
1333pub fn skip_words() -> Vec<String> {
1334    std::env::var("BORG_ASSIMILATE_SKIP").unwrap_or_default().split(',').map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty()).collect()
1335}
1336
1337/// Whether `text` mentions any of `words` (lowercase).
1338pub fn mentions(text: &str, words: &[String]) -> bool {
1339    !words.is_empty() && {
1340        let t = text.to_lowercase();
1341        words.iter().any(|w| t.contains(w.as_str()))
1342    }
1343}
1344
1345fn obsolete(f: &Found, words: &[String]) -> bool {
1346    let e = &f.episode;
1347    [&f.cwd, &f.task_cwd, &f.agent_cwd, &f.tool_cwd, &e.task, &e.agent, &e.correction, e.rejected_tool.as_deref().unwrap_or_default()]
1348        .iter()
1349        .any(|s| mentions(s, words))
1350}
1351
1352fn read_jsonl<T: for<'de> serde::Deserialize<'de>>(path: &Path) -> Vec<T> {
1353    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
1354}
1355
1356/// Episodes in a store that `borg learn` has not folded into a lesson or skipped yet.
1357fn waiting(home: &Path) -> usize {
1358    let done: HashSet<String> = learn::lessons(&home.join("lessons.jsonl")).into_iter().flat_map(|l: Lesson| l.episodes).collect();
1359    let skipped: HashSet<String> = std::fs::read_to_string(home.join("episodes-not-lessons.txt")).unwrap_or_default().lines().map(str::to_string).collect();
1360    read_jsonl::<Episode>(&home.join("episodes.jsonl")).iter().filter(|e| !done.contains(&e.id) && !skipped.contains(&e.id)).count()
1361}
1362
1363fn repo_of(cwd: &str) -> String {
1364    borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
1365}
1366
1367/// Mine the sources, route every episode and, with `apply`, record and learn them per collective.
1368/// `cfg` and `base` are the collectives config and the root store (`BORG_HOME`); `ask` is the model.
1369pub fn run(opts: &Options, cfg: Option<&Config>, base: &Path, ask: &dyn Fn(&str) -> Option<Value>) -> Report {
1370    let mut report = Report::default();
1371    let mut found: Vec<Found> = Vec::new();
1372    // Where the engineer works: opencode 2 can only list sessions per project.
1373    let mut known = collective_dirs(cfg);
1374    // Every format names its files after the session (Claude `<id>.jsonl`, omp `<ts>_<id>.jsonl`, Codex
1375    // `rollout-<time>-<id>.jsonl`): --session reads only the files that can be it.
1376    let maybe = |f: &PathBuf| opts.session.as_deref().is_none_or(|s| f.file_name().is_some_and(|n| n.to_string_lossy().contains(s)));
1377    for (root, source) in [(&opts.claude, Source::Claude), (&opts.omp, Source::Omp), (&opts.codex, Source::Codex)] {
1378        let Some(root) = root else { continue };
1379        let files = if source == Source::Codex { codex::rollouts(root) } else { session_files(root) };
1380        for f in files.into_iter().filter(maybe) {
1381            let (r, n) = match source {
1382                Source::Claude => (claude_replay(&f), &mut report.claude),
1383                Source::Omp => (omp_replay(&f), &mut report.omp),
1384                _ => (codex_replay(&f), &mut report.codex),
1385            };
1386            // A Codex rollout that is no main session reads as nothing; Claude's sub-agents are deeper.
1387            *n += usize::from(source != Source::Codex || !r.session.is_empty());
1388            known.extend(r.cwds.iter().map(PathBuf::from));
1389            found.extend(r.out);
1390        }
1391    }
1392    if let Some(from) = &opts.opencode {
1393        let mut n = 0;
1394        let mut each = |v: &Value| {
1395            if engineers_session(v) && opts.session.as_deref().is_none_or(|s| v["info"]["id"] == s) {
1396                n += 1;
1397                found.extend(opencode_session(v));
1398            }
1399        };
1400        match from {
1401            OpencodeFrom::Dir(d) => dir_sessions(d, &mut each),
1402            OpencodeFrom::Cli => {
1403                let v2 = runner::opencode() == runner::Opencode::V2;
1404                cli_sessions(v2, opts.since, opts.session.as_deref(), &known, &|a, d| opencode::cli(v2, None, a, d), &mut each)
1405            }
1406        }
1407        report.opencode = Some(n);
1408    }
1409    report.scanned = report.claude + report.omp + report.opencode.unwrap_or(0) + report.codex;
1410    let wanted = |f: &Found| opts.session.as_deref().is_none_or(|s| f.episode.session == s);
1411    found.retain(|f| !obsolete(f, &opts.skip) && opts.since.is_none_or(|s| f.episode.ms >= s) && wanted(f));
1412    found.sort_by_key(|f| f.episode.ms);
1413
1414    // Route each episode by its own cwd; off episodes are counted and dropped here.
1415    let mut by: BTreeMap<String, (PathBuf, bool, Vec<Found>)> = BTreeMap::new();
1416    let mut off_sessions = HashSet::new();
1417    for f in found {
1418        let session = f.episode.session.clone();
1419        match place(cfg, base, f) {
1420            None => {
1421                report.off_episodes += 1;
1422                off_sessions.insert(session);
1423            }
1424            Some(p) => by.entry(p.name).or_insert_with(|| (p.home, p.may_sync, Vec::new())).2.push(p.found),
1425        }
1426    }
1427    report.off_sessions = off_sessions.len();
1428
1429    // Episodes not in their collective's store yet; the rest are counted as known.
1430    let mut tallies: BTreeMap<String, (Tally, Vec<Found>)> = BTreeMap::new();
1431    for (name, (home, may_sync, eps)) in by {
1432        let mut known = learn::Known::read(&home);
1433        let mut t = Tally { home, may_sync, ..Default::default() };
1434        let mut fresh = Vec::new();
1435        for f in eps {
1436            if known.holds(&f.episode) {
1437                t.known += 1;
1438                report.harnesses.entry(f.episode.harness.clone()).or_default().known += 1;
1439                continue;
1440            }
1441            known.add(&f.episode);
1442            fresh.push(f);
1443        }
1444        tallies.insert(name, (t, fresh));
1445    }
1446
1447    // --limit keeps the newest new episodes across collectives, so the next run takes the next batch.
1448    if let Some(n) = opts.limit {
1449        let mut newest: Vec<(i64, String, usize)> =
1450            tallies.iter().flat_map(|(name, (_, fresh))| fresh.iter().enumerate().map(move |(i, f)| (f.episode.ms, name.clone(), i))).collect();
1451        newest.sort_by_key(|(ms, ..)| std::cmp::Reverse(*ms));
1452        let keep: HashSet<(String, usize)> = newest.into_iter().take(n).map(|(_, name, i)| (name, i)).collect();
1453        for (name, (_, fresh)) in tallies.iter_mut() {
1454            let mut i = 0;
1455            fresh.retain(|_| {
1456                i += 1;
1457                keep.contains(&(name.clone(), i - 1))
1458            });
1459        }
1460    }
1461
1462    let mut new: Vec<&Episode> = tallies.values().flat_map(|(_, fresh)| fresh).map(|f| &f.episode).collect();
1463    new.sort_by_key(|e| e.ms);
1464    for e in new {
1465        let k = report.harnesses.entry(e.harness.clone()).or_default();
1466        *(if e.via == learn::ANSWER {
1467            &mut k.answers
1468        } else if e.rejected_tool.is_some() {
1469            &mut k.rejections
1470        } else {
1471            &mut k.prompts
1472        }) += 1;
1473        if !e.harness_version.is_empty() {
1474            k.version.clone_from(&e.harness_version);
1475        }
1476    }
1477    report.usd_per_call = learn_cost(base);
1478
1479    let mut engineers: HashMap<String, String> = HashMap::new();
1480    for (name, (mut t, fresh)) in tallies {
1481        t.sessions = fresh.iter().map(|f| &f.episode.session).collect::<HashSet<_>>().len();
1482        t.episodes = fresh.len();
1483        t.calls = fresh.len() + waiting(&t.home);
1484        if opts.apply {
1485            // Appended under the store's lock, against what it holds now: a turn-end sweep may have
1486            // appended some of these while an earlier collective was learning.
1487            match learn::record_new_in(&t.home, fresh, |f| complete(f, &mut engineers)) {
1488                Ok(appended) => {
1489                    appended.iter().for_each(|f| count_veto(&t.home, &f.episode));
1490                    t.known += t.episodes - appended.len();
1491                    t.episodes = appended.len();
1492                    // What the learning ahead calls the model for: the store's unlearned episodes now,
1493                    // each once, whoever appended it.
1494                    t.calls = waiting(&t.home);
1495                }
1496                Err(_) => eprintln!("borg assimilate: cannot write {}", t.home.join("episodes.jsonl").display()),
1497            }
1498            eprintln!("learning {name}: one model call per episode, in order (each sees the lessons before it)");
1499            (t.new_lessons, t.merged, t.pending) = learn::learn_in_reporting(&t.home, None, ask, learn::learning(), learn::now_ms(), &mut learn::print_step);
1500            crate::vectors::refresh(&t.home.join("lessons.jsonl"));
1501        }
1502        report.collectives.insert(name, t);
1503    }
1504    report
1505}
1506
1507/// The mean cost of one learn call on this machine and the number of calls it is from: borg's own
1508/// record of its model calls (stats.rs `MODEL_CALL`, `why` `learn` or `assimilate`) in the root store.
1509/// None when none reported a cost (a CLI that reports none, or no call yet): the cost is then unknown,
1510/// never guessed.
1511fn learn_cost(base: &Path) -> Option<(f64, usize)> {
1512    let costs: Vec<f64> = crate::stats::live(base)
1513        .into_iter()
1514        .filter(|e| e.kind == crate::stats::MODEL_CALL && (e.why == "learn" || e.why == "assimilate") && e.usd > 0.0)
1515        .map(|e| e.usd)
1516        .collect();
1517    (!costs.is_empty()).then(|| (costs.iter().sum::<f64>() / costs.len() as f64, costs.len()))
1518}
1519
1520/// Where an episode found in a transcript goes ([`place`]).
1521pub struct Placed {
1522    pub name: String,
1523    pub home: PathBuf,
1524    pub may_sync: bool,
1525    pub found: Found,
1526}
1527
1528/// Where `f` goes: the collective its cwd routes to, with the state carried over from another
1529/// collective's (or an off) repo dropped (the task, the drone's text, the refused call: each stays where
1530/// it was seen); None for an off repo, whose episodes are never stored. `base` is the root store.
1531pub fn place(cfg: Option<&Config>, base: &Path, mut f: Found) -> Option<Placed> {
1532    let r = collective::route(cfg, &f.cwd, base);
1533    let may_sync = collective::may_sync(&r);
1534    let Route::Collective { name, home, .. } = r else { return None };
1535    let elsewhere = |cwd: &str| !matches!(collective::route(cfg, cwd, base), Route::Collective { name: n, .. } if n == name);
1536    if elsewhere(&f.task_cwd) {
1537        f.episode.task.clear();
1538    }
1539    if elsewhere(&f.agent_cwd) {
1540        f.episode.agent.clear();
1541    }
1542    if elsewhere(&f.tool_cwd) {
1543        f.episode.rejected_tool = None;
1544    }
1545    Some(Placed { name, home, may_sync, found: f })
1546}
1547
1548/// Complete an episode found in a transcript for its store: its repo and engineer, from its cwd
1549/// (`engineers` remembers each cwd's engineer: one `git config` per cwd).
1550pub fn complete(f: &mut Found, engineers: &mut HashMap<String, String>) {
1551    f.episode.repo = repo_of(&f.cwd);
1552    f.episode.engineer = engineers.entry(f.cwd.clone()).or_insert_with(|| learn::engineer(&f.cwd)).clone();
1553}
1554
1555/// A veto read from a transcript, counted as a `veto` at the time it was said (veto.rs `record`; the
1556/// lessons shown then are not known, so nothing is credited); any other episode counts nothing here
1557/// (`learn::count_correction` counts it).
1558pub fn count_veto(store: &Path, e: &Episode) {
1559    if e.kind == crate::veto::KIND {
1560        crate::stats::count(store, crate::stats::VETO, &e.session, |ev| {
1561            ev.ms = e.ms;
1562            ev.engineer.clone_from(&e.engineer);
1563            ev.repo.clone_from(&e.repo);
1564            ev.why = "prompt".into();
1565            ev.text = crate::stats::excerpt(&e.correction, crate::stats::FEED_CHARS);
1566        });
1567    }
1568}
1569
1570pub fn render(r: &Report, apply: bool) -> String {
1571    let mut s = format!("{} sessions scanned{}\n", r.scanned, if apply { "" } else { " (dry run: nothing written; --apply to assimilate)" });
1572    // Per source, so a source that found nothing (or is not installed) shows instead of going silent.
1573    let opencode = r.opencode.map_or("opencode not installed".to_string(), |n| format!("opencode {n}"));
1574    s.push_str(&format!("sources: claude {} sessions, omp {}, {opencode}, codex {}\n", r.claude, r.omp, r.codex));
1575    for (h, k) in &r.harnesses {
1576        let version = if k.version.is_empty() { String::new() } else { format!(" {}", k.version) };
1577        s.push_str(&format!("{h}{version}: {} rejections, {} answers, {} prompts new; {} already present\n", k.rejections, k.answers, k.prompts, k.known));
1578    }
1579    for (name, t) in &r.collectives {
1580        let sync = if t.may_sync { "team" } else { "local, never syncs" };
1581        s.push_str(&format!("{name} ({sync}) {}: {} new episodes from {} sessions, {} already known", t.home.display(), t.episodes, t.sessions, t.known));
1582        if apply {
1583            s.push_str(&format!("; {} new lessons, {} merged", t.new_lessons, t.merged));
1584            if t.pending > 0 {
1585                s.push_str(&format!("; {} still pending (model unavailable), re-run to retry", t.pending));
1586            }
1587            s.push('\n');
1588        } else {
1589            s.push_str(&format!("; ~{} model calls to learn\n", t.calls));
1590        }
1591    }
1592    s.push_str(&format!("off: {} episodes in {} sessions skipped (not stored)\n", r.off_episodes, r.off_sessions));
1593    if !apply {
1594        // One model call per episode, new or already waiting in a store.
1595        let calls: usize = r.collectives.values().map(|t| t.calls).sum();
1596        s.push_str(&match r.usd_per_call {
1597            Some((usd, n)) => {
1598                format!("learn cost: ~{calls} model calls x ${usd:.3} (the mean of this machine's last {n} learn calls) = ~${:.2}\n", usd * calls as f64)
1599            }
1600            None => format!("learn cost: ~{calls} model calls; unknown per call (no learn call with a reported cost on this machine yet)\n"),
1601        });
1602        // Last line, where it is read: the first line's note is easy to miss.
1603        s.push_str("Dry run: nothing was written. Run `borg assimilate --apply` to learn these episodes.\n");
1604    }
1605    s
1606}
1607
1608/// `YYYY-MM-DD` (or a full RFC 3339 UTC time) to epoch ms.
1609fn parse_date(d: &str) -> Option<i64> {
1610    let full = if d.len() == 10 { format!("{d}T00:00:00Z") } else { d.to_string() };
1611    let ms = borg_trace::parse_ts(&full);
1612    (ms > 0).then_some(ms)
1613}
1614
1615const USAGE: &str =
1616    "usage: borg assimilate [--apply] [--claude DIR] [--omp DIR] [--codex DIR] [--opencode DIR] [--since YYYY-MM-DD] [--limit N] [--session ID]";
1617
1618/// `borg assimilate [--apply] [--claude DIR] [--omp DIR] [--codex DIR] [--opencode DIR] [--since DATE]
1619/// [--limit N] [--session ID]`; the exit code. Each harness's directory defaults to where it keeps its
1620/// transcripts (its config-dir variable honoured: harness/*.rs). opencode sessions come from the
1621/// `opencode` CLI when it is installed, or from `--opencode DIR`, a directory of session exports.
1622pub fn main(args: &[&str], ask: &dyn Fn(&str) -> Option<Value>) -> i32 {
1623    let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
1624    let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
1625    let dir = |f: &str, default: PathBuf| Some(flag(f).map_or(default, PathBuf::from));
1626    let since = match flag("--since").map(parse_date) {
1627        Some(None) => {
1628            eprintln!("borg assimilate: --since wants YYYY-MM-DD\n{USAGE}");
1629            return 2;
1630        }
1631        s => s.flatten(),
1632    };
1633    let limit = match flag("--limit").map(str::parse::<usize>) {
1634        Some(Err(_)) => {
1635            eprintln!("borg assimilate: --limit wants a number\n{USAGE}");
1636            return 2;
1637        }
1638        l => l.and_then(Result::ok),
1639    };
1640    let opts = Options {
1641        claude: dir("--claude", harness::claude::projects(&home)),
1642        omp: dir("--omp", harness::omp::sessions(&home)),
1643        opencode: flag("--opencode").map(|d| OpencodeFrom::Dir(d.into())).or_else(|| runner::on_path("opencode").then_some(OpencodeFrom::Cli)),
1644        codex: dir("--codex", codex::sessions(&home)),
1645        session: flag("--session").map(String::from),
1646        since,
1647        limit,
1648        apply: args.contains(&"--apply"),
1649        skip: skip_words(),
1650    };
1651    // A broken config routes everything off, exactly as in the hook.
1652    let cfg = match collective::load() {
1653        Ok(c) => c,
1654        Err(e) => {
1655            eprintln!("borg assimilate: invalid collectives config ({e}): every repo is off, nothing to assimilate");
1656            return 1;
1657        }
1658    };
1659    let report = run(&opts, cfg.as_ref(), &learn::home(), ask);
1660    print!("{}", render(&report, opts.apply));
1661    0
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666    use super::*;
1667    use serde_json::json;
1668
1669    fn temp(name: &str) -> PathBuf {
1670        let d = std::env::temp_dir().join(format!("borg-assimilate-{name}-{}", std::process::id()));
1671        let _ = std::fs::remove_dir_all(&d);
1672        std::fs::create_dir_all(&d).unwrap();
1673        d
1674    }
1675
1676    fn write(path: &Path, rows: &[Value]) {
1677        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1678        std::fs::write(path, rows.iter().map(|r| r.to_string() + "\n").collect::<String>()).unwrap();
1679    }
1680
1681    fn user(cwd: &str, t: &str, text: &str) -> Value {
1682        json!({"type": "user", "cwd": cwd, "timestamp": t, "message": {"role": "user", "content": text}})
1683    }
1684
1685    fn says(cwd: &str, t: &str, text: &str) -> Value {
1686        json!({"type": "assistant", "cwd": cwd, "timestamp": t, "message": {"content": [{"type": "text", "text": text}]}})
1687    }
1688
1689    fn tool_use(cwd: &str, t: &str, id: &str) -> Value {
1690        json!({"type": "assistant", "cwd": cwd, "timestamp": t,
1691            "message": {"content": [{"type": "tool_use", "id": id, "name": "Bash", "input": {"command": "rm -rf build"}}]}})
1692    }
1693
1694    fn result(cwd: &str, t: &str, id: &str, is_error: bool, text: &str) -> Value {
1695        json!({"type": "user", "cwd": cwd, "timestamp": t,
1696            "message": {"content": [{"type": "tool_result", "tool_use_id": id, "is_error": is_error, "content": text}]}})
1697    }
1698
1699    /// The model: every episode is a new lesson.
1700    fn ask(p: &str) -> Option<Value> {
1701        let c = p.split("## Engineer's correction\n").nth(1)?.lines().next()?.to_string();
1702        Some(json!({"is_lesson": true, "question": format!("Q {c}"), "situations": [], "consequence": "c", "same_as": ""}))
1703    }
1704
1705    const CFG: &str = r#"{ "default": "off", "off": ["/w/secret"],
1706        "collectives": [ { "name": "corp", "paths": ["/w/corp"] }, { "name": "mine", "paths": ["/w/mine"], "share": "local" } ] }"#;
1707
1708    /// A history with: a correction in a corp repo, a rejection with words in a private repo, a
1709    /// correction in an off repo, and tool output that merely echoes an old rejection.
1710    fn fixture(root: &Path) -> (PathBuf, PathBuf) {
1711        let (claude, omp) = (root.join("claude"), root.join("omp"));
1712        let c = "/w/corp/app";
1713        write(
1714            &claude.join("-w-corp-app/s-corp-1.jsonl"),
1715            &[
1716                user(c, "2026-09-01T10:00:00.000Z", "add a retry to the relay"),
1717                says(c, "2026-09-01T10:00:05.000Z", "I'll wrap delivery in a ReentrantLock."),
1718                user(c, "2026-09-01T10:01:00.000Z", "no, check the TOCTOU on the closed flag first"),
1719                tool_use(c, "2026-09-01T10:02:00.000Z", "t1"),
1720                // A script printing an old transcript: the rejection words are output, not a rejection.
1721                result(
1722                    c,
1723                    "2026-09-01T10:02:01.000Z",
1724                    "t1",
1725                    true,
1726                    "Exit code 1\nold log: The user doesn't want to proceed with this tool use. the user said: never touch prod",
1727                ),
1728                tool_use(c, "2026-09-01T10:03:00.000Z", "t2"),
1729                result(c, "2026-09-01T10:03:01.000Z", "t2", false, "The user doesn't want to proceed with this tool use. the user said: use the staging db"),
1730            ],
1731        );
1732        let m = "/w/mine/notes";
1733        write(
1734            &claude.join("-w-mine-notes/s-mine-1.jsonl"),
1735            &[
1736                user(m, "2026-09-02T09:00:00.000Z", "clean the build"),
1737                says(m, "2026-09-02T09:00:01.000Z", "Removing build/."),
1738                tool_use(m, "2026-09-02T09:00:02.000Z", "t1"),
1739                result(
1740                    m,
1741                    "2026-09-02T09:00:03.000Z",
1742                    "t1",
1743                    true,
1744                    "The user doesn't want to proceed with this tool use. The tool use was rejected. the user said:\nkeep the cache dir\nNote: The user's next message may contain a correction",
1745                ),
1746            ],
1747        );
1748        let s = "/w/secret/diary";
1749        write(
1750            &claude.join("-w-secret-diary/s-off-1.jsonl"),
1751            &[
1752                user(s, "2026-09-03T09:00:00.000Z", "summarise the week"),
1753                says(s, "2026-09-03T09:00:01.000Z", "Here is a summary."),
1754                user(s, "2026-09-03T09:01:00.000Z", "no, don't mention the doctor"),
1755            ],
1756        );
1757        // Sub-agents are never the engineer talking.
1758        write(
1759            &claude.join("-w-corp-app/s-corp-1/subagents/a.jsonl"),
1760            &[user(c, "2026-09-01T10:00:00.000Z", "task"), user(c, "2026-09-01T10:00:09.000Z", "no, wrong file")],
1761        );
1762        // omp: a wordless denial, then the engineer's reason as the next prompt.
1763        write(
1764            &omp.join("-w-corp-svc/2026-09-04_x.jsonl"),
1765            &[
1766                json!({"type": "session", "id": "omp-sess-1", "cwd": "/w/corp/svc", "timestamp": "2026-09-04T08:00:00.000Z"}),
1767                json!({"type": "message", "timestamp": "2026-09-04T08:00:01.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "bump the dependency"}]}}),
1768                json!({"type": "message", "timestamp": "2026-09-04T08:00:02.000Z", "message": {"role": "assistant", "content": [
1769                {"type": "text", "text": "Running cargo update."}, {"type": "toolCall", "id": "c1", "name": "bash", "arguments": {"command": "cargo update"}}]}}),
1770                json!({"type": "message", "timestamp": "2026-09-04T08:00:03.000Z", "message": {"role": "toolResult", "toolCallId": "c1", "isError": true,
1771                "content": [{"type": "text", "text": "Tool call denied by user: bash"}]}}),
1772                json!({"type": "message", "timestamp": "2026-09-04T08:00:09.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "only the one crate, pin the rest"}]}}),
1773                json!({"type": "message", "timestamp": "2026-09-04T08:01:00.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "no, the OldProject client too"}]}}),
1774            ],
1775        );
1776        (claude, omp)
1777    }
1778
1779    fn opts(claude: &Path, omp: &Path, apply: bool) -> Options {
1780        Options {
1781            claude: Some(claude.into()),
1782            omp: Some(omp.into()),
1783            opencode: None,
1784            codex: None,
1785            session: None,
1786            since: None,
1787            limit: None,
1788            apply,
1789            skip: vec!["oldproject".into()],
1790        }
1791    }
1792
1793    fn files(dir: &Path) -> Vec<PathBuf> {
1794        borg_trace::jsonl_files(dir)
1795    }
1796
1797    #[test]
1798    fn echoed_rejection_in_tool_output_is_not_a_correction() {
1799        let root = temp("echo");
1800        let (claude, _) = fixture(&root);
1801        let eps = claude_session(&claude.join("-w-corp-app/s-corp-1.jsonl"));
1802        let said: Vec<&str> = eps.iter().map(|f| f.episode.correction.as_str()).collect();
1803        assert_eq!(said, ["no, check the TOCTOU on the closed flag first"], "only the real prompt correction");
1804        let e = &eps[0].episode;
1805        assert_eq!((e.task.as_str(), e.agent.as_str(), e.session.as_str()), ("add a retry to the relay", "I'll wrap delivery in a ReentrantLock.", "s-corp-1"));
1806        assert_eq!(e.ms, borg_trace::parse_ts("2026-09-01T10:01:00.000Z"));
1807        assert!(claude_session(&claude.join("-w-corp-app/s-corp-1/subagents/a.jsonl")).len() == 1, "parser itself is neutral");
1808        // The doctor's raw count applies the same first-line rule: the echo is no lost rejection.
1809        let m = marks(&harness::claude::Claude, &claude.join("-w-corp-app/s-corp-1.jsonl")).unwrap();
1810        assert!(m.rejections.is_empty(), "{:?}", m.rejections);
1811        let _ = std::fs::remove_dir_all(root);
1812    }
1813
1814    #[test]
1815    fn a_veto_prompt_is_replayed_as_the_hook_reads_it() {
1816        let mut r = Replay { session: "sess-veto-1".into(), ..Default::default() };
1817        r.prompt(1, "fix the merge lookup");
1818        r.prompt(2, "borg no: never read the commit log");
1819        r.prompt(3, "no, use the review API");
1820        // After a wordless denial, the veto answers it: no rejected-tool correction.
1821        r.denied = Some(("Bash git log".into(), String::new(), "t1".into()));
1822        r.prompt(5, "borg no, stop reading history");
1823        let eps: Vec<&Episode> = r.out.iter().map(|f| &f.episode).collect();
1824        let got: Vec<(&str, &str, &str, &str)> = eps.iter().map(|e| (e.id.as_str(), e.kind.as_str(), e.task.as_str(), e.correction.as_str())).collect();
1825        assert_eq!(
1826            got,
1827            [
1828                ("no-sess-vet-2", crate::veto::KIND, "fix the merge lookup", "never read the commit log"),
1829                ("sess-vet-3", "", "fix the merge lookup", "no, use the review API"),
1830                ("no-sess-vet-5", crate::veto::KIND, "fix the merge lookup", "stop reading history"),
1831            ]
1832        );
1833        assert!(eps.iter().all(|e| e.rejected_tool.is_none()) && r.denied.is_none());
1834    }
1835
1836    #[test]
1837    fn dry_run_writes_nothing_and_counts_per_collective() {
1838        let root = temp("dry");
1839        let (claude, omp) = fixture(&root);
1840        let base = root.join("home");
1841        let cfg: Config = serde_json::from_str(CFG).unwrap();
1842        let r = run(&opts(&claude, &omp, false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
1843        assert!(!base.exists(), "dry run wrote something");
1844        assert_eq!(r.scanned, 4, "main sessions only");
1845        let corp = &r.collectives["corp"];
1846        assert_eq!((corp.sessions, corp.episodes, corp.calls, corp.may_sync), (2, 2, 2, true));
1847        let mine = &r.collectives["mine"];
1848        assert_eq!((mine.sessions, mine.episodes, mine.may_sync), (1, 1, false));
1849        assert_eq!((r.off_sessions, r.off_episodes), (1, 1));
1850        assert!(render(&r, false).contains("dry run"));
1851        assert!(render(&r, false).trim_end().ends_with("to learn these episodes."));
1852        assert!(!render(&r, true).contains("Dry run"));
1853        let _ = std::fs::remove_dir_all(root);
1854    }
1855
1856    #[test]
1857    fn apply_routes_episodes_learns_and_is_idempotent() {
1858        let root = temp("apply");
1859        let (claude, omp) = fixture(&root);
1860        let base = root.join("home");
1861        let cfg: Config = serde_json::from_str(CFG).unwrap();
1862        let r = run(&opts(&claude, &omp, true), Some(&cfg), &base, &ask);
1863        assert_eq!((r.collectives["corp"].new_lessons, r.collectives["mine"].new_lessons), (2, 1));
1864
1865        let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1866        let said: Vec<&str> = corp.iter().map(|e| e.correction.as_str()).collect();
1867        assert_eq!(said, ["no, check the TOCTOU on the closed flag first", "only the one crate, pin the rest"]);
1868        assert_eq!(corp[1].rejected_tool.as_deref(), Some(r#"bash {"command":"cargo update"}"#), "a denial's reason is the next prompt");
1869        assert_eq!((corp[1].session.as_str(), corp[1].repo.as_str()), ("omp-sess-1", "svc"));
1870        let mine: Vec<Episode> = read_jsonl(&base.join("collectives/mine/episodes.jsonl"));
1871        assert_eq!(mine.len(), 1);
1872        assert_eq!(mine[0].correction, "keep the cache dir");
1873        assert!(mine[0].rejected_tool.as_deref().unwrap().starts_with("Bash "));
1874        // Nothing of the off repo, nor of anything mentioning a skipped word, anywhere.
1875        for f in files(&base) {
1876            let t = std::fs::read_to_string(&f).unwrap();
1877            assert!(!t.contains("doctor") && !t.contains("diary") && !t.to_lowercase().contains("oldproject"), "{}", f.display());
1878        }
1879        assert_eq!(learn::lessons(&base.join("collectives/corp/lessons.jsonl")).len(), 2);
1880
1881        // Re-running adds nothing and calls no model.
1882        let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
1883        let before = snapshot(&base);
1884        let again = run(&opts(&claude, &omp, true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
1885        assert_eq!((again.collectives["corp"].episodes, again.collectives["corp"].known), (0, 2));
1886        assert_eq!(snapshot(&base), before);
1887        let _ = std::fs::remove_dir_all(root);
1888    }
1889
1890    #[test]
1891    fn off_repo_leaves_nothing_and_live_episodes_are_not_duplicated() {
1892        let root = temp("off");
1893        let (claude, omp) = fixture(&root);
1894        let base = root.join("home");
1895        let only_off: Config = serde_json::from_str(r#"{"default": "off", "off": ["/w"]}"#).unwrap();
1896        let r = run(&opts(&claude, &omp, true), Some(&only_off), &base, &|_| panic!("off calls no model"));
1897        assert!(r.collectives.is_empty() && !base.exists());
1898        assert_eq!(r.off_episodes, 4);
1899
1900        // The live hook already captured the corp correction (stamped a moment after the message).
1901        let corp = base.join("collectives/corp");
1902        std::fs::create_dir_all(&corp).unwrap();
1903        let live = Episode {
1904            id: "s-corp-1-live".into(),
1905            ms: borg_trace::parse_ts("2026-09-01T10:01:00.800Z"),
1906            repo: "app".into(),
1907            session: "s-corp-1".into(),
1908            task: String::new(),
1909            agent: String::new(),
1910            rejected_tool: None,
1911            correction: "no".into(),
1912            ..Episode::default()
1913        };
1914        std::fs::write(corp.join("episodes.jsonl"), serde_json::to_string(&live).unwrap() + "\n").unwrap();
1915        let cfg: Config = serde_json::from_str(CFG).unwrap();
1916        let since = Options { since: parse_date("2026-09-01"), limit: Some(10), ..opts(&claude, &omp, false) };
1917        let r = run(&since, Some(&cfg), &base, &|_| None);
1918        assert_eq!((r.collectives["corp"].episodes, r.collectives["corp"].known, r.collectives["corp"].calls), (1, 1, 2));
1919        // --since and --limit bound what is considered.
1920        let late = Options { since: parse_date("2026-09-04"), ..opts(&claude, &omp, false) };
1921        assert!(!run(&late, Some(&cfg), &base, &|_| None).collectives.contains_key("mine"));
1922        let one = Options { limit: Some(1), ..opts(&claude, &omp, false) };
1923        assert_eq!(run(&one, Some(&cfg), &base, &|_| None).collectives.values().map(|t| t.episodes).sum::<usize>(), 1);
1924        let _ = std::fs::remove_dir_all(root);
1925    }
1926
1927    #[test]
1928    fn limit_works_through_the_history_in_batches() {
1929        let root = temp("limit");
1930        let (claude, omp) = fixture(&root);
1931        let base = root.join("home");
1932        let cfg: Config = serde_json::from_str(CFG).unwrap();
1933        let one = Options { limit: Some(1), ..opts(&claude, &omp, true) };
1934        let stored = || -> Vec<String> {
1935            ["corp", "mine"].iter().flat_map(|c| read_jsonl::<Episode>(&base.join(format!("collectives/{c}/episodes.jsonl")))).map(|e| e.correction).collect()
1936        };
1937        // Newest first: the omp reason (09-04), then the private rejection (09-02), then the corp prompt (09-01).
1938        // The off episode (09-03) and the skipped one never use up the limit.
1939        run(&one, Some(&cfg), &base, &ask);
1940        assert_eq!(stored(), ["only the one crate, pin the rest"]);
1941        run(&one, Some(&cfg), &base, &ask);
1942        assert_eq!(stored(), ["only the one crate, pin the rest", "keep the cache dir"], "the second batch is the next episode");
1943        run(&one, Some(&cfg), &base, &ask);
1944        let last = run(&one, Some(&cfg), &base, &|_| panic!("nothing left to learn"));
1945        assert_eq!(stored().len(), 3);
1946        assert_eq!(last.collectives.values().map(|t| (t.episodes, t.known)).fold((0, 0), |a, b| (a.0 + b.0, a.1 + b.1)), (0, 3));
1947        let _ = std::fs::remove_dir_all(root);
1948    }
1949
1950    #[test]
1951    fn state_from_an_off_repo_never_reaches_a_collective() {
1952        let root = temp("moved");
1953        let claude = root.join("claude");
1954        let (s, c) = ("/w/secret/notes", "/w/corp/app");
1955        // One session: the task and a denied tool call in an off repo, then the cwd moves to corp.
1956        write(
1957            &claude.join("-w-secret-notes/s-moved-1.jsonl"),
1958            &[
1959                user(s, "2026-09-05T09:00:00.000Z", "summarise my therapy notes"),
1960                says(s, "2026-09-05T09:00:01.000Z", "Reading the therapy notes now."),
1961                tool_use(s, "2026-09-05T09:00:02.000Z", "t1"),
1962                result(
1963                    s,
1964                    "2026-09-05T09:00:03.000Z",
1965                    "t1",
1966                    true,
1967                    "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.",
1968                ),
1969                user(c, "2026-09-05T09:05:00.000Z", "never read files outside the repo"),
1970                user(c, "2026-09-05T09:06:00.000Z", "no, use the relay config instead"),
1971                says(c, "2026-09-05T09:06:30.000Z", "Switching to the relay config."),
1972                user(c, "2026-09-05T09:07:00.000Z", "no, the staging one"),
1973            ],
1974        );
1975        let base = root.join("home");
1976        let cfg: Config = serde_json::from_str(CFG).unwrap();
1977        let r = run(
1978            &Options { claude: Some(claude), omp: None, opencode: None, codex: None, session: None, since: None, limit: None, apply: true, skip: Vec::new() },
1979            Some(&cfg),
1980            &base,
1981            &ask,
1982        );
1983        assert_eq!(r.collectives["corp"].episodes, 3);
1984        let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1985        let t = std::fs::read_to_string(base.join("collectives/corp/episodes.jsonl")).unwrap();
1986        assert!(!t.contains("therapy") && !t.contains("rm -rf"), "off text in the team store: {t}");
1987        // The denial's reason is kept (said in corp), but not the off task, drone text or tool call.
1988        assert_eq!((corp[0].task.as_str(), corp[0].agent.as_str(), corp[0].rejected_tool.as_ref()), ("", "", None));
1989        assert_eq!((corp[0].correction.as_str(), corp[1].task.as_str()), ("never read files outside the repo", ""));
1990        // State seen in corp itself is kept.
1991        assert_eq!((corp[2].task.as_str(), corp[2].agent.as_str()), ("", "Switching to the relay config."));
1992        for f in files(&base) {
1993            assert!(!std::fs::read_to_string(&f).unwrap().contains("therapy"), "{}", f.display());
1994        }
1995        let _ = std::fs::remove_dir_all(root);
1996    }
1997
1998    // ---- opencode
1999
2000    /// A redacted real export (crates/borg/tests/fixtures/opencode).
2001    macro_rules! export {
2002        ($f:literal) => {
2003            serde_json::from_str::<Value>(include_str!(concat!("../tests/fixtures/opencode/", $f))).unwrap()
2004        };
2005    }
2006
2007    /// A 1.x / 2.x user message typed by the engineer, appended to an export.
2008    fn say_v1(mut export: Value, ms: i64, text: &str) -> Value {
2009        let m = json!({"info": {"role": "user", "time": {"created": ms}}, "parts": [{"type": "text", "text": text}]});
2010        export["messages"].as_array_mut().unwrap().push(m);
2011        export
2012    }
2013
2014    fn say_v2(mut export: Value, ms: i64, text: &str) -> Value {
2015        export["messages"].as_array_mut().unwrap().push(json!({"type": "user", "time": {"created": ms}, "text": text}));
2016        export
2017    }
2018
2019    fn corrections(found: &[Found]) -> Vec<&str> {
2020        found.iter().map(|f| f.episode.correction.as_str()).collect()
2021    }
2022
2023    #[test]
2024    fn opencode_errors_take_claudes_wording_and_only_the_engineers_refusals_count() {
2025        let feedback = canonical(None, &format!("{OC_FEEDBACK} keep the public API"));
2026        assert_eq!(learn::rejection(&feedback).as_deref(), Some("keep the public API"));
2027        assert!(feedback.lines().next().unwrap().contains(NOTICE), "the notice is on the first line, as the replay requires");
2028        for wordless in [canonical(None, OC_REJECTED), canonical(Some("aborted"), OC_DECLINED), canonical(Some("permission.rejected"), "")] {
2029            assert!(learn::denied(&wordless), "{wordless}");
2030        }
2031        let words = canonical(Some("permission.rejected"), "no, do not delete files, use git rm instead");
2032        assert_eq!(learn::rejection(&words).as_deref(), Some("no, do not delete files, use git rm instead"));
2033        // Nobody's correction: opencode run's own auto-reject, a config rule, a dismissed question, an
2034        // abort, a forced call under deny-all, a failed tool.
2035        for (kind, m) in [
2036            (Some("permission.rejected"), OC_AUTO_REJECTED),
2037            (None, "The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules []"),
2038            (None, "The user dismissed this question"),
2039            (None, "Tool execution aborted"),
2040            (Some("aborted"), "Step interrupted"),
2041            (None, "Model tried to call unavailable tool 'invalid'. Available tools: ."),
2042            (Some("tool.execution"), "File not found: /home/dev/work/missing.rs"),
2043        ] {
2044            let c = canonical(kind, m);
2045            assert!(learn::rejection(&c).is_none() && !learn::denied(&c), "{m}");
2046        }
2047    }
2048
2049    #[test]
2050    fn opencode_run_quoting_is_stripped_from_prompts() {
2051        let quirk = export!("v1/run-deny-all-forced-tool-call-export.json")["messages"][0]["parts"][0]["text"].as_str().unwrap().to_string();
2052        assert_eq!(unquote(&quirk), r#"Read relay.rs and answer with {"ok": true}"#);
2053        assert_eq!(unquote("\"no, that is wrong, use a Mutex instead\""), "no, that is wrong, use a Mutex instead");
2054        assert_eq!(unquote("no quotes"), "no quotes");
2055        assert_eq!(unquote(r#""a" and "b""#), r#""a" and "b""#, "an unescaped quote inside: typed, not argv");
2056        assert_eq!(unquote("\""), "\"");
2057    }
2058
2059    #[test]
2060    fn opencode_1_rejection_with_feedback_is_an_episode() {
2061        let found = opencode_session(&export!("v1/reject-with-feedback.json"));
2062        assert_eq!(corrections(&found), ["no, that is wrong, use the AtomicBoolean compareAndSet instead"]);
2063        let (f, e) = (&found[0], &found[0].episode);
2064        assert_eq!((f.cwd.as_str(), e.session.as_str(), e.task.as_str()), ("/home/dev/work", "ses_ec5c7766cffe7lNfHnHDzyNoUc", "Fix the race in relay.rs"));
2065        assert!(e.rejected_tool.as_deref().unwrap().starts_with(r#"read {"filePath":"/home/dev/work/"#));
2066        assert_eq!(e.ms, 1791978152946, "when the engineer answered, not when the message began");
2067        // The sibling 1.x rejected along with it (plain words, 4 ms later) is the cascade, not the
2068        // engineer: the next prompt is a new task, no second episode.
2069        let next = say_v1(export!("v1/reject-with-feedback.json"), 1791978160000, "now add a test for the relay");
2070        assert_eq!(corrections(&opencode_session(&next)), ["no, that is wrong, use the AtomicBoolean compareAndSet instead"]);
2071    }
2072
2073    #[test]
2074    fn opencode_tool_calls_replay_in_the_order_they_finished() {
2075        // A wordless rejection made first but answered last, and a sibling read that completed
2076        // before it: live, the hook hears the read finish and then the rejection, so the denial waits.
2077        let mut export = export!("v1/reject-plain-then-correction.json");
2078        let sibling = &mut export["messages"][1]["parts"][2]["state"];
2079        sibling["status"] = json!("completed");
2080        sibling["output"] = json!("fn deliver() {}");
2081        sibling["time"]["end"] = json!(1791978164300i64);
2082        sibling.as_object_mut().unwrap().remove("error");
2083        let found = opencode_session(&export);
2084        assert_eq!(corrections(&found), ["no, that is wrong, use compareAndSet instead of get then set"]);
2085        assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("read "));
2086        // Text keeps its place; only the calls between two texts are reordered.
2087        let steps = by_completion(vec![
2088            Step::Tool(9, None, "b".into(), "1".into()),
2089            Step::Tool(3, None, "a".into(), "2".into()),
2090            Step::Said("then".into()),
2091            Step::Tool(2, None, "c".into(), "3".into()),
2092        ]);
2093        let order: Vec<String> = steps
2094            .iter()
2095            .map(|s| match s {
2096                Step::Tool(_, _, t, _) | Step::Said(t) => t.clone(),
2097                Step::Prompt(..) | Step::Answer(..) => unreachable!(),
2098            })
2099            .collect();
2100        assert_eq!(order, ["a", "b", "then", "c"]);
2101    }
2102
2103    #[test]
2104    fn opencode_1_wordless_rejection_makes_the_next_prompt_the_reason() {
2105        let found = opencode_session(&export!("v1/reject-plain-then-correction.json"));
2106        assert_eq!(corrections(&found), ["no, that is wrong, use compareAndSet instead of get then set"]);
2107        assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("read "));
2108        assert_eq!(found[0].episode.ms, 1791978166088);
2109        // A real session: the reason need not read like a correction.
2110        let found = opencode_session(&export!("v1/bash-rejected-then-correction-window.json"));
2111        assert_eq!(corrections(&found), ["wait, revert that one. no, that is wrong, use the existing close constant instead"]);
2112        assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("bash "));
2113        assert_eq!(found[0].cwd, "/home/dev/work/service-a");
2114    }
2115
2116    #[test]
2117    fn opencode_1_correcting_prompts_are_episodes_and_a_dismissed_question_is_not() {
2118        let found = opencode_session(&export!("v1/correction-rejection-dismissed.json"));
2119        assert_eq!(
2120            corrections(&found),
2121            ["no, that is wrong, use the test script of this project instead", "no, you need to wait until the server is ready, read the project notes first"]
2122        );
2123        assert!(found.iter().all(|f| f.episode.task == "Run the test suite and then the app." && f.episode.rejected_tool.is_none()));
2124        assert_eq!(found[0].episode.agent, "I will look at the relevant file and then make the change.");
2125    }
2126
2127    #[test]
2128    fn opencode_1_a_rule_that_denies_a_call_is_not_the_engineer() {
2129        let by_rule = say_v1(export!("v1/permission-denied-by-rule.json"), 1776791500000, "use the plan file");
2130        assert!(opencode_session(&by_rule).is_empty());
2131        // The same call refused by the engineer: the next prompt is the reason.
2132        let mut refused = by_rule.clone();
2133        refused["messages"][0]["parts"][2]["state"]["error"] = json!(OC_REJECTED);
2134        assert_eq!(corrections(&opencode_session(&refused)), ["use the plan file"]);
2135    }
2136
2137    #[test]
2138    fn opencode_1_echoes_and_synthetic_parts_are_not_the_engineer() {
2139        // borg's prompt context (a synthetic part) is not in the task; borg's echoed question
2140        // (metadata.borg) does not replace it.
2141        let echo = say_v1(export!("v1/echo-marker-and-synthetic-part.json"), 1791978190000, "no, use a channel");
2142        let found = opencode_session(&echo);
2143        assert_eq!(corrections(&found), ["no, use a channel"]);
2144        assert_eq!(found[0].episode.task, "Fix the race in relay.rs");
2145        assert_eq!(found[0].episode.agent, "Plan: I check closed.get() on the AtomicBoolean before deliver() in relay.rs.");
2146        // An attached file and editor context are synthetic; the engineer's words are the task.
2147        let file = say_v1(export!("v1/synthetic-file-attachment.json"), 1776791100000, "no, the other plan");
2148        assert_eq!(opencode_session(&file)[0].episode.task, "Read @docs/plan.md and tell me what is missing.");
2149        let editor = say_v1(export!("v1/synthetic-editor-context.json"), 1779367490000, "no, the other workflow");
2150        assert_eq!(opencode_session(&editor)[0].episode.task, "Please make the requested change in this project.");
2151        // A compaction's "Continue" is no task: the next prompt becomes the task, not a correction.
2152        let compacted = say_v1(export!("v1/compaction-synthetic-continue.json"), 1776858910000, "no, the other module");
2153        assert!(opencode_session(&compacted).is_empty());
2154    }
2155
2156    #[test]
2157    fn opencode_sub_agents_and_borgs_model_calls_are_skipped() {
2158        for export in [
2159            say_v1(export!("v1/subagent-child.json"), 1776861800000, "no, the other file"),
2160            export!("v1/model-call-session.json"),
2161            export!("v1/run-deny-all-forced-tool-call-export.json"),
2162            say_v2(export!("v2/session-export-child.json"), 1791977829000, "no, the other file"),
2163        ] {
2164            assert!(!engineers_session(&export) && opencode_session(&export).is_empty(), "{}", export["info"]["title"]);
2165        }
2166        assert!(engineers_session(&export!("v2/session-export.json")) && !engineers_session(&json!({"messages": []})));
2167    }
2168
2169    #[test]
2170    fn opencode_2_session_replays_like_the_live_hook() {
2171        // The tail: a synthetic input (borg's context) and borg's echoed question, then the engineer.
2172        let export = say_v2(export!("v2/session-export.json"), 1791977840000, "no, check the queue bound first");
2173        let found = opencode_session(&export);
2174        assert_eq!(
2175            corrections(&found),
2176            [
2177                "no, that is wrong, use a Mutex instead of the AtomicBoolean", // run's argv quoting stripped
2178                "SHELL clean up the file",                                     // the reason after a declined call
2179                "no, do not delete files, use git rm instead",                 // feedback; the auto-reject before it is not
2180                "no, check the queue bound first",
2181            ]
2182        );
2183        let e: Vec<&Episode> = found.iter().map(|f| &f.episode).collect();
2184        assert!(found.iter().all(|f| f.cwd == "/home/dev/work" && f.episode.session == "ses_ec5cd46d0ffeGGDxs4GhqC9Ifu"));
2185        assert_eq!(
2186            (e[0].task.as_str(), e[0].agent.as_str(), e[0].rejected_tool.as_deref()),
2187            ("READS Please fix the race in relay.rs", "Done: the mock finished this turn.", None)
2188        );
2189        assert_eq!(e[1].rejected_tool.as_deref(), Some(r#"shell {"command":"rm -f relay.rs"}"#));
2190        assert_eq!(e[1].task, "SHELL remove the old file again");
2191        assert_eq!(e[2].rejected_tool.as_deref(), Some(r#"shell {"command":"rm -f relay.rs"}"#));
2192        assert_eq!(e[3].task, "SUBAGENT delegate a check of relay.rs", "the synthetic input and the echo are not the task");
2193        assert!(e.windows(2).all(|w| w[0].ms < w[1].ms));
2194    }
2195
2196    #[test]
2197    fn opencode_2_session_migrated_from_1_reads_its_location() {
2198        let found = opencode_session(&say_v2(export!("v2/session-export-migrated-from-v1.json"), 1791978280000, "no, keep the lock"));
2199        assert_eq!(corrections(&found), ["no, keep the lock"]);
2200        assert_eq!((found[0].cwd.as_str(), found[0].episode.task.as_str()), ("/home/dev/legacy-work", "no, that is wrong, use a Mutex instead"));
2201    }
2202
2203    #[test]
2204    fn opencode_discovery_asks_the_cli_read_only() {
2205        let db = db_args(Some(1_700_000_000_000));
2206        assert_eq!(&db[..4], ["db", "--pure", "--format", "json"]);
2207        assert_eq!(
2208            db[4],
2209            "SELECT id FROM session WHERE parent_id IS NULL AND title != 'borg (model call)' AND time_updated >= 1700000000000 ORDER BY time_created"
2210        );
2211        assert!(!db_args(None)[4].contains("time_updated"));
2212        assert_eq!(list_args(true), ["session", "list", "--standalone", "--format", "json", "-n", "100000"]);
2213        assert_eq!(list_args(false), ["session", "list", "--pure", "--format", "json", "-n", "100000"]);
2214        assert_eq!(opencode::export_args(true, "ses_a"), ["session", "export", "--standalone", "ses_a"]);
2215        assert_eq!(opencode::export_args(false, "ses_a"), ["export", "--pure", "ses_a"]);
2216        let listed = include_str!("../tests/fixtures/opencode/v2/session-list.json").as_bytes();
2217        assert_eq!(ids_of(listed, None).unwrap().len(), 3);
2218        assert_eq!(ids_of(listed, Some(1791977900000)).unwrap(), ["ses_ec5bbe808ffe4C17dEwmu9aHAe", "ses_ec5caa71cffeRc4s7USxbim6zn"]);
2219        assert_eq!(ids_of(br#"[{"id": "a", "title": "borg (model call)"}, {"id": "b"}]"#, None).unwrap(), ["b"]);
2220        assert_eq!(ids_of(b"Error: no such table", None), None);
2221    }
2222
2223    #[test]
2224    fn the_cwds_of_claude_and_omp_sessions_seed_opencode_2s_listing() {
2225        let root = temp("cwds");
2226        let (claude, omp) = fixture(&root);
2227        let cwds = |r: Replay| r.cwds.into_iter().collect::<Vec<_>>();
2228        assert_eq!(cwds(claude_replay(&claude.join("-w-corp-app/s-corp-1.jsonl"))), ["/w/corp/app"]);
2229        assert_eq!(cwds(omp_replay(&omp.join("-w-corp-svc/2026-09-04_x.jsonl"))), ["/w/corp/svc"]);
2230        let moved = claude.join("-w-secret-notes/moved.jsonl");
2231        write(&moved, &[user("/w/secret/notes", "2026-09-05T09:00:00.000Z", "a task"), user("/w/corp/app", "2026-09-05T09:05:00.000Z", "next")]);
2232        assert_eq!(cwds(claude_replay(&moved)), ["/w/corp/app", "/w/secret/notes"]);
2233        let _ = std::fs::remove_dir_all(root);
2234    }
2235
2236    #[test]
2237    fn a_collective_folder_of_repos_lists_each_repo() {
2238        let root = temp("collective-dirs");
2239        let work = root.join("work-repos");
2240        for repo in ["a", "team/b"] {
2241            std::fs::create_dir_all(work.join(repo).join(".git")).unwrap();
2242        }
2243        std::fs::create_dir_all(work.join("a/src")).unwrap();
2244        std::fs::create_dir_all(work.join("notes")).unwrap();
2245        let cfg: Config =
2246            serde_json::from_str(&format!(r#"{{"collectives": [{{"name": "w", "paths": ["{}", "{}"]}}]}}"#, work.display(), work.join("a").display())).unwrap();
2247        let known = collective_dirs(Some(&cfg));
2248        assert_eq!(known, [work.clone(), work.join("a"), work.join("team/b")].into_iter().collect(), "repos two levels deep; not inside a repo");
2249        // With no Claude Code or omp session at all, opencode 2 lists in each repo.
2250        let cli = FakeCli { calls: Default::default(), db: None, list: "[]" };
2251        cli.run(true, &known);
2252        let lists: Vec<PathBuf> = cli.calls.borrow().iter().map(|(_, d)| d.clone()).collect();
2253        assert_eq!(lists, [work.clone(), work.join("a"), work.join("team/b")]);
2254        assert!(collective_dirs(None).is_empty());
2255        let _ = std::fs::remove_dir_all(root);
2256    }
2257
2258    #[test]
2259    fn the_cli_wrapper_passes_every_argument_and_reads_a_long_answer_whole() {
2260        let fake = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/opencode/fake"));
2261        let path = std::env::join_paths(std::iter::once(fake).chain(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()))).unwrap();
2262        let dir = temp("cli-wrapper");
2263        let args = db_args(Some(1));
2264        let out = opencode::cli(true, Some(&path), &args, &dir).unwrap();
2265        assert!(out.len() > 64 * 1024, "the fake answers more than a pipe holds");
2266        let ids = ids_of(&out, None).unwrap();
2267        assert_eq!((ids.len(), ids.last().map(String::as_str)), (3001, Some("ses_last")));
2268        let seen = std::fs::read_to_string(dir.join("args.txt")).unwrap();
2269        let mut want = vec![dir.display().to_string()];
2270        want.extend(args);
2271        assert_eq!(seen.lines().collect::<Vec<_>>(), want, "PWD, then the arguments unsplit (the query holds spaces and quotes)");
2272        // opencode 2 runs with a model call's env: no plugins, no project config.
2273        let env = std::fs::read_to_string(dir.join("env.txt")).unwrap();
2274        assert!(env.contains("OPENCODE_CONFIG_DIR=") && env.contains("OPENCODE_DISABLE_PROJECT_CONFIG=1"), "{env}");
2275        let _ = std::fs::remove_dir_all(dir);
2276    }
2277
2278    #[test]
2279    fn opencode_projects_are_git_roots_of_existing_dirs() {
2280        let root = temp("projects");
2281        let repo = root.join("repo");
2282        std::fs::create_dir_all(repo.join(".git")).unwrap();
2283        std::fs::create_dir_all(repo.join("src/deep")).unwrap();
2284        std::fs::create_dir_all(root.join("loose")).unwrap();
2285        let dirs: BTreeSet<PathBuf> = [repo.join("src/deep"), repo.clone(), root.join("loose"), root.join("gone")].into_iter().collect();
2286        assert_eq!(projects(&dirs), [root.join("loose"), repo]);
2287        let _ = std::fs::remove_dir_all(root);
2288    }
2289
2290    /// A fake opencode CLI: the calls it got, and canned answers by first argument / session id.
2291    struct FakeCli {
2292        calls: std::cell::RefCell<Vec<(Vec<String>, PathBuf)>>,
2293        db: Option<&'static str>,
2294        list: &'static str,
2295    }
2296
2297    impl FakeCli {
2298        fn exec(&self, args: &[String], dir: &Path) -> Option<Vec<u8>> {
2299            self.calls.borrow_mut().push((args.to_vec(), dir.to_path_buf()));
2300            let id = args.last().unwrap().as_str();
2301            let export = |v: Value| Some(v.to_string().into_bytes());
2302            match args[0].as_str() {
2303                "db" => self.db.map(|d| d.as_bytes().to_vec()),
2304                "session" if args[1] == "list" => Some(self.list.as_bytes().to_vec()),
2305                _ if id == "ses_v1" => export(export!("v1/reject-with-feedback.json")),
2306                _ if id == "ses_v2" => export(export!("v2/session-export.json")),
2307                _ => Some(b"Session not found".to_vec()),
2308            }
2309        }
2310
2311        fn run(&self, v2: bool, known: &BTreeSet<PathBuf>) -> Vec<String> {
2312            let mut got = Vec::new();
2313            cli_sessions(v2, None, None, known, &|a, d| self.exec(a, d), &mut |v| got.push(v["info"]["id"].as_str().unwrap().to_string()));
2314            got
2315        }
2316    }
2317
2318    #[test]
2319    fn opencode_1_lists_every_session_with_one_query_and_exports_each() {
2320        let tmp = std::env::temp_dir();
2321        let cli = FakeCli { calls: Default::default(), db: Some(r#"[{"id": "ses_v1"}, {"id": "ses_gone"}]"#), list: "[]" };
2322        let known: BTreeSet<PathBuf> = [tmp.clone()].into_iter().collect();
2323        assert_eq!(cli.run(false, &known).len(), 1, "an export that is no JSON is skipped");
2324        let calls = cli.calls.borrow();
2325        let firsts: Vec<&str> = calls.iter().map(|(a, _)| a[0].as_str()).collect();
2326        assert_eq!(firsts, ["db", "export", "export"], "no listing when the query works");
2327        assert!(calls.iter().all(|(_, d)| *d == tmp));
2328    }
2329
2330    #[test]
2331    fn opencode_1_without_its_query_and_opencode_2_list_the_known_projects() {
2332        let root = temp("cli");
2333        let (a, b) = (root.join("a"), root.join("b"));
2334        std::fs::create_dir_all(&a).unwrap();
2335        std::fs::create_dir_all(&b).unwrap();
2336        let known: BTreeSet<PathBuf> = [a.clone(), b.clone(), root.join("gone")].into_iter().collect();
2337        let list = r#"[{"id": "ses_v2", "title": "research main"}, {"id": "ses_x", "title": "borg (model call)"}, {"id": "ses_v1", "title": "t"}]"#;
2338        for v2 in [false, true] {
2339            let cli = FakeCli { calls: Default::default(), db: (!v2).then_some("Error: no such column"), list };
2340            // Both projects list the same sessions: each is exported once, in the first dir it was seen in.
2341            assert_eq!(cli.run(v2, &known), ["ses_ec5cd46d0ffeGGDxs4GhqC9Ifu", "ses_ec5c7766cffe7lNfHnHDzyNoUc"]);
2342            let calls = cli.calls.borrow();
2343            let lists: Vec<&PathBuf> = calls.iter().filter(|(c, _)| c[1] == "list").map(|(_, d)| d).collect();
2344            assert_eq!(lists, [&a, &b], "existing dirs only");
2345            let exports: Vec<(&str, &PathBuf)> =
2346                calls.iter().filter(|(c, _)| c.contains(&"ses_v2".to_string()) || c.contains(&"ses_v1".to_string())).map(|(c, d)| (c[0].as_str(), d)).collect();
2347            assert_eq!(exports.len(), 2);
2348            assert!(exports.iter().all(|(_, d)| **d == a));
2349            if v2 {
2350                assert!(calls.iter().all(|(c, _)| c[0] == "session" && c.contains(&"--standalone".to_string())), "never the background service: {calls:?}");
2351            } else {
2352                assert_eq!(calls[0].0[0], "db");
2353                assert!(calls[1..].iter().all(|(c, _)| c.contains(&"--pure".to_string())));
2354            }
2355        }
2356        let _ = std::fs::remove_dir_all(root);
2357    }
2358
2359    #[test]
2360    fn render_names_every_source() {
2361        let r = Report { scanned: 61, claude: 40, omp: 12, opencode: Some(9), ..Default::default() };
2362        assert!(render(&r, false).contains("sources: claude 40 sessions, omp 12, opencode 9, codex 0\n"));
2363        let r = Report { opencode: None, ..r };
2364        assert!(render(&r, true).contains("sources: claude 40 sessions, omp 12, opencode not installed, codex 0\n"));
2365    }
2366
2367    // ---- every harness's own transcript: rejections, denials and answers (neutral rewrites of real formats)
2368
2369    fn fixtures(rel: &str) -> PathBuf {
2370        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(rel)
2371    }
2372
2373    const CLAUDE_SESSION: &str = "7f3c2a10-5b6d-4e8f-9a01-23456789abcd";
2374    const CODEX_THREAD: &str = "0199e0aa-1111-7222-8333-444455556666";
2375
2376    /// (id, via, rejected tool's start, correction) of each episode.
2377    fn shape(found: &[Found]) -> Vec<(String, String, String, String)> {
2378        found
2379            .iter()
2380            .map(|f| {
2381                let e = &f.episode;
2382                let tool = e.rejected_tool.as_deref().unwrap_or_default().split(' ').next().unwrap_or_default().to_string();
2383                (e.id.clone(), e.via.clone(), tool, e.correction.clone())
2384            })
2385            .collect()
2386    }
2387
2388    fn row(id: &str, via: &str, tool: &str, said: &str) -> (String, String, String, String) {
2389        (id.into(), via.into(), tool.into(), said.into())
2390    }
2391
2392    #[test]
2393    fn claude_2_1_312_transcript_yields_its_worded_rejection_and_answers() {
2394        let found = claude_session(&fixtures(&format!("claude/transcripts/-home-dev-work-relay/{CLAUDE_SESSION}.jsonl")));
2395        assert_eq!(
2396            shape(&found),
2397            [
2398                row("7f3c2a10-Ttoolu_01RelayEdit", "", "Edit", "use exponential backoff with jitter, a fixed sleep hammers the broker"),
2399                row(
2400                    "7f3c2a10-Ttoolu_01RelayAsk",
2401                    learn::ANSWER,
2402                    "",
2403                    "Budget: Per connection; notes: a slow consumer must not starve the others\nErrors: Timeouts, Connection resets"
2404                ),
2405                row("7f3c2a10-Ttoolu_01RelayPlace", learn::ANSWER, "", "Keep it in the relay crate, nothing else retries this way"),
2406            ]
2407        );
2408        assert!(found.iter().all(|f| f.episode.harness == "claude" && f.episode.harness_version == "2.1.312" && f.cwd == "/home/dev/work/relay"));
2409        let (rejected, asked) = (&found[0].episode, &found[1].episode);
2410        assert_eq!(
2411            (rejected.task.as_str(), rejected.agent.as_str()),
2412            ("Add a retry to the relay delivery", "I'll wrap delivery in a retry loop with a fixed one second sleep.")
2413        );
2414        assert_eq!(
2415            asked.agent,
2416            "Where should the retry budget live?\n  options: Per message (Recommended) | Per connection\nWhich errors are retryable?\n  options: Timeouts | Connection resets | Auth failures",
2417            "the proposal is the questions with their options"
2418        );
2419        assert_eq!(asked.ms, borg_trace::parse_ts("2026-10-28T09:02:10.000Z"));
2420        // Without the structured result (an older line), the answer's text is the answer.
2421        let bare = claude_asked(&Value::Null, &Value::Null, "The user answered: \"Q\"=\"keep it small\". You can now continue with these answers in mind.");
2422        assert_eq!(answer_text(&bare).as_deref(), Some("\"Q\"=\"keep it small\""));
2423        // Its other closing is the harness's instruction to the agent, not the engineer's words.
2424        let careful = claude_asked(
2425            &Value::Null,
2426            &Value::Null,
2427            "The user answered: \"Q\"=\"keep it small\". Read the answers carefully — they may request clarification, changes, or that you not proceed — and follow what they actually say.",
2428        );
2429        assert_eq!(answer_text(&careful).as_deref(), Some("\"Q\"=\"keep it small\""));
2430        // A multi-select's (or a typed) answer is a list in `toolUseResult.answers`; the fixture's
2431        // "Errors" question above is one.
2432        let q = json!({"questions": [{"question": "Q", "options": [{"label": "A"}, {"label": "B"}]}]});
2433        let listed = claude_asked(&q, &json!({"answers": {"Q": ["A", "my own words"]}}), "");
2434        assert_eq!(answer_text(&listed).as_deref(), Some("A; my own words"));
2435    }
2436
2437    #[test]
2438    fn a_transcripts_tail_alone_still_yields_the_same_ids() {
2439        let path = fixtures(&format!("claude/transcripts/-home-dev-work-relay/{CLAUDE_SESSION}.jsonl"));
2440        let all: Vec<Value> = lines(&path).collect();
2441        // From the rejection's result on: the calls before the tail are unknown.
2442        let tail = claude_lines(CLAUDE_SESSION, all[2..].iter().cloned());
2443        let whole = claude_session(&path);
2444        let ids = |f: &[Found]| f.iter().map(|f| f.episode.id.clone()).collect::<Vec<_>>();
2445        assert_eq!(ids(&tail), ids(&whole));
2446        assert_eq!(tail[0].episode.rejected_tool.as_deref(), Some(""), "the call's text is before the tail");
2447        let codex = fixtures(&format!("codex/sessions/2026/10/28/rollout-2026-10-28T11-00-00-{CODEX_THREAD}.jsonl"));
2448        let meta = codex::first_meta(&codex).unwrap();
2449        assert_eq!(codex_lines(&meta, codex::records(&codex)).len(), 1);
2450        let omp: Vec<Value> = lines(&fixtures("omp/sessions/-home-dev-work-relay/2026-10-28T10-00-00-000Z_omp-relay-1.jsonl")).collect();
2451        assert_eq!(omp_lines("omp-relay-1", omp[5..].iter().cloned()).len(), 3, "the three answers after the denial's reason");
2452    }
2453
2454    #[test]
2455    fn omp_session_yields_its_denial_reason_and_answers() {
2456        let found = omp_session(&fixtures("omp/sessions/-home-dev-work-relay/2026-10-28T10-00-00-000Z_omp-relay-1.jsonl"));
2457        assert_eq!(
2458            shape(&found),
2459            [
2460                row("omp-rela-Tcall_bump", "", "bash", "only the broker client, pin the rest"),
2461                row("omp-rela-Tcall_lock", learn::ANSWER, "", "Commit"),
2462                row("omp-rela-Tcall_registry", learn::ANSWER, "", "use the internal mirror, the public one is rate limited"),
2463                row("omp-rela-Tcall_scope", learn::ANSWER, "", "scope: All crates\ntests: run only the relay tests"),
2464            ]
2465        );
2466        assert!(found.iter().all(|f| f.episode.harness == "omp" && f.episode.harness_version == "18.13.3" && f.episode.session == "omp-relay-1"));
2467        assert_eq!(found[1].episode.agent, "Commit the lockfile change?\n  options: Commit | Leave it");
2468        assert_eq!(found[0].episode.task, "bump the broker client");
2469    }
2470
2471    #[test]
2472    fn opencode_2_export_yields_its_rejection_and_question_answer_and_a_dismissed_question_is_none() {
2473        let export: Value = serde_json::from_str(include_str!("../tests/fixtures/opencode/v2/session-export-question.json")).unwrap();
2474        let found = opencode_session(&export);
2475        assert_eq!(
2476            shape(&found),
2477            [
2478                row("ses_0c1a-Tcall_oc_shell", "", "shell", "no, keep the retries in the client, not in the shell script"),
2479                row("ses_0c1a-Tcall_oc_question", learn::ANSWER, "", "Backoff: Exponential\nCap: cap at 30 s, the health check gives up at 45"),
2480            ]
2481        );
2482        assert!(found.iter().all(|f| f.episode.harness == "opencode" && f.episode.harness_version.is_empty()), "2.x exports name no version");
2483        assert_eq!(found[1].episode.task, "add retries to the relay");
2484        // 1.x names its version; a dismissed question there is nobody's answer.
2485        let v1 = opencode_session(&export!("v1/correction-rejection-dismissed.json"));
2486        assert!(v1.iter().all(|f| f.episode.via.is_empty() && f.episode.harness_version == "1.17.8"));
2487        assert_eq!(v1.len(), 2, "the two prompts only");
2488    }
2489
2490    #[test]
2491    fn codex_rollout_yields_answers_only_and_never_a_spawned_agents() {
2492        let day = fixtures("codex/sessions/2026/10/28");
2493        let found = codex_session(&day.join(format!("rollout-2026-10-28T11-00-00-{CODEX_THREAD}.jsonl")));
2494        assert_eq!(shape(&found), [row("0199e0aa-Tcall_cx_settings", learn::ANSWER, "", "Config: Config file\nDefault: 3")]);
2495        let e = &found[0].episode;
2496        assert_eq!((e.harness.as_str(), e.harness_version.as_str(), e.session.as_str()), ("codex", "0.153.2", CODEX_THREAD));
2497        assert_eq!(e.task, "Make the relay retry configurable", "the injected AGENTS.md and context are not the task");
2498        assert!(e.agent.starts_with("Where should the retry settings come from?\n  options: Environment (Recommended) | Config file"));
2499        assert!(codex_session(&day.join("rollout-2026-10-28T11-05-00-0199e0aa-9999-7222-8333-444455556666.jsonl")).is_empty());
2500        // An output that answers none of the questions (a dismissed prompt) is nobody's answer, never
2501        // its raw JSON.
2502        let q = json!({"questions": [{"id": "cfg", "question": "Where from?", "options": [{"label": "Env"}]}]});
2503        assert_eq!(answer_text(&codex_asked(&q, &json!({"answers": {}}))), None);
2504        assert_eq!(answer_text(&codex_asked(&q, &json!({"answers": {"other": {"answers": ["Env"]}}}))), None);
2505        // The transcript locations honour each harness's own variable (here: their defaults under a home).
2506        let home = Path::new("/h");
2507        if std::env::var_os("CODEX_HOME").is_none() {
2508            assert_eq!(codex::sessions(home), home.join(".codex/sessions"));
2509        }
2510        assert_eq!(codex::rollout_of(&fixtures("codex/sessions"), CODEX_THREAD), Some(day.join(format!("rollout-2026-10-28T11-00-00-{CODEX_THREAD}.jsonl"))));
2511        assert_eq!(
2512            harness::omp::session_file(&fixtures("omp/sessions"), "omp-relay-1"),
2513            Some(fixtures("omp/sessions/-home-dev-work-relay/2026-10-28T10-00-00-000Z_omp-relay-1.jsonl"))
2514        );
2515        assert_eq!(
2516            harness::claude::session_file(&fixtures("claude/transcripts"), CLAUDE_SESSION),
2517            Some(fixtures(&format!("claude/transcripts/-home-dev-work-relay/{CLAUDE_SESSION}.jsonl")))
2518        );
2519        assert_eq!(harness::omp::session_file(&fixtures("omp/sessions"), "nope"), None);
2520    }
2521
2522    const DEV: &str = r#"{ "default": "off", "collectives": [ { "name": "dev", "paths": ["/home/dev/work"] } ] }"#;
2523
2524    /// Every harness's fixture: the opencode export copied into a directory of its own.
2525    fn every_harness(root: &Path, apply: bool) -> Options {
2526        let oc = root.join("opencode");
2527        std::fs::create_dir_all(&oc).unwrap();
2528        std::fs::copy(fixtures("opencode/v2/session-export-question.json"), oc.join("relay.json")).unwrap();
2529        Options {
2530            claude: Some(fixtures("claude/transcripts")),
2531            omp: Some(fixtures("omp/sessions")),
2532            opencode: Some(OpencodeFrom::Dir(oc)),
2533            codex: Some(fixtures("codex/sessions")),
2534            session: None,
2535            since: None,
2536            limit: None,
2537            apply,
2538            skip: Vec::new(),
2539        }
2540    }
2541
2542    #[test]
2543    fn backfill_reports_per_harness_and_rerunning_adds_nothing() {
2544        let root = temp("every-harness");
2545        let base = root.join("home");
2546        let cfg: Config = serde_json::from_str(DEV).unwrap();
2547        let dry = run(&every_harness(&root, false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
2548        assert!(!base.exists(), "dry run wrote something");
2549        assert_eq!((dry.claude, dry.omp, dry.opencode, dry.codex), (1, 1, Some(1), 1), "main sessions only");
2550        let k = |h: &str| dry.harnesses.get(h).map(|k| (k.rejections, k.answers, k.prompts, k.known, k.version.clone())).unwrap();
2551        assert_eq!(k("claude"), (1, 2, 0, 0, "2.1.312".into()));
2552        assert_eq!(k("omp"), (1, 3, 0, 0, "18.13.3".into()));
2553        assert_eq!(k("opencode"), (1, 1, 0, 0, String::new()));
2554        assert_eq!(k("codex"), (0, 1, 0, 0, "0.153.2".into()));
2555        let text = render(&dry, false);
2556        assert!(text.contains("claude 2.1.312: 1 rejections, 2 answers, 0 prompts new; 0 already present\n"), "{text}");
2557        assert!(text.contains("learn cost: ~10 model calls; unknown per call"), "{text}");
2558
2559        let applied = run(&every_harness(&root, true), Some(&cfg), &base, &ask);
2560        assert_eq!(applied.collectives["dev"].new_lessons, 10);
2561        let stored: Vec<Episode> = read_jsonl(&base.join("collectives/dev/episodes.jsonl"));
2562        assert_eq!(stored.iter().filter(|e| e.via == learn::ANSWER).count(), 7);
2563        let whys: Vec<String> = crate::stats::live(&base.join("collectives/dev")).into_iter().map(|e| e.why).collect();
2564        assert_eq!(whys.iter().filter(|w| *w == learn::ANSWER).count(), 7, "answers are counted as answers: {whys:?}");
2565
2566        // Re-running adds nothing and calls no model.
2567        let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
2568        let before = snapshot(&base);
2569        let again = run(&every_harness(&root, true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
2570        assert_eq!((again.collectives["dev"].episodes, again.collectives["dev"].known), (0, 10));
2571        assert_eq!(again.harnesses["omp"].known, 4);
2572        assert_eq!(snapshot(&base), before);
2573        let _ = std::fs::remove_dir_all(root);
2574    }
2575
2576    #[test]
2577    fn a_rejection_the_hook_recorded_is_the_transcripts_too() {
2578        let root = temp("hook-and-transcript");
2579        let store = root.join("home/collectives/dev");
2580        std::fs::create_dir_all(&store).unwrap();
2581        // The hook stamped its capture time, half a minute after the message, and wrapped the words.
2582        let hook = Episode {
2583            id: "7f3c2a10-1793178071000".into(),
2584            ms: borg_trace::parse_ts("2026-10-28T09:01:11.000Z"),
2585            session: CLAUDE_SESSION.into(),
2586            rejected_tool: Some("Edit {}".into()),
2587            correction: "use exponential backoff with jitter,\n a fixed sleep hammers the broker ".into(),
2588            ..Episode::default()
2589        };
2590        std::fs::write(store.join("episodes.jsonl"), serde_json::to_string(&hook).unwrap() + "\n").unwrap();
2591        let cfg: Config = serde_json::from_str(DEV).unwrap();
2592        let one = Options { session: Some(CLAUDE_SESSION.into()), ..every_harness(&root, false) };
2593        let r = run(&one, Some(&cfg), &root.join("home"), &|_| None);
2594        assert_eq!((r.collectives["dev"].episodes, r.collectives["dev"].known), (2, 1), "the two answers are new, the rejection is held");
2595        assert_eq!(r.harnesses.keys().collect::<Vec<_>>(), ["claude"], "--session reads that session alone");
2596        assert_eq!((r.claude, r.omp, r.codex), (1, 0, 0));
2597        let _ = std::fs::remove_dir_all(root);
2598    }
2599
2600    /// `--opencode DIR` with exports of both versions: a 2.x session in a corp repo, a 1.x one in a
2601    /// private repo, a 1.x one in an off repo, a sub-agent and one of borg's model calls.
2602    fn opencode_dir(root: &Path) -> PathBuf {
2603        let dir = root.join("opencode");
2604        std::fs::create_dir_all(&dir).unwrap();
2605        let put = |name: &str, mut v: Value, cwd: &str| {
2606            if v["info"].get("location").is_some() {
2607                v["info"]["location"]["directory"] = json!(cwd);
2608            } else {
2609                v["info"]["directory"] = json!(cwd);
2610            }
2611            std::fs::write(dir.join(name), v.to_string()).unwrap();
2612        };
2613        // The research run answered the permission 0.2 s after the prompt before it; an engineer takes
2614        // longer (two episodes of a session within SAME_MS are one).
2615        let mut corp = export!("v2/session-export.json");
2616        for m in corp["messages"].as_array_mut().unwrap() {
2617            for c in m["content"].as_array_mut().into_iter().flatten().filter(|c| c["id"] == "call_1791977813763_0") {
2618                c["time"]["completed"] = json!(1791977830000i64);
2619            }
2620        }
2621        put("corp.json", corp, "/w/corp/relay");
2622        put("mine.json", export!("v1/reject-with-feedback.json"), "/w/mine/notes");
2623        put("off.json", export!("v1/correction-rejection-dismissed.json"), "/w/secret/app");
2624        put("child.json", export!("v2/session-export-child.json"), "/w/corp/relay");
2625        put("call.json", export!("v1/model-call-session.json"), "/w/corp/relay");
2626        std::fs::write(dir.join("notes.txt"), "not an export").unwrap();
2627        dir
2628    }
2629
2630    #[test]
2631    fn apply_opencode_exports_routes_episodes_learns_and_is_idempotent() {
2632        let root = temp("oc-apply");
2633        let dir = opencode_dir(&root);
2634        let base = root.join("home");
2635        let cfg: Config = serde_json::from_str(CFG).unwrap();
2636        let opts = |apply| Options {
2637            claude: None,
2638            omp: None,
2639            opencode: Some(OpencodeFrom::Dir(dir.clone())),
2640            codex: None,
2641            session: None,
2642            since: None,
2643            limit: None,
2644            apply,
2645            skip: Vec::new(),
2646        };
2647
2648        let dry = run(&opts(false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
2649        assert_eq!((dry.scanned, dry.claude, dry.omp, dry.opencode), (3, 0, 0, Some(3)), "main sessions only");
2650        assert!(render(&dry, false).contains("sources: claude 0 sessions, omp 0, opencode 3, codex 0\n"));
2651        assert_eq!((dry.off_sessions, dry.off_episodes), (1, 2));
2652        assert!(!base.exists());
2653
2654        let r = run(&opts(true), Some(&cfg), &base, &ask);
2655        assert_eq!((r.collectives["corp"].new_lessons, r.collectives["mine"].new_lessons), (3, 1));
2656        let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
2657        assert_eq!(
2658            corp.iter().map(|e| e.correction.as_str()).collect::<Vec<_>>(),
2659            ["no, that is wrong, use a Mutex instead of the AtomicBoolean", "SHELL clean up the file", "no, do not delete files, use git rm instead"]
2660        );
2661        assert_eq!((corp[0].session.as_str(), corp[0].repo.as_str()), ("ses_ec5cd46d0ffeGGDxs4GhqC9Ifu", "relay"));
2662        let mine: Vec<Episode> = read_jsonl(&base.join("collectives/mine/episodes.jsonl"));
2663        assert_eq!(mine.len(), 1);
2664        assert_eq!(mine[0].correction, "no, that is wrong, use the AtomicBoolean compareAndSet instead");
2665        for f in files(&base) {
2666            let t = std::fs::read_to_string(&f).unwrap();
2667            assert!(!t.contains("test script") && !t.contains("CHILDTASK") && !t.contains("is_lesson"), "{}", f.display());
2668        }
2669
2670        let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
2671        let before = snapshot(&base);
2672        let again = run(&opts(true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
2673        assert_eq!((again.collectives["corp"].episodes, again.collectives["corp"].known), (0, 3));
2674        assert_eq!(snapshot(&base), before);
2675        let _ = std::fs::remove_dir_all(root);
2676    }
2677}