1use 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
37const SAME_MS: i64 = 5_000;
41
42pub 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#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum OpencodeFrom {
70 Cli,
72 Dir(PathBuf),
74}
75
76#[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 pub opencode: Option<OpencodeFrom>,
92 pub since: Option<i64>,
94 pub limit: Option<usize>,
97 pub apply: bool,
98 pub skip: Vec<String>,
100}
101
102#[derive(Debug, Default, Clone, PartialEq, Eq)]
104pub struct Tally {
105 pub home: PathBuf,
106 pub may_sync: bool,
107 pub sessions: usize,
108 pub episodes: usize,
110 pub known: usize,
112 pub calls: usize,
114 pub new_lessons: usize,
115 pub merged: usize,
116 pub pending: usize,
118}
119
120#[derive(Debug, Default)]
121pub struct Report {
122 pub scanned: usize,
123 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";
133const 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#[derive(Default)]
166struct Replay {
167 session: String,
168 cwd: String,
169 task: (String, String),
171 said: (String, String),
172 denied: Option<(String, String)>,
174 out: Vec<Found>,
175 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 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 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; return;
244 }
245 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
269pub 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
321pub 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
375const OC_REJECTED: &str = "The user rejected permission to use this specific tool call.";
383const OC_FEEDBACK: &str = "The user rejected permission to use this specific tool call with the following feedback:";
385const OC_DECLINED: &str = "The user declined this tool call";
387const OC_AUTO_REJECTED: &str = "This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action.";
390const DENIED: &str = "The user doesn't want to proceed with this tool use.";
392
393fn 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
413fn 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#[derive(Debug, PartialEq)]
426enum Step {
427 Prompt(i64, String),
428 Said(String),
429 Tool(i64, Option<String>, String),
431}
432
433fn 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
450fn 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
502fn 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
516fn 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
523pub 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
544pub 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
551fn 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
561fn list_args(v2: bool) -> Vec<String> {
564 strings(&["session", "list", if v2 { "--standalone" } else { "--pure" }, "--format", "json", "-n", "100000"])
565}
566
567fn 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
575fn 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
599fn 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
606fn 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
653fn 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
665fn 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
683pub 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
689pub 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
708fn 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
719pub 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 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 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 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 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 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
847fn 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 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 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 s.push_str("Dry run: nothing was written. Run `borg assimilate --apply` to learn these episodes.\n");
897 }
898 s
899}
900
901fn 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
910pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 macro_rules! export {
1274 ($f:literal) => {
1275 serde_json::from_str::<Value>(include_str!(concat!("../tests/fixtures/opencode/", $f))).unwrap()
1276 };
1277 }
1278
1279 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 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 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 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 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 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 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 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 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 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 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", "SHELL clean up the file", "no, do not delete files, use git rm instead", "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 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 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 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 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 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 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}