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