1use std::path::{Path, PathBuf};
11
12use crate::conscience::Challenge;
13use crate::learn::{Lesson, append_jsonl};
14
15pub const REPO: &str = "repo";
17pub const PERSONAL: &str = "personal";
19
20pub fn deliverable(c: &Challenge, repos: &[String]) -> bool {
22 match c.reach.as_str() {
23 PERSONAL => false,
24 REPO => repos.iter().any(|r| r.eq_ignore_ascii_case(&c.repo)),
25 _ => true,
26 }
27}
28
29pub fn narrower<'a>(a: &'a str, b: &'a str) -> &'a str {
31 let rank = |r: &str| match r {
32 PERSONAL => 2,
33 REPO => 1,
34 _ => 0,
35 };
36 if rank(b) > rank(a) { b } else { a }
37}
38
39pub const WITHDRAWN: &str = "(personal preference, moved to a private collective)";
41
42#[derive(Debug, Clone)]
44pub enum Placed {
45 Here(Box<Lesson>),
48 Private(PathBuf),
50 Dropped,
52}
53
54pub fn place(home: &Path, l: &Lesson) -> Placed {
56 if l.reach != PERSONAL {
57 return Placed::Here(Box::new(l.clone()));
58 }
59 place_with(home, l, crate::collective::load().ok().flatten().as_ref())
60}
61
62pub fn place_with(home: &Path, l: &Lesson, cfg: Option<&crate::collective::Config>) -> Placed {
64 if l.reach != PERSONAL {
65 return Placed::Here(Box::new(l.clone()));
66 }
67 match crate::collective::personal_store(cfg, home) {
68 Some(p) if p == home => Placed::Here(Box::new(Lesson { reach: String::new(), ..l.clone() })),
70 Some(p) => {
71 if move_to(home, &p, l).is_ok() {
72 Placed::Private(p)
73 } else {
74 Placed::Dropped
75 }
76 }
77 None => {
78 if l.version > 1 {
80 let _ = append_jsonl(&home.join("lessons.jsonl"), &tombstone(l));
81 }
82 Placed::Dropped
83 }
84 }
85}
86
87pub fn move_to(team: &Path, private: &Path, l: &Lesson) -> std::io::Result<()> {
90 let held = crate::learn::lessons(&private.join("lessons.jsonl")).into_iter().find(|x| x.id == l.id).map_or(0, |x| x.version);
91 let copy = Lesson { version: held + 1, reach: String::new(), ..l.clone() };
92 std::fs::create_dir_all(private)?;
93 append_jsonl(&private.join("lessons.jsonl"), ©)?;
94 if crate::learn::lessons(&team.join("lessons.jsonl")).iter().any(|x| x.id == l.id) {
95 append_jsonl(&team.join("lessons.jsonl"), &tombstone(l))?;
96 }
97 Ok(())
98}
99
100pub fn tombstone(l: &Lesson) -> Lesson {
102 Lesson {
103 version: l.version.max(1) + 1,
104 question: WITHDRAWN.into(),
105 situations: Vec::new(),
106 consequence: String::new(),
107 history: Vec::new(),
108 facets: Vec::new(),
109 reach: PERSONAL.into(),
110 ..l.clone()
111 }
112}
113
114pub const TEAM_OPTION: &str = "team: everyone working on this code should hear it";
116pub const PERSONAL_OPTION: &str = "personal: move it to my private collective";
117
118fn repo_option(repo: &str) -> String {
119 format!("repo: only in {}", if repo.is_empty() { "the repo it was learned in" } else { repo })
120}
121
122fn verdict(text: &str) -> &'static str {
124 let t = text.trim().to_lowercase();
125 if t.starts_with("personal") || t.contains("private") || t.contains("only me") {
126 PERSONAL
127 } else if t.starts_with("repo") {
128 REPO
129 } else {
130 ""
131 }
132}
133
134pub fn apply_one(home: &Path, l: &Lesson, to: &str, cfg: Option<&crate::collective::Config>) -> Result<(), String> {
137 match to {
138 PERSONAL => {
139 let private = crate::collective::personal_store(cfg, home)
140 .filter(|p| p != home)
141 .ok_or("no private collective to move it to: add one with \"share\": \"local\" (or name it in \"personal\")")?;
142 move_to(home, &private, l).map_err(|e| e.to_string())
143 }
144 REPO => append_jsonl(&home.join("lessons.jsonl"), &Lesson { version: l.version + 1, reach: REPO.into(), ..l.clone() }).map_err(|e| e.to_string()),
145 _ => Ok(()),
146 }
147}
148
149pub fn answer(store: &Path, e: &crate::queen::Entry, text: &str) -> Result<(), String> {
151 let id = e.evidence.first().and_then(|x| x.strip_prefix("lesson ")).ok_or("this question names no lesson")?;
152 let l = crate::learn::lessons(&store.join("lessons.jsonl")).into_iter().find(|l| l.id == id).ok_or(format!("lesson {id} is not in this collective"))?;
153 apply_one(store, &l, verdict(text), crate::collective::load().ok().flatten().as_ref())
154}
155
156const PROMPT: &str = "A coding agent learned this lesson from an engineer's correction. Decide its reach: who should hear it. \
157Ask: would a teammate working on the same code get this correction too?
158- personal: how this one engineer likes the agent to work with them, not a property of the code: commit message style or \
159length, AI attribution or co-author trailers, whether and when to commit, push, branch or open a PR, what to stage, how to \
160report or ask, language and tone, their own tooling habits.
161- repo: a convention of the repo it was learned in that other repos do not share (its branch or deploy workflow, a tool or \
162command it mandates).
163- team: an engineering concern about the code, design, correctness or verification that any teammate should hear.
164Set confident to false when the lesson mixes these or you cannot tell. why: one short sentence.";
165
166fn schema() -> serde_json::Value {
167 serde_json::json!({"type": "object", "additionalProperties": false, "properties": {
168 "reach": {"type": "string", "enum": ["team", "repo", "personal"]}, "confident": {"type": "boolean"}, "why": {"type": "string"}},
169 "required": ["reach", "confident", "why"]})
170}
171
172#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
174pub struct Verdict {
175 pub id: String,
176 pub repo: String,
177 pub question: String,
178 pub reach: String,
179 pub confident: bool,
180 pub why: String,
181}
182
183pub fn candidates(home: &Path) -> Vec<Lesson> {
186 crate::learn::lessons(&home.join("lessons.jsonl")).into_iter().filter(|l| l.reach.is_empty() && (l.source.is_empty() || l.source == "queen")).collect()
187}
188
189pub type Ask = dyn Fn(&str, &serde_json::Value) -> Option<(serde_json::Value, Option<f64>)> + Sync;
191
192pub fn classify(lessons: &[Lesson], jobs: usize, ask: &Ask) -> (Vec<Verdict>, f64) {
194 let out = std::sync::Mutex::new((Vec::new(), 0.0));
195 let next = std::sync::atomic::AtomicUsize::new(0);
196 std::thread::scope(|s| {
197 for _ in 0..jobs.max(1) {
198 s.spawn(|| {
199 loop {
200 let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
201 let Some(l) = lessons.get(i) else { break };
202 let prompt = format!("{PROMPT}\n\nLesson (learned in repo {}):\nQuestion: {}\nConsequence: {}\n", l.repo, l.question, l.consequence);
203 let Some((v, cost)) = ask(&prompt, &schema()) else { continue };
204 let reach = v["reach"].as_str().filter(|r| [REPO, PERSONAL].contains(r)).unwrap_or_default();
205 let verdict = Verdict {
206 id: l.id.clone(),
207 repo: l.repo.clone(),
208 question: l.question.clone(),
209 reach: reach.into(),
210 confident: v["confident"].as_bool().unwrap_or(false),
211 why: v["why"].as_str().unwrap_or_default().into(),
212 };
213 let mut g = out.lock().unwrap();
214 g.0.push(verdict);
215 g.1 += cost.unwrap_or(0.0);
216 }
217 });
218 }
219 });
220 let (mut v, cost) = out.into_inner().unwrap();
221 v.sort_by(|a, b| a.id.cmp(&b.id));
222 (v, cost)
223}
224
225#[derive(Debug, Default, PartialEq)]
227pub struct Applied {
228 pub moved: usize,
229 pub scoped: usize,
230 pub asked: usize,
231}
232
233pub fn apply(home: &Path, plan: &[Verdict], cfg: Option<&crate::collective::Config>, now: i64) -> Result<Applied, String> {
236 let lessons = crate::learn::lessons(&home.join("lessons.jsonl"));
237 let mut done = Applied::default();
238 for v in plan {
239 let Some(l) = lessons.iter().find(|l| l.id == v.id && l.reach.is_empty()) else { continue };
240 match (v.confident, v.reach.as_str()) {
241 (true, PERSONAL) => {
242 apply_one(home, l, PERSONAL, cfg)?;
243 done.moved += 1;
244 }
245 (true, REPO) => {
246 apply_one(home, l, REPO, cfg)?;
247 done.scoped += 1;
248 }
249 (true, _) => {}
250 (false, _) => {
251 let key = crate::queen::decision_key(crate::queen::Kind::Reach, &l.repo, &l.id);
252 let default = match v.reach.as_str() {
253 PERSONAL => PERSONAL_OPTION.to_string(),
254 REPO => repo_option(&l.repo),
255 _ => TEAM_OPTION.to_string(),
256 };
257 let e = crate::queen::Entry {
258 id: format!("q{}", &key[..8]),
259 decision_key: key,
260 kind: crate::queen::Kind::Reach,
261 question: format!("Who should hear this lesson? \"{}\"", crate::stats::excerpt(&l.question, 300)),
262 options: vec![TEAM_OPTION.into(), repo_option(&l.repo), PERSONAL_OPTION.into()],
263 default,
264 evidence: vec![format!("lesson {}", l.id), format!("model: {}", v.why)],
265 session: "reach".into(),
266 cwd: String::new(),
267 repo: l.repo.clone(),
268 engineer: l.engineers.first().cloned().unwrap_or_default(),
269 ms: now,
270 answer: None,
271 answered_by: None,
272 answered_ms: None,
273 disagreeing: Vec::new(),
274 dismissed: None,
275 dismissed_ms: None,
276 payload: None,
277 };
278 if crate::queen::seed(home, &e).map_err(|e| e.to_string())? {
279 done.asked += 1;
280 }
281 }
282 }
283 }
284 Ok(done)
285}
286
287pub fn main(args: &[&str]) -> i32 {
289 let flag = |name: &str| args.iter().position(|a| *a == name).and_then(|i| args.get(i + 1)).copied();
290 let apply_it = args.contains(&"--apply");
291 let cwd = std::env::current_dir().unwrap_or_default().display().to_string();
292 let cfg = match crate::collective::load() {
293 Ok(c) => c,
294 Err(e) => {
295 eprintln!("borg reach: invalid collectives config: {e}");
296 return 1;
297 }
298 };
299 let crate::collective::Route::Collective { name, home, .. } = crate::collective::route(cfg.as_ref(), &cwd, &crate::learn::home()) else {
300 eprintln!("borg reach: {cwd} belongs to no collective");
301 return 1;
302 };
303 let plan_path = flag("--plan").map(PathBuf::from).unwrap_or_else(|| home.join("reach-plan.json"));
304 if apply_it {
305 let plan: Vec<Verdict> = match std::fs::read_to_string(&plan_path).ok().and_then(|t| serde_json::from_str(&t).ok()) {
306 Some(p) => p,
307 None => {
308 eprintln!("borg reach: no plan at {} (run `borg reach` first)", plan_path.display());
309 return 1;
310 }
311 };
312 return match apply(&home, &plan, cfg.as_ref(), crate::learn::now_ms()) {
313 Ok(a) => {
314 println!(
315 "{name}: {} moved to the private collective, {} limited to their repo, {} queued for the queen (`borg queen`)",
316 a.moved, a.scoped, a.asked
317 );
318 0
319 }
320 Err(e) => {
321 eprintln!("borg reach: {e}");
322 1
323 }
324 };
325 }
326 let todo = candidates(&home);
327 let jobs = flag("--jobs").and_then(|j| j.parse().ok()).unwrap_or(4);
328 eprintln!("borg reach: classifying {} lessons of {name} (one model call each, {jobs} at a time)", todo.len());
329 let (plan, cost) = classify(&todo, jobs, &|p: &str, s: &serde_json::Value| crate::learn::ask_model(p, s));
330 for (label, pick) in [("personal: move to the private collective", PERSONAL), ("repo: deliver only in its repo", REPO)] {
331 let hits: Vec<&Verdict> = plan.iter().filter(|v| v.confident && v.reach == pick).collect();
332 println!("\n{label} ({})", hits.len());
333 for v in hits {
334 println!(" {} {} ({})", v.id, crate::stats::excerpt(&v.question, 110), v.why);
335 }
336 }
337 let unsure: Vec<&Verdict> = plan.iter().filter(|v| !v.confident).collect();
338 println!("\nunsure: a question for the queen ({})", unsure.len());
339 for v in unsure {
340 println!(" {} [{}?] {}", v.id, if v.reach.is_empty() { "team" } else { &v.reach }, crate::stats::excerpt(&v.question, 110));
341 }
342 let team = plan.iter().filter(|v| v.confident && v.reach.is_empty()).count();
343 println!("\nteam: unchanged ({team}); {} of {} classified, ${cost:.2}", plan.len(), todo.len());
344 match std::fs::write(&plan_path, serde_json::to_string_pretty(&plan).unwrap_or_default()) {
345 Ok(()) => {
346 println!("plan: {} (apply with `borg reach --apply --plan {}`)", plan_path.display(), plan_path.display());
347 0
348 }
349 Err(e) => {
350 eprintln!("borg reach: cannot write {}: {e}", plan_path.display());
351 1
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 fn lesson(reach: &str, repo: &str) -> Challenge {
361 Challenge { id: "L".into(), reach: reach.into(), repo: repo.into(), ..Default::default() }
362 }
363
364 #[test]
365 fn personal_is_never_delivered_and_repo_only_in_its_repo() {
366 let here = ["app".to_string()];
367 assert!(deliverable(&lesson("", "other"), &here), "team lessons go everywhere");
368 assert!(!deliverable(&lesson(PERSONAL, "app"), &here));
369 assert!(deliverable(&lesson(REPO, "App"), &here));
370 assert!(!deliverable(&lesson(REPO, "other"), &here));
371 assert!(!deliverable(&lesson(REPO, "app"), &[]), "unknown repo: a repo convention stays out");
372 }
373
374 fn tmp(tag: &str) -> PathBuf {
375 let d = std::env::temp_dir().join(format!("borg-reach-{tag}-{}", std::process::id()));
376 let _ = std::fs::remove_dir_all(&d);
377 std::fs::create_dir_all(&d).unwrap();
378 d
379 }
380
381 fn config(local: &[&str]) -> crate::collective::Config {
382 let mut collectives = vec![serde_json::json!({"name": "work", "paths": ["/w"], "share": "team"})];
383 collectives.extend(local.iter().map(|n| serde_json::json!({"name": n, "paths": [format!("/{n}")], "share": "local"})));
384 serde_json::from_value(serde_json::json!({"collectives": collectives})).unwrap()
385 }
386
387 fn learned(id: &str, version: u32, reach: &str) -> Lesson {
388 Lesson { id: id.into(), question: "never add a co-author trailer?".into(), consequence: "c".into(), version, reach: reach.into(), ..Default::default() }
389 }
390
391 #[test]
392 fn a_personal_lesson_goes_to_the_private_store_and_is_withdrawn_from_the_team() {
393 let base = tmp("move");
394 let cfg = config(&["private"]);
395 let (team, private) = (crate::collective::store(&base, "work"), crate::collective::store(&base, "private"));
396 std::fs::create_dir_all(&team).unwrap();
397 assert!(matches!(place_with(&team, &learned("L1", 1, PERSONAL), Some(&cfg)), Placed::Private(p) if p == private));
399 assert!(crate::learn::lessons(&team.join("lessons.jsonl")).is_empty());
400 let copy = &crate::learn::lessons(&private.join("lessons.jsonl"))[0];
401 assert_eq!((copy.id.as_str(), copy.reach.as_str(), copy.version), ("L1", "", 1));
402 append_jsonl(&team.join("lessons.jsonl"), &learned("L2", 3, "")).unwrap();
404 move_to(&team, &private, &learned("L2", 3, "")).unwrap();
405 let t = crate::learn::lessons(&team.join("lessons.jsonl")).into_iter().find(|l| l.id == "L2").unwrap();
406 assert_eq!((t.version, t.reach.as_str(), t.question.as_str(), t.consequence.as_str()), (4, PERSONAL, WITHDRAWN, ""));
407 let c = Challenge { id: t.id.clone(), reach: t.reach.clone(), ..Default::default() };
408 assert!(!deliverable(&c, &["app".into()]), "the tombstone is never delivered");
409 }
410
411 #[test]
412 fn without_a_private_collective_personal_lessons_are_dropped_not_shared() {
413 let base = tmp("drop");
414 let team = crate::collective::store(&base, "work");
415 std::fs::create_dir_all(&team).unwrap();
416 assert!(matches!(place_with(&team, &learned("L1", 1, PERSONAL), Some(&config(&[]))), Placed::Dropped));
417 assert!(matches!(place_with(&team, &learned("L1", 1, PERSONAL), Some(&config(&["a", "b"]))), Placed::Dropped), "two local ones and no key: none");
418 assert!(crate::learn::lessons(&team.join("lessons.jsonl")).is_empty());
419 assert!(matches!(place_with(&team, &learned("L2", 1, ""), Some(&config(&[]))), Placed::Here(l) if l.reach.is_empty()));
420 }
421
422 #[test]
423 fn learned_inside_the_private_collective_it_stays_there() {
424 let base = tmp("here");
425 let private = crate::collective::store(&base, "private");
426 let kept = place_with(&private, &learned("L1", 1, PERSONAL), Some(&config(&["private"])));
427 assert!(matches!(kept, Placed::Here(l) if l.reach.is_empty()), "kept, and deliverable there");
428 }
429
430 #[test]
431 fn a_plan_moves_scopes_and_asks_and_answers_apply_the_move() {
432 let base = tmp("plan");
433 let cfg = config(&["private"]);
434 let (team, private) = (crate::collective::store(&base, "work"), crate::collective::store(&base, "private"));
435 std::fs::create_dir_all(&team).unwrap();
436 for (id, q) in [("La", "no co-author trailer?"), ("Lb", "stage branch refspec?"), ("Lc", "lock order?"), ("Ld", "report style?")] {
437 append_jsonl(&team.join("lessons.jsonl"), &Lesson { id: id.into(), question: q.into(), repo: "app".into(), version: 1, ..Default::default() })
438 .unwrap();
439 }
440 let fake = |p: &str, _: &serde_json::Value| {
441 let p = p.split("Question: ").nth(1).unwrap_or_default();
442 let (reach, confident) = if p.contains("trailer") {
443 ("personal", true)
444 } else if p.contains("refspec") {
445 ("repo", true)
446 } else if p.contains("report") {
447 ("personal", false)
448 } else {
449 ("team", true)
450 };
451 Some((serde_json::json!({"reach": reach, "confident": confident, "why": "w"}), Some(0.01)))
452 };
453 let (plan, cost) = classify(&candidates(&team), 2, &fake);
454 assert_eq!((plan.len(), (cost * 100.0).round()), (4, 4.0));
455 let done = apply(&team, &plan, Some(&cfg), 1000).unwrap();
456 assert_eq!(done, Applied { moved: 1, scoped: 1, asked: 1 });
457 let latest = |h: &Path, id: &str| crate::learn::lessons(&h.join("lessons.jsonl")).into_iter().find(|l| l.id == id);
458 assert_eq!(latest(&team, "La").unwrap().reach, PERSONAL);
459 assert_eq!(latest(&private, "La").unwrap().question, "no co-author trailer?");
460 assert_eq!(latest(&team, "Lb").unwrap().reach, REPO);
461 assert_eq!(latest(&team, "Lc").unwrap().version, 1, "team: untouched");
462 assert!(candidates(&team).iter().all(|l| l.id == "Lc" || l.id == "Ld"), "moved and scoped ones are no candidates any more");
463 let q = crate::queen::entries(&team).into_iter().find(|e| e.kind == crate::queen::Kind::Reach).unwrap();
465 assert_eq!(q.options.len(), 3);
466 let l = latest(&team, "Ld").unwrap();
467 apply_one(&team, &l, verdict(&q.options[2]), Some(&cfg)).unwrap();
468 assert_eq!(latest(&team, "Ld").unwrap().reach, PERSONAL);
469 assert_eq!((verdict(&q.options[0]), verdict(&q.options[1])), ("", REPO));
470 }
471
472 #[test]
473 fn merges_never_widen() {
474 assert_eq!(narrower("", REPO), REPO);
475 assert_eq!(narrower(PERSONAL, ""), PERSONAL);
476 assert_eq!(narrower(REPO, PERSONAL), PERSONAL);
477 assert_eq!(narrower("", ""), "");
478 }
479}