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/projects/<slug>/<session>.jsonl`), omp sessions
5//! (`~/.omp/agent/sessions/<slug>/<session>.jsonl`) and opencode 1.x and 2.x sessions (exported through
6//! the `opencode` CLI, see `opencode_session` and `cli_sessions`); main sessions only (sub-agents never
7//! talk to the engineer, and borg's own model calls are not the engineer either). Each is replayed the
8//! way the live hooks would have seen it:
9//! - a human prompt that `learn::is_correction` flags becomes an episode; any other prompt becomes the task;
10//! - an `is_error` tool result that *starts* as the harness's rejection notice and carries the engineer's
11//!   words (`learn::rejection`) becomes an episode; the same words inside ordinary tool output are echoed
12//!   history (a script printing an old transcript), not a correction;
13//! - a wordless denial (`learn::denied`) waits for the next prompt, which is the engineer's reason; a later
14//!   successful tool call ends the wait, as in the hook.
15//!
16//! Every episode is routed by its own cwd (`collective::route`): an off repo is only counted, never
17//! stored; each collective gets its own episodes. The task, the drone's text and a denied tool call
18//! last across prompts, and a session can move between repos, so each remembers the cwd it was seen
19//! in and is dropped from an episode routed to another collective: the live hook keeps that state per
20//! 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`
21//! (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
22//! `borg learn` on each collective. Re-running adds nothing: an episode already in the store for the same
23//! session within `SAME_MS` (the live hook stamps capture time, the transcript message time) is a duplicate.
24
25use std::borrow::Cow;
26use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
27use std::io::{BufRead, BufReader, Write};
28use std::path::{Path, PathBuf};
29
30use serde_json::Value;
31
32use crate::collective::{self, Config, Route};
33use crate::harness::opencode;
34use crate::learn::{self, Episode, Lesson};
35use crate::runner;
36
37/// Two episodes of one session this close together are the same correction. Method: a dedupe
38/// window; the live hook's capture time and the transcript's message time of one correction differ
39/// by a second or two, two corrections of a session by far more.
40const SAME_MS: i64 = 5_000;
41
42/// Its flags and `--from-dir`'s (completions.rs).
43pub const FLAGS: &[&str] = &[
44    "--apply",
45    "--since",
46    "--limit",
47    "--claude",
48    "--omp",
49    "--opencode",
50    "--from-dir",
51    "--plan",
52    "--for",
53    "--collective",
54    "--knowledge",
55    "--retros",
56    "--families",
57    "--decisions",
58];
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Source {
62    Claude,
63    Omp,
64    Opencode,
65}
66
67/// Where opencode sessions come from.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum OpencodeFrom {
70    /// The installed `opencode` CLI (the default when it is on PATH).
71    Cli,
72    /// A directory of session exports of either version (`--opencode DIR`: tests, offline copies).
73    Dir(PathBuf),
74}
75
76/// An episode found in a transcript, with the cwd it happened in (for routing) and the cwds its
77/// task, drone text and rejected tool call were seen in (they may come from an earlier repo).
78#[derive(Debug, Clone)]
79pub struct Found {
80    pub cwd: String,
81    pub task_cwd: String,
82    pub agent_cwd: String,
83    pub tool_cwd: String,
84    pub episode: Episode,
85}
86
87pub struct Options {
88    pub claude: Option<PathBuf>,
89    pub omp: Option<PathBuf>,
90    /// None: opencode is not installed.
91    pub opencode: Option<OpencodeFrom>,
92    /// Only episodes at or after this epoch ms.
93    pub since: Option<i64>,
94    /// At most this many new episodes (the newest not yet stored, across collectives), so repeated
95    /// runs work through the history in batches.
96    pub limit: Option<usize>,
97    pub apply: bool,
98    /// Episodes mentioning any of these (lowercase) are dropped (`skip_words`).
99    pub skip: Vec<String>,
100}
101
102/// What assimilation found (dry run) or did (`--apply`) for one collective.
103#[derive(Debug, Default, Clone, PartialEq, Eq)]
104pub struct Tally {
105    pub home: PathBuf,
106    pub may_sync: bool,
107    pub sessions: usize,
108    /// Episodes not yet in the store.
109    pub episodes: usize,
110    /// Episodes already in the store (from an earlier run or the live hook).
111    pub known: usize,
112    /// Model calls `borg learn` needs: the new episodes plus those already waiting in the store.
113    pub calls: usize,
114    pub new_lessons: usize,
115    pub merged: usize,
116    /// Episodes `borg learn` could not get a verdict on (model unavailable); a re-run retries them.
117    pub pending: usize,
118}
119
120#[derive(Debug, Default)]
121pub struct Report {
122    pub scanned: usize,
123    /// Main sessions read per source; `opencode` is None when it is not installed.
124    pub claude: usize,
125    pub omp: usize,
126    pub opencode: Option<usize>,
127    pub collectives: BTreeMap<String, Tally>,
128    pub off_sessions: usize,
129    pub off_episodes: usize,
130}
131
132const NOTICE: &str = "want to proceed with this tool use";
133/// The characters of a failed tool call's first line searched for `NOTICE`. Method, not a display:
134/// the harness writes its notice at the very start of that line, so past this many characters the same
135/// words are the tool's own output (a long first line of a command's output), not a rejection.
136const NOTICE_CHARS: usize = 200;
137
138fn human(t: &str) -> bool {
139    t.len() > 3 && !t.starts_with('<') && !t.contains("[Request interrupted")
140}
141
142fn take(s: &str, n: usize) -> String {
143    s.chars().take(n).collect()
144}
145
146fn tail(s: &str, n: usize) -> String {
147    let c: Vec<char> = s.chars().collect();
148    c[c.len().saturating_sub(n)..].iter().collect()
149}
150
151fn text_of(v: &Value) -> String {
152    match v {
153        Value::String(s) => s.clone(),
154        Value::Array(a) => a
155            .iter()
156            .filter(|b| b.get("type").is_none_or(|t| t == "text"))
157            .filter_map(|b| b.get("text").and_then(Value::as_str).or(b.as_str()))
158            .collect::<Vec<_>>()
159            .join("\n"),
160        _ => String::new(),
161    }
162}
163
164/// Replays one session's events with the live hooks' capture rules.
165#[derive(Default)]
166struct Replay {
167    session: String,
168    cwd: String,
169    /// Each piece of state with the cwd it was seen in.
170    task: (String, String),
171    said: (String, String),
172    /// The tool call a wordless denial refused, waiting for the engineer's next prompt.
173    denied: Option<(String, String)>,
174    out: Vec<Found>,
175    /// Every cwd the session worked in: where opencode 2's sessions are listed (`cli_sessions`).
176    cwds: BTreeSet<String>,
177}
178
179impl Replay {
180    fn at(&mut self, cwd: &str) {
181        if self.cwd != cwd {
182            self.cwd = cwd.to_string();
183            self.cwds.insert(self.cwd.clone());
184        }
185    }
186
187    /// `rejected_tool` is the refused call and the cwd it was refused in.
188    fn episode(&mut self, ms: i64, rejected_tool: Option<(String, String)>, correction: String) {
189        let (rejected_tool, tool_cwd) = match rejected_tool {
190            Some((tool, cwd)) => (Some(tool), cwd),
191            None => (None, self.cwd.clone()),
192        };
193        let episode = Episode {
194            id: format!("{}-{ms}", take(&self.session, 8)),
195            ms,
196            repo: String::new(),
197            session: self.session.clone(),
198            task: self.task.0.clone(),
199            agent: self.said.0.clone(),
200            rejected_tool,
201            correction,
202            ..Episode::default()
203        };
204        self.out.push(Found { cwd: self.cwd.clone(), task_cwd: self.task.1.clone(), agent_cwd: self.said.1.clone(), tool_cwd, episode });
205    }
206
207    fn assistant_text(&mut self, t: &str) {
208        if !t.trim().is_empty() {
209            self.said = (tail(t.trim(), learn::PROPOSAL_CHARS), self.cwd.clone());
210        }
211    }
212
213    fn prompt(&mut self, ms: i64, t: &str) {
214        let t = t.trim();
215        if !human(t) {
216            return;
217        }
218        // "borg no <reason>", as the prompt hook reads it (veto.rs): a veto episode that answers a pending
219        // wordless denial, never the session's task nor a correction.
220        if let Some(reason) = crate::veto::enabled().then(|| crate::veto::reason_in(t)).flatten() {
221            self.denied = None;
222            self.episode(ms, None, reason);
223            if let Some(f) = self.out.last_mut() {
224                f.episode.id = format!("no-{}", f.episode.id);
225                f.episode.kind = crate::veto::KIND.into();
226            }
227            return;
228        }
229        if let Some(tool) = self.denied.take() {
230            self.episode(ms, Some(tool), take(t, learn::EXCERPT_CHARS));
231            return;
232        }
233        if learn::is_correction(t) && !self.task.0.is_empty() {
234            self.episode(ms, None, take(t, learn::EXCERPT_CHARS));
235        } else {
236            self.task = (take(t, learn::TASK_CHARS), self.cwd.clone());
237        }
238    }
239
240    fn tool_result(&mut self, ms: i64, is_error: bool, text: &str, tool: String) {
241        if !is_error {
242            self.denied = None; // the drone moved on: the next prompt is no longer the reason
243            return;
244        }
245        // Only the harness's own notice, on the first line, is a rejection; the same words further down
246        // are tool output (e.g. "Exit code 1" and then an old transcript).
247        let first: String = take(text.trim_start().lines().next().unwrap_or_default(), NOTICE_CHARS);
248        if let Some(said) = learn::rejection(text).filter(|_| first.contains(NOTICE)) {
249            self.episode(ms, Some((tool, self.cwd.clone())), said);
250        } else if learn::denied(text) {
251            self.denied = Some((tool, self.cwd.clone()));
252        }
253    }
254}
255
256fn tool_text(name: &str, input: &Value) -> String {
257    format!("{name} {}", take(&input.to_string(), learn::EXCERPT_CHARS))
258}
259
260fn lines(path: &Path) -> impl Iterator<Item = Value> {
261    let file = std::fs::File::open(path).ok();
262    file.into_iter().flat_map(|f| BufReader::new(f).lines()).map_while(Result::ok).filter_map(|l| serde_json::from_str::<Value>(&l).ok())
263}
264
265fn ts(v: &Value) -> i64 {
266    v.get("timestamp").and_then(Value::as_str).map_or(0, borg_trace::parse_ts)
267}
268
269/// Episodes of one Claude Code transcript (none for a sub-agent).
270pub fn claude_session(path: &Path) -> Vec<Found> {
271    claude_replay(path).out
272}
273
274fn claude_replay(path: &Path) -> Replay {
275    let mut r = Replay { session: path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), ..Default::default() };
276    let mut tools: HashMap<String, String> = HashMap::new();
277    for v in lines(path) {
278        if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
279            return Replay::default();
280        }
281        if let Some(c) = v.get("cwd").and_then(Value::as_str) {
282            r.at(c);
283        }
284        if v.get("isMeta") == Some(&Value::Bool(true)) || v.get("isCompactSummary") == Some(&Value::Bool(true)) {
285            continue;
286        }
287        let Some(msg) = v.get("message") else { continue };
288        let content = msg.get("content").unwrap_or(&Value::Null);
289        match v.get("type").and_then(Value::as_str) {
290            Some("assistant") => {
291                for b in content.as_array().into_iter().flatten() {
292                    match b.get("type").and_then(Value::as_str) {
293                        Some("text") => r.assistant_text(b.get("text").and_then(Value::as_str).unwrap_or_default()),
294                        Some("tool_use") => {
295                            let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
296                            let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
297                            tools.insert(id.to_string(), tool_text(name, b.get("input").unwrap_or(&Value::Null)));
298                        }
299                        _ => {}
300                    }
301                }
302            }
303            Some("user") => {
304                let results: Vec<&Value> =
305                    content.as_array().into_iter().flatten().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")).collect();
306                if results.is_empty() {
307                    r.prompt(ts(&v), &text_of(content));
308                }
309                for b in results {
310                    let tool = b.get("tool_use_id").and_then(Value::as_str).and_then(|id| tools.get(id)).cloned().unwrap_or_default();
311                    let is_error = b.get("is_error").and_then(Value::as_bool) == Some(true);
312                    r.tool_result(ts(&v), is_error, &text_of(b.get("content").unwrap_or(&Value::Null)), tool);
313                }
314            }
315            _ => {}
316        }
317    }
318    r
319}
320
321/// Episodes of one omp session (none for a sub-agent session).
322pub fn omp_session(path: &Path) -> Vec<Found> {
323    omp_replay(path).out
324}
325
326fn omp_replay(path: &Path) -> Replay {
327    let mut r = Replay { session: path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), ..Default::default() };
328    let mut tools: HashMap<String, String> = HashMap::new();
329    for v in lines(path) {
330        match v.get("type").and_then(Value::as_str) {
331            Some("session") => {
332                if v.get("parentSession").is_some_and(|p| !p.is_null()) {
333                    return Replay::default();
334                }
335                if let Some(c) = v.get("cwd").and_then(Value::as_str) {
336                    r.at(c);
337                }
338                if let Some(id) = v.get("id").and_then(Value::as_str) {
339                    r.session = id.to_string();
340                }
341            }
342            Some("message") => {
343                let Some(msg) = v.get("message") else { continue };
344                let content = msg.get("content").unwrap_or(&Value::Null);
345                match msg.get("role").and_then(Value::as_str) {
346                    Some("user") => r.prompt(ts(&v), &text_of(content)),
347                    Some("assistant") => {
348                        let blocks = content.as_array().into_iter().flatten();
349                        for b in blocks {
350                            match b.get("type").and_then(Value::as_str) {
351                                Some("text") => r.assistant_text(b.get("text").and_then(Value::as_str).unwrap_or_default()),
352                                Some("toolCall") => {
353                                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
354                                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
355                                    tools.insert(id.to_string(), tool_text(name, b.get("arguments").unwrap_or(&Value::Null)));
356                                }
357                                _ => {}
358                            }
359                        }
360                    }
361                    Some("toolResult") => {
362                        let tool = msg.get("toolCallId").and_then(Value::as_str).and_then(|id| tools.get(id)).cloned().unwrap_or_default();
363                        let is_error = msg.get("isError").and_then(Value::as_bool) == Some(true);
364                        r.tool_result(ts(&v), is_error, &text_of(content), tool);
365                    }
366                    _ => {}
367                }
368            }
369            _ => {}
370        }
371    }
372    r
373}
374
375// ---- opencode: one parser for both export shapes, normalised into `Step`s at one point.
376//
377// 1.x `opencode export`: {info: {id, directory, parentID?, title}, messages: [{info: {role, time}, parts}]}.
378// 2.x `opencode session export`: {info: {id, location: {directory}, parentID?, title}, messages:
379// [{type: user|synthetic|assistant|idle, time, text | content}]}.
380
381/// opencode 1.x: a permission the engineer rejected without words (it halts the turn).
382const OC_REJECTED: &str = "The user rejected permission to use this specific tool call.";
383/// opencode 1.x: a rejection with the engineer's words after it.
384const OC_FEEDBACK: &str = "The user rejected permission to use this specific tool call with the following feedback:";
385/// opencode 2.x (`aborted`): a call the engineer declined without words.
386const OC_DECLINED: &str = "The user declined this tool call";
387/// opencode 2.x (`permission.rejected`): `opencode run` cannot ask, so it rejects every ask by itself.
388/// It shares its type with the engineer's feedback but is nobody's correction.
389const OC_AUTO_REJECTED: &str = "This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action.";
390/// Claude's wordless rejection, which `learn::denied` matches.
391const DENIED: &str = "The user doesn't want to proceed with this tool use.";
392
393/// An opencode tool error in Claude's wording, so the replay (`learn::rejection`, `learn::denied`)
394/// reads it as the hook reads Claude's; the adapter's `canonicalError` does the same live. `kind` is
395/// 2.x's `error.type` (1.x errors are bare strings). Every other error comes back as it was and is no
396/// correction: a rule that denied the call, a dismissed question, an abort, a failed tool.
397fn canonical(kind: Option<&str>, message: &str) -> String {
398    let m = message.trim();
399    let said = |words: &str| {
400        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}")
401    };
402    match kind {
403        None if m == OC_REJECTED => DENIED.into(),
404        None => m.strip_prefix(OC_FEEDBACK).map_or_else(|| m.to_string(), |w| said(w.trim())),
405        Some("aborted") if m == OC_DECLINED => DENIED.into(),
406        Some("permission.rejected") if m == OC_AUTO_REJECTED => m.into(),
407        Some("permission.rejected") if m.is_empty() => DENIED.into(),
408        Some("permission.rejected") => said(m),
409        _ => m.into(),
410    }
411}
412
413/// `opencode run "fix it"` stores one argument that holds whitespace wrapped in literal double
414/// quotes, its inner quotes backslash-escaped (1.x and 2.x); the engineer's prompt is inside. A text
415/// with an unescaped quote inside is not such an argument and stays as it is.
416fn unquote(t: &str) -> Cow<'_, str> {
417    let Some(inner) = t.trim().strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return t.into() };
418    if inner.match_indices('"').any(|(i, _)| !inner[..i].ends_with('\\')) {
419        return t.into();
420    }
421    inner.replace("\\\"", "\"").into()
422}
423
424/// What the replay needs of one opencode message, either version.
425#[derive(Debug, PartialEq)]
426enum Step {
427    Prompt(i64, String),
428    Said(String),
429    /// A finished tool call: its time, its error in Claude's wording (None: it completed), the call.
430    Tool(i64, Option<String>, String),
431}
432
433/// A finished tool call: 1.x names it `tool`, 2.x `name`; 1.x errors are strings, 2.x `{type, message}`.
434/// None while it is still pending or running. `ms` is when it ended (1.x `state.time.end`, 2.x the
435/// item's `time.completed`), else its message's time: a rejection happens when the engineer answers,
436/// often long after the message began, and two episodes of a session within `SAME_MS` are one.
437fn tool_step(ms: i64, name: &Value, state: &Value) -> Option<Step> {
438    let tool = tool_text(name.as_str().unwrap_or_default(), &state["input"]);
439    let (kind, message) = match &state["error"] {
440        Value::String(s) => (None, s.as_str()),
441        e => (e["type"].as_str(), e["message"].as_str().unwrap_or_default()),
442    };
443    match state["status"].as_str() {
444        Some("completed") => Some(Step::Tool(ms, None, tool)),
445        Some("error") => Some(Step::Tool(ms, Some(canonical(kind, message)), tool)),
446        _ => None,
447    }
448}
449
450/// The steps of one message: the one place the two export shapes differ.
451/// - 1.x: the engineer's words are the text parts not marked `synthetic` (borg's prompt context, a
452///   compaction's "Continue", an attached file's contents, editor context) or `ignored`; a message
453///   with a part carrying `metadata.borg` is borg's own question coming back. When the engineer
454///   rejects a call with words, 1.x rejects every other pending call of the message with the plain
455///   `OC_REJECTED`: that cascade is nobody's denial and is dropped (the adapter does the same live).
456/// - 2.x: a `user` message's `text`, unless it carries `metadata.borg`; `synthetic` (borg's context,
457///   hidden inputs) and `idle` messages are nobody speaking.
458fn opencode_steps(m: &Value) -> Vec<Step> {
459    let spoken = |p: &&Value| p["type"] == "text" && p["synthetic"] != true && p["ignored"] != true;
460    let text = |p: &Value| p["text"].as_str().unwrap_or_default().to_string();
461    if let Some(info) = m.get("info") {
462        let ms = info["time"]["created"].as_i64().unwrap_or(0);
463        let parts = m["parts"].as_array().map(Vec::as_slice).unwrap_or_default();
464        let error = |p: &Value| p["state"]["error"].as_str().unwrap_or_default().trim().to_string();
465        let worded = parts.iter().any(|p| error(p).starts_with(OC_FEEDBACK));
466        return match info["role"].as_str() {
467            Some("user") if !parts.iter().any(|p| p["metadata"]["borg"] == true) => {
468                vec![Step::Prompt(ms, parts.iter().filter(spoken).map(text).collect::<Vec<_>>().join("\n"))]
469            }
470            Some("assistant") => by_completion(
471                parts
472                    .iter()
473                    .filter_map(|p| match p["type"].as_str() {
474                        Some("tool") if worded && error(p) == OC_REJECTED => None,
475                        Some("tool") => tool_step(p["state"]["time"]["end"].as_i64().unwrap_or(ms), &p["tool"], &p["state"]),
476                        _ => spoken(&p).then(|| Step::Said(text(p))),
477                    })
478                    .collect(),
479            ),
480            _ => Vec::new(),
481        };
482    }
483    let ms = m["time"]["created"].as_i64().unwrap_or(0);
484    match m["type"].as_str() {
485        Some("user") if m["metadata"]["borg"] != true => vec![Step::Prompt(ms, text(m))],
486        Some("assistant") => by_completion(
487            m["content"]
488                .as_array()
489                .into_iter()
490                .flatten()
491                .filter_map(|c| match c["type"].as_str() {
492                    Some("text") => Some(Step::Said(text(c))),
493                    Some("tool") => tool_step(c["time"]["completed"].as_i64().unwrap_or(ms), &c["name"], &c["state"]),
494                    _ => None,
495                })
496                .collect(),
497        ),
498        _ => Vec::new(),
499    }
500}
501
502/// Each run of tool calls in the order they finished, text keeping its place. A message lists
503/// parallel calls in the order they were made, and one that waits for the engineer's permission
504/// often finishes last; the live hook hears them finish, so a sibling that completed first does
505/// not end the wait for a reason that a later wordless rejection starts.
506fn by_completion(mut steps: Vec<Step>) -> Vec<Step> {
507    for run in steps.split_mut(|s| matches!(s, Step::Said(_))) {
508        run.sort_by_key(|s| match s {
509            Step::Tool(ms, ..) => *ms,
510            _ => 0,
511        });
512    }
513    steps
514}
515
516/// A session the engineer talked in: not a sub-agent's (`parentID`; its first prompt is the parent
517/// drone's task) and not one of borg's own model calls (`runner::OPENCODE_TITLE`).
518fn engineers_session(export: &Value) -> bool {
519    let info = &export["info"];
520    info.is_object() && info.get("parentID").is_none_or(Value::is_null) && info["title"] != runner::OPENCODE_TITLE
521}
522
523/// Episodes of one opencode session export, 1.x or 2.x (none for a sub-agent or a model call of borg's).
524/// Like the live hook, a wordless 1.x rejection cannot be told from 1.x `opencode run`'s own
525/// auto-reject (the same `OC_REJECTED`): a session continued with `run -c` takes its next prompt
526/// for the reason of a refusal nobody made.
527pub fn opencode_session(export: &Value) -> Vec<Found> {
528    if !engineers_session(export) {
529        return Vec::new();
530    }
531    let info = &export["info"];
532    let mut r = Replay { session: info["id"].as_str().unwrap_or_default().to_string(), ..Default::default() };
533    r.at(info["directory"].as_str().or(info["location"]["directory"].as_str()).unwrap_or_default());
534    for step in export["messages"].as_array().into_iter().flatten().flat_map(opencode_steps) {
535        match step {
536            Step::Prompt(ms, t) => r.prompt(ms, &unquote(&t)),
537            Step::Said(t) => r.assistant_text(&t),
538            Step::Tool(ms, error, tool) => r.tool_result(ms, error.is_some(), error.as_deref().unwrap_or_default(), tool),
539        }
540    }
541    r.out
542}
543
544/// One opencode CLI call: (arguments, the directory it runs in) -> stdout. `opencode::cli` in production.
545pub type Exec<'a> = &'a dyn Fn(&[String], &Path) -> Option<Vec<u8>>;
546
547fn strings(xs: &[&str]) -> Vec<String> {
548    xs.iter().map(|x| x.to_string()).collect()
549}
550
551/// opencode 1.x's read-only query for every main session that is not one of borg's model calls.
552fn db_args(since: Option<i64>) -> Vec<String> {
553    let since = since.map(|ms| format!(" AND time_updated >= {ms}")).unwrap_or_default();
554    let title = runner::OPENCODE_TITLE.replace('\'', "''");
555    let sql = format!("SELECT id FROM session WHERE parent_id IS NULL AND title != '{title}'{since} ORDER BY time_created");
556    let mut a = strings(&["db", "--pure", "--format", "json"]);
557    a.push(sql);
558    a
559}
560
561/// The main sessions of the project the call runs in. 2.x always `--standalone`: a private server,
562/// so the engineer's background service is never started or asked.
563fn list_args(v2: bool) -> Vec<String> {
564    strings(&["session", "list", if v2 { "--standalone" } else { "--pure" }, "--format", "json", "-n", "100000"])
565}
566
567/// The session ids in a JSON array of rows (`opencode db`, `opencode session list`), without borg's
568/// model calls and sessions last updated before `since`. None: no such array (the call failed).
569fn ids_of(out: &[u8], since: Option<i64>) -> Option<Vec<String>> {
570    let rows: Vec<Value> = serde_json::from_slice(out).ok()?;
571    let recent = |r: &Value| since.is_none_or(|s| r["updated"].as_i64().is_none_or(|u| u >= s));
572    Some(rows.iter().filter(|r| r["title"] != runner::OPENCODE_TITLE && recent(r)).filter_map(|r| Some(r["id"].as_str()?.to_string())).collect())
573}
574
575/// Where the collectives config says the engineer works: each collective path and the git repos up
576/// to two levels inside it. A path is a prefix, often a folder of repos (`~/work-repos`), and
577/// opencode 2 lists sessions per project (a folder outside git is a project of its own), so the
578/// folder alone would list none of its repos' sessions.
579fn collective_dirs(cfg: Option<&Config>) -> BTreeSet<PathBuf> {
580    cfg.into_iter().flat_map(|c| &c.collectives).flat_map(|c| &c.paths).flat_map(|p| with_repos(collective::expand(p))).collect()
581}
582
583fn with_repos(dir: PathBuf) -> Vec<PathBuf> {
584    let repo = |d: &Path| d.join(".git").exists();
585    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() };
586    let mut out = Vec::new();
587    if !dir.ancestors().any(repo) {
588        for child in subdirs(&dir) {
589            match repo(&child) {
590                true => out.push(child),
591                false => out.extend(subdirs(&child).into_iter().filter(|g| repo(g))),
592            }
593        }
594    }
595    out.push(dir);
596    out
597}
598
599/// One directory per opencode project among `dirs` (a project is a git repo; a dir outside any repo
600/// stands for itself), existing ones only.
601fn projects(dirs: &BTreeSet<PathBuf>) -> Vec<PathBuf> {
602    let root = |d: &Path| d.ancestors().find(|a| a.join(".git").exists()).unwrap_or(d).to_path_buf();
603    dirs.iter().filter(|d| d.is_dir()).map(|d| root(d)).collect::<BTreeSet<_>>().into_iter().collect()
604}
605
606/// The exports of the engineer's opencode sessions, through opencode's own CLI: read-only, never its
607/// database file, never its HTTP API. 1.x lists every main session with one `opencode db` query.
608/// 2.x has no such query and lists per project, so it sees the projects in `known` only (the cwds of
609/// this run's Claude Code and omp sessions and `collective_dirs`); so does 1.x when its query fails
610/// (an older 1.x without `db`, a changed schema). Each session is exported in the directory it was
611/// listed in and handed to `each`; a call that fails or answers no JSON is skipped.
612///
613/// 2.x does not list the sessions it migrated from 1.x: it files them under the project `global`,
614/// and a listing in their own directory (now a project of its own) does not show them; no
615/// directory is known whose listing does. 2.x keeps the 1.x tables, so a 1.x `opencode` still
616/// lists and exports them; their exports go in through `--opencode DIR`.
617fn cli_sessions(v2: bool, since: Option<i64>, known: &BTreeSet<PathBuf>, exec: Exec, each: &mut dyn FnMut(&Value)) {
618    let tmp = std::env::temp_dir();
619    if v2 {
620        eprintln!(
621            "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)"
622        );
623    }
624    let queried = if v2 { None } else { exec(&db_args(since), &tmp).and_then(|o| ids_of(&o, since)) };
625    let listed: Vec<(String, PathBuf)> = match queried {
626        Some(ids) => ids.into_iter().map(|id| (id, tmp.clone())).collect(),
627        None => {
628            if !v2 {
629                eprintln!("borg assimilate: `opencode db` failed; reading the opencode sessions of known projects only");
630            }
631            let mut seen = HashSet::new();
632            let mut out = Vec::new();
633            for dir in projects(known) {
634                for id in exec(&list_args(v2), &dir).and_then(|o| ids_of(&o, since)).unwrap_or_default() {
635                    if seen.insert(id.clone()) {
636                        out.push((id, dir.clone()));
637                    }
638                }
639            }
640            out
641        }
642    };
643    if !listed.is_empty() {
644        eprintln!("borg assimilate: exporting {} opencode sessions", listed.len());
645    }
646    for (id, dir) in listed {
647        if let Some(v) = exec(&opencode::export_args(v2, &id), &dir).and_then(|o| serde_json::from_slice::<Value>(&o).ok()) {
648            each(&v);
649        }
650    }
651}
652
653/// The `*.json` exports in `dir`, in name order; a file that is no JSON is skipped.
654fn dir_sessions(dir: &Path, each: &mut dyn FnMut(&Value)) {
655    let mut files: Vec<PathBuf> =
656        std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect();
657    files.sort();
658    for f in files {
659        if let Some(v) = std::fs::read(&f).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) {
660            each(&v);
661        }
662    }
663}
664
665/// `<root>/<slug>/<session>.jsonl`, one level deep: deeper files are sub-agents.
666fn session_files(root: &Path) -> Vec<PathBuf> {
667    let mut out = Vec::new();
668    for slug in std::fs::read_dir(root).into_iter().flatten().flatten() {
669        if !slug.file_type().is_ok_and(|t| t.is_dir()) {
670            continue;
671        }
672        for f in std::fs::read_dir(slug.path()).into_iter().flatten().flatten() {
673            let p = f.path();
674            if f.file_type().is_ok_and(|t| t.is_file()) && p.extension().is_some_and(|x| x == "jsonl") {
675                out.push(p);
676            }
677        }
678    }
679    out.sort();
680    out
681}
682
683/// Words whose mention keeps a session's text or a document out of assimilation, from
684/// `BORG_ASSIMILATE_SKIP` (comma-separated, case-insensitive; default none): knowledge the team abandoned.
685pub fn skip_words() -> Vec<String> {
686    std::env::var("BORG_ASSIMILATE_SKIP").unwrap_or_default().split(',').map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty()).collect()
687}
688
689/// Whether `text` mentions any of `words` (lowercase).
690pub fn mentions(text: &str, words: &[String]) -> bool {
691    !words.is_empty() && {
692        let t = text.to_lowercase();
693        words.iter().any(|w| t.contains(w.as_str()))
694    }
695}
696
697fn obsolete(f: &Found, words: &[String]) -> bool {
698    let e = &f.episode;
699    [&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()]
700        .iter()
701        .any(|s| mentions(s, words))
702}
703
704fn read_jsonl<T: for<'de> serde::Deserialize<'de>>(path: &Path) -> Vec<T> {
705    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
706}
707
708/// Episodes in a store that `borg learn` has not folded into a lesson or skipped yet.
709fn waiting(home: &Path) -> usize {
710    let done: HashSet<String> = learn::lessons(&home.join("lessons.jsonl")).into_iter().flat_map(|l: Lesson| l.episodes).collect();
711    let skipped: HashSet<String> = std::fs::read_to_string(home.join("episodes-not-lessons.txt")).unwrap_or_default().lines().map(str::to_string).collect();
712    read_jsonl::<Episode>(&home.join("episodes.jsonl")).iter().filter(|e| !done.contains(&e.id) && !skipped.contains(&e.id)).count()
713}
714
715fn repo_of(cwd: &str) -> String {
716    borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
717}
718
719/// Mine the sources, route every episode and, with `apply`, record and learn them per collective.
720/// `cfg` and `base` are the collectives config and the root store (`BORG_HOME`); `ask` is the model.
721pub fn run(opts: &Options, cfg: Option<&Config>, base: &Path, ask: &dyn Fn(&str) -> Option<Value>) -> Report {
722    let mut report = Report::default();
723    let mut found: Vec<Found> = Vec::new();
724    // Where the engineer works: opencode 2 can only list sessions per project.
725    let mut known = collective_dirs(cfg);
726    for (root, source) in [(&opts.claude, Source::Claude), (&opts.omp, Source::Omp)] {
727        let Some(root) = root else { continue };
728        for f in session_files(root) {
729            let r = match source {
730                Source::Claude => claude_replay(&f),
731                _ => omp_replay(&f),
732            };
733            *(if source == Source::Claude { &mut report.claude } else { &mut report.omp }) += 1;
734            known.extend(r.cwds.iter().map(PathBuf::from));
735            found.extend(r.out);
736        }
737    }
738    if let Some(from) = &opts.opencode {
739        let mut n = 0;
740        let mut each = |v: &Value| {
741            if engineers_session(v) {
742                n += 1;
743                found.extend(opencode_session(v));
744            }
745        };
746        match from {
747            OpencodeFrom::Dir(d) => dir_sessions(d, &mut each),
748            OpencodeFrom::Cli => {
749                let v2 = runner::opencode() == runner::Opencode::V2;
750                cli_sessions(v2, opts.since, &known, &|a, d| opencode::cli(v2, None, a, d), &mut each)
751            }
752        }
753        report.opencode = Some(n);
754    }
755    report.scanned = report.claude + report.omp + report.opencode.unwrap_or(0);
756    found.retain(|f| !obsolete(f, &opts.skip) && opts.since.is_none_or(|s| f.episode.ms >= s));
757    found.sort_by_key(|f| f.episode.ms);
758
759    // Route each episode by its own cwd; off episodes are counted and dropped here.
760    let collective_of = |cwd: &str| match collective::route(cfg, cwd, base) {
761        Route::Off => None,
762        Route::Collective { name, .. } => Some(name),
763    };
764    let mut by: BTreeMap<String, (PathBuf, bool, Vec<Found>)> = BTreeMap::new();
765    let mut off_sessions = HashSet::new();
766    for mut f in found {
767        match collective::route(cfg, &f.cwd, base) {
768            Route::Off => {
769                report.off_episodes += 1;
770                off_sessions.insert(f.episode.session.clone());
771            }
772            r @ Route::Collective { .. } => {
773                let may = collective::may_sync(&r);
774                let Route::Collective { name, home, .. } = r else { unreachable!() };
775                // State carried over from another collective's (or an off) repo stays there.
776                let here = Some(name.as_str());
777                if collective_of(&f.task_cwd).as_deref() != here {
778                    f.episode.task.clear();
779                }
780                if collective_of(&f.agent_cwd).as_deref() != here {
781                    f.episode.agent.clear();
782                }
783                if collective_of(&f.tool_cwd).as_deref() != here {
784                    f.episode.rejected_tool = None;
785                }
786                by.entry(name).or_insert_with(|| (home, may, Vec::new())).2.push(f);
787            }
788        }
789    }
790    report.off_sessions = off_sessions.len();
791
792    // Episodes not in their collective's store yet; the rest are counted as known.
793    let mut tallies: BTreeMap<String, (Tally, Vec<Found>)> = BTreeMap::new();
794    for (name, (home, may_sync, eps)) in by {
795        let mut known: HashMap<String, Vec<i64>> = HashMap::new();
796        for e in read_jsonl::<Episode>(&home.join("episodes.jsonl")) {
797            known.entry(e.session).or_default().push(e.ms);
798        }
799        let mut t = Tally { home, may_sync, ..Default::default() };
800        let mut fresh = Vec::new();
801        for f in eps {
802            let seen = known.entry(f.episode.session.clone()).or_default();
803            if seen.iter().any(|ms| (ms - f.episode.ms).abs() < SAME_MS) {
804                t.known += 1;
805                continue;
806            }
807            seen.push(f.episode.ms);
808            fresh.push(f);
809        }
810        tallies.insert(name, (t, fresh));
811    }
812
813    // --limit keeps the newest new episodes across collectives, so the next run takes the next batch.
814    if let Some(n) = opts.limit {
815        let mut newest: Vec<(i64, String, usize)> =
816            tallies.iter().flat_map(|(name, (_, fresh))| fresh.iter().enumerate().map(move |(i, f)| (f.episode.ms, name.clone(), i))).collect();
817        newest.sort_by_key(|(ms, ..)| std::cmp::Reverse(*ms));
818        let keep: HashSet<(String, usize)> = newest.into_iter().take(n).map(|(_, name, i)| (name, i)).collect();
819        for (name, (_, fresh)) in tallies.iter_mut() {
820            let mut i = 0;
821            fresh.retain(|_| {
822                i += 1;
823                keep.contains(&(name.clone(), i - 1))
824            });
825        }
826    }
827
828    let mut engineers: HashMap<String, String> = HashMap::new();
829    for (name, (mut t, mut fresh)) in tallies {
830        let ep_path = t.home.join("episodes.jsonl");
831        t.sessions = fresh.iter().map(|f| &f.episode.session).collect::<HashSet<_>>().len();
832        t.episodes = fresh.len();
833        t.calls = fresh.len() + waiting(&t.home);
834        if opts.apply {
835            if !fresh.is_empty() && append(&ep_path, &mut fresh, &mut engineers).is_err() {
836                eprintln!("borg assimilate: cannot write {}", ep_path.display());
837            }
838            eprintln!("learning {name}: one model call per episode, in order (each sees the lessons before it)");
839            (t.new_lessons, t.merged, t.pending) = learn::learn_in_reporting(&t.home, None, ask, learn::learning(), learn::now_ms(), &mut learn::print_step);
840            crate::vectors::refresh(&t.home.join("lessons.jsonl"));
841        }
842        report.collectives.insert(name, t);
843    }
844    report
845}
846
847/// Complete the episodes (repo, engineer) and append them to the store.
848fn append(path: &Path, eps: &mut [Found], engineers: &mut HashMap<String, String>) -> std::io::Result<()> {
849    if let Some(d) = path.parent() {
850        std::fs::create_dir_all(d)?;
851    }
852    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
853    for x in eps {
854        x.episode.repo = repo_of(&x.cwd);
855        x.episode.engineer = engineers.entry(x.cwd.clone()).or_insert_with(|| learn::engineer(&x.cwd)).clone();
856        writeln!(f, "{}", serde_json::to_string(&x.episode)?)?;
857        // An imported correction counts at the time it was made, an imported veto as a `veto` (veto.rs
858        // `record`; the lessons shown then are not known, so nothing is credited).
859        if let Some(store) = path.parent() {
860            learn::count_correction(store, &x.episode);
861            if x.episode.kind == crate::veto::KIND {
862                crate::stats::count(store, crate::stats::VETO, &x.episode.session, |ev| {
863                    ev.ms = x.episode.ms;
864                    ev.engineer.clone_from(&x.episode.engineer);
865                    ev.repo.clone_from(&x.episode.repo);
866                    ev.why = "prompt".into();
867                    ev.text = crate::stats::excerpt(&x.episode.correction, crate::stats::FEED_CHARS);
868                });
869            }
870        }
871    }
872    Ok(())
873}
874
875pub fn render(r: &Report, apply: bool) -> String {
876    let mut s = format!("{} sessions scanned{}\n", r.scanned, if apply { "" } else { " (dry run: nothing written; --apply to assimilate)" });
877    // Per source, so a source that found nothing (or is not installed) shows instead of going silent.
878    let opencode = r.opencode.map_or("opencode not installed".to_string(), |n| format!("opencode {n}"));
879    s.push_str(&format!("sources: claude {} sessions, omp {}, {opencode}\n", r.claude, r.omp));
880    for (name, t) in &r.collectives {
881        let sync = if t.may_sync { "team" } else { "local, never syncs" };
882        s.push_str(&format!("{name} ({sync}) {}: {} new episodes from {} sessions, {} already known", t.home.display(), t.episodes, t.sessions, t.known));
883        if apply {
884            s.push_str(&format!("; {} new lessons, {} merged", t.new_lessons, t.merged));
885            if t.pending > 0 {
886                s.push_str(&format!("; {} still pending (model unavailable), re-run to retry", t.pending));
887            }
888            s.push('\n');
889        } else {
890            s.push_str(&format!("; ~{} model calls to learn\n", t.calls));
891        }
892    }
893    s.push_str(&format!("off: {} episodes in {} sessions skipped (not stored)\n", r.off_episodes, r.off_sessions));
894    if !apply {
895        // Last line, where it is read: the first line's note is easy to miss.
896        s.push_str("Dry run: nothing was written. Run `borg assimilate --apply` to learn these episodes.\n");
897    }
898    s
899}
900
901/// `YYYY-MM-DD` (or a full RFC 3339 UTC time) to epoch ms.
902fn parse_date(d: &str) -> Option<i64> {
903    let full = if d.len() == 10 { format!("{d}T00:00:00Z") } else { d.to_string() };
904    let ms = borg_trace::parse_ts(&full);
905    (ms > 0).then_some(ms)
906}
907
908const USAGE: &str = "usage: borg assimilate [--apply] [--claude DIR] [--omp DIR] [--opencode DIR] [--since YYYY-MM-DD] [--limit N]";
909
910/// `borg assimilate [--apply] [--claude DIR] [--omp DIR] [--opencode DIR] [--since DATE] [--limit N]`;
911/// the exit code. opencode sessions come from the `opencode` CLI when it is installed, or from
912/// `--opencode DIR`, a directory of session exports.
913pub fn main(args: &[&str], ask: &dyn Fn(&str) -> Option<Value>) -> i32 {
914    let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
915    let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
916    let dir = |f: &str, default: &str| Some(flag(f).map(PathBuf::from).unwrap_or_else(|| home.join(default)));
917    let since = match flag("--since").map(parse_date) {
918        Some(None) => {
919            eprintln!("borg assimilate: --since wants YYYY-MM-DD\n{USAGE}");
920            return 2;
921        }
922        s => s.flatten(),
923    };
924    let limit = match flag("--limit").map(str::parse::<usize>) {
925        Some(Err(_)) => {
926            eprintln!("borg assimilate: --limit wants a number\n{USAGE}");
927            return 2;
928        }
929        l => l.and_then(Result::ok),
930    };
931    let opts = Options {
932        claude: dir("--claude", ".claude/projects"),
933        omp: dir("--omp", ".omp/agent/sessions"),
934        opencode: flag("--opencode").map(|d| OpencodeFrom::Dir(d.into())).or_else(|| runner::on_path("opencode").then_some(OpencodeFrom::Cli)),
935        since,
936        limit,
937        apply: args.contains(&"--apply"),
938        skip: skip_words(),
939    };
940    // A broken config routes everything off, exactly as in the hook.
941    let cfg = match collective::load() {
942        Ok(c) => c,
943        Err(e) => {
944            eprintln!("borg assimilate: invalid collectives config ({e}): every repo is off, nothing to assimilate");
945            return 1;
946        }
947    };
948    let report = run(&opts, cfg.as_ref(), &learn::home(), ask);
949    print!("{}", render(&report, opts.apply));
950    0
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use serde_json::json;
957
958    fn temp(name: &str) -> PathBuf {
959        let d = std::env::temp_dir().join(format!("borg-assimilate-{name}-{}", std::process::id()));
960        let _ = std::fs::remove_dir_all(&d);
961        std::fs::create_dir_all(&d).unwrap();
962        d
963    }
964
965    fn write(path: &Path, rows: &[Value]) {
966        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
967        std::fs::write(path, rows.iter().map(|r| r.to_string() + "\n").collect::<String>()).unwrap();
968    }
969
970    fn user(cwd: &str, t: &str, text: &str) -> Value {
971        json!({"type": "user", "cwd": cwd, "timestamp": t, "message": {"role": "user", "content": text}})
972    }
973
974    fn says(cwd: &str, t: &str, text: &str) -> Value {
975        json!({"type": "assistant", "cwd": cwd, "timestamp": t, "message": {"content": [{"type": "text", "text": text}]}})
976    }
977
978    fn tool_use(cwd: &str, t: &str, id: &str) -> Value {
979        json!({"type": "assistant", "cwd": cwd, "timestamp": t,
980            "message": {"content": [{"type": "tool_use", "id": id, "name": "Bash", "input": {"command": "rm -rf build"}}]}})
981    }
982
983    fn result(cwd: &str, t: &str, id: &str, is_error: bool, text: &str) -> Value {
984        json!({"type": "user", "cwd": cwd, "timestamp": t,
985            "message": {"content": [{"type": "tool_result", "tool_use_id": id, "is_error": is_error, "content": text}]}})
986    }
987
988    /// The model: every episode is a new lesson.
989    fn ask(p: &str) -> Option<Value> {
990        let c = p.split("## Engineer's correction\n").nth(1)?.lines().next()?.to_string();
991        Some(json!({"is_lesson": true, "question": format!("Q {c}"), "situations": [], "consequence": "c", "same_as": ""}))
992    }
993
994    const CFG: &str = r#"{ "default": "off", "off": ["/w/secret"],
995        "collectives": [ { "name": "corp", "paths": ["/w/corp"] }, { "name": "mine", "paths": ["/w/mine"], "share": "local" } ] }"#;
996
997    /// A history with: a correction in a corp repo, a rejection with words in a private repo, a
998    /// correction in an off repo, and tool output that merely echoes an old rejection.
999    fn fixture(root: &Path) -> (PathBuf, PathBuf) {
1000        let (claude, omp) = (root.join("claude"), root.join("omp"));
1001        let c = "/w/corp/app";
1002        write(
1003            &claude.join("-w-corp-app/s-corp-1.jsonl"),
1004            &[
1005                user(c, "2026-09-01T10:00:00.000Z", "add a retry to the relay"),
1006                says(c, "2026-09-01T10:00:05.000Z", "I'll wrap delivery in a ReentrantLock."),
1007                user(c, "2026-09-01T10:01:00.000Z", "no, check the TOCTOU on the closed flag first"),
1008                tool_use(c, "2026-09-01T10:02:00.000Z", "t1"),
1009                // A script printing an old transcript: the rejection words are output, not a rejection.
1010                result(
1011                    c,
1012                    "2026-09-01T10:02:01.000Z",
1013                    "t1",
1014                    true,
1015                    "Exit code 1\nold log: The user doesn't want to proceed with this tool use. the user said: never touch prod",
1016                ),
1017                tool_use(c, "2026-09-01T10:03:00.000Z", "t2"),
1018                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"),
1019            ],
1020        );
1021        let m = "/w/mine/notes";
1022        write(
1023            &claude.join("-w-mine-notes/s-mine-1.jsonl"),
1024            &[
1025                user(m, "2026-09-02T09:00:00.000Z", "clean the build"),
1026                says(m, "2026-09-02T09:00:01.000Z", "Removing build/."),
1027                tool_use(m, "2026-09-02T09:00:02.000Z", "t1"),
1028                result(
1029                    m,
1030                    "2026-09-02T09:00:03.000Z",
1031                    "t1",
1032                    true,
1033                    "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",
1034                ),
1035            ],
1036        );
1037        let s = "/w/secret/diary";
1038        write(
1039            &claude.join("-w-secret-diary/s-off-1.jsonl"),
1040            &[
1041                user(s, "2026-09-03T09:00:00.000Z", "summarise the week"),
1042                says(s, "2026-09-03T09:00:01.000Z", "Here is a summary."),
1043                user(s, "2026-09-03T09:01:00.000Z", "no, don't mention the doctor"),
1044            ],
1045        );
1046        // Sub-agents are never the engineer talking.
1047        write(
1048            &claude.join("-w-corp-app/s-corp-1/subagents/a.jsonl"),
1049            &[user(c, "2026-09-01T10:00:00.000Z", "task"), user(c, "2026-09-01T10:00:09.000Z", "no, wrong file")],
1050        );
1051        // omp: a wordless denial, then the engineer's reason as the next prompt.
1052        write(
1053            &omp.join("-w-corp-svc/2026-09-04_x.jsonl"),
1054            &[
1055                json!({"type": "session", "id": "omp-sess-1", "cwd": "/w/corp/svc", "timestamp": "2026-09-04T08:00:00.000Z"}),
1056                json!({"type": "message", "timestamp": "2026-09-04T08:00:01.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "bump the dependency"}]}}),
1057                json!({"type": "message", "timestamp": "2026-09-04T08:00:02.000Z", "message": {"role": "assistant", "content": [
1058                {"type": "text", "text": "Running cargo update."}, {"type": "toolCall", "id": "c1", "name": "bash", "arguments": {"command": "cargo update"}}]}}),
1059                json!({"type": "message", "timestamp": "2026-09-04T08:00:03.000Z", "message": {"role": "toolResult", "toolCallId": "c1", "isError": true,
1060                "content": [{"type": "text", "text": "Tool call denied by user: bash"}]}}),
1061                json!({"type": "message", "timestamp": "2026-09-04T08:00:09.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "only the one crate, pin the rest"}]}}),
1062                json!({"type": "message", "timestamp": "2026-09-04T08:01:00.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "no, the OldProject client too"}]}}),
1063            ],
1064        );
1065        (claude, omp)
1066    }
1067
1068    fn opts(claude: &Path, omp: &Path, apply: bool) -> Options {
1069        Options { claude: Some(claude.into()), omp: Some(omp.into()), opencode: None, since: None, limit: None, apply, skip: vec!["oldproject".into()] }
1070    }
1071
1072    fn files(dir: &Path) -> Vec<PathBuf> {
1073        borg_trace::jsonl_files(dir)
1074    }
1075
1076    #[test]
1077    fn echoed_rejection_in_tool_output_is_not_a_correction() {
1078        let root = temp("echo");
1079        let (claude, _) = fixture(&root);
1080        let eps = claude_session(&claude.join("-w-corp-app/s-corp-1.jsonl"));
1081        let said: Vec<&str> = eps.iter().map(|f| f.episode.correction.as_str()).collect();
1082        assert_eq!(said, ["no, check the TOCTOU on the closed flag first"], "only the real prompt correction");
1083        let e = &eps[0].episode;
1084        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"));
1085        assert_eq!(e.ms, borg_trace::parse_ts("2026-09-01T10:01:00.000Z"));
1086        assert!(claude_session(&claude.join("-w-corp-app/s-corp-1/subagents/a.jsonl")).len() == 1, "parser itself is neutral");
1087        let _ = std::fs::remove_dir_all(root);
1088    }
1089
1090    #[test]
1091    fn a_veto_prompt_is_replayed_as_the_hook_reads_it() {
1092        let mut r = Replay { session: "sess-veto-1".into(), ..Default::default() };
1093        r.prompt(1, "fix the merge lookup");
1094        r.prompt(2, "borg no: never read the commit log");
1095        r.prompt(3, "no, use the review API");
1096        // After a wordless denial, the veto answers it: no rejected-tool correction.
1097        r.denied = Some(("Bash git log".into(), String::new()));
1098        r.prompt(5, "borg no, stop reading history");
1099        let eps: Vec<&Episode> = r.out.iter().map(|f| &f.episode).collect();
1100        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();
1101        assert_eq!(
1102            got,
1103            [
1104                ("no-sess-vet-2", crate::veto::KIND, "fix the merge lookup", "never read the commit log"),
1105                ("sess-vet-3", "", "fix the merge lookup", "no, use the review API"),
1106                ("no-sess-vet-5", crate::veto::KIND, "fix the merge lookup", "stop reading history"),
1107            ]
1108        );
1109        assert!(eps.iter().all(|e| e.rejected_tool.is_none()) && r.denied.is_none());
1110    }
1111
1112    #[test]
1113    fn dry_run_writes_nothing_and_counts_per_collective() {
1114        let root = temp("dry");
1115        let (claude, omp) = fixture(&root);
1116        let base = root.join("home");
1117        let cfg: Config = serde_json::from_str(CFG).unwrap();
1118        let r = run(&opts(&claude, &omp, false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
1119        assert!(!base.exists(), "dry run wrote something");
1120        assert_eq!(r.scanned, 4, "main sessions only");
1121        let corp = &r.collectives["corp"];
1122        assert_eq!((corp.sessions, corp.episodes, corp.calls, corp.may_sync), (2, 2, 2, true));
1123        let mine = &r.collectives["mine"];
1124        assert_eq!((mine.sessions, mine.episodes, mine.may_sync), (1, 1, false));
1125        assert_eq!((r.off_sessions, r.off_episodes), (1, 1));
1126        assert!(render(&r, false).contains("dry run"));
1127        assert!(render(&r, false).trim_end().ends_with("to learn these episodes."));
1128        assert!(!render(&r, true).contains("Dry run"));
1129        let _ = std::fs::remove_dir_all(root);
1130    }
1131
1132    #[test]
1133    fn apply_routes_episodes_learns_and_is_idempotent() {
1134        let root = temp("apply");
1135        let (claude, omp) = fixture(&root);
1136        let base = root.join("home");
1137        let cfg: Config = serde_json::from_str(CFG).unwrap();
1138        let r = run(&opts(&claude, &omp, true), Some(&cfg), &base, &ask);
1139        assert_eq!((r.collectives["corp"].new_lessons, r.collectives["mine"].new_lessons), (2, 1));
1140
1141        let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1142        let said: Vec<&str> = corp.iter().map(|e| e.correction.as_str()).collect();
1143        assert_eq!(said, ["no, check the TOCTOU on the closed flag first", "only the one crate, pin the rest"]);
1144        assert_eq!(corp[1].rejected_tool.as_deref(), Some(r#"bash {"command":"cargo update"}"#), "a denial's reason is the next prompt");
1145        assert_eq!((corp[1].session.as_str(), corp[1].repo.as_str()), ("omp-sess-1", "svc"));
1146        let mine: Vec<Episode> = read_jsonl(&base.join("collectives/mine/episodes.jsonl"));
1147        assert_eq!(mine.len(), 1);
1148        assert_eq!(mine[0].correction, "keep the cache dir");
1149        assert!(mine[0].rejected_tool.as_deref().unwrap().starts_with("Bash "));
1150        // Nothing of the off repo, nor of anything mentioning a skipped word, anywhere.
1151        for f in files(&base) {
1152            let t = std::fs::read_to_string(&f).unwrap();
1153            assert!(!t.contains("doctor") && !t.contains("diary") && !t.to_lowercase().contains("oldproject"), "{}", f.display());
1154        }
1155        assert_eq!(learn::lessons(&base.join("collectives/corp/lessons.jsonl")).len(), 2);
1156
1157        // Re-running adds nothing and calls no model.
1158        let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
1159        let before = snapshot(&base);
1160        let again = run(&opts(&claude, &omp, true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
1161        assert_eq!((again.collectives["corp"].episodes, again.collectives["corp"].known), (0, 2));
1162        assert_eq!(snapshot(&base), before);
1163        let _ = std::fs::remove_dir_all(root);
1164    }
1165
1166    #[test]
1167    fn off_repo_leaves_nothing_and_live_episodes_are_not_duplicated() {
1168        let root = temp("off");
1169        let (claude, omp) = fixture(&root);
1170        let base = root.join("home");
1171        let only_off: Config = serde_json::from_str(r#"{"default": "off", "off": ["/w"]}"#).unwrap();
1172        let r = run(&opts(&claude, &omp, true), Some(&only_off), &base, &|_| panic!("off calls no model"));
1173        assert!(r.collectives.is_empty() && !base.exists());
1174        assert_eq!(r.off_episodes, 4);
1175
1176        // The live hook already captured the corp correction (stamped a moment after the message).
1177        let corp = base.join("collectives/corp");
1178        std::fs::create_dir_all(&corp).unwrap();
1179        let live = Episode {
1180            id: "s-corp-1-live".into(),
1181            ms: borg_trace::parse_ts("2026-09-01T10:01:00.800Z"),
1182            repo: "app".into(),
1183            session: "s-corp-1".into(),
1184            task: String::new(),
1185            agent: String::new(),
1186            rejected_tool: None,
1187            correction: "no".into(),
1188            ..Episode::default()
1189        };
1190        std::fs::write(corp.join("episodes.jsonl"), serde_json::to_string(&live).unwrap() + "\n").unwrap();
1191        let cfg: Config = serde_json::from_str(CFG).unwrap();
1192        let since = Options { since: parse_date("2026-09-01"), limit: Some(10), ..opts(&claude, &omp, false) };
1193        let r = run(&since, Some(&cfg), &base, &|_| None);
1194        assert_eq!((r.collectives["corp"].episodes, r.collectives["corp"].known, r.collectives["corp"].calls), (1, 1, 2));
1195        // --since and --limit bound what is considered.
1196        let late = Options { since: parse_date("2026-09-04"), ..opts(&claude, &omp, false) };
1197        assert!(!run(&late, Some(&cfg), &base, &|_| None).collectives.contains_key("mine"));
1198        let one = Options { limit: Some(1), ..opts(&claude, &omp, false) };
1199        assert_eq!(run(&one, Some(&cfg), &base, &|_| None).collectives.values().map(|t| t.episodes).sum::<usize>(), 1);
1200        let _ = std::fs::remove_dir_all(root);
1201    }
1202
1203    #[test]
1204    fn limit_works_through_the_history_in_batches() {
1205        let root = temp("limit");
1206        let (claude, omp) = fixture(&root);
1207        let base = root.join("home");
1208        let cfg: Config = serde_json::from_str(CFG).unwrap();
1209        let one = Options { limit: Some(1), ..opts(&claude, &omp, true) };
1210        let stored = || -> Vec<String> {
1211            ["corp", "mine"].iter().flat_map(|c| read_jsonl::<Episode>(&base.join(format!("collectives/{c}/episodes.jsonl")))).map(|e| e.correction).collect()
1212        };
1213        // Newest first: the omp reason (09-04), then the private rejection (09-02), then the corp prompt (09-01).
1214        // The off episode (09-03) and the skipped one never use up the limit.
1215        run(&one, Some(&cfg), &base, &ask);
1216        assert_eq!(stored(), ["only the one crate, pin the rest"]);
1217        run(&one, Some(&cfg), &base, &ask);
1218        assert_eq!(stored(), ["only the one crate, pin the rest", "keep the cache dir"], "the second batch is the next episode");
1219        run(&one, Some(&cfg), &base, &ask);
1220        let last = run(&one, Some(&cfg), &base, &|_| panic!("nothing left to learn"));
1221        assert_eq!(stored().len(), 3);
1222        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));
1223        let _ = std::fs::remove_dir_all(root);
1224    }
1225
1226    #[test]
1227    fn state_from_an_off_repo_never_reaches_a_collective() {
1228        let root = temp("moved");
1229        let claude = root.join("claude");
1230        let (s, c) = ("/w/secret/notes", "/w/corp/app");
1231        // One session: the task and a denied tool call in an off repo, then the cwd moves to corp.
1232        write(
1233            &claude.join("-w-secret-notes/s-moved-1.jsonl"),
1234            &[
1235                user(s, "2026-09-05T09:00:00.000Z", "summarise my therapy notes"),
1236                says(s, "2026-09-05T09:00:01.000Z", "Reading the therapy notes now."),
1237                tool_use(s, "2026-09-05T09:00:02.000Z", "t1"),
1238                result(
1239                    s,
1240                    "2026-09-05T09:00:03.000Z",
1241                    "t1",
1242                    true,
1243                    "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.",
1244                ),
1245                user(c, "2026-09-05T09:05:00.000Z", "never read files outside the repo"),
1246                user(c, "2026-09-05T09:06:00.000Z", "no, use the relay config instead"),
1247                says(c, "2026-09-05T09:06:30.000Z", "Switching to the relay config."),
1248                user(c, "2026-09-05T09:07:00.000Z", "no, the staging one"),
1249            ],
1250        );
1251        let base = root.join("home");
1252        let cfg: Config = serde_json::from_str(CFG).unwrap();
1253        let r =
1254            run(&Options { claude: Some(claude), omp: None, opencode: None, since: None, limit: None, apply: true, skip: Vec::new() }, Some(&cfg), &base, &ask);
1255        assert_eq!(r.collectives["corp"].episodes, 3);
1256        let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1257        let t = std::fs::read_to_string(base.join("collectives/corp/episodes.jsonl")).unwrap();
1258        assert!(!t.contains("therapy") && !t.contains("rm -rf"), "off text in the team store: {t}");
1259        // The denial's reason is kept (said in corp), but not the off task, drone text or tool call.
1260        assert_eq!((corp[0].task.as_str(), corp[0].agent.as_str(), corp[0].rejected_tool.as_ref()), ("", "", None));
1261        assert_eq!((corp[0].correction.as_str(), corp[1].task.as_str()), ("never read files outside the repo", ""));
1262        // State seen in corp itself is kept.
1263        assert_eq!((corp[2].task.as_str(), corp[2].agent.as_str()), ("", "Switching to the relay config."));
1264        for f in files(&base) {
1265            assert!(!std::fs::read_to_string(&f).unwrap().contains("therapy"), "{}", f.display());
1266        }
1267        let _ = std::fs::remove_dir_all(root);
1268    }
1269
1270    // ---- opencode
1271
1272    /// A redacted real export (crates/borg/tests/fixtures/opencode).
1273    macro_rules! export {
1274        ($f:literal) => {
1275            serde_json::from_str::<Value>(include_str!(concat!("../tests/fixtures/opencode/", $f))).unwrap()
1276        };
1277    }
1278
1279    /// A 1.x / 2.x user message typed by the engineer, appended to an export.
1280    fn say_v1(mut export: Value, ms: i64, text: &str) -> Value {
1281        let m = json!({"info": {"role": "user", "time": {"created": ms}}, "parts": [{"type": "text", "text": text}]});
1282        export["messages"].as_array_mut().unwrap().push(m);
1283        export
1284    }
1285
1286    fn say_v2(mut export: Value, ms: i64, text: &str) -> Value {
1287        export["messages"].as_array_mut().unwrap().push(json!({"type": "user", "time": {"created": ms}, "text": text}));
1288        export
1289    }
1290
1291    fn corrections(found: &[Found]) -> Vec<&str> {
1292        found.iter().map(|f| f.episode.correction.as_str()).collect()
1293    }
1294
1295    #[test]
1296    fn opencode_errors_take_claudes_wording_and_only_the_engineers_refusals_count() {
1297        let feedback = canonical(None, &format!("{OC_FEEDBACK} keep the public API"));
1298        assert_eq!(learn::rejection(&feedback).as_deref(), Some("keep the public API"));
1299        assert!(feedback.lines().next().unwrap().contains(NOTICE), "the notice is on the first line, as the replay requires");
1300        for wordless in [canonical(None, OC_REJECTED), canonical(Some("aborted"), OC_DECLINED), canonical(Some("permission.rejected"), "")] {
1301            assert!(learn::denied(&wordless), "{wordless}");
1302        }
1303        let words = canonical(Some("permission.rejected"), "no, do not delete files, use git rm instead");
1304        assert_eq!(learn::rejection(&words).as_deref(), Some("no, do not delete files, use git rm instead"));
1305        // Nobody's correction: opencode run's own auto-reject, a config rule, a dismissed question, an
1306        // abort, a forced call under deny-all, a failed tool.
1307        for (kind, m) in [
1308            (Some("permission.rejected"), OC_AUTO_REJECTED),
1309            (None, "The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules []"),
1310            (None, "The user dismissed this question"),
1311            (None, "Tool execution aborted"),
1312            (Some("aborted"), "Step interrupted"),
1313            (None, "Model tried to call unavailable tool 'invalid'. Available tools: ."),
1314            (Some("tool.execution"), "File not found: /home/dev/work/missing.rs"),
1315        ] {
1316            let c = canonical(kind, m);
1317            assert!(learn::rejection(&c).is_none() && !learn::denied(&c), "{m}");
1318        }
1319    }
1320
1321    #[test]
1322    fn opencode_run_quoting_is_stripped_from_prompts() {
1323        let quirk = export!("v1/run-deny-all-forced-tool-call-export.json")["messages"][0]["parts"][0]["text"].as_str().unwrap().to_string();
1324        assert_eq!(unquote(&quirk), r#"Read relay.rs and answer with {"ok": true}"#);
1325        assert_eq!(unquote("\"no, that is wrong, use a Mutex instead\""), "no, that is wrong, use a Mutex instead");
1326        assert_eq!(unquote("no quotes"), "no quotes");
1327        assert_eq!(unquote(r#""a" and "b""#), r#""a" and "b""#, "an unescaped quote inside: typed, not argv");
1328        assert_eq!(unquote("\""), "\"");
1329    }
1330
1331    #[test]
1332    fn opencode_1_rejection_with_feedback_is_an_episode() {
1333        let found = opencode_session(&export!("v1/reject-with-feedback.json"));
1334        assert_eq!(corrections(&found), ["no, that is wrong, use the AtomicBoolean compareAndSet instead"]);
1335        let (f, e) = (&found[0], &found[0].episode);
1336        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"));
1337        assert!(e.rejected_tool.as_deref().unwrap().starts_with(r#"read {"filePath":"/home/dev/work/"#));
1338        assert_eq!(e.ms, 1791978152946, "when the engineer answered, not when the message began");
1339        // The sibling 1.x rejected along with it (plain words, 4 ms later) is the cascade, not the
1340        // engineer: the next prompt is a new task, no second episode.
1341        let next = say_v1(export!("v1/reject-with-feedback.json"), 1791978160000, "now add a test for the relay");
1342        assert_eq!(corrections(&opencode_session(&next)), ["no, that is wrong, use the AtomicBoolean compareAndSet instead"]);
1343    }
1344
1345    #[test]
1346    fn opencode_tool_calls_replay_in_the_order_they_finished() {
1347        // A wordless rejection made first but answered last, and a sibling read that completed
1348        // before it: live, the hook hears the read finish and then the rejection, so the denial waits.
1349        let mut export = export!("v1/reject-plain-then-correction.json");
1350        let sibling = &mut export["messages"][1]["parts"][2]["state"];
1351        sibling["status"] = json!("completed");
1352        sibling["output"] = json!("fn deliver() {}");
1353        sibling["time"]["end"] = json!(1791978164300i64);
1354        sibling.as_object_mut().unwrap().remove("error");
1355        let found = opencode_session(&export);
1356        assert_eq!(corrections(&found), ["no, that is wrong, use compareAndSet instead of get then set"]);
1357        assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("read "));
1358        // Text keeps its place; only the calls between two texts are reordered.
1359        let steps =
1360            by_completion(vec![Step::Tool(9, None, "b".into()), Step::Tool(3, None, "a".into()), Step::Said("then".into()), Step::Tool(2, None, "c".into())]);
1361        let order: Vec<String> = steps
1362            .iter()
1363            .map(|s| match s {
1364                Step::Tool(_, _, t) | Step::Said(t) => t.clone(),
1365                Step::Prompt(..) => unreachable!(),
1366            })
1367            .collect();
1368        assert_eq!(order, ["a", "b", "then", "c"]);
1369    }
1370
1371    #[test]
1372    fn opencode_1_wordless_rejection_makes_the_next_prompt_the_reason() {
1373        let found = opencode_session(&export!("v1/reject-plain-then-correction.json"));
1374        assert_eq!(corrections(&found), ["no, that is wrong, use compareAndSet instead of get then set"]);
1375        assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("read "));
1376        assert_eq!(found[0].episode.ms, 1791978166088);
1377        // A real session: the reason need not read like a correction.
1378        let found = opencode_session(&export!("v1/bash-rejected-then-correction-window.json"));
1379        assert_eq!(corrections(&found), ["wait, revert that one. no, that is wrong, use the existing close constant instead"]);
1380        assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("bash "));
1381        assert_eq!(found[0].cwd, "/home/dev/work/service-a");
1382    }
1383
1384    #[test]
1385    fn opencode_1_correcting_prompts_are_episodes_and_a_dismissed_question_is_not() {
1386        let found = opencode_session(&export!("v1/correction-rejection-dismissed.json"));
1387        assert_eq!(
1388            corrections(&found),
1389            ["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"]
1390        );
1391        assert!(found.iter().all(|f| f.episode.task == "Run the test suite and then the app." && f.episode.rejected_tool.is_none()));
1392        assert_eq!(found[0].episode.agent, "I will look at the relevant file and then make the change.");
1393    }
1394
1395    #[test]
1396    fn opencode_1_a_rule_that_denies_a_call_is_not_the_engineer() {
1397        let by_rule = say_v1(export!("v1/permission-denied-by-rule.json"), 1776791500000, "use the plan file");
1398        assert!(opencode_session(&by_rule).is_empty());
1399        // The same call refused by the engineer: the next prompt is the reason.
1400        let mut refused = by_rule.clone();
1401        refused["messages"][0]["parts"][2]["state"]["error"] = json!(OC_REJECTED);
1402        assert_eq!(corrections(&opencode_session(&refused)), ["use the plan file"]);
1403    }
1404
1405    #[test]
1406    fn opencode_1_echoes_and_synthetic_parts_are_not_the_engineer() {
1407        // borg's prompt context (a synthetic part) is not in the task; borg's echoed question
1408        // (metadata.borg) does not replace it.
1409        let echo = say_v1(export!("v1/echo-marker-and-synthetic-part.json"), 1791978190000, "no, use a channel");
1410        let found = opencode_session(&echo);
1411        assert_eq!(corrections(&found), ["no, use a channel"]);
1412        assert_eq!(found[0].episode.task, "Fix the race in relay.rs");
1413        assert_eq!(found[0].episode.agent, "Plan: I check closed.get() on the AtomicBoolean before deliver() in relay.rs.");
1414        // An attached file and editor context are synthetic; the engineer's words are the task.
1415        let file = say_v1(export!("v1/synthetic-file-attachment.json"), 1776791100000, "no, the other plan");
1416        assert_eq!(opencode_session(&file)[0].episode.task, "Read @docs/plan.md and tell me what is missing.");
1417        let editor = say_v1(export!("v1/synthetic-editor-context.json"), 1779367490000, "no, the other workflow");
1418        assert_eq!(opencode_session(&editor)[0].episode.task, "Please make the requested change in this project.");
1419        // A compaction's "Continue" is no task: the next prompt becomes the task, not a correction.
1420        let compacted = say_v1(export!("v1/compaction-synthetic-continue.json"), 1776858910000, "no, the other module");
1421        assert!(opencode_session(&compacted).is_empty());
1422    }
1423
1424    #[test]
1425    fn opencode_sub_agents_and_borgs_model_calls_are_skipped() {
1426        for export in [
1427            say_v1(export!("v1/subagent-child.json"), 1776861800000, "no, the other file"),
1428            export!("v1/model-call-session.json"),
1429            export!("v1/run-deny-all-forced-tool-call-export.json"),
1430            say_v2(export!("v2/session-export-child.json"), 1791977829000, "no, the other file"),
1431        ] {
1432            assert!(!engineers_session(&export) && opencode_session(&export).is_empty(), "{}", export["info"]["title"]);
1433        }
1434        assert!(engineers_session(&export!("v2/session-export.json")) && !engineers_session(&json!({"messages": []})));
1435    }
1436
1437    #[test]
1438    fn opencode_2_session_replays_like_the_live_hook() {
1439        // The tail: a synthetic input (borg's context) and borg's echoed question, then the engineer.
1440        let export = say_v2(export!("v2/session-export.json"), 1791977840000, "no, check the queue bound first");
1441        let found = opencode_session(&export);
1442        assert_eq!(
1443            corrections(&found),
1444            [
1445                "no, that is wrong, use a Mutex instead of the AtomicBoolean", // run's argv quoting stripped
1446                "SHELL clean up the file",                                     // the reason after a declined call
1447                "no, do not delete files, use git rm instead",                 // feedback; the auto-reject before it is not
1448                "no, check the queue bound first",
1449            ]
1450        );
1451        let e: Vec<&Episode> = found.iter().map(|f| &f.episode).collect();
1452        assert!(found.iter().all(|f| f.cwd == "/home/dev/work" && f.episode.session == "ses_ec5cd46d0ffeGGDxs4GhqC9Ifu"));
1453        assert_eq!(
1454            (e[0].task.as_str(), e[0].agent.as_str(), e[0].rejected_tool.as_deref()),
1455            ("READS Please fix the race in relay.rs", "Done: the mock finished this turn.", None)
1456        );
1457        assert_eq!(e[1].rejected_tool.as_deref(), Some(r#"shell {"command":"rm -f relay.rs"}"#));
1458        assert_eq!(e[1].task, "SHELL remove the old file again");
1459        assert_eq!(e[2].rejected_tool.as_deref(), Some(r#"shell {"command":"rm -f relay.rs"}"#));
1460        assert_eq!(e[3].task, "SUBAGENT delegate a check of relay.rs", "the synthetic input and the echo are not the task");
1461        assert!(e.windows(2).all(|w| w[0].ms < w[1].ms));
1462    }
1463
1464    #[test]
1465    fn opencode_2_session_migrated_from_1_reads_its_location() {
1466        let found = opencode_session(&say_v2(export!("v2/session-export-migrated-from-v1.json"), 1791978280000, "no, keep the lock"));
1467        assert_eq!(corrections(&found), ["no, keep the lock"]);
1468        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"));
1469    }
1470
1471    #[test]
1472    fn opencode_discovery_asks_the_cli_read_only() {
1473        let db = db_args(Some(1_700_000_000_000));
1474        assert_eq!(&db[..4], ["db", "--pure", "--format", "json"]);
1475        assert_eq!(
1476            db[4],
1477            "SELECT id FROM session WHERE parent_id IS NULL AND title != 'borg (model call)' AND time_updated >= 1700000000000 ORDER BY time_created"
1478        );
1479        assert!(!db_args(None)[4].contains("time_updated"));
1480        assert_eq!(list_args(true), ["session", "list", "--standalone", "--format", "json", "-n", "100000"]);
1481        assert_eq!(list_args(false), ["session", "list", "--pure", "--format", "json", "-n", "100000"]);
1482        assert_eq!(opencode::export_args(true, "ses_a"), ["session", "export", "--standalone", "ses_a"]);
1483        assert_eq!(opencode::export_args(false, "ses_a"), ["export", "--pure", "ses_a"]);
1484        let listed = include_str!("../tests/fixtures/opencode/v2/session-list.json").as_bytes();
1485        assert_eq!(ids_of(listed, None).unwrap().len(), 3);
1486        assert_eq!(ids_of(listed, Some(1791977900000)).unwrap(), ["ses_ec5bbe808ffe4C17dEwmu9aHAe", "ses_ec5caa71cffeRc4s7USxbim6zn"]);
1487        assert_eq!(ids_of(br#"[{"id": "a", "title": "borg (model call)"}, {"id": "b"}]"#, None).unwrap(), ["b"]);
1488        assert_eq!(ids_of(b"Error: no such table", None), None);
1489    }
1490
1491    #[test]
1492    fn the_cwds_of_claude_and_omp_sessions_seed_opencode_2s_listing() {
1493        let root = temp("cwds");
1494        let (claude, omp) = fixture(&root);
1495        let cwds = |r: Replay| r.cwds.into_iter().collect::<Vec<_>>();
1496        assert_eq!(cwds(claude_replay(&claude.join("-w-corp-app/s-corp-1.jsonl"))), ["/w/corp/app"]);
1497        assert_eq!(cwds(omp_replay(&omp.join("-w-corp-svc/2026-09-04_x.jsonl"))), ["/w/corp/svc"]);
1498        let moved = claude.join("-w-secret-notes/moved.jsonl");
1499        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")]);
1500        assert_eq!(cwds(claude_replay(&moved)), ["/w/corp/app", "/w/secret/notes"]);
1501        let _ = std::fs::remove_dir_all(root);
1502    }
1503
1504    #[test]
1505    fn a_collective_folder_of_repos_lists_each_repo() {
1506        let root = temp("collective-dirs");
1507        let work = root.join("work-repos");
1508        for repo in ["a", "team/b"] {
1509            std::fs::create_dir_all(work.join(repo).join(".git")).unwrap();
1510        }
1511        std::fs::create_dir_all(work.join("a/src")).unwrap();
1512        std::fs::create_dir_all(work.join("notes")).unwrap();
1513        let cfg: Config =
1514            serde_json::from_str(&format!(r#"{{"collectives": [{{"name": "w", "paths": ["{}", "{}"]}}]}}"#, work.display(), work.join("a").display())).unwrap();
1515        let known = collective_dirs(Some(&cfg));
1516        assert_eq!(known, [work.clone(), work.join("a"), work.join("team/b")].into_iter().collect(), "repos two levels deep; not inside a repo");
1517        // With no Claude Code or omp session at all, opencode 2 lists in each repo.
1518        let cli = FakeCli { calls: Default::default(), db: None, list: "[]" };
1519        cli.run(true, &known);
1520        let lists: Vec<PathBuf> = cli.calls.borrow().iter().map(|(_, d)| d.clone()).collect();
1521        assert_eq!(lists, [work.clone(), work.join("a"), work.join("team/b")]);
1522        assert!(collective_dirs(None).is_empty());
1523        let _ = std::fs::remove_dir_all(root);
1524    }
1525
1526    #[test]
1527    fn the_cli_wrapper_passes_every_argument_and_reads_a_long_answer_whole() {
1528        let fake = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/opencode/fake"));
1529        let path = std::env::join_paths(std::iter::once(fake).chain(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()))).unwrap();
1530        let dir = temp("cli-wrapper");
1531        let args = db_args(Some(1));
1532        let out = opencode::cli(true, Some(&path), &args, &dir).unwrap();
1533        assert!(out.len() > 64 * 1024, "the fake answers more than a pipe holds");
1534        let ids = ids_of(&out, None).unwrap();
1535        assert_eq!((ids.len(), ids.last().map(String::as_str)), (3001, Some("ses_last")));
1536        let seen = std::fs::read_to_string(dir.join("args.txt")).unwrap();
1537        let mut want = vec![dir.display().to_string()];
1538        want.extend(args);
1539        assert_eq!(seen.lines().collect::<Vec<_>>(), want, "PWD, then the arguments unsplit (the query holds spaces and quotes)");
1540        // opencode 2 runs with a model call's env: no plugins, no project config.
1541        let env = std::fs::read_to_string(dir.join("env.txt")).unwrap();
1542        assert!(env.contains("OPENCODE_CONFIG_DIR=") && env.contains("OPENCODE_DISABLE_PROJECT_CONFIG=1"), "{env}");
1543        let _ = std::fs::remove_dir_all(dir);
1544    }
1545
1546    #[test]
1547    fn opencode_projects_are_git_roots_of_existing_dirs() {
1548        let root = temp("projects");
1549        let repo = root.join("repo");
1550        std::fs::create_dir_all(repo.join(".git")).unwrap();
1551        std::fs::create_dir_all(repo.join("src/deep")).unwrap();
1552        std::fs::create_dir_all(root.join("loose")).unwrap();
1553        let dirs: BTreeSet<PathBuf> = [repo.join("src/deep"), repo.clone(), root.join("loose"), root.join("gone")].into_iter().collect();
1554        assert_eq!(projects(&dirs), [root.join("loose"), repo]);
1555        let _ = std::fs::remove_dir_all(root);
1556    }
1557
1558    /// A fake opencode CLI: the calls it got, and canned answers by first argument / session id.
1559    struct FakeCli {
1560        calls: std::cell::RefCell<Vec<(Vec<String>, PathBuf)>>,
1561        db: Option<&'static str>,
1562        list: &'static str,
1563    }
1564
1565    impl FakeCli {
1566        fn exec(&self, args: &[String], dir: &Path) -> Option<Vec<u8>> {
1567            self.calls.borrow_mut().push((args.to_vec(), dir.to_path_buf()));
1568            let id = args.last().unwrap().as_str();
1569            let export = |v: Value| Some(v.to_string().into_bytes());
1570            match args[0].as_str() {
1571                "db" => self.db.map(|d| d.as_bytes().to_vec()),
1572                "session" if args[1] == "list" => Some(self.list.as_bytes().to_vec()),
1573                _ if id == "ses_v1" => export(export!("v1/reject-with-feedback.json")),
1574                _ if id == "ses_v2" => export(export!("v2/session-export.json")),
1575                _ => Some(b"Session not found".to_vec()),
1576            }
1577        }
1578
1579        fn run(&self, v2: bool, known: &BTreeSet<PathBuf>) -> Vec<String> {
1580            let mut got = Vec::new();
1581            cli_sessions(v2, None, known, &|a, d| self.exec(a, d), &mut |v| got.push(v["info"]["id"].as_str().unwrap().to_string()));
1582            got
1583        }
1584    }
1585
1586    #[test]
1587    fn opencode_1_lists_every_session_with_one_query_and_exports_each() {
1588        let tmp = std::env::temp_dir();
1589        let cli = FakeCli { calls: Default::default(), db: Some(r#"[{"id": "ses_v1"}, {"id": "ses_gone"}]"#), list: "[]" };
1590        let known: BTreeSet<PathBuf> = [tmp.clone()].into_iter().collect();
1591        assert_eq!(cli.run(false, &known).len(), 1, "an export that is no JSON is skipped");
1592        let calls = cli.calls.borrow();
1593        let firsts: Vec<&str> = calls.iter().map(|(a, _)| a[0].as_str()).collect();
1594        assert_eq!(firsts, ["db", "export", "export"], "no listing when the query works");
1595        assert!(calls.iter().all(|(_, d)| *d == tmp));
1596    }
1597
1598    #[test]
1599    fn opencode_1_without_its_query_and_opencode_2_list_the_known_projects() {
1600        let root = temp("cli");
1601        let (a, b) = (root.join("a"), root.join("b"));
1602        std::fs::create_dir_all(&a).unwrap();
1603        std::fs::create_dir_all(&b).unwrap();
1604        let known: BTreeSet<PathBuf> = [a.clone(), b.clone(), root.join("gone")].into_iter().collect();
1605        let list = r#"[{"id": "ses_v2", "title": "research main"}, {"id": "ses_x", "title": "borg (model call)"}, {"id": "ses_v1", "title": "t"}]"#;
1606        for v2 in [false, true] {
1607            let cli = FakeCli { calls: Default::default(), db: (!v2).then_some("Error: no such column"), list };
1608            // Both projects list the same sessions: each is exported once, in the first dir it was seen in.
1609            assert_eq!(cli.run(v2, &known), ["ses_ec5cd46d0ffeGGDxs4GhqC9Ifu", "ses_ec5c7766cffe7lNfHnHDzyNoUc"]);
1610            let calls = cli.calls.borrow();
1611            let lists: Vec<&PathBuf> = calls.iter().filter(|(c, _)| c[1] == "list").map(|(_, d)| d).collect();
1612            assert_eq!(lists, [&a, &b], "existing dirs only");
1613            let exports: Vec<(&str, &PathBuf)> =
1614                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();
1615            assert_eq!(exports.len(), 2);
1616            assert!(exports.iter().all(|(_, d)| **d == a));
1617            if v2 {
1618                assert!(calls.iter().all(|(c, _)| c[0] == "session" && c.contains(&"--standalone".to_string())), "never the background service: {calls:?}");
1619            } else {
1620                assert_eq!(calls[0].0[0], "db");
1621                assert!(calls[1..].iter().all(|(c, _)| c.contains(&"--pure".to_string())));
1622            }
1623        }
1624        let _ = std::fs::remove_dir_all(root);
1625    }
1626
1627    #[test]
1628    fn render_names_every_source() {
1629        let r = Report { scanned: 61, claude: 40, omp: 12, opencode: Some(9), ..Default::default() };
1630        assert!(render(&r, false).contains("sources: claude 40 sessions, omp 12, opencode 9\n"));
1631        let r = Report { opencode: None, ..r };
1632        assert!(render(&r, true).contains("sources: claude 40 sessions, omp 12, opencode not installed\n"));
1633    }
1634
1635    /// `--opencode DIR` with exports of both versions: a 2.x session in a corp repo, a 1.x one in a
1636    /// private repo, a 1.x one in an off repo, a sub-agent and one of borg's model calls.
1637    fn opencode_dir(root: &Path) -> PathBuf {
1638        let dir = root.join("opencode");
1639        std::fs::create_dir_all(&dir).unwrap();
1640        let put = |name: &str, mut v: Value, cwd: &str| {
1641            if v["info"].get("location").is_some() {
1642                v["info"]["location"]["directory"] = json!(cwd);
1643            } else {
1644                v["info"]["directory"] = json!(cwd);
1645            }
1646            std::fs::write(dir.join(name), v.to_string()).unwrap();
1647        };
1648        // The research run answered the permission 0.2 s after the prompt before it; an engineer takes
1649        // longer (two episodes of a session within SAME_MS are one).
1650        let mut corp = export!("v2/session-export.json");
1651        for m in corp["messages"].as_array_mut().unwrap() {
1652            for c in m["content"].as_array_mut().into_iter().flatten().filter(|c| c["id"] == "call_1791977813763_0") {
1653                c["time"]["completed"] = json!(1791977830000i64);
1654            }
1655        }
1656        put("corp.json", corp, "/w/corp/relay");
1657        put("mine.json", export!("v1/reject-with-feedback.json"), "/w/mine/notes");
1658        put("off.json", export!("v1/correction-rejection-dismissed.json"), "/w/secret/app");
1659        put("child.json", export!("v2/session-export-child.json"), "/w/corp/relay");
1660        put("call.json", export!("v1/model-call-session.json"), "/w/corp/relay");
1661        std::fs::write(dir.join("notes.txt"), "not an export").unwrap();
1662        dir
1663    }
1664
1665    #[test]
1666    fn apply_opencode_exports_routes_episodes_learns_and_is_idempotent() {
1667        let root = temp("oc-apply");
1668        let dir = opencode_dir(&root);
1669        let base = root.join("home");
1670        let cfg: Config = serde_json::from_str(CFG).unwrap();
1671        let opts =
1672            |apply| Options { claude: None, omp: None, opencode: Some(OpencodeFrom::Dir(dir.clone())), since: None, limit: None, apply, skip: Vec::new() };
1673
1674        let dry = run(&opts(false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
1675        assert_eq!((dry.scanned, dry.claude, dry.omp, dry.opencode), (3, 0, 0, Some(3)), "main sessions only");
1676        assert!(render(&dry, false).contains("sources: claude 0 sessions, omp 0, opencode 3\n"));
1677        assert_eq!((dry.off_sessions, dry.off_episodes), (1, 2));
1678        assert!(!base.exists());
1679
1680        let r = run(&opts(true), Some(&cfg), &base, &ask);
1681        assert_eq!((r.collectives["corp"].new_lessons, r.collectives["mine"].new_lessons), (3, 1));
1682        let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1683        assert_eq!(
1684            corp.iter().map(|e| e.correction.as_str()).collect::<Vec<_>>(),
1685            ["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"]
1686        );
1687        assert_eq!((corp[0].session.as_str(), corp[0].repo.as_str()), ("ses_ec5cd46d0ffeGGDxs4GhqC9Ifu", "relay"));
1688        let mine: Vec<Episode> = read_jsonl(&base.join("collectives/mine/episodes.jsonl"));
1689        assert_eq!(mine.len(), 1);
1690        assert_eq!(mine[0].correction, "no, that is wrong, use the AtomicBoolean compareAndSet instead");
1691        for f in files(&base) {
1692            let t = std::fs::read_to_string(&f).unwrap();
1693            assert!(!t.contains("test script") && !t.contains("CHILDTASK") && !t.contains("is_lesson"), "{}", f.display());
1694        }
1695
1696        let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
1697        let before = snapshot(&base);
1698        let again = run(&opts(true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
1699        assert_eq!((again.collectives["corp"].episodes, again.collectives["corp"].known), (0, 3));
1700        assert_eq!(snapshot(&base), before);
1701        let _ = std::fs::remove_dir_all(root);
1702    }
1703}