Skip to main content

borg/
moves.rs

1//! Crossing a collective boundary, only ever on the engineer's word: `borg move` and `borg correct`.
2//!
3//! Collectives never mix on their own (collective.rs): a session reads and writes only the store its
4//! repo routes to. But a lesson can land in the wrong collective, a repo can change collectives, and
5//! one session can correct things that belong to two collectives. Each of those is an explicit act
6//! the engineer triggers, through the harness skill or a terminal:
7//!
8//! - **`borg move <lesson-id> --to <collective>`** appends the lesson's latest version to the target
9//!   store as its next version and withdraws it from the source with a tombstone ([`MOVED`]) that keeps
10//!   its episodes, so the source's own "done" rule never learns them again. The target notes the move
11//!   in its local [`PLACED_FILE`] (never synced: the source's name never reaches the target's team), so
12//!   `borg doctor` never calls the lesson misplaced. A lesson another engineer is on stays in a team
13//!   store as it is (copied, never hidden from their team); a local store's copy is always withdrawn,
14//!   since no team reads it. A lesson in the source's `personal.jsonl` moves to the target's;
15//!   `--personal` puts a lesson there. Moving it again changes nothing.
16//! - **`borg move <repo-path> --to <collective>`** moves the episodes of that repo the stores hold but
17//!   have not learned into a lesson (with their not-a-lesson marks) to the target store, deduped there,
18//!   when the repo routes to the target now. Without `--from` only stores that hold them because the
19//!   repo routes there (learn.rs `held`) give them up. Episodes already learned stay with their lessons;
20//!   `borg move <id>` moves a lesson.
21//! - **`borg correct --collective <name> <words>`** records a correction of the current session as an
22//!   episode in that collective's store (`via` [`VIA`]), learned there at the session's end like any
23//!   other. One command per collective the correction concerns, the session's own included: the raw
24//!   words the hooks captured in the session's collective are set aside, so each collective learns only
25//!   what was said for it. Into another collective goes only what the engineer said: no task, proposal
26//!   or email of the session's collective.
27//!
28//! Both are dry runs where they change stores (`--apply` writes), and take each store's locks (in path
29//! order, never one twice). `borg doctor` lists what may need a move ([`doctor`]): evidence held in a
30//! store its repo no longer routes to, lessons that name another collective's repo, and the repair of
31//! lessons a borg before 0.7.4 withdrew to a private collective. It only shows the commands; it never
32//! moves anything.
33
34use 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
44/// A store's lessons only its engineer hears, beside `lessons.jsonl`; never synced (reach.rs).
45const PERSONAL_FILE: &str = reach::PERSONAL_FILE;
46/// A store's team lessons.
47const LESSONS_FILE: &str = "lessons.jsonl";
48/// A store's episodes, and the ones learning judged no lesson.
49const EPISODES_FILE: &str = "episodes.jsonl";
50const SKIPPED_FILE: &str = "episodes-not-lessons.txt";
51
52/// The question of the tombstone a move leaves in the source store: no text left to deliver or sync.
53pub const MOVED: &str = reach::MOVED;
54/// A store's record of the lessons `borg move` placed in it (`{"id", "from", "ms"}` lines): placed by
55/// the engineer, so never "misplaced?". Local: borg-mesh `SYNCED` does not list it.
56pub const PLACED_FILE: &str = "placed.jsonl";
57/// `Episode::via` of a correction `borg correct` recorded for a named collective: placed by the
58/// engineer, so no routing check ever holds it back or calls it misplaced (learn.rs `CORRECT`).
59pub const VIA: &str = learn::CORRECT;
60/// The most characters of a correction `borg correct` records. Guard: bounds the episode as the hook
61/// bounds a captured correction (learn.rs `EXCERPT_CHARS`): derived, the same bound.
62pub const CORRECTION_CHARS: usize = learn::EXCERPT_CHARS;
63/// How often a repo move re-reads a source's episodes when an append landed while it rewrote them.
64/// Guard: a store written without pause never stalls the move; a second run moves what is left.
65const REWRITE_TRIES: usize = 3;
66
67/// One collective's store.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Store {
70    pub name: String,
71    pub home: PathBuf,
72    pub share: Share,
73}
74
75/// Every collective's store under the root store `base` (without a config, "default" at `base`).
76#[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
84/// The store of the collective `name`.
85///
86/// # Errors
87/// An unknown or off collective, with the list of collectives.
88pub 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
97/// The store `cwd` routes to.
98fn 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
105/// Whether every engineer on `l` is `me` (and there is one): only then may a team store lose it
106/// (reach.rs `only`, the one rule for both).
107fn mine(l: &Lesson, me: &str, aliases: &Aliases) -> bool {
108    reach::only_with(&l.engineers, me, aliases)
109}
110
111/// The engineers on `l` other than `me`.
112fn 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
116/// Who a lesson that is not only `me`'s belongs to, for a line of the doctor.
117fn 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
124/// Whether `l`, the latest version of its id in `file`, withdraws it: in `lessons.jsonl` a tombstone
125/// (reach.rs `PERSONAL` marks one); in [`PERSONAL_FILE`], where every lesson is personal, the tombstone
126/// a move leaves there ([`MOVED`]).
127fn withdrawn(file: &str, l: &Lesson) -> bool {
128    match file {
129        PERSONAL_FILE => l.question == MOVED,
130        _ => l.reach == reach::PERSONAL,
131    }
132}
133
134/// The latest version of `id` in `file`, whatever it is.
135fn latest(file: &Path, id: &str) -> Option<Lesson> {
136    learn::lessons(file).into_iter().find(|l| l.id == id)
137}
138
139/// Runs `f` holding the locks of `files` (live.rs `hold`), taken in path order so two moves in
140/// opposite directions never wait on each other; each file once.
141fn 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
151/// The ids of the lessons `borg move` placed in the store `home`, with the collective each came from.
152fn 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/// What moving a lesson does.
158#[derive(Debug, Clone)]
159pub struct LessonMove {
160    pub id: String,
161    pub from: Store,
162    pub to: Store,
163    /// The file it is read from and the one it is appended to (`lessons.jsonl` or [`PERSONAL_FILE`]).
164    pub from_file: &'static str,
165    pub to_file: &'static str,
166    /// The source's latest version, and the line the target gets (its next version).
167    pub source_version: u32,
168    pub copy: Lesson,
169    /// The target holds the lesson as the source has it already (a move applied before): no copy.
170    pub already: bool,
171    /// The source's tombstone; None when another engineer is on the lesson in a team store, or the
172    /// source holds one already.
173    pub tombstone: Option<Lesson>,
174    /// The other engineers on it.
175    pub others: Vec<String>,
176}
177
178impl LessonMove {
179    /// What the move changes, one line each.
180    #[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
206/// Plans moving lesson `id` from `from` to `to` (`personal`: into the target's [`PERSONAL_FILE`]);
207/// `me` is this engineer.
208///
209/// # Errors
210/// The same store, or no live lesson `id` in the source.
211pub 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    // The version this engineer has: a personal one over the team's (learn.rs `store_lessons`).
216    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        // Moved before: the source holds a move's tombstone and the target the lesson. Nothing to do.
221        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    // Applied before (a teammate's lesson stays in its source): the target's live version says what the
245    // source does, so a second move appends nothing.
246    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    // A local store and personal lessons are no team's: only a team store keeps a teammate's lesson.
259    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
283/// Moves lesson `id` (`plan_lesson`), with `apply` writing it under both stores' `lessons.jsonl` locks,
284/// planned again under them; the dry run writes nothing.
285///
286/// # Errors
287/// What `plan_lesson` refuses, or a write that failed.
288pub 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    // The locks live beside the files: a store not written yet gets its directory first.
294    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), &note).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/// What moving a repo's evidence takes from one store.
314#[derive(Debug, Clone)]
315pub struct RepoFrom {
316    pub store: Store,
317    /// The episodes it gives the target, and which of them learning judged no lesson there.
318    pub moved: Vec<Episode>,
319    pub skipped: Vec<String>,
320    /// Of `moved`, the ones the target holds already: removed here, not appended twice.
321    pub held: usize,
322    /// The repo's episodes that stay: learned into a lesson here, or recorded here by `borg correct`.
323    pub stay: usize,
324    /// Where the source's `episodes.jsonl` was copied before the move rewrote it (applied only).
325    pub backup: Option<PathBuf>,
326}
327
328/// What moving a repo's evidence does.
329#[derive(Debug, Clone)]
330pub struct RepoMove {
331    pub repo: String,
332    pub to: Store,
333    pub from: Vec<RepoFrom>,
334}
335
336impl RepoMove {
337    /// What the move changes, one line each.
338    #[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
366/// The ids of the episodes `home`'s lessons (team and personal) were learned from.
367fn 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
379/// The repo's episodes `from` gives `to` (pure on the stores' files).
380fn 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
402/// Writes one source's part of a repo move: the target gets what it does not hold, then the source
403/// loses the moved episodes (a rename of its rewritten file, done again if an append landed meanwhile),
404/// its file as it was kept beside it first. Returns that copy.
405fn 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
436/// Moves the waiting episodes of `repo` (a repo name, as episodes carry it) from `sources` to `to`, one
437/// source at a time under both stores' `episodes.jsonl` and `lessons.jsonl` locks (so a running
438/// `borg learn` on either finishes first and never learns an episode as it leaves); the dry run writes
439/// nothing.
440///
441/// # Errors
442/// A write that failed.
443pub 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/// The stores a `borg move <repo-path> --to <to>` takes `repo`'s evidence from when no `--from` names
469/// one: those that hold it waiting only because the repo routes to `to` now (learn.rs `held`). A store
470/// that may hold another repo of that name, or this one by right, is never a source unasked.
471#[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/// One correction `borg correct` records.
478#[derive(Debug, Clone, Default)]
479pub struct Said {
480    pub session: String,
481    /// The repo it is about, when the engineer names one; empty otherwise (the session's repo belongs
482    /// to another collective).
483    pub repo: String,
484    pub engineer: String,
485    /// The session's task and the agent's last proposal, as far as they are known.
486    pub task: String,
487    pub agent: String,
488    pub words: String,
489    pub ms: i64,
490}
491
492/// Records `s` as a pushback episode in the store `home`, once (learn.rs `record_new_in`, which also
493/// counts it); None when the store holds it already.
494///
495/// # Errors
496/// When the episode cannot be appended.
497pub 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
514// ---------------------------------------------------------------- doctor
515
516/// `s` as one shell word.
517fn 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/// `borg doctor`'s checks of where lessons and evidence sit (see the module doc); `me` is this
526/// engineer's git email. Nothing without a config: one store holds everything.
527#[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/// [`doctor`] under explicit aliases.
534#[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    // Held evidence: what `borg learn` holds in a store because its repo routes elsewhere now (learn.rs `held`).
543    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    // Whose lesson a line is: unknowable without this engineer's email. Then nothing is classified,
570    // for a wrong guess would suggest publishing a personal lesson to a team.
571    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    // The repair of withdrawn lessons: a team tombstone of a borg before 0.7.4 (reach.rs `WITHDRAWN`)
579    // whose text a local store holds goes back where it was learned: for the team when another engineer
580    // is on it and the local copy learned nothing of its own, into that collective's personal lessons
581    // when it is this engineer's. A tombstone no local store holds the text of has nothing to restore
582    // here (a teammate's machine sees every synced one).
583    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; // kept in this collective's personal lessons
590            }
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            // The holder's version counts: what the local store learned into it is its engineer's.
594            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    // The safety net: a team lesson whose repo routes to another collective (shown, never acted on).
630    // In a local store it came from that collective (a personal placement), so it goes back as personal.
631    // Never one the engineer placed: moved there (`PLACED_FILE`), copied from there to where its repo
632    // routes, or learned from a `borg correct` alone.
633    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; // copied there already
651            }
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
684// ---------------------------------------------------------------- CLI
685
686/// `borg move`'s flags (completions.rs).
687pub const MOVE_FLAGS: &[&str] = &["--to", "--from", "--dir", "--personal", "--apply"];
688/// `borg correct`'s flags (completions.rs).
689pub 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
699/// A command line: each flag's value, the flags without one, and the other words.
700type Parsed<'a> = (BTreeMap<String, &'a str>, BTreeSet<String>, Vec<&'a str>);
701
702/// The value after each flag of `with_value`, the `bare` flags given, and the arguments that are
703/// neither flags nor their values.
704fn 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
727/// The directory a command runs for: `--dir`, else the current one, absolute (routing compares path
728/// text, as hooks' cwd is absolute).
729fn 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
734/// Whether a `borg move` argument names a repo (a path) rather than a lesson id.
735fn is_path(arg: &str) -> bool {
736    arg.contains('/') || arg.starts_with('.') || arg.starts_with('~') || Path::new(arg).is_dir()
737}
738
739/// `borg move …`; returns the exit code.
740pub 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        // Evidence moves only where its repo routes now: anywhere else learning would hold it again.
774        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
810/// The session's task as the hooks kept it (hook.rs, `<state>/<session>.task`), else `fallback`.
811fn 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
818/// The agent's last proposal in `session` on `harness`, its last `learn::PROPOSAL_CHARS` characters,
819/// where the harness writes a transcript borg can read; empty otherwise.
820fn 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
832/// This engineer as the collective of `target` knows them: the git email in the repo `repo` there
833/// (collective.rs `places_of`), else in the collective's first path on disk (git's global email outside
834/// a repo); empty when none is known. Never the email of a repo of another collective.
835fn 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
846/// Sets aside, in the session's own store `here`, the engineer's latest prompt the hooks recorded as a
847/// correction (hook.rs `record`: `#borg-episode <id>` in the session's state file): a `borg correct`
848/// restates it for each collective it concerns, so its raw words are not learned in this collective
849/// besides. Only the latest prompt's episode (newer than the session's task, which the hook rewrites on
850/// every other prompt), and only while unlearned. Marked as settled (`episodes-not-lessons.txt`) under
851/// the store's learning lock; the episode stays, so transcript capture never records it again.
852/// Returns its id.
853///
854/// # Errors
855/// When the mark cannot be written.
856pub 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); // a later prompt was no correction: this episode is not what is restated
868    }
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
877/// The session's list of stores `borg correct` recorded into (`<state>/<session>.correct`), which its
878/// end-of-session `borg learn` learns too (main.rs `learn_cmd`).
879fn corrected_file(session: &str) -> PathBuf {
880    crate::hook::state_file(session).with_extension("correct")
881}
882
883/// The stores `borg correct` recorded a correction of `session` into.
884#[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
891/// `msg::caller`'s error, with the flag of `borg correct` that names the session.
892fn session_hint(e: &str) -> String {
893    e.replace("pass --from <session>", "name it with --session ID")
894}
895
896/// `borg correct …`; returns the exit code.
897pub 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    // The session lives where its own repo routes: its presence, task and drones are there.
931    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    // Into another collective goes only what the engineer said: the session's task, the agent's
950    // proposal and the email of its repo belong to the session's collective.
951    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        // Moving it again changes nothing, and says so.
1061        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        // A second apply copies nothing more.
1088        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        // Nobody known: not provably own, so not withdrawn either.
1091        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        // A personal lesson (reach.rs: personal reach in its file) moves within its file kind, and its
1120        // source keeps a tombstone even with a teammate's email on it: no team reads that file.
1121        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        // Held: two waiting episodes of lib in corp; one learned, one placed by borg correct.
1188        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        // Misplaced: a lesson of lib in corp. Left alone: one moved there on purpose, one learned from a
1193        // borg correct alone.
1194        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        // Repair: a teammate's and an own lesson withdrawn from corp, their text in the private store;
1199        // and an own corp-repo lesson the private store learned itself.
1200        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        // No engineer on the team's, but the private copy is this engineer's and learned a private
1206        // episode since: never published to the team as it is.
1207        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        // A teammate's own tombstone, synced from their machine, no text here: nothing to act on.
1212        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        // Without this engineer's email nothing is classified: one warning instead of guesses.
1238        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        // After the repair the items are gone.
1242        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        // A teammate's lesson of lib copied to oss stays in corp, and is no suggestion any more.
1250        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    /// A tombstone as a borg before 0.7.4 wrote it (reach.rs `WITHDRAWN`).
1258    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        // corp waits on episodes of its own api (a namesake of oss's) and of lib, which routes to oss.
1276        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        // `borg learn` holds the store's lessons.jsonl lock while it reads and learns the episodes.
1298        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}