1use std::collections::{BTreeMap, BTreeSet, HashSet};
35use std::path::{Path, PathBuf};
36
37use serde_json::Value;
38
39use crate::collective::{self, Aliases, Config, Route, Share};
40use crate::harness::Level;
41use crate::learn::{self, Episode, Lesson};
42use crate::reach;
43
44const PERSONAL_FILE: &str = reach::PERSONAL_FILE;
46const LESSONS_FILE: &str = "lessons.jsonl";
48const EPISODES_FILE: &str = "episodes.jsonl";
50const SKIPPED_FILE: &str = "episodes-not-lessons.txt";
51
52pub const MOVED: &str = reach::MOVED;
54pub const PLACED_FILE: &str = "placed.jsonl";
57pub const VIA: &str = learn::CORRECT;
60pub const CORRECTION_CHARS: usize = learn::EXCERPT_CHARS;
63const REWRITE_TRIES: usize = 3;
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Store {
70 pub name: String,
71 pub home: PathBuf,
72 pub share: Share,
73}
74
75#[must_use]
77pub fn stores(cfg: Option<&Config>, base: &Path) -> Vec<Store> {
78 match cfg {
79 None => vec![Store { name: "default".into(), home: base.to_path_buf(), share: Share::Team }],
80 Some(c) => c.collectives.iter().map(|e| Store { name: e.name.clone(), home: collective::store(base, &e.name), share: e.share }).collect(),
81 }
82}
83
84pub fn named(cfg: Option<&Config>, base: &Path, name: &str) -> Result<Store, String> {
89 let all = stores(cfg, base);
90 all.iter().find(|s| s.name == name).cloned().ok_or_else(|| {
91 let names: Vec<&str> = all.iter().map(|s| s.name.as_str()).collect();
92 let what = if name == "off" { "off is no collective".to_string() } else { format!("no collective {name:?}") };
93 format!("{what}; the collectives are: {} (borg collective, borg status)", names.join(", "))
94 })
95}
96
97fn routed(cfg: Option<&Config>, base: &Path, cwd: &str) -> Result<Store, String> {
99 match collective::route(cfg, cwd, base) {
100 Route::Collective { name, home, share } => Ok(Store { name, home, share }),
101 Route::Off => Err(format!("{cwd} belongs to no collective: name the source with --from <collective>")),
102 }
103}
104
105fn mine(l: &Lesson, me: &str, aliases: &Aliases) -> bool {
108 reach::only_with(&l.engineers, me, aliases)
109}
110
111fn others(l: &Lesson, me: &str, aliases: &Aliases) -> Vec<String> {
113 l.engineers.iter().filter(|e| me.is_empty() || !aliases.same(e, me)).map(|e| aliases.display(e)).collect()
114}
115
116fn whose(l: &Lesson, me: &str, aliases: &Aliases) -> String {
118 match others(l, me, aliases) {
119 o if o.is_empty() => "no recorded engineer".into(),
120 o => o.join(", "),
121 }
122}
123
124fn withdrawn(file: &str, l: &Lesson) -> bool {
128 match file {
129 PERSONAL_FILE => l.question == MOVED,
130 _ => l.reach == reach::PERSONAL,
131 }
132}
133
134fn latest(file: &Path, id: &str) -> Option<Lesson> {
136 learn::lessons(file).into_iter().find(|l| l.id == id)
137}
138
139fn with_locks<T>(files: &[PathBuf], f: impl FnOnce() -> T) -> T {
142 let mut sorted: Vec<&PathBuf> = files.iter().collect();
143 sorted.sort();
144 sorted.dedup();
145 let held: Vec<_> = sorted.into_iter().map(|p| crate::live::hold(p)).collect();
146 let out = f();
147 drop(held);
148 out
149}
150
151fn placed(home: &Path) -> HashSet<(String, String)> {
153 let line = |v: Value| Some((v["id"].as_str()?.to_string(), v["from"].as_str().unwrap_or_default().to_string()));
154 read_lines(&home.join(PLACED_FILE)).lines().filter_map(|l| serde_json::from_str(l).ok()).filter_map(line).collect()
155}
156
157#[derive(Debug, Clone)]
159pub struct LessonMove {
160 pub id: String,
161 pub from: Store,
162 pub to: Store,
163 pub from_file: &'static str,
165 pub to_file: &'static str,
166 pub source_version: u32,
168 pub copy: Lesson,
169 pub already: bool,
171 pub tombstone: Option<Lesson>,
174 pub others: Vec<String>,
176}
177
178impl LessonMove {
179 #[must_use]
181 pub fn describe(&self) -> Vec<String> {
182 let version = self.copy.version;
183 let reach = match self.copy.reach.as_str() {
184 _ if self.to_file == PERSONAL_FILE => "personal: heard by this engineer only",
185 "" => "team",
186 r => r,
187 };
188 let mut out = vec![format!("move lesson {} (v{} in {}'s {}) to {}", self.id, self.source_version, self.from.name, self.from_file, self.to.name)];
189 out.push(match self.already {
190 true => format!(" {}: holds v{version} of it in {} already, as {} has it: nothing appended", self.to.name, self.to_file, self.from.name),
191 false => format!(" {}: append v{version} to {}, reach {reach}", self.to.name, self.to_file),
192 });
193 out.push(match &self.tombstone {
194 Some(t) => {
195 format!(" {}: append v{} tombstone {MOVED:?} to {}, keeping its {} episode(s)", self.from.name, t.version, self.from_file, t.episodes.len())
196 }
197 None if self.already && self.others.is_empty() => format!(" {}: withdrawn there already", self.from.name),
198 None => {
199 format!(" {}: kept as it is: another engineer is on it ({}); copied, never hidden from their team", self.from.name, self.others.join(", "))
200 }
201 });
202 out
203 }
204}
205
206pub fn plan_lesson(from: &Store, to: &Store, id: &str, personal: bool, me: &str, aliases: &Aliases) -> Result<LessonMove, String> {
212 if from.home == to.home {
213 return Err(format!("lesson {id} is in {} already", to.name));
214 }
215 let live = |home: &Path, file: &'static str| latest(&home.join(file), id).filter(|l| !withdrawn(file, l)).map(|l| (file, l));
217 let Some((from_file, src)) = live(&from.home, PERSONAL_FILE).or_else(|| live(&from.home, LESSONS_FILE)) else {
218 let moved = [PERSONAL_FILE, LESSONS_FILE].iter().find_map(|f| latest(&from.home.join(f), id).filter(|l| l.question == MOVED).map(|l| (*f, l)));
219 let there = live(&to.home, PERSONAL_FILE).or_else(|| live(&to.home, LESSONS_FILE));
220 if let (Some((from_file, t)), Some((to_file, copy))) = (moved, there) {
222 return Ok(LessonMove {
223 id: id.to_string(),
224 from: from.clone(),
225 to: to.clone(),
226 from_file,
227 to_file,
228 source_version: t.version,
229 copy,
230 already: true,
231 tombstone: None,
232 others: Vec::new(),
233 });
234 }
235 let gone = latest(&from.home.join(LESSONS_FILE), id).is_some();
236 return Err(match gone {
237 true => format!("lesson {id} is withdrawn in {} (a tombstone): name the collective holding its text with --from", from.name),
238 false => format!("no lesson {id} in {} ({}): name its collective with --from", from.name, from.home.display()),
239 });
240 };
241 let to_file = if personal || from_file == PERSONAL_FILE { PERSONAL_FILE } else { LESSONS_FILE };
242 let target = latest(&to.home.join(to_file), id);
243 let held = target.as_ref().map_or(0, |l| l.version);
244 let already = target
247 .filter(|l| !withdrawn(to_file, l))
248 .is_some_and(|t| (&t.question, &t.consequence, &t.situations, &t.episodes) == (&src.question, &src.consequence, &src.situations, &src.episodes));
249 let reach = match to_file {
250 PERSONAL_FILE => reach::PERSONAL.to_string(),
251 _ if src.reach == reach::PERSONAL => String::new(),
252 _ => src.reach.clone(),
253 };
254 let copy = match already {
255 true => latest(&to.home.join(to_file), id).unwrap_or_default(),
256 false => Lesson { version: held.max(src.version) + 1, reach, ..src.clone() },
257 };
258 let tombstone = (from.share == Share::Local || from_file == PERSONAL_FILE || mine(&src, me, aliases)).then(|| Lesson {
260 version: src.version + 1,
261 question: MOVED.into(),
262 situations: Vec::new(),
263 consequence: String::new(),
264 history: Vec::new(),
265 facets: Vec::new(),
266 reach: reach::PERSONAL.into(),
267 ..src.clone()
268 });
269 Ok(LessonMove {
270 id: id.to_string(),
271 from: from.clone(),
272 to: to.clone(),
273 from_file,
274 to_file,
275 source_version: src.version,
276 copy,
277 already,
278 tombstone,
279 others: others(&src, me, aliases),
280 })
281}
282
283pub fn move_lesson(from: &Store, to: &Store, id: &str, personal: bool, me: &str, aliases: &Aliases, apply: bool) -> Result<LessonMove, String> {
289 if !apply || from.home == to.home {
290 return plan_lesson(from, to, id, personal, me, aliases);
291 }
292 let fail = |e: std::io::Error| format!("cannot write: {e}");
293 for home in [&from.home, &to.home] {
295 std::fs::create_dir_all(home).map_err(fail)?;
296 }
297 with_locks(&[from.home.join(LESSONS_FILE), to.home.join(LESSONS_FILE)], || {
298 let m = plan_lesson(from, to, id, personal, me, aliases)?;
299 if !m.already {
300 learn::append_jsonl(&to.home.join(m.to_file), &m.copy).map_err(fail)?;
301 }
302 if !placed(&to.home).contains(&(id.to_string(), from.name.clone())) {
303 let note = serde_json::json!({"id": id, "from": from.name, "ms": learn::now_ms()});
304 learn::append_jsonl(&to.home.join(PLACED_FILE), ¬e).map_err(fail)?;
305 }
306 if let Some(t) = &m.tombstone {
307 learn::append_jsonl(&from.home.join(m.from_file), t).map_err(fail)?;
308 }
309 Ok(m)
310 })
311}
312
313#[derive(Debug, Clone)]
315pub struct RepoFrom {
316 pub store: Store,
317 pub moved: Vec<Episode>,
319 pub skipped: Vec<String>,
320 pub held: usize,
322 pub stay: usize,
324 pub backup: Option<PathBuf>,
326}
327
328#[derive(Debug, Clone)]
330pub struct RepoMove {
331 pub repo: String,
332 pub to: Store,
333 pub from: Vec<RepoFrom>,
334}
335
336impl RepoMove {
337 #[must_use]
339 pub fn describe(&self) -> Vec<String> {
340 let mut out = vec![format!("move the evidence of {} to {}", self.repo, self.to.name)];
341 for f in &self.from {
342 if !f.moved.is_empty() {
343 out.push(format!(
344 " {}: {} episode(s) to {}'s {EPISODES_FILE} ({} held there already, {} judged no lesson)",
345 f.store.name,
346 f.moved.len(),
347 self.to.name,
348 f.held,
349 f.skipped.len()
350 ));
351 }
352 if let Some(b) = &f.backup {
353 out.push(format!(" {}: its episodes before the move are kept in {}", f.store.name, b.display()));
354 }
355 if f.stay > 0 {
356 out.push(format!(" {}: {} episode(s) stay with their lessons or were placed there by borg correct", f.store.name, f.stay));
357 }
358 }
359 if self.from.iter().all(|f| f.moved.is_empty()) {
360 out.push(format!(" nothing to move: no store but {} holds waiting episodes of {}", self.to.name, self.repo));
361 }
362 out
363 }
364}
365
366fn learned(home: &Path) -> HashSet<String> {
368 learn::learned_episodes(home)
369}
370
371fn read_lines(path: &Path) -> String {
372 std::fs::read_to_string(path).unwrap_or_default()
373}
374
375fn skipped_ids(home: &Path) -> HashSet<String> {
376 read_lines(&home.join(SKIPPED_FILE)).lines().map(str::to_string).collect()
377}
378
379fn plan_repo_from(from: &Store, to: &Store, repo: &str) -> RepoFrom {
381 let done = learned(&from.home);
382 let skipped = skipped_ids(&from.home);
383 let known = learn::Known::read(&to.home);
384 let mut f = RepoFrom { store: from.clone(), moved: Vec::new(), skipped: Vec::new(), held: 0, stay: 0, backup: None };
385 let episodes = read_lines(&from.home.join(EPISODES_FILE));
386 for e in episodes.lines().filter_map(|l| serde_json::from_str::<Episode>(l).ok()).filter(|e| e.repo == repo) {
387 if done.contains(&e.id) || e.via == VIA {
388 f.stay += 1;
389 continue;
390 }
391 if known.holds(&e) {
392 f.held += 1;
393 }
394 if skipped.contains(&e.id) {
395 f.skipped.push(e.id.clone());
396 }
397 f.moved.push(e);
398 }
399 f
400}
401
402fn apply_repo_from(f: &RepoFrom, to: &Store) -> std::io::Result<PathBuf> {
406 let mut known = learn::Known::read(&to.home);
407 let target_skipped = skipped_ids(&to.home);
408 std::fs::create_dir_all(&to.home)?;
409 for e in &f.moved {
410 if !known.holds(e) {
411 learn::append_jsonl(&to.home.join(EPISODES_FILE), e)?;
412 known.add(e);
413 }
414 }
415 for id in f.skipped.iter().filter(|id| !target_skipped.contains(*id)) {
416 learn::append_line(&to.home.join(SKIPPED_FILE), id)?;
417 }
418 let gone: HashSet<&str> = f.moved.iter().map(|e| e.id.as_str()).collect();
419 let path = f.store.home.join(EPISODES_FILE);
420 let tmp = f.store.home.join(format!("{EPISODES_FILE}.moving"));
421 let backup = f.store.home.join(format!("{EPISODES_FILE}.before-move-{}", learn::now_ms()));
422 for _ in 0..REWRITE_TRIES {
423 let before = read_lines(&path);
424 let kept: String =
425 before.lines().filter(|l| serde_json::from_str::<Episode>(l).map_or(true, |e| !gone.contains(e.id.as_str()))).map(|l| format!("{l}\n")).collect();
426 std::fs::write(&tmp, kept)?;
427 if std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == before.len() as u64 {
428 std::fs::write(&backup, &before)?;
429 return std::fs::rename(&tmp, &path).map(|()| backup);
430 }
431 }
432 let _ = std::fs::remove_file(&tmp);
433 Err(std::io::Error::other(format!("{} kept changing; the episodes are in {} too, run the move again", path.display(), to.name)))
434}
435
436pub fn move_repo(sources: &[Store], to: &Store, repo: &str, apply: bool) -> Result<RepoMove, String> {
444 let mut out = RepoMove { repo: repo.to_string(), to: to.clone(), from: Vec::new() };
445 for s in sources.iter().filter(|s| s.home != to.home) {
446 let f = match apply {
447 false => plan_repo_from(s, to, repo),
448 true => {
449 let locks: Vec<PathBuf> = [&s.home, &to.home].iter().flat_map(|h| [h.join(EPISODES_FILE), h.join(LESSONS_FILE)]).collect();
450 std::fs::create_dir_all(&to.home).map_err(|e| format!("{}: {e}", to.name))?;
451 with_locks(&locks, || {
452 let mut f = plan_repo_from(s, to, repo);
453 if !f.moved.is_empty() {
454 f.backup = Some(apply_repo_from(&f, to)?);
455 }
456 Ok::<_, std::io::Error>(f)
457 })
458 .map_err(|e| format!("{}: {e}", s.name))?
459 }
460 };
461 if !f.moved.is_empty() || f.stay > 0 {
462 out.from.push(f);
463 }
464 }
465 Ok(out)
466}
467
468#[must_use]
472pub fn repo_sources(cfg: Option<&Config>, base: &Path, repo: &str, to: &Store) -> Vec<Store> {
473 let routes_to = |r: &Route| matches!(r, Route::Collective { home, .. } if *home == to.home);
474 stores(cfg, base).into_iter().filter(|s| learn::held(&s.home, cfg).iter().any(|h| h.repo == repo && routes_to(&h.route))).collect()
475}
476
477#[derive(Debug, Clone, Default)]
479pub struct Said {
480 pub session: String,
481 pub repo: String,
484 pub engineer: String,
485 pub task: String,
487 pub agent: String,
488 pub words: String,
489 pub ms: i64,
490}
491
492pub fn record_correction(home: &Path, s: &Said) -> std::io::Result<Option<Episode>> {
498 let e = Episode {
499 id: format!("{VIA}-{}-{}", s.session.chars().take(8).collect::<String>(), s.ms),
500 ms: s.ms,
501 repo: s.repo.clone(),
502 session: s.session.clone(),
503 task: s.task.clone(),
504 agent: s.agent.clone(),
505 rejected_tool: None,
506 correction: s.words.chars().take(CORRECTION_CHARS).collect(),
507 engineer: s.engineer.clone(),
508 via: VIA.into(),
509 ..Episode::default()
510 };
511 Ok(learn::record_new_in(home, vec![e], |_| {})?.pop())
512}
513
514fn q(s: &str) -> String {
518 if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "-_./~:@+=".contains(c)) {
519 s.to_string()
520 } else {
521 format!("'{}'", s.replace('\'', r"'\''"))
522 }
523}
524
525#[must_use]
528pub fn doctor(cfg: Option<&Config>, base: &Path, me: &str) -> Vec<(Level, String)> {
529 let Some(c) = cfg else { return Vec::new() };
530 doctor_with(c, base, me, &c.aliases())
531}
532
533#[must_use]
535pub fn doctor_with(cfg: &Config, base: &Path, me: &str, aliases: &Aliases) -> Vec<(Level, String)> {
536 let stores = stores(Some(cfg), base);
537 let mut out = Vec::new();
538 let home_of = |name: &str| stores.iter().find(|s| s.name == name);
539 let mut routes: BTreeMap<String, Option<Route>> = BTreeMap::new();
540 let mut route_of = |repo: &str| routes.entry(repo.to_string()).or_insert_with(|| collective::route_repo(cfg, repo, base)).clone();
541 let path_of = |repo: &str| collective::places_of(cfg, repo).into_iter().next().map_or_else(|| repo.to_string(), |p| p.display().to_string());
542 for s in &stores {
544 for h in learn::held(&s.home, Some(cfg)) {
545 let n = h.episodes.len();
546 out.push(match &h.route {
547 Route::Collective { name: to, .. } => (
548 Level::Warn,
549 format!(
550 "evidence: {n} waiting episode(s) of {} are in {}, but {} routes to {to} now: borg move {} --from {} --to {}",
551 h.repo,
552 s.name,
553 h.repo,
554 q(&path_of(&h.repo)),
555 q(&s.name),
556 q(to)
557 ),
558 ),
559 Route::Off => (
560 Level::Warn,
561 format!(
562 "evidence: {n} waiting episode(s) of {} are in {}, but {} routes off now; they wait unlearned (route it to a collective in the config, then borg doctor names the move)",
563 h.repo, s.name, h.repo
564 ),
565 ),
566 });
567 }
568 }
569 let known = !me.trim().is_empty();
572 if !known {
573 out.push((
574 Level::Warn,
575 "collectives: cannot tell whose lessons these are (no git user.email here): run borg doctor in a repo with git user.email set".into(),
576 ));
577 }
578 let local: Vec<&Store> = stores.iter().filter(|s| s.share == Share::Local).collect();
584 let live = |s: &Store, id: &str| [LESSONS_FILE, PERSONAL_FILE].iter().find_map(|f| latest(&s.home.join(f), id).filter(|l| !withdrawn(f, l)));
585 let mut repaired: HashSet<(String, String)> = HashSet::new();
586 for y in stores.iter().filter(|s| s.share == Share::Team && known) {
587 for t in learn::lessons(&y.home.join(LESSONS_FILE)).into_iter().filter(|l| l.question == reach::WITHDRAWN) {
588 if live(y, &t.id).is_some() {
589 continue; }
591 let Some((p, held)) = local.iter().find_map(|p| live(p, &t.id).map(|l| (*p, l))) else { continue };
592 repaired.insert((p.name.clone(), t.id.clone()));
593 let both: Vec<String> = t.engineers.iter().chain(&held.engineers).cloned().collect();
595 let own = reach::only_with(&both, me, aliases);
596 let learned_there = held.episodes.iter().any(|e| !t.episodes.contains(e));
597 let item = match (own, learned_there) {
598 (true, _) => format!(
599 "repair: your lesson {} was withdrawn from {} as personal; keep it personal there: borg move {} --from {} --to {} --personal",
600 t.id,
601 y.name,
602 t.id,
603 q(&p.name),
604 q(&y.name)
605 ),
606 (false, false) => format!(
607 "repair: lesson {} of {} was withdrawn from {} as personal; restore it for the team: borg move {} --from {} --to {}",
608 t.id,
609 whose(&t, me, aliases),
610 y.name,
611 t.id,
612 q(&p.name),
613 q(&y.name)
614 ),
615 (false, true) => format!(
616 "repair: lesson {} of {} was withdrawn from {} as personal, and its copy in {} learned more there since: no move restores it as the team had it; keep it personal there with borg move {} --from {} --to {} --personal, or teach the team again",
617 t.id,
618 whose(&Lesson { engineers: both, ..t.clone() }, me, aliases),
619 y.name,
620 p.name,
621 t.id,
622 q(&p.name),
623 q(&y.name)
624 ),
625 };
626 out.push((Level::Warn, item));
627 }
628 }
629 let placed_in: BTreeMap<&str, HashSet<(String, String)>> = stores.iter().map(|s| (s.name.as_str(), placed(&s.home))).collect();
634 for s in &stores {
635 let here = &placed_in[s.name.as_str()];
636 let named: HashSet<String> = read_lines(&s.home.join(EPISODES_FILE))
637 .lines()
638 .filter_map(|l| serde_json::from_str::<Episode>(l).ok())
639 .filter(|e| e.via == VIA)
640 .map(|e| e.id)
641 .collect();
642 for l in learn::lessons(&s.home.join(LESSONS_FILE)).into_iter().filter(|l| !withdrawn(LESSONS_FILE, l)) {
643 let by_name = !l.episodes.is_empty() && l.episodes.iter().all(|e| named.contains(e));
644 if repaired.contains(&(s.name.clone(), l.id.clone())) || here.iter().any(|(id, _)| *id == l.id) || by_name {
645 continue;
646 }
647 let Some(Route::Collective { name: to, .. }) = route_of(&l.repo) else { continue };
648 let Some(y) = home_of(&to).filter(|y| y.name != s.name) else { continue };
649 if placed_in[y.name.as_str()].contains(&(l.id.clone(), s.name.clone())) {
650 continue; }
652 let item = match s.share == Share::Local && y.share == Share::Team {
653 true if !known => continue,
654 true if mine(&l, me, aliases) => format!(
655 "repair: lesson {} in {} was learned in {to} ({}): keep it personal there: borg move {} --from {} --to {} --personal",
656 l.id,
657 s.name,
658 l.repo,
659 l.id,
660 q(&s.name),
661 q(&to)
662 ),
663 _ => format!(
664 "misplaced? lesson {} in {} names {}, which routes to {to}: borg move {} --from {} --to {}",
665 l.id,
666 s.name,
667 l.repo,
668 l.id,
669 q(&s.name),
670 q(&to)
671 ),
672 };
673 out.push((Level::Warn, item));
674 }
675 }
676 if out.is_empty() {
677 out.push((Level::Ok, "collectives: every lesson and waiting episode sits in the collective its repo routes to".into()));
678 } else if out.iter().any(|(_, t)| t.contains("borg move ")) {
679 out.push((Level::Ok, "collectives: the moves above are suggestions; borg move shows each as a dry run and changes nothing without --apply".into()));
680 }
681 out
682}
683
684pub const MOVE_FLAGS: &[&str] = &["--to", "--from", "--dir", "--personal", "--apply"];
688pub const CORRECT_FLAGS: &[&str] = &["--collective", "--session", "--repo", "--dir"];
690
691const MOVE_USAGE: &str = "usage: borg move <lesson-id|repo-path> --to <collective> [--from <collective>] [--dir DIR] [--personal] [--apply]
692 a lesson: appended to the target as its next version, withdrawn from the source unless another engineer is on it
693 a repo path: its waiting episodes move to the target store
694 a dry run without --apply";
695
696const CORRECT_USAGE: &str = "usage: borg correct --collective <name> [--session ID] [--repo NAME] [--dir DIR] <words…>
697 records a correction of this session in that collective's store; learned there like any other";
698
699type Parsed<'a> = (BTreeMap<String, &'a str>, BTreeSet<String>, Vec<&'a str>);
701
702fn parse<'a>(args: &[&'a str], with_value: &[&str], bare: &[&str]) -> Result<Parsed<'a>, String> {
705 let (mut values, mut flags, mut words) = (BTreeMap::new(), BTreeSet::new(), Vec::new());
706 let mut i = 0;
707 while i < args.len() {
708 let a = args[i];
709 if with_value.contains(&a) {
710 let v = args.get(i + 1).ok_or_else(|| format!("{a} needs a value"))?;
711 values.insert(a.to_string(), *v);
712 i += 2;
713 continue;
714 }
715 if bare.contains(&a) {
716 flags.insert(a.to_string());
717 } else if a.starts_with("--") {
718 return Err(format!("unknown flag {a}"));
719 } else {
720 words.push(a);
721 }
722 i += 1;
723 }
724 Ok((values, flags, words))
725}
726
727fn cwd_of(dir: Option<&str>) -> String {
730 let d = dir.map_or_else(|| std::env::current_dir().unwrap_or_default(), PathBuf::from);
731 std::path::absolute(&d).unwrap_or(d).display().to_string()
732}
733
734fn is_path(arg: &str) -> bool {
736 arg.contains('/') || arg.starts_with('.') || arg.starts_with('~') || Path::new(arg).is_dir()
737}
738
739pub fn main_move(args: &[&str]) -> i32 {
741 let (values, flags, words) = match parse(args, &["--to", "--from", "--dir"], &["--personal", "--apply"]) {
742 Ok(p) => p,
743 Err(e) => {
744 eprintln!("borg move: {e}\n{MOVE_USAGE}");
745 return 2;
746 }
747 };
748 let ([what], Some(to)) = (words.as_slice(), values.get("--to")) else {
749 eprintln!("{MOVE_USAGE}");
750 return 2;
751 };
752 let cfg = match collective::load() {
753 Ok(c) => c,
754 Err(e) => {
755 eprintln!("borg move: invalid collectives config: {e}");
756 return 1;
757 }
758 };
759 let base = collective::root_of(&learn::home());
760 let apply = flags.contains("--apply");
761 let cwd = cwd_of(values.get("--dir").copied());
762 let resolve = |name: &str| named(cfg.as_ref(), &base, name);
763 let target = match resolve(to) {
764 Ok(t) => t,
765 Err(e) => {
766 eprintln!("borg move: {e}");
767 return 2;
768 }
769 };
770 let result = if is_path(what) {
771 let path = std::path::absolute(collective::expand(what)).unwrap_or_else(|_| PathBuf::from(what));
772 let repo = crate::hook::repo_of(&path.display().to_string());
773 let sources = match collective::route(cfg.as_ref(), &path.display().to_string(), &base) {
775 Route::Collective { home, .. } if home == target.home => match values.get("--from") {
776 Some(f) => resolve(f).map(|s| vec![s]),
777 None => Ok(repo_sources(cfg.as_ref(), &base, &repo, &target)),
778 },
779 Route::Collective { name, .. } => Err(format!(
780 "{} routes to {name}, not {to}: its evidence would wait unlearned in {to}; route it to {to} in the collectives config first (borg collective)",
781 path.display()
782 )),
783 Route::Off => Err(format!("{} routes to no collective: route it to {to} in the collectives config first (borg collective)", path.display())),
784 };
785 sources.and_then(|s| move_repo(&s, &target, &repo, apply)).map(|m| m.describe())
786 } else {
787 let source = match values.get("--from") {
788 Some(f) => resolve(f),
789 None => routed(cfg.as_ref(), &base, &cwd),
790 };
791 let aliases = cfg.as_ref().map(Config::aliases).unwrap_or_default();
792 let me = learn::engineer(&cwd);
793 source.and_then(|s| move_lesson(&s, &target, what, flags.contains("--personal"), &me, &aliases, apply)).map(|m| m.describe())
794 };
795 match result {
796 Ok(lines) => {
797 for l in lines {
798 println!("{l}");
799 }
800 println!("{}", if apply { "moved" } else { "dry run: nothing changed; run it again with --apply" });
801 0
802 }
803 Err(e) => {
804 eprintln!("borg move: {e}");
805 1
806 }
807 }
808}
809
810fn task_of(session: &str, fallback: &str) -> String {
812 std::fs::read_to_string(crate::hook::state_file(session).with_extension("task"))
813 .ok()
814 .filter(|t| !t.trim().is_empty())
815 .unwrap_or_else(|| fallback.to_string())
816}
817
818fn proposal_of(harness: &str, session: &str) -> String {
821 let Some(h) = crate::harness::by_name(harness) else { return String::new() };
822 if h.caps().transcript != crate::harness::Transcript::Claude {
823 return String::new();
824 }
825 let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
826 let Some(log) = h.session_log(&home, session, &Value::Null) else { return String::new() };
827 let t = crate::harness::claude::last_assistant_full(&log);
828 let n = t.chars().count();
829 t.chars().skip(n.saturating_sub(learn::PROPOSAL_CHARS)).collect()
830}
831
832fn engineer_in(cfg: Option<&Config>, base: &Path, target: &Store, repo: &str) -> String {
836 let Some(c) = cfg else { return String::new() };
837 let routes_there = |p: &Path| matches!(collective::route(Some(c), &p.display().to_string(), base), Route::Collective { home, .. } if home == target.home);
838 let paths = c.collectives.iter().filter(|x| x.name == target.name).flat_map(|x| x.paths.iter().map(|p| collective::expand(p)));
839 collective::places_of(c, repo)
840 .into_iter()
841 .chain(paths)
842 .find(|p| p.is_dir() && routes_there(p))
843 .map_or_else(String::new, |p| learn::engineer(&p.display().to_string()))
844}
845
846pub fn set_aside(here: &Path, session: &str) -> std::io::Result<Option<String>> {
857 let state = crate::hook::state_file(session);
858 let last = read_lines(&state).lines().rev().find_map(|l| l.strip_prefix("#borg-episode ").map(|id| id.trim().to_string()));
859 let Some(id) = last else { return Ok(None) };
860 let ms_of = |t: std::time::SystemTime| t.duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX));
861 let task_ms = std::fs::metadata(state.with_extension("task")).and_then(|m| m.modified()).map_or(0, ms_of);
862 let episodes = read_lines(&here.join(EPISODES_FILE));
863 let Some(e) = episodes.lines().filter_map(|l| serde_json::from_str::<Episode>(l).ok()).find(|e| e.id == id && e.session == session) else {
864 return Ok(None);
865 };
866 if e.ms < task_ms {
867 return Ok(None); }
869 crate::live::locked(&here.join(LESSONS_FILE), || {
870 if learned(here).contains(&id) || skipped_ids(here).contains(&id) {
871 return Ok(None);
872 }
873 learn::append_line(&here.join(SKIPPED_FILE), &id).map(|()| Some(id.clone()))
874 })
875}
876
877fn corrected_file(session: &str) -> PathBuf {
880 crate::hook::state_file(session).with_extension("correct")
881}
882
883#[must_use]
885pub fn corrected_homes(session: &str) -> Vec<PathBuf> {
886 let mut out: Vec<PathBuf> = read_lines(&corrected_file(session)).lines().filter(|l| !l.trim().is_empty()).map(PathBuf::from).collect();
887 out.dedup();
888 out
889}
890
891fn session_hint(e: &str) -> String {
893 e.replace("pass --from <session>", "name it with --session ID")
894}
895
896pub fn main_correct(args: &[&str]) -> i32 {
898 let (values, _, words) = match parse(args, &["--collective", "--session", "--repo", "--dir"], &[]) {
899 Ok(p) => p,
900 Err(e) => {
901 eprintln!("borg correct: {e}\n{CORRECT_USAGE}");
902 return 2;
903 }
904 };
905 let said = words.join(" ").trim().to_string();
906 let Some(name) = values.get("--collective").filter(|_| !said.is_empty()) else {
907 eprintln!("{CORRECT_USAGE}");
908 return 2;
909 };
910 let cfg = match collective::load() {
911 Ok(c) => c,
912 Err(e) => {
913 eprintln!("borg correct: invalid collectives config: {e}");
914 return 1;
915 }
916 };
917 let base = collective::root_of(&learn::home());
918 let target = match named(cfg.as_ref(), &base, name) {
919 Ok(t) => t,
920 Err(e) => {
921 eprintln!("borg correct: {e}");
922 return 2;
923 }
924 };
925 if !learn::learning() {
926 println!("learning is off (BORG_LEARN=off): nothing recorded");
927 return 0;
928 }
929 let cwd = cwd_of(values.get("--dir").copied());
930 let here = routed(cfg.as_ref(), &base, &cwd).ok();
932 let now = learn::now_ms();
933 let drones = here.as_ref().map(|h| crate::top::drones(&h.home, false, now)).unwrap_or_default();
934 let session = match values.get("--session") {
935 Some(s) => Some((*s).to_string()),
936 None => match crate::msg::caller(&drones) {
937 Ok(c) => c.map(|(s, _)| s),
938 Err(e) => {
939 eprintln!("borg correct: {}", session_hint(&e));
940 return 1;
941 }
942 },
943 };
944 let Some(session) = session else {
945 eprintln!("borg correct: no session calls this; name it with --session ID");
946 return 1;
947 };
948 let repo = values.get("--repo").map_or_else(String::new, |r| (*r).to_string());
949 let own = here.as_ref().is_some_and(|h| h.home == target.home);
952 let drone = drones.iter().find(|d| d.node.is_empty() && d.p.session == session).filter(|_| own);
953 let s = Said {
954 session: session.clone(),
955 engineer: if own { learn::engineer(&cwd) } else { engineer_in(cfg.as_ref(), &base, &target, &repo) },
956 repo,
957 task: if own { task_of(&session, drone.map_or("", |d| d.p.prompt.as_str())) } else { String::new() },
958 agent: drone.map_or_else(String::new, |d| proposal_of(&d.p.harness, &session)),
959 words: said,
960 ms: now,
961 };
962 let recorded = match record_correction(&target.home, &s) {
963 Ok(Some(e)) => format!("recorded in {}: episode {}; learned there when this session ends", target.name, e.id),
964 Ok(None) => format!("{} holds this correction of the session already: nothing recorded", target.name),
965 Err(e) => {
966 eprintln!("borg correct: cannot record it in {}: {e}", target.name);
967 return 1;
968 }
969 };
970 println!("{recorded}");
971 let path = corrected_file(&session);
972 if !corrected_homes(&session).contains(&target.home) {
973 let _ = learn::append_line(&path, &target.home.display().to_string());
974 }
975 if let Some(h) = &here {
976 match set_aside(&h.home, &session) {
977 Ok(Some(id)) if own => println!("set aside in {}: the words as the hooks captured them (episode {id}); this restatement stands for them", h.name),
978 Ok(Some(id)) => println!(
979 "set aside in {}: the words as the hooks captured them (episode {id}); what belongs to {} needs its own borg correct --collective {}",
980 h.name, h.name, h.name
981 ),
982 Ok(None) => {}
983 Err(e) => eprintln!("borg correct: cannot set aside the captured words in {}: {e}", h.name),
984 }
985 }
986 0
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 fn temp(name: &str) -> PathBuf {
994 let d = std::env::temp_dir().join(format!("borg-moves-{name}-{}", std::process::id()));
995 let _ = std::fs::remove_dir_all(&d);
996 std::fs::create_dir_all(&d).unwrap();
997 d
998 }
999
1000 fn store(root: &Path, name: &str, share: Share) -> Store {
1001 let home = root.join(name);
1002 std::fs::create_dir_all(&home).unwrap();
1003 Store { name: name.into(), home, share }
1004 }
1005
1006 fn lesson(id: &str, version: u32, engineers: &[&str], episodes: &[&str]) -> Lesson {
1007 Lesson {
1008 id: id.into(),
1009 repo: "app".into(),
1010 question: format!("Does {id} hold?"),
1011 consequence: "it breaks".into(),
1012 version,
1013 engineers: engineers.iter().map(|e| (*e).to_string()).collect(),
1014 episodes: episodes.iter().map(|e| (*e).to_string()).collect(),
1015 ..Lesson::default()
1016 }
1017 }
1018
1019 fn write(path: &Path, lines: &[Value]) {
1020 std::fs::write(path, lines.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
1021 }
1022
1023 fn lines(path: &Path) -> Vec<Value> {
1024 read_lines(path).lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
1025 }
1026
1027 fn episode(id: &str, repo: &str) -> Episode {
1028 Episode { id: id.into(), ms: 1, repo: repo.into(), session: format!("s-{id}"), correction: format!("words of {id}"), ..Episode::default() }
1029 }
1030
1031 const ME: &str = "me@x";
1032
1033 #[test]
1034 fn a_dry_run_changes_nothing() {
1035 let root = temp("dry");
1036 let (a, b) = (store(&root, "corp", Share::Team), store(&root, "oss", Share::Team));
1037 learn::append_jsonl(&a.home.join(LESSONS_FILE), &lesson("L1", 2, &[ME], &["e1"])).unwrap();
1038 let before = read_lines(&a.home.join(LESSONS_FILE));
1039 let m = move_lesson(&a, &b, "L1", false, ME, &Aliases::default(), false).unwrap();
1040 assert!(m.describe().iter().any(|l| l.contains("append v3 to lessons.jsonl")), "{:?}", m.describe());
1041 assert_eq!(read_lines(&a.home.join(LESSONS_FILE)), before);
1042 assert!(!b.home.join(LESSONS_FILE).exists(), "the target is untouched");
1043 }
1044
1045 #[test]
1046 fn applying_moves_an_own_lesson_and_leaves_a_tombstone_that_keeps_its_episodes() {
1047 let root = temp("own");
1048 let (a, b) = (store(&root, "corp", Share::Team), store(&root, "oss", Share::Team));
1049 learn::append_jsonl(&a.home.join(LESSONS_FILE), &lesson("L1", 2, &["Me@X"], &["e1", "e2"])).unwrap();
1050 move_lesson(&a, &b, "L1", false, ME, &Aliases::default(), true).unwrap();
1051 let got = lines(&b.home.join(LESSONS_FILE));
1052 assert_eq!(got.len(), 1);
1053 assert_eq!(got[0]["version"], 3, "the target's next version, above the source's");
1054 assert!(got[0].get("moved_from").is_none(), "the source's name never goes into the target's synced lessons");
1055 assert_eq!(placed(&b.home), [("L1".to_string(), "corp".to_string())].into_iter().collect(), "noted in the target's local placed file");
1056 assert_eq!(got[0]["question"], "Does L1 hold?");
1057 let src = latest(&a.home.join(LESSONS_FILE), "L1").unwrap();
1058 assert_eq!((src.version, src.question.as_str(), src.reach.as_str()), (3, MOVED, reach::PERSONAL));
1059 assert_eq!(learned(&a.home), ["e1", "e2"].iter().map(|s| (*s).to_string()).collect(), "the source's done rule still sees the episodes");
1060 let (before_a, before_b) = (read_lines(&a.home.join(LESSONS_FILE)), read_lines(&b.home.join(LESSONS_FILE)));
1062 let again = move_lesson(&a, &b, "L1", false, ME, &Aliases::default(), true).unwrap();
1063 assert!(again.already && again.describe().iter().any(|l| l.contains("already")), "{:?}", again.describe());
1064 assert_eq!((read_lines(&a.home.join(LESSONS_FILE)), read_lines(&b.home.join(LESSONS_FILE))), (before_a, before_b));
1065 }
1066
1067 #[test]
1068 fn a_move_into_a_store_not_written_yet_holds_its_lock() {
1069 let root = temp("fresh");
1070 let a = store(&root, "corp", Share::Team);
1071 let b = Store { name: "oss".into(), home: root.join("oss"), share: Share::Team };
1072 learn::append_jsonl(&a.home.join(LESSONS_FILE), &lesson("L1", 1, &[ME], &["e1"])).unwrap();
1073 move_lesson(&a, &b, "L1", false, ME, &Aliases::default(), true).unwrap();
1074 assert!(b.home.join("lessons.jsonl.lock").exists(), "the target's lock was taken, not skipped for a missing directory");
1075 }
1076
1077 #[test]
1078 fn a_teammates_lesson_is_copied_and_stays_in_its_team_store() {
1079 let root = temp("teammate");
1080 let (a, b) = (store(&root, "corp", Share::Team), store(&root, "oss", Share::Team));
1081 learn::append_jsonl(&a.home.join(LESSONS_FILE), &lesson("L2", 1, &[ME, "bo@x"], &["e1"])).unwrap();
1082 let m = move_lesson(&a, &b, "L2", false, ME, &Aliases::default(), true).unwrap();
1083 assert!(m.tombstone.is_none());
1084 assert!(m.describe().iter().any(|l| l.contains("another engineer is on it (bo@x)")), "{:?}", m.describe());
1085 assert_eq!(latest(&a.home.join(LESSONS_FILE), "L2").unwrap().question, "Does L2 hold?", "never hidden from the team");
1086 assert_eq!(lines(&b.home.join(LESSONS_FILE))[0]["version"], 2);
1087 assert!(move_lesson(&a, &b, "L2", false, ME, &Aliases::default(), true).unwrap().already);
1089 assert_eq!(lines(&b.home.join(LESSONS_FILE)).len(), 1, "one copy, however often it is applied");
1090 learn::append_jsonl(&a.home.join(LESSONS_FILE), &lesson("L3", 1, &[], &["e3"])).unwrap();
1092 assert!(plan_lesson(&a, &b, "L3", false, ME, &Aliases::default()).unwrap().tombstone.is_none());
1093 }
1094
1095 #[test]
1096 fn a_local_stores_copy_is_withdrawn_and_restored_above_a_team_tombstone() {
1097 let root = temp("restore");
1098 let (team, private) = (store(&root, "corp", Share::Team), store(&root, "mine", Share::Local));
1099 let l = lesson("L4", 1, &["bo@x"], &["e1"]);
1100 learn::append_jsonl(&team.home.join(LESSONS_FILE), &l).unwrap();
1101 learn::append_jsonl(&team.home.join(LESSONS_FILE), &reach::tombstone(&l)).unwrap();
1102 learn::append_jsonl(&private.home.join(LESSONS_FILE), &Lesson { version: 1, ..l.clone() }).unwrap();
1103 move_lesson(&private, &team, "L4", false, ME, &Aliases::default(), true).unwrap();
1104 let back = latest(&team.home.join(LESSONS_FILE), "L4").unwrap();
1105 assert_eq!((back.version, back.question.as_str(), back.reach.as_str()), (3, "Does L4 hold?", ""), "team reach, above the tombstone");
1106 assert!(withdrawn(LESSONS_FILE, &latest(&private.home.join(LESSONS_FILE), "L4").unwrap()), "the private copy is withdrawn");
1107 }
1108
1109 #[test]
1110 fn personal_moves_into_the_targets_personal_lessons() {
1111 let root = temp("personal");
1112 let (team, private) = (store(&root, "corp", Share::Team), store(&root, "mine", Share::Local));
1113 learn::append_jsonl(&private.home.join(LESSONS_FILE), &lesson("L5", 1, &[ME], &["e1"])).unwrap();
1114 move_lesson(&private, &team, "L5", true, ME, &Aliases::default(), true).unwrap();
1115 assert!(!team.home.join(LESSONS_FILE).exists(), "nothing in the team's synced lessons");
1116 let got = lines(&team.home.join(PERSONAL_FILE));
1117 assert_eq!((got[0]["id"].as_str(), got[0]["reach"].as_str()), (Some("L5"), Some(reach::PERSONAL)));
1118 assert!(placed(&team.home).contains(&("L5".to_string(), "mine".to_string())));
1119 let other = store(&root, "oss", Share::Team);
1122 learn::append_jsonl(&team.home.join(PERSONAL_FILE), &Lesson { reach: reach::PERSONAL.into(), ..lesson("L6", 1, &["bo@x"], &["e6"]) }).unwrap();
1123 move_lesson(&team, &other, "L5", false, ME, &Aliases::default(), true).unwrap();
1124 move_lesson(&team, &other, "L6", false, ME, &Aliases::default(), true).unwrap();
1125 assert!(other.home.join(PERSONAL_FILE).exists() && !other.home.join(LESSONS_FILE).exists());
1126 for id in ["L5", "L6"] {
1127 let t = latest(&team.home.join(PERSONAL_FILE), id).unwrap();
1128 assert!(withdrawn(PERSONAL_FILE, &t), "{id}: {t:?}");
1129 assert!(!withdrawn(PERSONAL_FILE, &latest(&other.home.join(PERSONAL_FILE), id).unwrap()), "{id} is live in the target");
1130 }
1131 }
1132
1133 #[test]
1134 fn a_repos_waiting_evidence_moves_and_dedupes_at_the_target() {
1135 let root = temp("repo");
1136 let (a, b, c) = (store(&root, "mine", Share::Local), store(&root, "oss", Share::Team), store(&root, "corp", Share::Team));
1137 let eps = [episode("w1", "lib"), episode("w2", "lib"), episode("d1", "lib"), episode("o1", "app"), episode("s1", "lib")];
1138 write(&a.home.join(EPISODES_FILE), &eps.iter().map(|e| serde_json::to_value(e).unwrap()).collect::<Vec<_>>());
1139 learn::append_jsonl(&a.home.join(LESSONS_FILE), &lesson("Ld1", 1, &[ME], &["d1"])).unwrap();
1140 learn::append_line(&a.home.join(SKIPPED_FILE), "s1").unwrap();
1141 learn::append_jsonl(&b.home.join(EPISODES_FILE), &episode("w2", "lib")).unwrap();
1142 learn::append_jsonl(&c.home.join(EPISODES_FILE), &Episode { via: VIA.into(), ..episode("c1", "lib") }).unwrap();
1143 let sources = [a.clone(), b.clone(), c.clone()];
1144 let dry = move_repo(&sources, &b, "lib", false).unwrap();
1145 assert_eq!(dry.from[0].moved.len(), 3);
1146 assert_eq!(lines(&a.home.join(EPISODES_FILE)).len(), 5, "the dry run changes nothing");
1147 let m = move_repo(&sources, &b, "lib", true).unwrap();
1148 assert_eq!((m.from[0].held, m.from[0].stay, m.from[0].skipped.clone()), (1, 1, vec!["s1".to_string()]));
1149 let ids = |s: &Store| lines(&s.home.join(EPISODES_FILE)).iter().map(|v| v["id"].as_str().unwrap_or_default().to_string()).collect::<Vec<_>>();
1150 assert_eq!(ids(&b), ["w2", "w1", "s1"], "deduped by id at the target");
1151 assert_eq!(ids(&a), ["d1", "o1"], "learned and other repos' episodes stay");
1152 assert_eq!(ids(&c), ["c1"], "an episode placed by borg correct stays");
1153 assert!(skipped_ids(&b.home).contains("s1"), "its not-a-lesson mark moves along");
1154 assert!(move_repo(&sources, &b, "lib", true).unwrap().from.iter().all(|f| f.moved.is_empty()), "a second run moves nothing");
1155 }
1156
1157 #[test]
1158 fn a_correction_lands_only_in_the_named_store_and_is_held_once() {
1159 let root = temp("correct");
1160 let (a, b) = (store(&root, "corp", Share::Team), store(&root, "oss", Share::Team));
1161 let s = Said { session: "sess-1234".into(), engineer: ME.into(), task: "t".into(), words: "use the upstream parser".into(), ms: 7, ..Said::default() };
1162 let e = record_correction(&b.home, &s).unwrap().unwrap();
1163 assert_eq!((e.via.as_str(), e.rejected_tool.is_none(), e.kind.as_str()), (VIA, true, ""), "a pushback, marked as placed");
1164 assert_eq!(lines(&b.home.join(EPISODES_FILE)).len(), 1);
1165 assert!(!a.home.join(EPISODES_FILE).exists());
1166 assert!(record_correction(&b.home, &Said { ms: 8, ..s }).unwrap().is_none(), "the same words of the session once");
1167 let cfg: Config = serde_json::from_str(r#"{"default": "off", "collectives": [{"name": "corp", "paths": []}, {"name": "oss", "paths": []}]}"#).unwrap();
1168 let err = named(Some(&cfg), &root, "nope").unwrap_err();
1169 assert!(err.contains("corp, oss"), "{err}");
1170 assert!(named(Some(&cfg), &root, "off").unwrap_err().starts_with("off is no collective"));
1171 }
1172
1173 #[test]
1174 fn doctor_lists_held_evidence_misplaced_lessons_and_the_repair_with_commands() {
1175 let root = temp("doctor");
1176 let base = root.join("base");
1177 for d in ["w/app", "o/lib"] {
1178 std::fs::create_dir_all(root.join(d)).unwrap();
1179 }
1180 let cfg: Config = serde_json::from_value(serde_json::json!({"default": "off", "collectives": [
1181 {"name": "corp", "paths": [root.join("w")], "share": "team"},
1182 {"name": "oss", "paths": [root.join("o")], "share": "team"},
1183 {"name": "mine", "paths": [root.join("p")], "share": "local"}]}))
1184 .unwrap();
1185 let at = |name: &str| stores(Some(&cfg), &base).into_iter().find(|s| s.name == name).unwrap();
1186 let (corp, oss, mine_) = (at("corp"), at("oss"), at("mine"));
1187 for e in [episode("h1", "lib"), episode("h2", "lib"), episode("h3", "lib"), Episode { via: VIA.into(), ..episode("h4", "lib") }] {
1189 learn::append_jsonl(&corp.home.join(EPISODES_FILE), &e).unwrap();
1190 }
1191 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &Lesson { repo: "app".into(), ..lesson("Lh", 1, &[ME], &["h3"]) }).unwrap();
1192 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &Lesson { repo: "lib".into(), ..lesson("Lm", 1, &[ME], &[]) }).unwrap();
1195 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &Lesson { repo: "lib".into(), ..lesson("Lp", 1, &[ME], &[]) }).unwrap();
1196 learn::append_jsonl(&corp.home.join(PLACED_FILE), &serde_json::json!({"id": "Lp", "from": "oss", "ms": 1})).unwrap();
1197 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &Lesson { repo: "lib".into(), ..lesson("Lc", 1, &[ME], &["h4"]) }).unwrap();
1198 for l in [lesson("Lt", 1, &["bo@x"], &["t1"]), lesson("Lo", 1, &[ME], &["o1"]), lesson("Ln", 1, &[], &["n1"])] {
1201 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &l).unwrap();
1202 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &tombstone_old(&l)).unwrap();
1203 learn::append_jsonl(&mine_.home.join(LESSONS_FILE), &l).unwrap();
1204 }
1205 let lw = lesson("Lw", 1, &[], &["w1"]);
1208 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &lw).unwrap();
1209 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &tombstone_old(&lw)).unwrap();
1210 learn::append_jsonl(&mine_.home.join(LESSONS_FILE), &lesson("Lw", 2, &[ME], &["w1", "private1"])).unwrap();
1211 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &tombstone_old(&lesson("Ls", 1, &["bo@x"], &["s1"]))).unwrap();
1213 learn::append_jsonl(&mine_.home.join(LESSONS_FILE), &lesson("Lr", 3, &[ME], &["r1"])).unwrap();
1214 let out = doctor_with(&cfg, &base, ME, &Aliases::default());
1215 let text: Vec<&str> = out.iter().map(|(_, t)| t.as_str()).collect();
1216 let has = |s: &str| text.iter().any(|t| t.contains(s));
1217 assert!(
1218 has(&format!(
1219 "2 waiting episode(s) of lib are in corp, but lib routes to oss now: borg move {} --from corp --to oss",
1220 root.join("o/lib").display()
1221 )),
1222 "{text:#?}"
1223 );
1224 assert!(has("lesson Lm in corp names lib, which routes to oss: borg move Lm --from corp --to oss"), "{text:#?}");
1225 assert!(!has("Lp ") && !has("Lc "), "placed by the engineer, no suggestion: {text:#?}");
1226 assert!(has("lesson Lt of bo@x was withdrawn from corp as personal; restore it for the team: borg move Lt --from mine --to corp"), "{text:#?}");
1227 assert!(has("your lesson Lo was withdrawn from corp as personal; keep it personal there: borg move Lo --from mine --to corp --personal"), "{text:#?}");
1228 assert!(has("lesson Lr in mine was learned in corp (app): keep it personal there: borg move Lr --from mine --to corp --personal"), "{text:#?}");
1229 assert!(
1230 has("lesson Ln of no recorded engineer was withdrawn from corp as personal; restore it for the team: borg move Ln --from mine --to corp"),
1231 "{text:#?}"
1232 );
1233 assert!(has("your lesson Lw was withdrawn from corp as personal; keep it personal there: borg move Lw --from mine --to corp --personal"), "{text:#?}");
1234 assert!(!has("Lw --from mine --to corp\n") && !text.iter().any(|t| t.ends_with("Lw --from mine --to corp")), "{text:#?}");
1235 assert!(!has(" Ls "), "a teammate's synced tombstone is no item here: {text:#?}");
1236 assert_eq!(text.iter().filter(|t| t.contains(" Lt ")).count(), 1, "one item per lesson: {text:#?}");
1237 let blind = doctor_with(&cfg, &base, "", &Aliases::default());
1239 assert!(blind.iter().any(|(_, t)| t.contains("cannot tell whose lessons these are")), "{blind:#?}");
1240 assert!(!blind.iter().any(|(_, t)| t.starts_with("repair") || t.contains("--from mine")), "nothing private suggested for a team: {blind:#?}");
1241 move_lesson(&mine_, &corp, "Lt", false, ME, &Aliases::default(), true).unwrap();
1243 move_lesson(&mine_, &corp, "Ln", false, ME, &Aliases::default(), true).unwrap();
1244 for id in ["Lo", "Lr", "Lw"] {
1245 move_lesson(&mine_, &corp, id, true, ME, &Aliases::default(), true).unwrap();
1246 }
1247 assert!(reach::personal_lessons(&corp.home).iter().any(|l| l.id == "Lw"), "kept for this engineer in corp");
1248 assert!(!read_lines(&corp.home.join(LESSONS_FILE)).contains("private1"), "nothing the private store learned reaches the team");
1249 learn::append_jsonl(&corp.home.join(LESSONS_FILE), &Lesson { repo: "lib".into(), ..lesson("Lb", 1, &[ME, "bo@x"], &[]) }).unwrap();
1251 move_lesson(&corp, &oss, "Lb", false, ME, &Aliases::default(), true).unwrap();
1252 move_lesson(&corp, &oss, "Lm", false, ME, &Aliases::default(), true).unwrap();
1253 let after = doctor_with(&cfg, &base, ME, &Aliases::default());
1254 assert!(!after.iter().any(|(_, t)| t.starts_with("repair") || t.starts_with("misplaced")), "{after:#?}");
1255 }
1256
1257 fn tombstone_old(l: &Lesson) -> Lesson {
1259 Lesson { question: reach::WITHDRAWN.into(), ..reach::tombstone(l) }
1260 }
1261
1262 #[test]
1263 fn a_repos_evidence_leaves_only_stores_that_hold_it_for_the_target() {
1264 let root = temp("sources");
1265 let base = root.join("base");
1266 for d in ["w/group/api", "o/api", "o/lib"] {
1267 std::fs::create_dir_all(root.join(d)).unwrap();
1268 }
1269 let cfg: Config = serde_json::from_value(serde_json::json!({"default": "off", "collectives": [
1270 {"name": "corp", "paths": [root.join("w")], "share": "team"},
1271 {"name": "oss", "paths": [root.join("o")], "share": "team"}]}))
1272 .unwrap();
1273 let at = |name: &str| stores(Some(&cfg), &base).into_iter().find(|s| s.name == name).unwrap();
1274 let (corp, oss) = (at("corp"), at("oss"));
1275 for e in [episode("a1", "api"), episode("l1", "lib")] {
1277 learn::append_jsonl(&corp.home.join(EPISODES_FILE), &e).unwrap();
1278 }
1279 assert!(repo_sources(Some(&cfg), &base, "api", &oss).is_empty(), "corp may hold api by right: never a source unasked");
1280 assert_eq!(repo_sources(Some(&cfg), &base, "lib", &oss), std::slice::from_ref(&corp));
1281 let m = move_repo(&repo_sources(Some(&cfg), &base, "api", &oss), &oss, "api", true).unwrap();
1282 assert!(m.from.is_empty());
1283 assert_eq!(lines(&corp.home.join(EPISODES_FILE)).len(), 2, "corp keeps its api evidence");
1284 let m = move_repo(&repo_sources(Some(&cfg), &base, "lib", &oss), &oss, "lib", true).unwrap();
1285 let backup = m.from[0].backup.clone().unwrap();
1286 assert!(read_lines(&backup).contains("\"l1\""), "the source's log as it was is kept");
1287 assert!(m.describe().iter().any(|l| l.contains(&backup.display().to_string())));
1288 }
1289
1290 #[test]
1291 fn a_repo_move_waits_for_a_learn_run_on_its_source() {
1292 let root = temp("learn-lock");
1293 let (a, b) = (store(&root, "mine", Share::Local), store(&root, "oss", Share::Team));
1294 learn::append_jsonl(&a.home.join(EPISODES_FILE), &episode("w1", "lib")).unwrap();
1295 let (held_tx, held_rx) = std::sync::mpsc::channel();
1296 let (go_tx, go_rx) = std::sync::mpsc::channel::<()>();
1297 let learner = {
1299 let lessons = a.home.join(LESSONS_FILE);
1300 std::thread::spawn(move || {
1301 crate::live::locked(&lessons, || {
1302 held_tx.send(()).unwrap();
1303 go_rx.recv().unwrap();
1304 })
1305 })
1306 };
1307 held_rx.recv().unwrap();
1308 let mover = {
1309 let (a, b) = (a.clone(), b.clone());
1310 std::thread::spawn(move || move_repo(&[a], &b, "lib", true).unwrap())
1311 };
1312 std::thread::sleep(std::time::Duration::from_millis(150));
1313 assert_eq!(lines(&a.home.join(EPISODES_FILE)).len(), 1, "the move waits while learning runs");
1314 go_tx.send(()).unwrap();
1315 learner.join().unwrap();
1316 assert_eq!(mover.join().unwrap().from[0].moved.len(), 1);
1317 assert!(lines(&a.home.join(EPISODES_FILE)).is_empty());
1318 }
1319
1320 #[test]
1321 fn the_ambiguous_session_error_names_the_flag_correct_takes() {
1322 let e = crate::msg::pick(vec![("s1".into(), "claude"), ("s2".into(), "codex")], &[], &[]).unwrap_err();
1323 assert!(e.contains("--from"), "msg's own wording: {e}");
1324 let h = session_hint(&e);
1325 assert!(h.contains("--session ID") && !h.contains("--from"), "{h}");
1326 }
1327}