1use std::io::{Read, Write};
11use std::path::PathBuf;
12
13use borg_trace::fingerprint::bash_read_paths;
14use serde_json::{Value, json};
15
16use crate::harness::{self, Harness};
17use crate::learn::learning;
18use crate::{collective, conscience, learn, live, outcome, predict, prefilter};
19
20pub(crate) fn state_file(session: &str) -> PathBuf {
21 let dir = std::env::var_os("BORG_STATE").map_or_else(|| PathBuf::from("/tmp/borg"), PathBuf::from);
22 let _ = std::fs::create_dir_all(&dir);
23 let safe: String = session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
24 dir.join(format!("{safe}.ctx"))
25}
26
27pub(crate) fn append(session: &str, text: &str) {
30 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session)) {
31 let _ = f.write_all(format!("{text}\n").as_bytes());
32 }
33}
34
35pub(crate) fn touched(input: &Value, tool: &str) -> String {
37 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
38 match tool {
39 "Bash" => {
40 let cmd = s("command");
41 let paths = bash_read_paths(&cmd);
42 if paths.is_empty() { cmd.chars().take(COMMAND_CHARS).collect() } else { paths.join(" ") }
43 }
44 "Grep" => format!("{} {}", s("pattern"), s("path")),
45 "Glob" => s("pattern"),
46 _ => {
47 let mut t = [s("file_path"), s("notebook_path"), s("path")].join(" ").trim().to_string();
48 for p in further_files(input) {
50 if !t.is_empty() {
51 t.push(' ');
52 }
53 t.push_str(p);
54 }
55 t
56 }
57 }
58}
59
60const COMMAND_CHARS: usize = borg_trace::dialogue::TOUCH_CHARS;
64
65pub(crate) fn further_files(input: &Value) -> impl Iterator<Item = &str> {
69 input.get("edits").and_then(Value::as_array).into_iter().flatten().filter_map(|e| e.get("file_path").and_then(Value::as_str)).filter(|p| !p.is_empty())
70}
71
72pub(crate) fn repo_of(cwd: &str) -> String {
73 borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
74}
75
76fn stat(session: &str, input: &Value, kind: &str, fill: impl FnOnce(&mut crate::stats::Event)) {
78 let home = learn::home();
79 let mut ev = crate::stats::Event::new(kind, learn::now_ms());
80 ev.session = session.into();
81 ev.repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
82 ev.engineer = crate::presence::read(&home, session).map(|p| p.engineer).unwrap_or_default();
84 fill(&mut ev);
85 crate::stats::record(&home, &ev);
86}
87
88fn prompts_file(session: &str) -> PathBuf {
90 state_file(session).with_extension("prompts")
91}
92
93fn turn_id(session: &str) -> u64 {
96 std::fs::metadata(prompts_file(session)).map_or(0, |m| m.len())
97}
98
99fn where_file(session: &str) -> PathBuf {
103 state_file(session).with_extension("where")
104}
105
106fn where_now(session: &str) -> String {
109 let paths = std::fs::read_to_string(where_file(session)).unwrap_or_default();
110 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
111 format!("{paths}\n{latest}")
112}
113
114fn note_where(session: &str, text: &str) {
115 if text.trim().is_empty() {
116 return;
117 }
118 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(where_file(session)) {
119 let _ = writeln!(f, "{text}");
120 }
121}
122
123fn task_file(session: &str) -> PathBuf {
124 state_file(session).with_extension("task")
125}
126
127fn denied_file(session: &str) -> PathBuf {
131 state_file(session).with_extension("denied")
132}
133
134fn capture_prompt(h: &dyn Harness, session: &str, input: &Value, prompt: &str) {
137 if !learning() {
138 return;
139 }
140 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
141 if let Ok(tool) = std::fs::read_to_string(denied_file(session)) {
142 let _ = std::fs::remove_file(denied_file(session));
143 let said = prompt.trim();
144 if said.len() > 3 && !said.starts_with('<') {
145 record(session, input, task, proposal(h, input), Some(tool), said.chars().take(learn::EXCERPT_CHARS).collect());
146 return;
147 }
148 }
149 if !learn::is_correction(prompt) || task.is_empty() {
150 let task: String = prompt.chars().take(learn::TASK_CHARS).collect();
151 let _ = std::fs::write(task_file(session), task);
152 return;
153 }
154 record(session, input, task, proposal(h, input), None, prompt.chars().take(learn::EXCERPT_CHARS).collect());
155}
156
157fn veto(h: &dyn Harness, session: &str, input: &Value, reason: &str) {
160 let _ = std::fs::remove_file(denied_file(session));
161 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
162 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
163 let said = crate::veto::Said {
164 session,
165 repo: &repo_of(cwd),
166 engineer: &learn::engineer(cwd),
167 task: &task,
168 agent: &proposal(h, input),
169 reason,
170 ms: learn::now_ms(),
171 via: "prompt",
172 };
173 if let Ok(v) = crate::veto::record(&learn::home(), &said) {
174 append(session, &format!("#borg-episode {}", v.episode));
175 }
176}
177
178fn capture_rejection(h: &dyn Harness, session: &str, input: &Value) {
181 if !learning() {
182 return;
183 }
184 let error = input.get("error").and_then(Value::as_str).unwrap_or_default();
185 let tool = format!(
186 "{} {}",
187 input.get("tool_name").and_then(Value::as_str).unwrap_or_default(),
188 input.get("tool_input").map(|v| v.to_string()).unwrap_or_default().chars().take(learn::EXCERPT_CHARS).collect::<String>()
189 );
190 let Some(said) = learn::rejection(error) else {
191 if learn::denied(error) {
192 let _ = std::fs::write(denied_file(session), tool);
193 }
194 return;
195 };
196 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
197 record(session, input, task, proposal(h, input), Some(tool), said);
198}
199
200fn proposal(h: &dyn Harness, input: &Value) -> String {
203 let t = full_proposal(h, input);
204 t.chars().rev().take(learn::PROPOSAL_CHARS).collect::<Vec<_>>().into_iter().rev().collect()
205}
206
207fn full_proposal(h: &dyn Harness, input: &Value) -> String {
210 harness::last_message(h.caps(), input)
211}
212
213fn record(session: &str, input: &Value, task: String, agent: String, rejected_tool: Option<String>, correction: String) {
214 let ms = learn::now_ms();
215 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
216 let e = learn::Episode {
217 id: format!("{}-{ms}", session.chars().take(8).collect::<String>()),
218 ms,
219 repo: repo_of(cwd),
220 session: session.into(),
221 task,
222 agent,
223 rejected_tool,
224 correction,
225 engineer: learn::engineer(cwd),
226 ..learn::Episode::default()
227 };
228 let _ = learn::record_episode(&e);
229 append(session, &format!("#borg-episode {}", e.id));
230}
231
232fn lessons(session: &str) -> Vec<conscience::Challenge> {
237 loaded(session).0
238}
239
240fn judged(all: &[conscience::Challenge], withheld: Vec<conscience::Challenge>) -> std::borrow::Cow<'_, [conscience::Challenge]> {
244 if withheld.is_empty() { all.into() } else { all.iter().cloned().chain(withheld).collect::<Vec<_>>().into() }
245}
246
247fn loaded(session: &str) -> (Vec<conscience::Challenge>, Vec<conscience::Challenge>) {
249 let before = std::env::var("BORG_BEFORE").ok().and_then(|v| v.parse().ok());
250 let mut all = conscience::load_store_for(&lessons_path(), before, Some(session));
252 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
255 let repos = crate::systems::work_repos(&where_text);
256 if crate::systems::on() {
257 crate::systems::gate(&mut all, &repos);
258 }
259 all.retain(|c| crate::reach::deliverable(c, &repos));
261 let withheld = crate::absorb::withhold_beside(&lessons_path(), &mut all, before);
263 if crate::concepts::on() {
266 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
267 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
268 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
269 crate::concepts::apply(&mut all, &crate::concepts::of_work(&edits, &where_text, &latest), crate::concepts::beta());
270 }
271 (all, withheld)
272}
273
274fn lessons_path() -> PathBuf {
276 std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
277}
278
279fn edits_file(session: &str) -> PathBuf {
284 state_file(session).with_extension("edits")
285}
286
287fn cost_file(session: &str) -> PathBuf {
291 state_file(session).with_extension("cost")
292}
293
294fn changes_file(session: &str) -> PathBuf {
297 state_file(session).with_extension("changes")
298}
299
300fn log_cost(session: &str, line: &str) {
301 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(cost_file(session)) {
302 let _ = writeln!(f, "{line}");
303 }
304}
305
306fn early_acted(session: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
313 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
314 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
315 let lines: Vec<&str> = edits.lines().filter(|l| !l.starts_with(WORK)).collect();
316 let mut out = std::collections::HashSet::new();
317 for (p, l) in lines.iter().enumerate() {
318 let Some(list) = l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")) else { continue };
319 let ids: std::collections::HashSet<&str> = ids_in(list).collect();
320 let asked: Vec<&conscience::Challenge> = all.iter().filter(|c| ids.contains(c.id.as_str())).collect();
321 let after: Vec<&str> = lines[p + 1..].iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
322 let upto = state.find(&format!("{l}\n")).map_or(state.as_str(), |i| &state[..i]);
323 let before = format!("{upto}\n{}", lines[..p].join("\n"));
324 out.extend(crate::memory::resolve(&asked, all, &before, &after.join("\n"), final_msg, crate::memory::NOVEL_WORDS).0);
325 }
326 out
327}
328
329fn done_in<'a>(state: &'a str, acted: &std::collections::HashSet<String>) -> std::collections::HashSet<&'a str> {
334 let mut done = listed(state, "#borg-asked ");
335 done.extend(listed(state, "#borg-early ").into_iter().chain(listed(state, "#borg-queued ")).filter(|i| acted.contains(*i)));
336 done
337}
338
339fn had_in(state: &str) -> std::collections::HashSet<&str> {
342 let mut had = listed(state, "#borg-asked ");
343 had.extend(listed(state, "#borg-early "));
344 had.extend(listed(state, "#borg-queued "));
345 had
346}
347
348const WORK: &str = "#borg-work ";
353
354pub(crate) fn edit_text(tool: &str, input: &Value) -> Option<String> {
357 if !["edit", "write", "multiedit", "notebookedit"].contains(&tool.to_ascii_lowercase().as_str()) {
358 return None;
359 }
360 let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
361 let mut text = [s(input, "file_path"), s(input, "notebook_path")].join(" ");
362 for p in further_files(input) {
363 text.push(' ');
364 text.push_str(p);
365 }
366 for k in ["new_string", "content", "new_source"] {
367 text.push(' ');
368 text.push_str(&s(input, k));
369 }
370 for e in input.get("edits").and_then(Value::as_array).into_iter().flatten() {
371 text.push(' ');
372 text.push_str(&s(e, "new_string"));
373 }
374 Some(text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(EDIT_CHARS).collect())
375}
376
377const EDIT_CHARS: usize = 600;
380
381const EVIDENCE_CHARS: usize = 4000;
387const _: () = assert!(EVIDENCE_CHARS >= 3 * predict::JUDGE_PART);
388
389pub(crate) fn one_line(text: &str, n: usize) -> String {
391 text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
392}
393
394fn note_answer(h: &dyn Harness, session: &str, input: &Value) {
398 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
399 let lines: Vec<&str> = edits.lines().collect();
400 let Some(i) = lines.iter().rposition(|l| l.starts_with("#borg-asked ")) else { return };
401 if lines[i + 1..].iter().any(|l| l.starts_with("#borg-answer ")) {
402 return;
403 }
404 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
405 let _ = writeln!(f, "#borg-answer {}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
406 }
407}
408
409const MAX_PENDING: usize = conscience::CAP;
414const MAX_JUDGE_TRIES: usize = 2;
420
421const END_MAX_PENDING: usize = 4 * MAX_PENDING;
427
428const END_WORK_CHARS: usize = predict::TAIL;
433
434fn end_timeout() -> std::time::Duration {
439 learn::learn_timeout().min(predict::timeout().saturating_mul(2))
440}
441
442const END_JUDGED: &str = "#borg-end-judged ";
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451enum When {
452 Live,
453 Stop,
454 End,
455}
456
457const DELIVERED: [&str; 4] = ["#borg-asked ", "#borg-early ", "#borg-queued ", "#borg-placebo "];
462
463const ROUTED: &str = "#borg-routed ";
467
468fn is_marker(line: &str) -> bool {
471 DELIVERED.iter().any(|m| line.starts_with(m)) || line.starts_with("#borg-live ") || line.starts_with(ROUTED)
472}
473
474#[must_use]
479pub fn stripped(check: &str, lesson: &str) -> bool {
480 let mut h = blake3::Hasher::new();
481 h.update(b"borg-strip-v1\0");
482 h.update(format!("{check}\0{lesson}").as_bytes());
483 h.finalize().as_bytes()[0] & 1 == 1
484}
485
486struct Judging<'a> {
489 pending: Vec<predict::Pending<'a>>,
490 question: bool,
491 crowded: Vec<String>,
492}
493
494fn pending<'a>(session: &str, state: &str, all: &'a [conscience::Challenge], final_msg: &str, when: When, judging: &str) -> Judging<'a> {
517 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
518 let mut settled = std::collections::HashSet::new();
519 for l in state.lines() {
520 if let Some(l) = l.strip_prefix("#borg-judged ") {
521 let (id, label) = l.split_once('=').unwrap_or((l, ""));
522 *tries.entry(id).or_default() += 1;
523 if label.trim() != "pending" {
524 settled.insert(id);
525 }
526 } else if let Some(list) = l.strip_prefix(END_JUDGED) {
527 settled.extend(ids_in(list));
528 } else if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
529 for id in ids_in(list) {
530 tries.remove(id);
531 settled.remove(id);
532 }
533 }
534 }
535 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
536 let lines: Vec<&str> = edits.lines().collect();
537 let part = predict::JUDGE_PART;
538 struct Delivery<'l> {
540 channel: &'static str,
541 question: bool,
543 before: &'l str,
544 answers: Vec<&'l str>,
545 work: Vec<&'l str>,
546 fresh: Vec<(&'l str, &'l str, bool)>,
547 held: Vec<&'l str>,
549 }
550 let mut deliveries: Vec<Delivery> = Vec::new();
551 for (p, l) in lines.iter().enumerate() {
552 let Some((marker, list)) = DELIVERED.iter().find_map(|m| l.strip_prefix(m).map(|rest| (*m, rest))) else { continue };
553 let question = marker == "#borg-asked ";
554 let later = &lines[p + 1..];
555 let channel = match marker {
556 "#borg-asked " if later.first().is_some_and(|n| n.starts_with("#borg-live ")) => "live",
557 "#borg-asked " => "stop",
558 "#borg-early " => "early",
559 "#borg-queued " => "queued",
560 _ => outcome::PLACEBO,
561 };
562 let answers: Vec<&str> = if question { later.iter().filter_map(|l| l.strip_prefix("#borg-answer ")).collect() } else { Vec::new() };
563 let work: Vec<&str> = later.iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
564 let (list, tag) = tagged(list);
565 if held(when, question, &answers) {
566 let held = list.split(',').map(str::trim).filter(|i| !i.is_empty()).collect();
567 deliveries.push(Delivery { channel, question, before: "", answers, work, fresh: Vec::new(), held });
568 continue;
569 }
570 let before = match lines[..p].iter().rev().find(|b| !is_marker(b)) {
573 Some(b) if b.starts_with(WORK) => &b[WORK.len()..],
574 Some(b) if !b.starts_with("#borg-") => b,
575 _ => "",
576 };
577 let routed: std::collections::HashMap<&str, &str> =
578 later.first().and_then(|n| n.strip_prefix(ROUTED)).into_iter().flat_map(|r| r.split(',')).filter_map(|it| it.split_once('=')).collect();
579 let fresh: Vec<&str> = list
580 .split(',')
581 .map(str::trim)
582 .filter(|i| !i.is_empty() && !settled.contains(i) && (when == When::End || tries.get(i).copied().unwrap_or(0) < MAX_JUDGE_TRIES))
583 .filter(|i| all.iter().any(|c| c.id == *i))
584 .collect();
585 let check_of = |id: &str| routed.get(id).copied().or(tag).unwrap_or_default();
586 let fresh =
587 fresh.into_iter().map(|id| (id, check_of(id), question && stripped(if check_of(id).is_empty() { judging } else { check_of(id) }, id))).collect();
588 deliveries.push(Delivery { channel, question, before, answers, work, fresh, held: Vec::new() });
589 }
590 let strip: Vec<&str> = deliveries.iter().flat_map(|d| &d.fresh).filter(|f| f.2).map(|f| f.0).collect();
593 let blind = |text: &str| strip.iter().fold(text.to_string(), |t, id| crate::memory::strip_declaration(&t, id));
594 let turn = predict::clip(&one_line(&blind(final_msg), part + 1), part);
595 let mut out: Vec<(predict::Pending, bool)> = Vec::new();
597 for d in deliveries {
598 out.retain(|x| !d.held.contains(&x.0.lesson.id.as_str()));
600 let answer = blind(&d.answers.join(" "));
602 let said = answer.to_lowercase();
603 let since: Vec<&str> = std::iter::once(answer.as_str()).filter(|a| !a.is_empty()).chain(d.work.iter().copied()).collect();
604 let after = format!("work since: {}\nthis turn: {turn}", predict::clip(&blind(&since.join(" | ")), 2 * part));
605 let before = blind(d.before);
606 for (id, check, stripped) in d.fresh {
607 let Some(c) = all.iter().find(|c| c.id == id) else { continue };
608 out.retain(|x| x.0.lesson.id != id);
610 let declared = if stripped { None } else { crate::memory::declared(&said, &id.to_lowercase()) };
611 let pending =
612 predict::Pending { lesson: c, before: before.clone(), after: after.clone(), declared, check: check.to_string(), channel: d.channel, stripped };
613 out.push((pending, d.question));
614 }
615 }
616 let slots = if when == When::End { END_MAX_PENDING } else { MAX_PENDING };
617 let questions = out.iter().filter(|x| x.1).count().min(slots);
618 let mut room = [slots - questions, questions];
619 let (mut kept, mut crowded): (Vec<(predict::Pending, bool)>, Vec<String>) = (Vec::new(), Vec::new());
620 for x in out.into_iter().rev() {
621 let r = &mut room[usize::from(x.1)];
622 if *r > 0 {
623 *r -= 1;
624 kept.push(x);
625 } else {
626 crowded.push(x.0.lesson.id.clone());
627 }
628 }
629 kept.reverse();
630 crowded.sort();
631 Judging { pending: kept.into_iter().map(|x| x.0).collect(), question: questions > 0, crowded }
632}
633
634fn held(when: When, question: bool, answers: &[&str]) -> bool {
647 when == When::Live && (!question || answers.is_empty())
648}
649
650fn without_declarations<'t>(text: &'t str, pending: &[predict::Pending]) -> std::borrow::Cow<'t, str> {
653 pending.iter().filter(|p| p.stripped).fold(std::borrow::Cow::Borrowed(text), |t, p| crate::memory::strip_declaration(&t, &p.lesson.id).into())
654}
655
656fn note_unjudged(session: &str, ids: &[String], why: &str) {
660 if !ids.is_empty() {
661 append(session, &format!("#borg-unjudged {} why={why}", ids.join(",")));
662 }
663}
664
665pub(crate) fn unjudged(state: &str) -> std::collections::HashMap<String, &'static str> {
670 let mut why: std::collections::HashMap<String, &'static str> = std::collections::HashMap::new();
671 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
672 for l in state.lines() {
673 if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
674 for id in ids_in(list) {
675 tries.remove(id);
676 why.insert(id.to_string(), outcome::UNJUDGED_NO_CALL);
677 }
678 } else if let Some(l) = l.strip_prefix("#borg-judged ") {
679 let (id, label) = l.split_once('=').unwrap_or((l, ""));
680 if label.trim() == "pending" {
681 let n = tries.entry(id).or_default();
682 *n += 1;
683 if *n >= MAX_JUDGE_TRIES {
684 why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
685 }
686 }
687 } else if let Some(list) = l.strip_prefix(END_JUDGED) {
688 for id in ids_in(list) {
690 tries.insert(id, MAX_JUDGE_TRIES);
691 why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
692 }
693 } else if let Some(rest) = l.strip_prefix("#borg-unjudged ") {
694 let (list, reason) = rest.rsplit_once(" why=").unwrap_or((rest, ""));
695 let reason = match reason.trim() {
696 "slots" => outcome::UNJUDGED_SLOTS,
697 "failed" => outcome::UNJUDGED_FAILED,
698 _ => continue,
699 };
700 for id in list.split(',').map(str::trim).filter(|i| !i.is_empty()) {
701 if tries.get(id).copied().unwrap_or(0) < MAX_JUDGE_TRIES {
702 why.insert(id.to_string(), reason);
703 }
704 }
705 }
706 }
707 why
708}
709
710fn record_verdicts(session: &str, repo: &str, at: &str, pending: &[predict::Pending], pred: &predict::Prediction, chars: usize, call: usize) {
725 type Key<'k> = (&'k str, &'k str, &'k str, bool);
728 let mut records: std::collections::BTreeMap<Key, Vec<(String, String)>> = std::collections::BTreeMap::new();
729 for p in pending {
730 let judged = pred.resolved.iter().find(|j| j.id == p.lesson.id);
731 let word = match judged {
734 Some(j) if j.label().is_some() && (j.verdict != predict::Verdict::NotArisen || at == outcome::AT_END) => j.verdict.word(),
735 _ => "pending",
736 };
737 append(session, &format!("#borg-judged {}={word}", p.lesson.id));
738 let Some(j) = judged else { continue };
739 records.entry((j.verdict.kind(), p.channel, p.check.as_str(), p.stripped)).or_default().push((p.lesson.id.clone(), j.reason.clone()));
740 }
741 let at_field = if at.is_empty() { String::new() } else { format!(" at={at}") };
742 log_cost(session, &format!("{}{at_field}", judge_line(call, pending, &pred.resolved, chars, &pred.returned)));
743 if learning() {
744 let (home, ms) = (learn::home(), learn::now_ms());
745 let mut counted: std::collections::BTreeMap<(&str, &str), Vec<String>> = std::collections::BTreeMap::new();
747 for ((kind, channel, check, stripped), judged) in records {
748 let (lessons, reasons): (Vec<String>, Vec<String>) = judged.into_iter().unzip();
749 counted.entry((kind, channel)).or_default().extend(lessons.iter().cloned());
750 let o = outcome::Outcome {
751 kind: kind.into(),
752 session: session.into(),
753 lessons,
754 ms,
755 repo: repo.into(),
756 channel: channel.into(),
757 check: check.into(),
758 judge: predict::JUDGE_VERSION,
759 stripped,
760 at: at.into(),
761 reasons: if reasons.iter().all(String::is_empty) { Vec::new() } else { reasons },
763 ..Default::default()
764 };
765 let _ = learn::append_jsonl(&home.join("outcomes.jsonl"), &o);
766 }
767 for ((kind, channel), lessons) in counted {
768 outcome::count_result(
769 &home,
770 &outcome::Outcome { kind: kind.into(), session: session.into(), lessons, ms, repo: repo.into(), channel: channel.into(), ..Default::default() },
771 );
772 }
773 }
774}
775
776fn judge_line(call: usize, pending: &[predict::Pending], resolved: &[predict::Judged], chars: usize, returned: &[String]) -> String {
784 let unmatched: Vec<&str> = returned.iter().map(String::as_str).filter(|id| !pending.iter().any(|p| p.lesson.id == *id)).collect();
785 let mine: Vec<&predict::Judged> = resolved.iter().filter(|j| pending.iter().any(|p| p.lesson.id == j.id)).collect();
786 let settled = mine.iter().filter(|j| j.label().is_some()).count();
787 let verdicts: Vec<String> = predict::Verdict::ALL.iter().map(|v| format!("{}:{}", v.word(), mine.iter().filter(|j| j.verdict == *v).count())).collect();
788 let reason_chars: usize = mine.iter().map(|j| j.reason.chars().count()).sum();
789 format!(
790 "judge call={call} pending={} resolved={settled} chars={chars} returned={} unmatched={} verdicts={} reason_chars={reason_chars}",
791 pending.len(),
792 returned.join(","),
793 unmatched.join(","),
794 verdicts.join(",")
795 )
796}
797
798pub fn judge_session(session: &str) -> usize {
810 if !learning() {
811 return 0;
812 }
813 let ctx = state_file(session);
814 let opened = |state: &str| state.lines().any(|l| DELIVERED.iter().any(|m| l.starts_with(m)));
815 if !opened(&std::fs::read_to_string(&ctx).unwrap_or_default()) {
816 return 0;
817 }
818 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
819 if crate::runner::choose(Some(&model)).is_empty() {
820 return 0;
821 }
822 let wait = u64::try_from(live::stale_ms()).unwrap_or(u64::MAX);
825 if live::wait_free(&ctx, wait) == live::Lock::Held {
826 return 0;
827 }
828 let Some(_guard) = live::acquire(&ctx, learn::now_ms()) else { return 0 };
829 let state = std::fs::read_to_string(&ctx).unwrap_or_default();
830 let (all, withheld) = loaded(session);
831 let judged = judged(&all, withheld);
832 let last = crate::notes::proposal(&crate::notes::state_dir(), session);
833 let check = crate::select::check_id(session, "end", learn::now_ms(), std::process::id());
834 let judging = pending(session, &state, &judged, &last, When::End, &check);
835 note_unjudged(session, &judging.crowded, "slots");
836 let pend = &judging.pending;
837 if pend.is_empty() {
838 return 0;
839 }
840 let work = session_work(session, &state, pend);
841 let repo = where_repo(session);
842 let started = std::time::Instant::now();
843 let pred = predict::judge(&work, pend, &model, end_timeout());
844 crate::stats::count(&learn::home(), crate::stats::MODEL_CALL, session, |ev| {
845 ev.why = "judge".into();
846 ev.repo = repo.clone();
847 ev.n = pend.len() as u64;
848 ev.usd = pred.as_ref().map_or(0.0, |p| p.cost);
849 ev.dur_ms = started.elapsed().as_millis() as u64;
850 if pred.is_none() {
851 ev.text = "no answer".into();
852 }
853 });
854 let ids: Vec<String> = pend.iter().map(|p| p.lesson.id.clone()).collect();
855 let Some(pred) = pred else {
856 note_unjudged(session, &ids, "failed");
857 return 0;
858 };
859 let call = state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1;
861 record_verdicts(session, &repo, outcome::AT_END, pend, &pred, predict::judge_prompt(&work, pend).len(), call);
862 append(session, &format!("{END_JUDGED}{}", ids.join(",")));
863 ids.len()
864}
865
866fn session_work(session: &str, state: &str, pending: &[predict::Pending]) -> String {
872 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
873 let done: Vec<&str> = edits
874 .lines()
875 .filter_map(|l| match l.strip_prefix("#borg-answer ") {
876 Some(a) => Some(a),
877 None => (!l.starts_with("#borg-")).then_some(l),
878 })
879 .collect();
880 let said = harness::session_texts(harness::current(), session, END_WORK_CHARS);
881 let parts = [
882 ("The engineer's prompts and the agent's touches", conscience::work_text(state).trim().to_string()),
883 ("The agent's edits and answers", done.join("\n")),
884 ("The agent's messages", said),
885 ];
886 without_declarations(&within(&parts, END_WORK_CHARS), pending).into_owned()
887}
888
889fn within(parts: &[(&str, String)], budget: usize) -> String {
893 let len: Vec<usize> = parts.iter().map(|(_, t)| t.chars().count()).collect();
894 let mut order: Vec<usize> = (0..parts.len()).filter(|i| len[*i] > 0).collect();
895 order.sort_by_key(|i| len[*i]);
896 let (mut keep, mut left) = (vec![0; parts.len()], budget);
897 for (k, &i) in order.iter().enumerate() {
898 keep[i] = len[i].min(left / (order.len() - k));
899 left -= keep[i];
900 }
901 let mut out = String::new();
902 for (((label, text), n), all) in parts.iter().zip(keep).zip(len) {
903 if n == 0 {
904 continue;
905 }
906 let cut = if n < all { "…" } else { "" };
907 out.push_str(&format!("### {label}\n{cut}{}\n", text.chars().skip(all - n).collect::<String>()));
908 }
909 out
910}
911
912fn where_repo(session: &str) -> String {
915 let text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
916 text.lines().map(str::trim).rfind(|l| !l.is_empty() && !l.contains(['/', ' '])).unwrap_or_default().to_string()
917}
918
919pub(crate) fn listed<'a>(context: &'a str, marker: &str) -> std::collections::HashSet<&'a str> {
921 context.lines().filter_map(|l| l.strip_prefix(marker)).flat_map(ids_in).collect()
922}
923
924fn ids_in(list: &str) -> impl Iterator<Item = &str> {
926 tagged(list).0.split(',').map(str::trim).filter(|i| !i.is_empty())
927}
928
929fn tag(check: &str) -> String {
931 if check.is_empty() { String::new() } else { format!(" @{check}") }
932}
933
934const MAX_EARLY_DELIVERIES: usize = 3;
951
952fn early(h: &dyn Harness, so_far: &SoFar, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<String> {
953 if text.trim().is_empty()
954 || std::env::var("BORG_EARLY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
955 || std::env::var_os("BORG_ONLY").is_some()
956 || std::env::var_os("BORG_CRITIC").is_some()
957 {
958 return None;
959 }
960 let challenges = lessons(session);
961 let context = std::fs::read_to_string(state_file(session)).unwrap_or_default();
962 let plain = h.caps().early_delivery && context.lines().filter(|l| l.starts_with("#borg-early ")).count() < MAX_EARLY_DELIVERIES;
965 let standing = challenges.iter().any(conscience::Challenge::standing);
966 if !standing && (!plain || challenges.iter().all(|c| c.scope.is_empty() && !c.consolidated)) {
967 return None;
968 }
969 let settled = listed(&context, "#borg-topic ");
970 let named = conscience::named_scopes(&challenges, &where_now(session));
971 let mut topics: Vec<String> = if plain { named.iter().filter(|t| !settled.contains(t.as_str())).cloned().collect() } else { Vec::new() };
972 if plain
974 && topics.is_empty()
975 && hook_event == "UserPromptSubmit"
976 && !settled.contains(conscience::ANY)
977 && challenges.iter().any(|c| c.consolidated && !c.standing())
978 {
979 topics.push(conscience::ANY.into());
980 }
981 let mut standing_topics = named;
983 if hook_event == "UserPromptSubmit" {
984 standing_topics.push(conscience::ANY.into());
985 }
986 let done = had_in(&context);
987 let mut pool = if standing { conscience::standing_pool(&challenges, &standing_topics, &done) } else { Vec::new() };
988 if !topics.is_empty() {
989 pool.extend(conscience::early_pool(&challenges, &conscience::work_text(&context), &topics, &done));
990 }
991 let mut shown_topics = topics.clone();
993 for t in standing_topics.into_iter().filter(|t| pool.iter().any(|(_, c)| c.standing() && c.scope.contains(t))) {
994 if !shown_topics.contains(&t) {
995 shown_topics.push(t);
996 }
997 }
998 let selected: Vec<&conscience::Challenge> = conscience::explored(pool.iter().copied()).into_iter().take(conscience::EARLY_CAP).collect();
1001 let shown = conscience::render_early(&selected, &shown_topics);
1002 let delivered = &selected[..shown.as_ref().map_or(0, |(_, n)| *n)];
1003 let now_settled: Vec<&str> = topics
1006 .iter()
1007 .filter(|t| {
1008 !delivered.is_empty()
1010 || !challenges.iter().any(|c| {
1011 !c.standing()
1012 && (c.scope.contains(t) || (t.as_str() == conscience::ANY && c.consolidated))
1013 && !done.contains(c.id.as_str())
1014 && !delivered.iter().any(|d| d.id == c.id)
1015 })
1016 })
1017 .map(String::as_str)
1018 .collect();
1019 if !now_settled.is_empty() {
1020 append(session, &format!("#borg-topic {}", now_settled.join(",")));
1021 }
1022 let (text, _) = shown?;
1023 let ids = delivered.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
1024 let check = crate::select::check_id(session, "early", learn::now_ms(), std::process::id());
1026 let line = format!("#borg-early {ids} @{check}");
1027 append(session, &line);
1028 note_work(session, so_far, &line);
1029 let p = conscience::early_propensity(&pool, session, &shown_topics);
1030 let p: Vec<f64> = delivered.iter().map(|c| p.get(&c.id).copied().unwrap_or(0.0)).collect();
1031 record_delivery(session, input, delivered, "early", &check, p, early_rel(&challenges, &conscience::work_text(&context), &topics, delivered));
1032 log_cost(session, &format!("free event={hook_event} lessons={} chars={}", delivered.len(), text.len()));
1033 crate::stats::count(&learn::home(), crate::stats::EARLY, session, |e| {
1034 e.n = delivered.len() as u64;
1035 e.lessons = delivered.iter().map(|c| c.id.clone()).collect();
1036 e.channel = "early".into();
1037 });
1038 Some(text)
1039}
1040
1041fn early_rel(challenges: &[conscience::Challenge], work: &str, topics: &[String], delivered: &[&conscience::Challenge]) -> Vec<f64> {
1047 let Some(&(top, _)) = conscience::early_pool(challenges, work, topics, &std::collections::HashSet::new()).first() else { return Vec::new() };
1048 let ranked = conscience::rank(challenges, work);
1049 delivered.iter().map(|c| outcome::round_p(ranked.iter().find(|(_, x)| x.id == c.id).map_or(0.0, |(s, _)| *s) / top)).collect()
1050}
1051
1052fn note_work(session: &str, so_far: &SoFar, lines: &str) {
1056 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
1057 let _ = writeln!(f, "{WORK}{}\n{lines}", one_line(so_far.get(), EVIDENCE_CHARS));
1058 }
1059}
1060
1061fn record_delivery(session: &str, input: &Value, delivered: &[&conscience::Challenge], channel: &str, check: &str, p: Vec<f64>, rel: Vec<f64>) {
1067 if !learning() || delivered.is_empty() {
1068 return;
1069 }
1070 let lessons: Vec<String> = delivered.iter().map(|c| c.id.clone()).collect();
1071 let (grades, ranks, applied) = graded_of(session, &lessons);
1072 let asked = outcome::Outcome {
1073 kind: outcome::ASKED.into(),
1074 session: session.into(),
1075 lessons,
1076 ms: learn::now_ms(),
1077 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
1078 groups: conscience::disagreement_ids(delivered),
1079 channel: channel.into(),
1080 grades,
1081 ranks,
1082 applied,
1083 p: p.into_iter().map(outcome::round_p).collect(),
1084 check: check.into(),
1085 rel,
1086 ..Default::default()
1087 };
1088 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1089}
1090
1091fn record_route(session: &str, input: &Value, check: &str, lessons: Vec<String>, channel: &str, route: &str, reason: &str) {
1094 if !learning() || lessons.is_empty() {
1095 return;
1096 }
1097 let o = outcome::Outcome {
1098 kind: outcome::DELIVERY.into(),
1099 session: session.into(),
1100 lessons,
1101 ms: learn::now_ms(),
1102 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
1103 channel: channel.into(),
1104 check: check.into(),
1105 route: route.into(),
1106 reason: reason.into(),
1107 ..Default::default()
1108 };
1109 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &o);
1110}
1111
1112fn record_routes(session: &str, input: &Value, routed: &[(&conscience::Challenge, &str)], channel: &str, route: &str, reason: &str) {
1114 for (check, ids) in by_check(routed) {
1115 record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, route, reason);
1116 }
1117}
1118
1119fn by_check<'a>(routed: &[(&'a conscience::Challenge, &'a str)]) -> Vec<(&'a str, Vec<&'a str>)> {
1121 let mut out: Vec<(&str, Vec<&str>)> = Vec::new();
1122 for (c, check) in routed {
1123 match out.iter_mut().find(|(k, _)| k == check) {
1124 Some((_, ids)) => ids.push(c.id.as_str()),
1125 None => out.push((check, vec![c.id.as_str()])),
1126 }
1127 }
1128 out
1129}
1130
1131fn queued_file(session: &str) -> PathBuf {
1139 state_file(session).with_extension("queued")
1140}
1141
1142#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1144enum Queued {
1145 Ask,
1146 Instead,
1148 Free,
1149 Placebo,
1150}
1151
1152const INSTEAD: &str = "instead ";
1159
1160const INSTEAD_NEXT: &str = "instead-next ";
1165
1166const LIVE_NEXT: &str = "live-next ";
1169
1170const ASKED_INSTEAD: &str = "in place of a hand-over";
1174
1175fn queue_line(line: &str) -> (Queued, Vec<&str>, &str) {
1177 let ask = line.strip_prefix("live ").or_else(|| line.strip_prefix(LIVE_NEXT));
1178 let instead = line.strip_prefix(INSTEAD).or_else(|| line.strip_prefix(INSTEAD_NEXT));
1179 let (route, list) = match (ask, line.strip_prefix("placebo "), instead) {
1180 (Some(l), _, _) => (Queued::Ask, l),
1181 (_, Some(l), _) => (Queued::Placebo, l),
1182 (_, _, Some(l)) => (Queued::Instead, l),
1183 _ => (Queued::Free, line),
1184 };
1185 (route, ids_in(list).collect(), tagged(list).1.unwrap_or_default())
1186}
1187
1188fn enqueue(session: &str, line: &str) {
1191 let q = queued_file(session);
1192 live::locked(&q, || {
1193 let _ = std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
1194 });
1195}
1196
1197fn take_queue(session: &str) -> Option<String> {
1200 let q = queued_file(session);
1201 let taken = state_file(session).with_extension(format!("queued.{}", std::process::id()));
1202 live::locked(&q, || std::fs::rename(&q, &taken)).ok()?;
1203 let text = std::fs::read_to_string(&taken).unwrap_or_default();
1204 let _ = std::fs::remove_file(&taken);
1205 Some(text)
1206}
1207
1208fn take_live(session: &str) -> Taken {
1215 let Some(text) = take_queue(session) else { return Taken::default() };
1216 let mut taken = Taken::default();
1217 for line in text.lines() {
1218 let to = match (line.starts_with("live "), line.starts_with(INSTEAD)) {
1219 (true, _) => &mut taken.asks,
1220 (_, true) => &mut taken.instead,
1221 _ => {
1222 enqueue(session, line);
1223 continue;
1224 }
1225 };
1226 let (_, ids, check) = queue_line(line);
1227 to.extend(ids.into_iter().map(|i| (i.to_string(), check.to_string())));
1228 }
1229 taken
1230}
1231
1232#[derive(Default)]
1234struct Taken {
1235 asks: Vec<(String, String)>,
1237 instead: Vec<(String, String)>,
1239}
1240
1241fn waiting_in(text: &str) -> impl Iterator<Item = &str> {
1245 text.lines().map(queue_line).filter(|(route, _, _)| *route != Queued::Placebo).flat_map(|(_, ids, _)| ids)
1246}
1247
1248fn note_never_delivered(session: &str, input: &Value) {
1252 let text = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1253 for line in text.lines() {
1254 let (route, ids, check) = queue_line(line);
1255 let channel = match route {
1256 Queued::Ask => "live",
1257 Queued::Instead | Queued::Free => "queued",
1258 Queued::Placebo => outcome::PLACEBO,
1259 };
1260 record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, outcome::ROUTE_NEVER, "session ended");
1261 }
1262}
1263
1264fn tagged_p(state: &str, prefix: &str, check: &str, ids: &[&str]) -> Vec<f64> {
1267 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1268 for line in state.lines().filter_map(|l| l.strip_prefix(prefix)) {
1269 let (items, at) = tagged(line);
1270 if at != Some(check) {
1271 continue;
1272 }
1273 for (id, p) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1274 if let Ok(p) = p.trim().parse::<f64>() {
1275 seen.insert(id.trim(), p);
1276 }
1277 }
1278 }
1279 ids.iter().map(|i| seen.get(i).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1280}
1281
1282type Pairs<'a> = Vec<(&'a conscience::Challenge, &'a str)>;
1284
1285type Drop<'a> = (&'a str, &'static str, &'static str, &'static str);
1288
1289fn queued(so_far: &SoFar, hook_event: &str, session: &str, input: &Value) -> Option<String> {
1303 let text = take_queue(session)?;
1304 let all = lessons(session);
1305 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1306 let had = had_in(&state);
1307 let mut done: Option<std::collections::HashSet<&str>> = None;
1308 let mut lines: Vec<(Queued, Vec<&str>, &str)> = text.lines().map(queue_line).collect();
1309 lines.sort_by_key(|l| l.0);
1312 let placebos = lines.iter().position(|l| l.0 == Queued::Placebo).unwrap_or(lines.len());
1313 let (mut firm, mut wanted, mut placebo): (Pairs, Pairs, Pairs) = (Vec::new(), Vec::new(), Vec::new());
1314 let mut dropped: Vec<(Drop, &str)> = Vec::new();
1315 let mut owed = 0;
1317 for (route, ids, check) in &lines[..placebos] {
1318 let channel = if *route == Queued::Ask { "live" } else { "queued" };
1319 for &id in ids {
1320 let Some(c) = all.iter().find(|c| c.id == id) else {
1321 dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
1322 continue;
1323 };
1324 let why = if firm.iter().chain(&wanted).any(|(c, _)| c.id == id) {
1325 Some("queued twice")
1326 } else if *route == Queued::Ask {
1327 done.get_or_insert_with(|| done_in(&state, &early_acted(session, &all, ""))).contains(id).then_some("done")
1328 } else {
1329 had.contains(id).then_some("given before")
1330 };
1331 match why {
1332 Some(why) => dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, why), id)),
1333 None if *route == Queued::Ask => firm.push((c, check)),
1334 None => {
1335 owed += usize::from(*route == Queued::Instead);
1336 wanted.push((c, check));
1337 }
1338 }
1339 }
1340 }
1341 let free: Vec<&conscience::Challenge> = wanted.iter().map(|(c, _)| *c).collect();
1342 let shown = conscience::render_owing(&free, &[], owed);
1343 let n = shown.as_ref().map_or(0, |(_, n)| *n);
1344 dropped.extend(wanted[n..].iter().map(|(c, check)| ((*check, "queued", outcome::ROUTE_DROPPED_CHARS, "budget"), c.id.as_str())));
1345 for (_, ids, check) in &lines[placebos..] {
1348 for &id in ids {
1349 let Some(c) = all.iter().find(|c| c.id == id) else {
1350 dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
1351 continue;
1352 };
1353 let why = if placebo.iter().any(|(c, _)| c.id == id) {
1354 Some("queued twice")
1355 } else {
1356 (firm.iter().chain(&wanted[..n]).any(|(c, _)| c.id == id) || had.contains(id)).then_some("delivered")
1357 };
1358 match why {
1359 Some(why) => dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, why), id)),
1360 None => placebo.push((c, check)),
1361 }
1362 }
1363 }
1364 let mut parts: Vec<String> = Vec::new();
1365 if !firm.is_empty() {
1366 parts.push(ask_live(so_far, hook_event, session, input, &mut firm));
1367 }
1368 let mut markers: Vec<String> = Vec::new();
1369 if let Some((text, n)) = shown {
1370 for (check, ids) in by_check(&wanted[..n]) {
1371 markers.push(format!("#borg-queued {}{}", ids.join(","), tag(check)));
1372 let delivered: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
1373 let p = if check.is_empty() { vec![1.0; ids.len()] } else { tagged_p(&state, "#borg-pf ", check, &ids) };
1376 record_delivery(session, input, &delivered, "queued", check, p, Vec::new());
1377 }
1378 record_routes(session, input, &wanted[..n], "queued", outcome::ROUTE_FREE, "");
1379 log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
1380 crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
1381 e.n = n as u64;
1382 e.lessons = wanted[..n].iter().map(|(c, _)| c.id.clone()).collect();
1383 e.channel = "queued".into();
1384 });
1385 parts.push(text);
1386 }
1387 for (check, ids) in by_check(&placebo) {
1389 markers.push(format!("#borg-placebo {}{}", ids.join(","), tag(check)));
1390 }
1391 record_routes(session, input, &placebo, outcome::PLACEBO, outcome::ROUTE_PLACEBO, "");
1392 if !markers.is_empty() {
1393 let markers = markers.join("\n");
1394 append(session, &markers);
1395 note_work(session, so_far, &markers);
1396 }
1397 let mut groups: Vec<(Drop, Vec<String>)> = Vec::new();
1398 for (key, id) in dropped {
1399 match groups.iter_mut().find(|(k, _)| *k == key) {
1400 Some((_, ids)) => ids.push(id.to_string()),
1401 None => groups.push((key, vec![id.to_string()])),
1402 }
1403 }
1404 for ((check, channel, route, why), ids) in groups {
1405 record_route(session, input, check, ids, channel, route, why);
1406 }
1407 (!parts.is_empty()).then(|| parts.join("\n\n"))
1408}
1409
1410fn ask_live(so_far: &SoFar, hook_event: &str, session: &str, input: &Value, firm: &mut [(&conscience::Challenge, &str)]) -> String {
1417 crate::memory::shuffle(firm, session);
1418 let groups = by_check(firm);
1419 let now = learn::now_ms();
1420 let lines: Vec<String> = groups
1422 .iter()
1423 .map(|(check, ids)| {
1424 let ids = ids.join(",");
1425 format!("#borg-asked {ids}{}\n#borg-live {ids} ms={now}", tag(check))
1426 })
1427 .collect();
1428 let lines = lines.join("\n");
1429 append(session, &lines);
1430 note_work(session, so_far, &lines);
1431 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1432 let lessons: Vec<&conscience::Challenge> = firm.iter().map(|(c, _)| *c).collect();
1433 if learning() {
1434 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1435 for (check, ids) in &groups {
1436 let members: Vec<&conscience::Challenge> = lessons.iter().copied().filter(|c| ids.contains(&c.id.as_str())).collect();
1437 let ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1438 let (grades, ranks, applied) = graded_of(session, &ids);
1439 let p = if check.is_empty() {
1440 propensity_in(&state, &ids)
1441 } else {
1442 tagged_p(&state, "#borg-p ", check, &members.iter().map(|c| c.id.as_str()).collect::<Vec<_>>())
1443 };
1444 let (pool, base, scoped) = pool_in(&state, &ids);
1445 let asked = outcome::Outcome {
1446 kind: outcome::ASKED.into(),
1447 session: session.into(),
1448 lessons: ids,
1449 ms: now,
1450 repo: repo_of(cwd),
1451 groups: conscience::disagreement_ids(&members),
1452 channel: "live".into(),
1453 grades,
1454 ranks,
1455 applied,
1456 p,
1457 pool,
1458 base,
1459 scoped,
1460 check: check.to_string(),
1461 ..Default::default()
1462 };
1463 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1464 }
1465 }
1466 record_routes(session, input, firm, "live", outcome::ROUTE_ASKED, "");
1467 let text = conscience::render_live(&lessons);
1468 log_cost(session, &format!("free event={hook_event} lessons={} chars={} live", lessons.len(), text.len()));
1469 stat(session, input, crate::stats::LIVE_ASK, |e| {
1470 e.n = lessons.len() as u64;
1471 e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
1472 e.channel = "live".into();
1473 });
1474 text
1475}
1476
1477fn work_so_far(h: &dyn Harness, input: &Value) -> String {
1480 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1481 harness::work_so_far(h.caps(), input, &turn_file(session), LIVE_WORK_CHARS)
1482}
1483
1484struct SoFar<'a> {
1488 h: &'a dyn Harness,
1489 input: &'a Value,
1490 text: std::cell::OnceCell<String>,
1491}
1492
1493impl<'a> SoFar<'a> {
1494 fn new(h: &'a dyn Harness, input: &'a Value) -> Self {
1495 SoFar { h, input, text: std::cell::OnceCell::new() }
1496 }
1497
1498 fn get(&self) -> &str {
1499 self.text.get_or_init(|| work_so_far(self.h, self.input))
1500 }
1501}
1502
1503fn turn_file(session: &str) -> PathBuf {
1506 state_file(session).with_extension("turn")
1507}
1508
1509const LIVE_WORK_CHARS: usize = EVIDENCE_CHARS;
1512
1513fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
1519 if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
1520 return None;
1521 }
1522 let repos = || {
1523 let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
1524 let here = repo_of(cwd);
1525 if !here.is_empty() && !repos.contains(&here) {
1526 repos.insert(0, here);
1527 }
1528 repos
1529 };
1530 let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
1531 let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
1532 if let Ok(exe) = std::env::current_exe() {
1533 q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
1534 }
1535 let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
1536 log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
1537 Some(text)
1538}
1539
1540fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
1542 free_context_with(h, hook_event, session, input, text, None)
1543}
1544
1545fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
1547 let so_far = SoFar::new(h, input);
1548 let parts: Vec<String> =
1549 [queued(&so_far, hook_event, session, input), early(h, &so_far, hook_event, session, input, text), extra].into_iter().flatten().collect();
1550 (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))
1551}
1552
1553enum Verdict {
1555 Skip(String),
1557 Failed,
1559 Ask(Vec<String>, bool),
1565}
1566
1567struct Work<'a> {
1569 all: &'a [conscience::Challenge],
1571 judged: &'a [conscience::Challenge],
1574 challenges: &'a [conscience::Challenge],
1577 done: &'a std::collections::HashSet<&'a str>,
1578 state: &'a str,
1580 context: &'a str,
1583 scope_text: &'a str,
1584 message: &'a str,
1586 changed: bool,
1588 judge: bool,
1591 budget: Option<std::time::Duration>,
1594 started: std::time::Instant,
1595 check: &'a str,
1598 summary: &'a crate::select::summary::Summary,
1602}
1603
1604struct Call<'a> {
1606 eligible: usize,
1609 order: &'a [&'a conscience::Challenge],
1610 tail: &'a [(usize, &'a conscience::Challenge)],
1614 pt: f64,
1615 budget: usize,
1617 model: &'a str,
1618 prompt_hash: String,
1619 dur_ms: u64,
1620 usd: f64,
1622}
1623
1624fn check(session: &str, input: &Value, w: &Work, live: bool) -> Verdict {
1634 let skip = if live {
1639 None
1640 } else if prefilter::edits_gate() {
1641 (!w.changed).then(|| "noedit".to_string())
1642 } else {
1643 prefilter::skip(w.challenges, w.context).map(|score| format!("{score:.2}"))
1644 };
1645 if let Some(score) = skip {
1646 return Verdict::Skip(score);
1647 }
1648 let text = conscience::work_text(w.context);
1650 let text = text.as_ref();
1651 let started = std::time::Instant::now();
1655 let shortlist_now = || {
1656 let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
1657 if crate::vectors::enabled() {
1658 log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
1659 }
1660 list
1661 };
1662 let whole = crate::whole::on();
1666 let (shortlist, eligible, order, tail, pt, budget) = if whole {
1667 let ranked = conscience::rank_scoped(w.challenges, text, w.scope_text);
1668 let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
1669 let n = eligible.len();
1670 let tokens = match std::env::var_os("BORG_STOP_POOL_TOKENS") {
1674 Some(_) => crate::whole::tokens(),
1675 None => {
1676 let limit = live::call_limit(w.budget, w.started.elapsed());
1677 crate::select::pool_tokens(w.summary, Some(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX)), session)
1678 }
1679 };
1680 let f = crate::whole::select_filled(eligible, shortlist_now, &ranked, tokens);
1681 let (drawn, pt) = if f.order.is_empty() { (Vec::new(), 0.0) } else { crate::whole::tail(&f.dropped, tail_seed(session, w.check, live, w.summary)) };
1686 let tail: Vec<(usize, &conscience::Challenge)> = drawn.into_iter().map(|(i, c)| (f.order.len() + i, c)).collect();
1687 let sampled = if tail.is_empty() { String::new() } else { format!(" tail={}", tail.len()) };
1688 log_cost(session, &format!("pool mode=all eligible={n} shown={} budget={}{sampled}", f.shown.len(), if f.fell { "full" } else { "fits" }));
1689 let mut shown = f.shown;
1690 shown.extend(tail.iter().map(|(_, c)| *c));
1691 (shown, n, f.order, tail, pt, tokens)
1692 } else {
1693 let list = shortlist_now();
1694 (list.clone(), list.len(), list, Vec::new(), 0.0, 0)
1695 };
1696 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
1697 let judging = if w.judge {
1699 pending(session, w.state, w.judged, w.message, if live { When::Live } else { When::Stop }, w.check)
1700 } else {
1701 Judging { pending: Vec::new(), question: false, crowded: Vec::new() }
1702 };
1703 let pend = &judging.pending;
1704 if shortlist.is_empty() && !judging.question {
1708 return Verdict::Skip("empty".into());
1709 }
1710 let extra = predict::judge_section(pend).len();
1711 let graded = crate::whole::graded();
1712 let compact = whole && !crate::whole::classic();
1713 let timeout = live::call_limit(w.budget, w.started.elapsed());
1714 let shown_work = without_declarations(text, pend);
1717 let called = std::time::Instant::now();
1718 let pred = if compact {
1719 predict::predict_whole(&shown_work, &shortlist, pend, &model, graded, timeout)
1720 } else {
1721 predict::predict(&shown_work, &shortlist, pend, &model, graded, timeout)
1722 };
1723 let dur_ms = called.elapsed().as_millis() as u64;
1724 stat(session, input, if live { crate::stats::LIVE_CHECK } else { crate::stats::CHECK }, |e| {
1725 e.n = shortlist.len() as u64;
1726 e.dur_ms = dur_ms;
1727 e.usd = pred.as_ref().map_or(0.0, |p| p.cost);
1728 if pred.is_none() {
1729 e.why = "failed".into();
1730 }
1731 });
1732 let usd = pred.as_ref().map_or(0.0, |p| p.cost);
1733 let call = Call { eligible, order: &order, tail: &tail, pt, budget, model: &model, prompt_hash: predict::template_hash(compact, graded), dur_ms, usd };
1734 note_unjudged(session, &judging.crowded, "slots");
1735 let Some(pred) = pred else {
1736 if graded {
1738 record_graded(session, input, w, live, &[], None, &call);
1739 }
1740 note_unjudged(session, &pend.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>(), "failed");
1741 return Verdict::Failed;
1742 };
1743 append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
1745 if live {
1746 log_cost(session, &format!("live usd={:.4} lessons={}", pred.cost, shortlist.len()));
1747 }
1748 if !pend.is_empty() {
1749 let call = w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1;
1750 record_verdicts(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()), "", pend, &pred, extra, call);
1751 }
1752 let applied: Vec<(&conscience::Challenge, u8)> =
1753 pred.applies.iter().zip(&pred.grades).filter_map(|(i, g)| shortlist.iter().find(|c| &c.id == i).map(|c| (*c, *g))).collect();
1754 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1755 if graded {
1756 let lessons = lessons_path();
1760 let at = Decide {
1763 session,
1764 check: w.check,
1765 channel: if live { "live" } else { "stop" },
1766 lessons: &lessons,
1767 tokens: crate::select::shown_tokens(call.order),
1768 recorded: records_reach_gate(&lessons),
1769 };
1770 let r = decide(crate::select::policy(), &applied, &at, w.summary, DECIDE_WAIT, |r, basis| {
1771 record_graded(session, input, w, live, &applied, Some((r, basis)), &call)
1772 });
1773 let routed = |cs: &[&conscience::Challenge], p: &[f64]| -> Vec<(&str, f64)> {
1774 applied.iter().zip(p).filter(|((c, _), _)| cs.iter().any(|x| x.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)).collect()
1775 };
1776 let mut lines = vec![graded_line(&applied), r.log.line(applied.len(), w.check)];
1777 lines.extend(r.log.attention.clone());
1778 lines.extend(p_line("#borg-p", routed(&r.ask, &r.p), Some(w.check)));
1779 lines.extend(p_line("#borg-pf", routed(&r.free, &r.pf), Some(w.check)));
1780 append(session, &lines.join("\n"));
1781 let queue = !r.free.is_empty();
1782 log_cost(session, &format!("graded applied={} ask={} free={} {}", applied.len(), r.ask.len(), r.free.len(), if queue { "queued" } else { "dropped" }));
1783 if queue {
1784 let asks = if r.log.policy == crate::select::Policy::Learned.name() { conscience::graded_without(&applied, None, &[]).0 } else { Vec::new() };
1789 let (owed, rest): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = r.free.iter().partition(|c| asks.iter().any(|a| a.id == c.id));
1790 if !owed.is_empty() {
1791 let prefix = if live && r.log.source == crate::select::policy::EXPERIMENT { INSTEAD } else { INSTEAD_NEXT };
1792 enqueue(session, &format!("{prefix}{}{}", ids(&owed).join(","), tag(w.check)));
1793 }
1794 if !rest.is_empty() {
1795 enqueue(session, &format!("{}{}", ids(&rest).join(","), tag(w.check)));
1796 }
1797 }
1798 if !r.placebo.is_empty() {
1799 enqueue(session, &format!("placebo {}{}", ids(&r.placebo).join(","), tag(w.check)));
1800 }
1801 Verdict::Ask(ids(&r.ask), applied.iter().any(|(_, g)| *g == 3))
1802 } else {
1803 let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
1806 let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, text, w.scope_text), &predicted, w.done);
1807 let widened: Vec<&conscience::Challenge> =
1808 conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
1809 append_p_of(session, &pool, &widened, w.check);
1810 Verdict::Ask(ids(&widened), true)
1812 }
1813}
1814
1815struct Decide<'x> {
1817 session: &'x str,
1818 check: &'x str,
1819 channel: &'static str,
1820 lessons: &'x std::path::Path,
1821 tokens: u32,
1823 recorded: bool,
1826}
1827
1828struct Basis<'s> {
1833 summary: &'s crate::select::summary::Summary,
1834 had: &'s [String],
1835 why: &'static str,
1836}
1837
1838const DECIDE_WAIT: std::time::Duration = std::time::Duration::from_millis(live::AFTER_CALL.as_millis() as u64 / 3);
1845
1846const DECIDE_POLL: std::time::Duration = std::time::Duration::from_millis(5);
1848
1849fn decide_lock(lessons: &std::path::Path) -> PathBuf {
1851 crate::select::summary::cache_path(lessons).with_extension("json.lock")
1852}
1853
1854fn lock_within(file: &std::path::Path, wait: std::time::Duration) -> Result<std::fs::File, &'static str> {
1858 let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file).map_err(|_| outcome::DECIDED_NO_LOCK)?;
1859 let until = std::time::Instant::now() + wait;
1860 loop {
1861 match lock.try_lock() {
1862 Ok(()) => return Ok(lock),
1863 Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < until => std::thread::sleep(DECIDE_POLL),
1864 Err(std::fs::TryLockError::WouldBlock) => return Err(outcome::DECIDED_BUSY),
1865 Err(std::fs::TryLockError::Error(_)) => return Err(outcome::DECIDED_NO_LOCK),
1866 }
1867 }
1868}
1869
1870fn decide<'a>(
1887 policy: crate::select::Policy,
1888 applied: &[(&'a conscience::Challenge, u8)],
1889 at: &Decide,
1890 stale: &crate::select::summary::Summary,
1891 wait: std::time::Duration,
1892 mut record: impl FnMut(&crate::select::Routed<'a>, &Basis) -> bool,
1893) -> crate::select::Routed<'a> {
1894 use crate::select::Policy;
1895 let state = std::fs::read_to_string(state_file(at.session)).unwrap_or_default();
1896 let queue = std::fs::read_to_string(queued_file(at.session)).unwrap_or_default();
1897 let mut had: Vec<String> = had_in(&state).into_iter().chain(waiting_in(&queue)).map(str::to_string).collect();
1898 had.sort_unstable();
1899 had.dedup();
1900 let alpha = crate::select::alpha(at.lessons);
1902 let mut on = |summary: &crate::select::summary::Summary, policy: Policy, why: &'static str| {
1904 let ctx =
1905 crate::select::Ctx { session: at.session, check: at.check, channel: at.channel, lessons: at.lessons, summary, tokens: at.tokens, had: &had, alpha };
1906 let r = crate::select::graded_under(policy, applied, &ctx);
1907 let landed = record(&r, &Basis { summary, had: &had, why });
1908 (r, landed)
1909 };
1910 if policy != Policy::Learned {
1911 return on(stale, policy, "").0;
1912 }
1913 if !at.recorded {
1914 return on(stale, Policy::Fixed, outcome::DECIDED_NO_RECORD).0;
1915 }
1916 match lock_within(&decide_lock(at.lessons), wait) {
1917 Ok(_held) => {
1918 let summary = crate::select::summary::refresh(at.lessons);
1919 match on(&summary, policy, "") {
1920 (r, true) => r,
1921 (_, false) => on(&summary, Policy::Fixed, outcome::DECIDED_NO_RECORD).0,
1923 }
1924 }
1925 Err(why) => on(stale, Policy::Fixed, why).0,
1926 }
1927}
1928
1929fn records_reach_gate(lessons: &std::path::Path) -> bool {
1934 learning() && outcome::path_beside(lessons) == graded_log()
1935}
1936
1937fn graded_log() -> PathBuf {
1939 learn::home().join("outcomes.jsonl")
1940}
1941
1942fn tail_seed(session: &str, check: &str, live: bool, summary: &crate::select::summary::Summary) -> u64 {
1945 let channel = if live { "live" } else { "stop" };
1946 let ctx =
1947 crate::select::Ctx { session, check, channel, lessons: std::path::Path::new(""), summary, tokens: 0, had: &[], alpha: crate::select::policy::ALPHA };
1948 crate::select::seed(&ctx) ^ u64::from_be_bytes(*b"tailpool")
1949}
1950
1951fn record_graded(
1954 session: &str,
1955 input: &Value,
1956 w: &Work,
1957 live: bool,
1958 applied: &[(&conscience::Challenge, u8)],
1959 decided: Option<(&crate::select::Routed, &Basis)>,
1960 call: &Call,
1961) -> bool {
1962 if !learning() {
1963 return false;
1964 }
1965 let repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
1966 let o = graded_outcome(session, &repo, w.check, live, applied, decided, w.summary, call);
1967 outcome::append(&graded_log(), &o).is_ok()
1968}
1969
1970#[allow(clippy::too_many_arguments)] fn graded_outcome(
1982 session: &str,
1983 repo: &str,
1984 check: &str,
1985 live: bool,
1986 applied: &[(&conscience::Challenge, u8)],
1987 decided: Option<(&crate::select::Routed, &Basis)>,
1988 read: &crate::select::summary::Summary,
1989 call: &Call,
1990) -> outcome::Outcome {
1991 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1992 let rounded = |p: &[f64]| p.iter().map(|x| outcome::round_p(*x)).collect::<Vec<_>>();
1993 let (routed, basis) = (decided.map(|d| d.0), decided.map(|d| d.1));
1994 let shown: u32 = call.order.len().try_into().unwrap_or(u32::MAX);
1995 let place = |id: &str| call.order.iter().position(|x| x.id == id).or_else(|| call.tail.iter().find(|(_, x)| x.id == id).map(|(at, _)| *at));
1996 outcome::Outcome {
1997 kind: outcome::GRADED.into(),
1998 session: session.into(),
1999 lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
2000 ms: learn::now_ms(),
2001 repo: repo.into(),
2002 channel: if live { "live" } else { "stop" }.into(),
2003 grades: applied.iter().map(|(_, g)| *g).collect(),
2004 ranks: (0..applied.len()).map(|r| u16::try_from(r).unwrap_or(u16::MAX)).collect(),
2005 applied: u16::try_from(applied.len()).unwrap_or(u16::MAX),
2006 chosen: routed.map(|r| ids(&r.ask)).unwrap_or_default(),
2007 explore: routed.is_some_and(|r| r.log.source == "explore"),
2008 p: routed.map(|r| rounded(&r.p)).unwrap_or_default(),
2009 check: check.into(),
2010 policy: routed.map_or_else(|| crate::select::policy().name(), |r| r.log.policy).into(),
2011 fit: routed.map_or_else(|| read.fit.key.clone(), |r| r.log.fit.clone()),
2012 seen: basis.map(|b| b.summary.key()).unwrap_or_default(),
2014 source: routed.map(|r| r.log.source).unwrap_or_default().into(),
2015 free: routed.map(|r| ids(&r.free)).unwrap_or_default(),
2016 pf: routed.map(|r| rounded(&r.pf)).unwrap_or_default(),
2017 placebo: routed.map(|r| ids(&r.placebo)).unwrap_or_default(),
2018 psi: routed.map_or(0.0, |r| r.log.psi),
2019 nofree: basis.map(|b| crate::select::nofree(applied, b.had)).unwrap_or_default(),
2020 xk: routed.and_then(|r| r.log.xk),
2022 xs: routed.and_then(|r| r.log.xs),
2023 alpha: routed.and_then(|r| r.log.alpha),
2025 tail: call.tail.iter().map(|(_, c)| c.id.clone()).collect(),
2026 pt: call.pt,
2027 reason: basis.map(|b| b.why).unwrap_or_default().into(),
2028 eligible: call.eligible.try_into().unwrap_or(u32::MAX),
2029 shown,
2030 tokens: crate::select::shown_tokens(call.order),
2031 budget: call.budget.try_into().unwrap_or(u32::MAX),
2032 fill: applied
2034 .iter()
2035 .map(|(c, _)| place(&c.id))
2036 .chain(call.tail.iter().map(|(at, _)| Some(*at)))
2037 .map(|i| i.map_or(u16::MAX, |i| u16::try_from(i).unwrap_or(u16::MAX)))
2038 .collect(),
2039 model: call.model.into(),
2040 prompt_hash: call.prompt_hash.clone(),
2041 harness: std::env::var("BORG_HARNESS").unwrap_or_default(),
2042 version: env!("CARGO_PKG_VERSION").into(),
2043 dur_ms: call.dur_ms,
2044 failed: routed.is_none(),
2045 usd: call.usd,
2046 ..Default::default()
2047 }
2048}
2049
2050fn live_trigger(session: &str, input: &Value, tool: &str, changed: bool) {
2054 let ctx = state_file(session);
2055 if let Some(why) = live::unsupported() {
2056 if std::fs::OpenOptions::new().write(true).create_new(true).open(ctx.with_extension("live.off")).is_ok() {
2058 eprintln!("borg: live mode needs the consequence model, which {why} replaces; this session stays in hook mode");
2059 }
2060 return;
2061 }
2062 if changed {
2063 live::bump(&ctx, tool);
2064 }
2065 let now = learn::now_ms();
2066 let home = learn::home();
2068 if crate::steer::active(&home) && crate::steer::paused(&home, session, crate::presence::read(&home, session).map(|p| p.started), now) {
2069 return;
2070 }
2071 let lock = live::lock_state(&ctx, now, live::stale_ms(), live::alive);
2072 if live::decide(live::Mode::Live, lock, live::count(&ctx), live::read_mark(&ctx), now, live::debounce_ms()).is_err() {
2073 return;
2074 }
2075 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
2076 if let Ok(exe) = std::env::current_exe() {
2078 let _ = std::process::Command::new(exe)
2079 .args(["check", "--session", session, "--transcript", &s("transcript_path"), "--cwd", &s("cwd")])
2080 .stdin(std::process::Stdio::null())
2081 .stdout(std::process::Stdio::null())
2082 .stderr(std::process::Stdio::null())
2083 .spawn();
2084 }
2085}
2086
2087pub fn live_check(h: &dyn Harness, session: &str, input: &Value) {
2095 let ctx = state_file(session);
2096 let started = learn::now_ms();
2097 let Some(_lock) = live::acquire(&ctx, started) else { return };
2098 let n = live::count(&ctx);
2099 let mark = live::read_mark(&ctx);
2100 if live::unsupported().is_some() || live::decide(live::current(), live::Lock::Free, n, mark, started, live::debounce_ms()).is_err() {
2101 return;
2102 }
2103 let turn = turn_id(session);
2104 let (all, withheld) = loaded(session);
2105 let judged = judged(&all, withheld);
2106 let state = std::fs::read_to_string(&ctx).unwrap_or_default();
2107 let message = work_so_far(h, input);
2108 let acted = early_acted(session, &judged, &message);
2111 let mut done = done_in(&state, &acted);
2112 let waiting = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
2113 done.extend(waiting_in(&waiting));
2114 let challenges: Vec<conscience::Challenge> = all.iter().filter(|c| !done.contains(c.id.as_str()) && !c.off_system).cloned().collect();
2115 let context = format!("{state}{message}");
2116 let scope_text = format!("{}\n{message}", where_now(session));
2117 let check_id = crate::select::check_id(session, "live", started, std::process::id());
2118 let summary = crate::select::summary::refresh(&lessons_path());
2120 let work = Work {
2121 all: &all,
2122 judged: &judged,
2123 challenges: &challenges,
2124 done: &done,
2125 state: &state,
2126 context: &context,
2127 scope_text: &scope_text,
2128 message: &message,
2129 changed: true,
2130 judge: true,
2131 budget: None,
2132 started: std::time::Instant::now(),
2133 check: &check_id,
2134 summary: &summary,
2135 };
2136 let before = mark.map_or(0, |m| m.covered);
2137 let covered = match check(session, input, &work, true) {
2138 Verdict::Skip(score) => {
2140 append(session, &format!("#borg-live-skip {score}"));
2141 before
2142 }
2143 Verdict::Failed => before,
2144 Verdict::Ask(ids, blocks) => {
2145 if !ids.is_empty() {
2146 let prefix = if blocks { "live " } else { LIVE_NEXT };
2147 enqueue(session, &format!("{prefix}{}{}", ids.join(","), tag(&check_id)));
2148 }
2149 n
2150 }
2151 };
2152 live::write_mark(&ctx, live::Mark { covered, started_ms: started, turn: Some(turn) });
2153}
2154
2155pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
2158 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2159 match event {
2160 "prompt" => on_prompt(h, session, input),
2161 "post_tool_failure" => {
2162 capture_test(session, input, true);
2163 if h.caps().deny_capture {
2164 capture_rejection(h, session, input);
2165 }
2166 None
2167 }
2168 "end" => {
2169 on_end(h, session, input);
2170 None
2171 }
2172 "post_tool" => {
2173 capture_test(session, input, false);
2174 on_post_tool(h, session, input)
2175 }
2176 "stop" => on_stop(h, session, input),
2177 _ => None,
2178 }
2179}
2180
2181fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2182 let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
2183 append(session, prompt);
2184 if learning() {
2185 crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
2187 crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
2188 }
2189 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
2191 let _ = writeln!(f, "{prompt}");
2192 }
2193 let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
2195 match &vetoed {
2196 Some(reason) => veto(h, session, input, reason),
2197 None => capture_prompt(h, session, input, prompt),
2198 }
2199 note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
2200 let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
2201 let checked = crate::update::maybe_check(learn::now_ms());
2204 let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
2207 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2208 let ask = queen_ask(h, session, cwd);
2209 let update = crate::update::notice(checked.as_ref(), &learn::home(), &state_file(session).with_extension("update"));
2212 let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render), update].into_iter().flatten().collect();
2213 free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
2214 });
2215 if harness::transcript(h.caps(), input).is_none() {
2217 let _ = std::fs::write(turn_file(session), "");
2218 }
2219 out.flatten()
2220}
2221
2222fn capture_test(session: &str, input: &Value, failed: bool) {
2227 if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
2228 return;
2229 }
2230 let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
2231 if !crate::notes::regress_signals::runs_tests(command) {
2232 return;
2233 }
2234 let output = crate::notes::regress_signals::tool_output(input);
2235 if !failed && output.trim().is_empty() {
2236 return;
2237 }
2238 if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
2239 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
2240 {
2241 let _ = writeln!(f, "{what} {}", learn::now_ms());
2242 }
2243}
2244
2245fn on_end(h: &dyn Harness, session: &str, input: &Value) {
2246 let _ = std::fs::remove_file(denied_file(session));
2247 if !learning() {
2248 return;
2249 }
2250 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
2252 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
2253 note_never_delivered(session, input);
2257 let end = outcome::Outcome {
2258 kind: outcome::ENDED.into(),
2259 session: session.into(),
2260 ms: learn::now_ms(),
2261 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
2262 ..Default::default()
2263 };
2264 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &end);
2265 if !crate::notes::on_end(session, input)
2268 && let Ok(exe) = std::env::current_exe()
2269 {
2270 let _ = std::process::Command::new(exe)
2271 .args(["learn", "--session", session])
2272 .stdin(std::process::Stdio::null())
2273 .stdout(std::process::Stdio::null())
2274 .stderr(std::process::Stdio::null())
2275 .spawn();
2276 }
2277}
2278
2279fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2280 let _ = std::fs::remove_file(denied_file(session));
2282 let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
2283 let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
2284 let touch = touched(tool_input, tool);
2285 append(session, &touch);
2286 note_where(
2288 session,
2289 &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
2290 );
2291 if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
2293 let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
2294 }
2295 let edit = edit_text(tool, tool_input);
2296 if let Some(edit) = &edit
2297 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
2298 {
2299 let _ = writeln!(f, "{edit}");
2300 }
2301 if harness::transcript(h.caps(), input).is_none()
2303 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
2304 {
2305 let _ = writeln!(f, "[{tool}] {touch}");
2306 if let Some(edit) = &edit {
2307 let _ = writeln!(f, "{edit}");
2308 }
2309 }
2310 if live::current() == live::Mode::Live {
2314 live_trigger(session, input, tool, prefilter::changes(tool, tool_input));
2315 }
2316 (h.caps().tool_context && !subagent(input)).then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
2320}
2321
2322fn subagent(input: &Value) -> bool {
2325 input.get("agent_id").and_then(Value::as_str).is_some_and(|a| !a.is_empty())
2326}
2327
2328struct StopLive {
2333 on: bool,
2334 budget: Option<std::time::Duration>,
2335 lock: live::Lock,
2336 guard: Option<live::Guard>,
2337 waiting: Vec<(String, String)>,
2341 instead: Vec<(String, String)>,
2344}
2345
2346impl StopLive {
2347 fn take(h: &dyn Harness, session: &str) -> StopLive {
2348 let on = live::current() == live::Mode::Live;
2349 let budget = crate::expert_route::stop_budget(h);
2350 let lock = if on { live::wait_free(&state_file(session), live::stop_wait(budget).as_millis() as u64) } else { live::Lock::Free };
2351 let guard = if on && lock != live::Lock::Held { live::acquire(&state_file(session), learn::now_ms()) } else { None };
2352 let lock = if on && guard.is_none() { live::Lock::Held } else { lock };
2353 let Taken { asks: waiting, instead } = if on { take_live(session) } else { Taken::default() };
2354 StopLive { on, budget, lock, guard, waiting, instead }
2355 }
2356}
2357
2358fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2359 let started = std::time::Instant::now();
2360 let started_ms = learn::now_ms();
2361 if learning() {
2362 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
2364 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
2365 crate::sweep::turn_end(h, session, input);
2367 }
2368 let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
2369 if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
2370 return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
2371 }
2372 let (all, withheld) = loaded(session);
2373 let judged = judged(&all, withheld);
2374 note_answer(h, session, input);
2377 let changed = std::fs::remove_file(changes_file(session)).is_ok();
2380 if second_round {
2382 return None;
2383 }
2384 stat(session, input, crate::stats::TURN_END, |_| {});
2385 let live = StopLive::take(h, session);
2386 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2387 let proposal = full_proposal(h, input);
2392 let acted = early_acted(session, &judged, &proposal);
2393 let only = std::env::var_os("BORG_ONLY").is_some();
2394 let done = if only { std::collections::HashSet::new() } else { done_in(&state, &acted) };
2395 let queue = if only { String::new() } else { std::fs::read_to_string(queued_file(session)).unwrap_or_default() };
2401 let waiting: std::collections::HashSet<&str> =
2402 if only { Default::default() } else { live.waiting.iter().chain(&live.instead).map(|(id, _)| id.as_str()).chain(waiting_in(&queue)).collect() };
2403 let challenges: Vec<conscience::Challenge> =
2405 all.iter().filter(|c| !done.contains(c.id.as_str()) && !waiting.contains(c.id.as_str()) && !c.off_system).cloned().collect();
2406 let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
2407 let context = format!("{state}{message}");
2408 let scope_text = format!("{}\n{message}", where_now(session));
2410 let check_id = crate::select::check_id(session, "stop", started_ms, std::process::id());
2411 let summary = crate::select::summary::refresh(&lessons_path());
2413 let work = Work {
2414 all: &all,
2415 judged: &judged,
2416 challenges: &challenges,
2417 done: &done,
2418 state: &state,
2419 context: &context,
2420 scope_text: &scope_text,
2421 message: &proposal,
2422 changed,
2423 judge: !live.on || live.guard.is_some(),
2424 budget: live.budget,
2425 started,
2426 check: &check_id,
2427 summary: &summary,
2428 };
2429 match stop_select(h, session, input, &work, &live) {
2430 Ok(selected) => stop_ask(h, session, input, selected, &check_id, &live.instead),
2431 Err(out) => out,
2432 }
2433}
2434
2435type Asks<'a> = Vec<(&'a conscience::Challenge, String)>;
2438
2439fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Asks<'a>, Option<Value>> {
2442 let own = |cs: Vec<&'a conscience::Challenge>| cs.into_iter().map(|c| (c, w.check.to_string())).collect();
2443 match std::env::var("BORG_ONLY") {
2444 Ok(ids) => Ok(own(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect())),
2446 Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w, live),
2449 Err(_) => {
2452 let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
2453 let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
2454 .into_iter()
2455 .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
2456 .collect();
2457 append_p_of(session, &pool, &asked, w.check);
2458 Ok(own(asked))
2459 }
2460 }
2461}
2462
2463fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Asks<'a>, Option<Value>> {
2469 let skip = |why: &str| {
2470 append(session, &format!("#borg-skip {why}"));
2471 stat(session, input, crate::stats::GATE_SKIP, |e| e.why = why.to_string());
2472 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
2473 Err(crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[]))
2474 };
2475 let mut waiting: Vec<(&conscience::Challenge, String)> = Vec::new();
2476 let instead = live.instead.iter().map(|(id, check)| (id, check, "queued"));
2479 for (id, check, channel) in live.waiting.iter().map(|(id, check)| (id, check, "live")).chain(instead) {
2480 let why = match w.all.iter().find(|c| &c.id == id).filter(|c| !c.off_system) {
2481 Some(_) if waiting.iter().any(|(c, _)| &c.id == id) => "queued twice",
2482 Some(_) if w.done.contains(id.as_str()) => "done at the stop",
2483 Some(c) => {
2484 waiting.push((c, check.clone()));
2485 continue;
2486 }
2487 None => "not deliverable",
2488 };
2489 record_route(session, input, check, vec![id.clone()], channel, outcome::ROUTE_DROPPED_DONE, why);
2490 }
2491 let ctx = state_file(session);
2494 if live.on && live::covered(live.lock, live::count(&ctx), live::read_mark(&ctx), turn_id(session)) {
2495 return if waiting.is_empty() { skip("live_covered") } else { Ok(waiting) };
2496 }
2497 let mut asked: Asks = match check(session, input, w, false) {
2498 Verdict::Skip(_) | Verdict::Failed if !waiting.is_empty() => Vec::new(),
2499 Verdict::Skip(score) => return skip(&score),
2500 Verdict::Failed => return Err(None),
2501 Verdict::Ask(ids, _) => ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).map(|c| (c, w.check.to_string())).collect(),
2502 };
2503 asked.extend(waiting);
2505 Ok(asked)
2506}
2507
2508fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
2511 let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
2512 format!("#borg-graded n={} {}", applied.len(), items.join(","))
2513}
2514
2515fn p_line<'a>(prefix: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) -> Option<String> {
2521 let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
2522 (!items.is_empty()).then(|| format!("{prefix} {}{}", items.join(","), tag(check.unwrap_or_default())))
2523}
2524
2525fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge], check: &str) {
2532 if asked.is_empty() {
2533 return;
2534 }
2535 let p = conscience::propensity(pool, session);
2536 let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
2537 let items: Vec<String> =
2538 pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
2539 let pool = format!("#borg-pool {}{}", items.join(","), tag(check));
2540 append(session, &p_line("#borg-p", chosen, Some(check)).map_or(pool.clone(), |p| format!("{p}\n{pool}")));
2541}
2542
2543fn tagged(line: &str) -> (&str, Option<&str>) {
2545 match line.rsplit_once(" @") {
2546 Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
2547 _ => (line, None),
2548 }
2549}
2550
2551fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
2556 let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
2557 let mut out = (Vec::new(), Vec::new(), Vec::new());
2558 for item in line.split(',') {
2559 let (id, rest) = item.rsplit_once('=')?;
2560 let (base, scoped) = match rest.strip_suffix(":s") {
2561 Some(b) => (b, true),
2562 None => (rest, false),
2563 };
2564 out.0.push(id.trim().to_string());
2565 out.1.push(base.trim().parse::<f64>().ok()?);
2566 out.2.push(scoped);
2567 }
2568 Some(out)
2569 };
2570 let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
2572 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
2573 let (items, tag) = tagged(line);
2574 for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
2575 check.insert(id.trim(), tag);
2576 }
2577 }
2578 let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
2579 if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
2580 return Default::default();
2581 }
2582 state
2583 .lines()
2584 .rev()
2585 .filter_map(|l| l.strip_prefix("#borg-pool "))
2586 .map(tagged)
2587 .filter(|(_, t)| *t == Some(tag))
2588 .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
2589 .unwrap_or_default()
2590}
2591
2592fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
2596 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
2597 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
2598 for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
2599 if let Ok(p) = p.trim().parse::<f64>() {
2600 seen.insert(id.trim(), p);
2601 }
2602 }
2603 }
2604 ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
2605}
2606
2607fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
2610 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2611 let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
2612 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
2613 let Some((n, items)) = line.split_once(' ') else { continue };
2614 let n: u16 = n.parse().unwrap_or(0);
2615 for it in items.split(',') {
2616 let mut p = it.rsplitn(3, ':');
2617 let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
2618 seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
2619 }
2620 }
2621 let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
2622 (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
2623}
2624
2625fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut selected: Asks, check: &str, instead: &[(String, String)]) -> Option<Value> {
2632 crate::memory::shuffle(&mut selected, session);
2635 let lessons: Vec<&conscience::Challenge> = selected.iter().map(|(c, _)| *c).collect();
2636 let log = lessons.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
2637 let others: Vec<String> = selected.iter().filter(|(_, k)| k != check).map(|(c, k)| format!("{}={k}", c.id)).collect();
2638 let mut marker = if lessons.is_empty() { "#borg-asked ".to_string() } else { format!("#borg-asked {log}{}", tag(check)) };
2640 if !others.is_empty() {
2641 marker.push_str(&format!("\n{ROUTED}{}", others.join(",")));
2642 }
2643 append(session, &marker);
2644 if !lessons.is_empty()
2645 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
2646 {
2647 let _ = writeln!(f, "{}\n{marker}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
2649 }
2650 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
2653 let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &lessons);
2654 if lessons.is_empty() {
2655 return queen;
2656 }
2657 if learning() {
2658 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2662 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2663 let ms = learn::now_ms();
2664 let disagree = conscience::disagreement_ids(&lessons);
2667 let (handed, routed): (Vec<(&conscience::Challenge, &str)>, Vec<_>) =
2668 selected.iter().map(|(c, k)| (*c, k.as_str())).partition(|(c, k)| instead.iter().any(|(id, at)| *id == c.id && at == k));
2669 let groups = by_check(&routed).into_iter().map(|g| (g, false)).chain(by_check(&handed).into_iter().map(|g| (g, true)));
2670 for ((k, members), handed) in groups {
2671 let ids: Vec<String> = members.iter().map(|i| (*i).to_string()).collect();
2672 let (grades, ranks, applied) = graded_of(session, &ids);
2673 let p = match handed {
2674 true => Vec::new(),
2675 false if k.is_empty() => propensity_in(&state, &ids),
2676 false => tagged_p(&state, "#borg-p ", k, &members),
2677 };
2678 let (pool, base, scoped) = if handed { Default::default() } else { pool_in(&state, &ids) };
2679 let asked = outcome::Outcome {
2680 kind: outcome::ASKED.into(),
2681 session: session.into(),
2682 lessons: ids.clone(),
2683 ms,
2684 repo: repo_of(cwd),
2685 groups: disagree.iter().filter(|g| g.iter().any(|x| ids.contains(x))).cloned().collect(),
2686 channel: "stop".into(),
2687 grades,
2688 ranks,
2689 applied,
2690 p,
2691 pool,
2692 base,
2693 scoped,
2694 check: k.into(),
2695 ..Default::default()
2696 };
2697 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
2698 let reason = match (handed, k == check) {
2699 (true, _) => ASKED_INSTEAD,
2700 (false, true) => "",
2701 (false, false) => "at the stop",
2702 };
2703 record_route(session, input, k, ids, "stop", outcome::ROUTE_ASKED, reason);
2704 }
2705 }
2706 let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&lessons)})));
2707 let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
2708 log_cost(session, &format!("block questions={} chars={chars}", lessons.len()));
2709 stat(session, input, crate::stats::BLOCK, |e| {
2710 e.n = lessons.len() as u64;
2711 e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
2712 e.channel = "stop".into();
2713 });
2714 out
2715}
2716
2717pub fn main(name: &str, event: &str) {
2722 let Some(h) = harness::by_name(name) else { return };
2723 let mut raw = String::new();
2724 let _ = std::io::stdin().read_to_string(&mut raw);
2725 let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
2726 if !h.caps().sends_pid
2728 && let Some(m) = input.as_object_mut()
2729 {
2730 m.shift_remove("pid");
2731 }
2732 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2735 let cfg = collective::load();
2736 let route = match &cfg {
2737 Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
2738 Err(_) => collective::Route::Off,
2739 };
2740 let collective::Route::Collective { home, name, .. } = route else { return };
2741 let mode = live::of(cfg.as_ref().ok().and_then(Option::as_ref), &name).mode;
2744 unsafe {
2747 std::env::set_var("BORG_HOME", &home);
2748 std::env::set_var("BORG_MODE", mode.name());
2749 std::env::set_var("BORG_HARNESS", h.name());
2751 if std::env::var_os("BORG_STATE").is_none() {
2752 std::env::set_var("BORG_STATE", home.join("state"));
2753 }
2754 }
2755 let now = learn::now_ms();
2757 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2758 crate::listen::touch(&home, event, session, now);
2762 if matches!(event, "start" | "halted") {
2764 return;
2765 }
2766 let me = match event {
2769 "pre_tool" => {
2770 if !crate::steer::active(&home) {
2771 return;
2772 }
2773 crate::presence::read(&home, session)
2774 }
2775 _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
2776 };
2777 let started = me.map(|p| p.started);
2780 let stopping = crate::listen::stopping(&home, event, session, now);
2782 let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
2783 let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
2785 let out = harness::with_budgets(h, &input, out);
2787 if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
2789 println!("{}", for_harness(h, out));
2790 }
2791}
2792
2793fn for_harness(h: &dyn Harness, mut out: Value) -> Value {
2797 if matches!(h.wiring(), harness::Wiring::CommandHooks { .. })
2798 && let Some(o) = out.as_object_mut()
2799 {
2800 o.shift_remove("shown_ids");
2801 }
2802 out
2803}
2804
2805#[cfg(test)]
2806mod tests {
2807 use super::*;
2808
2809 #[test]
2810 fn every_file_of_an_edit_survives_the_cut_of_its_text() {
2811 let long = "x".repeat(700);
2812 let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
2813 let text = edit_text("Edit", &input).unwrap();
2814 assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
2815 assert_eq!(text.chars().count(), 600);
2816 let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
2817 assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
2818 assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
2819 }
2820
2821 #[test]
2822 fn propensity_in_takes_the_latest_entry_and_nothing_when_one_is_missing() {
2823 let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
2824 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2825 assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
2826 assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
2827 assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
2828 assert!(propensity_in("", &ids(&["A"])).is_empty());
2829 }
2830
2831 #[test]
2832 fn pool_in_takes_the_pool_of_the_check_that_logged_each_p() {
2833 let state = "#borg-p A=0.6,B=0.4 @c1\n#borg-pool A=1.5:s,B=0.25,C=2 @c1\n#borg-asked A,B\n#borg-p B=0.5 @c2\n#borg-pool B=0.5,D=0.125:s @c2\n";
2834 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2835 assert_eq!(pool_in(state, &ids(&["B"])), (ids(&["B", "D"]), vec![0.5, 0.125], vec![false, true]), "B's latest p is check 2's");
2836 assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
2837 assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
2838 assert_eq!(pool_in("", &ids(&["A"])), Default::default());
2839 assert_eq!(pool_in(state, &[]), Default::default());
2840 let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
2842 assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
2843 assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
2844 assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
2846 }
2847
2848 #[test]
2849 fn a_queue_line_names_its_route_its_ids_and_its_check() {
2850 let c = "0123456789ab";
2851 assert_eq!(queue_line(&format!("A,B @{c}")), (Queued::Free, vec!["A", "B"], c));
2852 assert_eq!(queue_line(&format!("live A @{c}")), (Queued::Ask, vec!["A"], c));
2853 assert_eq!(queue_line(&format!("placebo X,Y @{c}")), (Queued::Placebo, vec!["X", "Y"], c));
2854 assert_eq!(queue_line(&format!("instead G0,G1 @{c}")), (Queued::Instead, vec!["G0", "G1"], c));
2855 assert_eq!(queue_line(&format!("instead-next G2 @{c}")), (Queued::Instead, vec!["G2"], c));
2856 assert_eq!(queue_line("A,B"), (Queued::Free, vec!["A", "B"], ""));
2858 assert_eq!(queue_line("live A"), (Queued::Ask, vec!["A"], ""));
2859 assert_eq!(queue_line(&format!("live-next A,B @{c}")), (Queued::Ask, vec!["A", "B"], c));
2860 assert_eq!(listed(&format!("#borg-asked A,B @{c}\n#borg-asked C\n"), "#borg-asked "), ["A", "B", "C"].into_iter().collect());
2861 }
2862
2863 #[test]
2864 fn a_tagged_propensity_is_its_checks_never_another_checks() {
2865 let state = "#borg-pf A=1,B=0.5 @c1\n#borg-pf A=0.25 @c2\n#borg-p A=0.75 @c1\n#borg-pf B=0.9\n";
2866 assert_eq!(tagged_p(state, "#borg-pf ", "c1", &["A", "B"]), [1.0, 0.5]);
2867 assert_eq!(tagged_p(state, "#borg-pf ", "c2", &["A"]), [0.25]);
2868 assert!(tagged_p(state, "#borg-pf ", "c2", &["A", "B"]).is_empty(), "B has none at c2: no guess");
2869 assert_eq!(tagged_p(state, "#borg-p ", "c1", &["A"]), [0.75], "#borg-p and #borg-pf are separate lines");
2870 assert_eq!(p_line("#borg-pf", [("A", 1.0), ("B", 0.33333)], Some("c1")).as_deref(), Some("#borg-pf A=1,B=0.3333 @c1"));
2871 assert_eq!(p_line("#borg-p", [], Some("c1")), None);
2872 }
2873
2874 #[test]
2875 fn unjudged_says_why_each_delivered_lesson_has_no_verdict() {
2876 let c = "0123456789ab";
2877 let state = format!(
2878 "#borg-asked A,B,C,D @{c}\n#borg-judged A=pending\n#borg-unjudged B why=slots\n#borg-judged A=pending\n#borg-unjudged D why=failed\n\
2879 #borg-queued E @{c}\n#borg-judged B=pending\n#borg-asked D\n"
2880 );
2881 let why = unjudged(&state);
2882 assert_eq!(why["A"], outcome::UNJUDGED_TRIES, "two unclear tries");
2883 assert_eq!(why["B"], outcome::UNJUDGED_SLOTS, "one try is not all of them; the last call left it out");
2884 assert_eq!(why["C"], outcome::UNJUDGED_NO_CALL);
2885 assert_eq!(why["D"], outcome::UNJUDGED_NO_CALL, "delivered again after its failed call");
2886 assert_eq!(why["E"], outcome::UNJUDGED_NO_CALL);
2887 }
2888
2889 #[test]
2890 fn the_declaration_coin_is_fixed_per_check_and_lesson_and_fair() {
2891 assert_eq!(stripped("0123456789ab", "L1"), stripped("0123456789ab", "L1"));
2892 let heads = (0..1000).filter(|i| stripped("0123456789ab", &format!("L{i}"))).count();
2893 assert!((400..600).contains(&heads), "{heads} of 1000");
2894 let checks = (0..1000).filter(|i| stripped(&format!("{i:012x}"), "L1")).count();
2895 assert!((400..600).contains(&checks), "{checks} of 1000");
2896 }
2897
2898 #[test]
2902 fn pending_gives_every_route_the_same_evidence_and_its_own_check() {
2903 let session = format!("pending-v2-{}", std::process::id());
2904 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2905 let all = [c("A"), c("B"), c("C"), c("D"), c("E")];
2906 let edits = "Plan: the blocked message\n#borg-asked A,B @0000000000c1\n#borg-routed B=0000000000c2\n#borg-answer A: applies. B: skip.\n\
2907 edit one\n#borg-work the work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\nedit two\n\
2908 #borg-work later work\n#borg-asked E @0000000000c5\n";
2909 let _ = std::fs::write(edits_file(&session), edits);
2910 let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
2911 let j = pending(&session, &state, &all, "the final message", When::Stop, "00000000cafe");
2912 let got: Vec<(&str, &str, &str)> = j.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel, p.check.as_str())).collect();
2913 assert_eq!(
2914 got,
2915 [
2916 ("A", "stop", "0000000000c1"),
2917 ("B", "stop", "0000000000c2"),
2918 ("C", "queued", "0000000000c3"),
2919 ("D", outcome::PLACEBO, "0000000000c4"),
2920 ("E", "stop", "0000000000c5")
2921 ]
2922 );
2923 let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
2924 assert_eq!(
2925 (p("A").before.as_str(), p("C").before.as_str(), p("D").before.as_str()),
2926 ("Plan: the blocked message", "the work so far", "the work so far")
2927 );
2928 assert_eq!(p("C").after, "work since: edit two\nthis turn: the final message");
2929 assert_eq!(p("D").after, p("C").after, "a placebo is judged as a free delivery");
2930 assert!(p("A").after.starts_with("work since: ") && p("A").after.contains("edit one | edit two"), "{}", p("A").after);
2931 assert_eq!(p("A").after, p("B").after, "delivered together, one shared evidence");
2932 for (id, word) in [("A", "applies"), ("B", "skip")] {
2933 let x = p(id);
2934 assert_eq!(x.stripped, stripped(&x.check, id));
2935 match x.stripped {
2936 true => assert!(x.declared.is_none() && !x.after.contains(&format!("{id}: {word}")), "{x:?}"),
2937 false => assert!(x.declared.is_some() && x.after.contains(&format!("{id}: {word}")), "{x:?}"),
2938 }
2939 }
2940 assert!(!p("C").stripped && !p("D").stripped, "only questions are stripped");
2941 let live = pending(&session, &state, &all, "", When::Live, "00000000cafe");
2943 let ids: Vec<&str> = live.pending.iter().map(|p| p.lesson.id.as_str()).collect();
2944 assert_eq!(ids, ["A", "B"], "C and D wait for the turn's end, E for its answer");
2945 assert!(j.question && j.crowded.is_empty());
2946 let _ = std::fs::remove_file(edits_file(&session));
2947 }
2948
2949 #[test]
2953 fn a_later_question_not_yet_answered_keeps_the_earlier_free_delivery_from_its_judge() {
2954 let session = format!("pending-later-{}", std::process::id());
2955 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2956 let all = [c("L"), c("M")];
2957 let (a, b) = ("00000000000a", "00000000000b");
2958 let asked = format!("#borg-work so far\n#borg-queued L,M @{a}\nedit one\n#borg-work more\n#borg-asked L @{b}\n#borg-live L ms=1\nedit two\n");
2959 let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
2960 let _ = std::fs::write(edits_file(&session), &asked);
2961 let live = pending(&session, &state_of(&asked), &all, "", When::Live, "00000000cafe");
2962 assert!(live.pending.is_empty(), "L waits for its answer, M for the turn's end");
2963 assert!(!live.question && live.crowded.is_empty(), "nothing held back takes a slot or a try");
2964 let answered = format!("{asked}#borg-answer L: applies\n");
2965 let _ = std::fs::write(edits_file(&session), &answered);
2966 let got = |when: When| -> Vec<(String, &str, String)> {
2967 let j = pending(&session, &state_of(&answered), &all, "", when, "00000000cafe");
2968 j.pending.iter().map(|p| (p.lesson.id.clone(), p.channel, p.check.clone())).collect()
2969 };
2970 assert_eq!(got(When::Live), [("L".to_string(), "live", b.to_string())], "answered: judged mid-turn; M still waits");
2971 for when in [When::Stop, When::End] {
2972 assert_eq!(got(when), [("M".to_string(), "queued", a.to_string()), ("L".to_string(), "live", b.to_string())], "{when:?}");
2973 }
2974 let _ = std::fs::remove_file(edits_file(&session));
2975 }
2976
2977 #[test]
2981 fn a_free_delivery_or_placebo_is_judged_at_the_turn_end_never_mid_turn() {
2982 let session = format!("pending-work-{}", std::process::id());
2983 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2984 let all = [c("A"), c("C"), c("D"), c("E")];
2985 let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
2986 let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
2987 let fresh = "Plan: blocked\n#borg-asked A @0000000000c1\n#borg-answer A: applies\n#borg-work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\n";
2988 for edits in [fresh.to_string(), format!("{fresh}edit one\n"), format!("{fresh}edit one\n#borg-work later\n#borg-early E @0000000000c5\nedit two\n")] {
2989 let _ = std::fs::write(edits_file(&session), &edits);
2990 let state = state_of(&edits);
2991 let live = pending(&session, &state, &all, "", When::Live, "00000000cafe");
2992 assert_eq!(ids(&live), ["A"], "mid-turn, only the answered question: {edits}");
2993 assert!(live.crowded.is_empty() && live.question);
2994 let early = edits.contains("#borg-early ");
2995 for when in [When::Stop, When::End] {
2996 let want: &[&str] = if early { &["A", "C", "D", "E"] } else { &["A", "C", "D"] };
2997 assert_eq!(ids(&pending(&session, &state, &all, "", when, "00000000cafe")), want, "{when:?}: {edits}");
2998 }
2999 }
3000 let _ = std::fs::remove_file(edits_file(&session));
3001 }
3002
3003 #[test]
3008 fn the_session_end_judge_never_outlives_its_lock() {
3009 let (end, stale) = (end_timeout(), live::stale_ms());
3010 assert!(i64::try_from(end.as_millis()).unwrap() < stale, "{end:?} against a lock stale after {stale} ms");
3011 assert!(end <= learn::learn_timeout());
3012 }
3013
3014 #[test]
3015 fn the_session_end_judge_takes_every_open_delivery_once() {
3016 let session = format!("pending-end-{}", std::process::id());
3017 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3018 let all = [c("A"), c("B"), c("C"), c("D"), c("Q")];
3019 let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
3020 let edits = "Plan\n#borg-asked A,B @0000000000c1\n#borg-answer A: applies\nedit\n#borg-work w\n#borg-queued C,D @0000000000c3\nedit two\nPlan two\n#borg-asked Q @0000000000c4\n";
3021 let _ = std::fs::write(edits_file(&session), edits);
3022 let delivered: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3023 let state = format!("{delivered}#borg-judged A=changed\n#borg-judged B=pending\n#borg-judged C=pending\n#borg-judged B=pending\n");
3025 assert_eq!(ids(&pending(&session, &state, &all, "", When::Stop, "00000000cafe")), ["C", "D", "Q"], "a check: B's tries are spent");
3026 assert_eq!(ids(&pending(&session, &state, &all, "", When::End, "00000000cafe")), ["B", "C", "D", "Q"], "the end: every open one");
3027 let ended = format!("{state}#borg-judged B=pending\n#borg-judged C=changed\n{END_JUDGED}B,C,D,Q\n");
3028 for when in [When::Live, When::Stop, When::End] {
3029 assert!(pending(&session, &ended, &all, "", when, "00000000cafe").pending.is_empty(), "{when:?}: judged at the end once");
3030 }
3031 let why = unjudged(&ended);
3032 assert_eq!((why["B"], why["D"], why["Q"]), (outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES, outcome::UNJUDGED_TRIES));
3033 let again = format!("{ended}#borg-queued D @0000000000c5\n");
3035 let _ = std::fs::write(edits_file(&session), format!("{edits}#borg-queued D @0000000000c5\n"));
3036 assert_eq!(ids(&pending(&session, &again, &all, "", When::End, "00000000cafe")), ["D"]);
3037 assert_eq!(unjudged(&again)["D"], outcome::UNJUDGED_NO_CALL);
3038 let _ = std::fs::remove_file(edits_file(&session));
3039 }
3040
3041 #[test]
3044 fn the_session_work_shares_its_budget_newest_first() {
3045 let parts = [("short", "abc".to_string()), ("long", "0123456789".to_string()), ("none", String::new())];
3046 assert_eq!(within(&parts, 100), "### short\nabc\n### long\n0123456789\n");
3047 assert_eq!(within(&parts, 9), "### short\nabc\n### long\n…456789\n", "short keeps its 3, long the other 6");
3048 assert_eq!(within(&parts, 4), "### short\n…bc\n### long\n…89\n", "two each when neither fits its share");
3049 }
3050
3051 #[test]
3054 fn the_judge_cost_line_names_the_returned_and_unmatched_ids() {
3055 fn p(lesson: &conscience::Challenge) -> predict::Pending<'_> {
3056 predict::Pending { lesson, before: String::new(), after: String::new(), declared: None, check: String::new(), channel: "stop", stripped: false }
3057 }
3058 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3059 let (a, b) = (c("La"), c("Lb"));
3060 let pend = [p(&a), p(&b)];
3061 let returned = ["La".to_string(), "Lz".to_string()];
3062 let j = |id: &str, verdict, reason: &str| predict::Judged { id: id.into(), verdict, reason: reason.into() };
3063 let one = [j("La", predict::Verdict::NotArisen, "only docs"), j("Lz", predict::Verdict::Changed, "not pending")];
3064 assert_eq!(
3065 judge_line(3, &pend, &one, 900, &returned),
3066 "judge call=3 pending=2 resolved=1 chars=900 returned=La,Lz unmatched=Lz \
3067 verdicts=changed:0,ignored:0,not_arisen:1,consequence_remains:0,unclear:0 reason_chars=9"
3068 );
3069 let pair = [j("La", predict::Verdict::Unclear, ""), j("Lb", predict::Verdict::ConsequenceRemains, "still awaits")];
3070 assert_eq!(
3071 judge_line(4, &pend, &pair, 10, &[]),
3072 "judge call=4 pending=2 resolved=0 chars=10 returned= unmatched= \
3073 verdicts=changed:0,ignored:0,not_arisen:0,consequence_remains:1,unclear:1 reason_chars=12"
3074 );
3075 }
3076
3077 #[test]
3082 fn a_stripped_declaration_leaves_every_evidence_of_the_call() {
3083 let session = format!("pending-strip-{}", std::process::id());
3084 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3085 let all = [c("A"), c("F"), c("G")];
3086 let check = |lesson: &str, strip: bool| (0u64..).map(|i| format!("{i:012x}")).find(|k| stripped(k, lesson) == strip).unwrap();
3087 let (ca, cf, cg) = (check("A", false), check("F", true), check("G", false));
3088 let answer = "Fixed the arc. F: applies";
3089 let edits = format!(
3090 "Plan: the blocked message\n#borg-asked A @{ca}\n#borg-answer A: applies\nedit one\n#borg-work the work so far\n\
3091 #borg-asked F @{cf}\n#borg-live F ms=1\n#borg-answer {answer}\n{answer}\n#borg-asked G @{cg}\n"
3092 );
3093 let _ = std::fs::write(edits_file(&session), &edits);
3094 let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
3095 let j = pending(&session, &state, &all, answer, When::Stop, "00000000cafe");
3096 let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
3097 assert!(p("F").stripped && p("F").declared.is_none(), "{:?}", p("F"));
3098 for x in &j.pending {
3099 assert!(!format!("{} {}", x.before, x.after).to_lowercase().contains("f: applies"), "{x:?}");
3100 }
3101 assert!(p("F").after.contains("this turn: Fixed the arc."), "the rest of the message stays: {}", p("F").after);
3102 assert!(!p("A").stripped && p("A").declared == Some(true) && p("A").after.contains("A: applies"), "{:?}", p("A"));
3103 assert_eq!(without_declarations(&format!("work\n{answer}"), &j.pending), "work\nFixed the arc. ");
3105 assert_eq!(without_declarations("A: applies", &j.pending), "A: applies");
3106 let _ = std::fs::remove_file(edits_file(&session));
3107 }
3108
3109 #[test]
3114 fn a_merged_ask_of_two_draws_carries_no_pool() {
3115 let session = format!("pool-merge-{}", std::process::id());
3116 let _ = std::fs::remove_file(state_file(&session));
3117 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3118 let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
3119 let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
3120 append_p_of(&session, &live, &[&a, &b], "c1");
3121 let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
3122 append_p_of(&session, &stop, &[&a], "c2");
3123 let state = std::fs::read_to_string(state_file(&session)).unwrap_or_default();
3124 let merged = vec!["A".to_string(), "B".to_string()];
3125 assert_eq!(propensity_in(&state, &merged).len(), 2, "each id has its latest p");
3126 assert_eq!(pool_in(&state, &merged), Default::default());
3127 assert_eq!(pool_in(&state, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
3128 assert_eq!(pool_in(&state, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
3129 assert_eq!(tagged_p(&state, "#borg-p ", "c1", &["B"]).len(), 1, "B's record carries its own check's p");
3130 let _ = std::fs::remove_file(state_file(&session));
3131 }
3132
3133 #[test]
3137 fn an_early_lessons_relative_score_is_its_share_of_the_best_on_topic_match() {
3138 let c = |id: &str, q: &str| conscience::Challenge { id: id.into(), question: q.into(), scope: vec!["Orion".into()], ..Default::default() };
3139 let standing = conscience::Challenge { source: conscience::CONSTRAINT.into(), ..c("S", "harpsichord tuning") };
3140 let all = vec![c("A", "orion budget place reset arc drain"), c("B", "orion budget place"), c("C", "telescope glaciers"), standing];
3141 let work = "drain the orion budget place, never with a reset arc";
3142 let topics = vec!["Orion".to_string()];
3143 let ranked = conscience::rank(&all, work);
3144 let score = |id: &str| ranked.iter().find(|(_, x)| x.id == id).map_or(0.0, |(s, _)| *s);
3145 assert!(score("A") > score("B") && score("B") > 0.0, "{ranked:?}");
3146 let rel = early_rel(&all, work, &topics, &[&all[1], &all[3]]);
3148 assert_eq!(rel, [outcome::round_p(score("B") / score("A")), 0.0]);
3149 assert!(rel[0] < 1.0);
3150 assert_eq!(early_rel(&all, work, &topics, &[&all[0]]), [1.0]);
3151 assert!(early_rel(&all, work, &["Beacon".to_string()], &[&all[3]]).is_empty());
3153 }
3154
3155 fn decide_store(name: &str) -> std::path::PathBuf {
3159 decide_store_with(name, true)
3160 }
3161
3162 fn decide_store_with(name: &str, sure: bool) -> std::path::PathBuf {
3166 use crate::select::{policy, summary};
3167 let d = std::env::temp_dir().join(format!("borg-decide-{name}-{}", std::process::id()));
3168 let _ = std::fs::remove_dir_all(&d);
3169 std::fs::create_dir_all(&d).unwrap();
3170 let lessons = d.join("lessons.jsonl");
3171 std::fs::write(&lessons, "").unwrap();
3172 std::fs::write(outcome::path_beside(&lessons), "").unwrap();
3173 let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
3174 let mut beta = vec![0.0; policy::P];
3175 beta[at("intercept")] = -0.5;
3176 if sure {
3177 beta[at("ask")] = -3.0;
3178 beta[at("not")] = -1.0;
3179 }
3180 let mut chol = vec![0.0; policy::P * policy::P];
3181 for k in 0..policy::P {
3182 chol[k * policy::P + k] = 0.05;
3183 }
3184 let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.1; 3], ..policy::Model::default() };
3185 let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.0, wait: 0.0, ask_live: 1.0 };
3186 let fit = policy::Fit { key: "9-abcdef01".into(), model: Some(model), rates, ..policy::Fit::default() };
3187 let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 5.0, ..summary::Gate::default() };
3188 summary::save(&lessons, &summary::Summary { fit, gate, ..summary::Summary::default() }).unwrap();
3189 lessons
3190 }
3191
3192 fn record_beside(lessons: &std::path::Path, applied: &[(&conscience::Challenge, u8)], check: &str, r: &crate::select::Routed, basis: &Basis) -> bool {
3195 let order: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
3196 let call = Call { eligible: order.len(), order: &order, tail: &[], pt: 0.0, budget: 0, model: "", prompt_hash: String::new(), dur_ms: 1, usd: 0.0 };
3197 let o = graded_outcome("decide-s", "r", check, true, applied, Some((r, basis)), basis.summary, &call);
3198 outcome::append(&outcome::path_beside(lessons), &o).is_ok()
3199 }
3200
3201 #[test]
3204 fn the_graded_record_carries_the_question_count_tests_draw() {
3205 use crate::select::{Log, Routed, policy, summary};
3206 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3207 let ls = [c("A"), c("B")];
3208 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([2, 1]).collect();
3209 let order: Vec<&conscience::Challenge> = ls.iter().collect();
3210 let call = Call { eligible: 2, order: &order, tail: &[], pt: 0.0, budget: 16_000, model: "", prompt_hash: String::new(), dur_ms: 1, usd: 0.0 };
3211 let s = summary::Summary::default();
3212 let basis = Basis { summary: &s, had: &[], why: "" };
3213 let routed = |source: &'static str, xk: Option<u32>, xs: Option<u32>| Routed {
3214 ask: Vec::new(),
3215 free: vec![&ls[0], &ls[1]],
3216 placebo: Vec::new(),
3217 p: vec![0.0, 0.0],
3218 pf: vec![1.0, 1.0],
3219 log: Log { policy: "learned", source, xk, xs, ..Log::default() },
3220 };
3221 let record = |r: Option<&Routed>| graded_outcome("s", "r", "0123456789ab", true, &applied, r.map(|r| (r, &basis)), &s, &call);
3222 let drawn = record(Some(&routed(policy::EXPERIMENT_CONTROL, Some(2), Some(0))));
3223 assert_eq!((drawn.source.as_str(), drawn.xk, drawn.xs), (policy::EXPERIMENT_CONTROL, Some(2), Some(0)));
3224 let outside = record(Some(&routed(policy::EVIDENCE, None, None)));
3225 assert_eq!((outside.source.as_str(), outside.xk, outside.xs), (policy::EVIDENCE, None, None));
3226 let failed = record(None);
3227 assert_eq!((failed.failed, failed.xk, failed.xs), (true, None, None));
3228 assert_eq!((drawn.budget, failed.budget), (16_000, 16_000));
3230 }
3231
3232 fn graded_records(lessons: &std::path::Path) -> Vec<outcome::Outcome> {
3233 outcome::read(&outcome::path_beside(lessons)).into_iter().filter(|o| o.kind == outcome::GRADED).collect()
3234 }
3235
3236 #[test]
3239 fn a_check_decides_on_the_summary_under_the_lock_with_every_decision_before_it() {
3240 use crate::select::{Policy, policy, summary};
3241 let lessons = decide_store("fresh");
3242 let stale = summary::refresh(&lessons);
3244 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3245 let ls = [c("A"), c("B"), c("C"), c("D")];
3246 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3247 let mut gates = Vec::new();
3248 for check in ["0000000000a1", "0000000000b2"] {
3251 let at = Decide { session: "decide-fresh", check, channel: "live", lessons: &lessons, tokens: 0, recorded: true };
3252 let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |r, b| record_beside(&lessons, &applied, check, r, b));
3253 gates.push(r.log.gate);
3254 }
3255 let g = graded_records(&lessons);
3256 assert_eq!(g.len(), 2);
3257 assert!(g.iter().all(|o| o.policy == "learned" && o.reason.is_empty()), "{g:?}");
3258 assert_ne!(g[0].free, Vec::<String>::new(), "the first deviates: everything free at a live check, {:?}", g[0]);
3259 assert_eq!(g[0].seen, stale.key(), "nothing was decided before the first");
3260 let log = std::fs::read_to_string(outcome::path_beside(&lessons)).unwrap();
3262 let first = log.lines().next().unwrap();
3263 let after = summary::fold(&format!("{first}\n"), stale.clone());
3264 assert_eq!(after.gate.checks, stale.gate.checks + 1, "the first's decision is in the gate's ledger");
3265 assert_eq!(g[1].seen, after.key(), "decided on the summary loaded under the lock, not the one read at its start");
3266 assert_eq!(gates[1], policy::remaining(&after.gate).max(0.0), "against the gate with the first's entry");
3267 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3268 }
3269
3270 #[test]
3276 fn two_checks_deciding_at_once_never_both_take_the_gate() {
3277 use crate::select::{Policy, policy, summary};
3278 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3279 let ls = [c("A"), c("B"), c("C"), c("D")];
3280 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3281 let mut stores = Vec::new();
3282 for (name, sure, sources) in [("race", true, &[policy::EVIDENCE][..]), ("race-test", false, &[policy::EXPERIMENT, policy::EXPERIMENT_CONTROL][..])] {
3283 let lessons = decide_store_with(name, sure);
3284 let stale = summary::refresh(&lessons);
3285 let both = std::sync::Barrier::new(2);
3288 std::thread::scope(|s| {
3289 for check in ["0000000000c1", "0000000000c2"] {
3290 let (applied, lessons, stale, both) = (&applied, &lessons, &stale, &both);
3291 s.spawn(move || {
3292 let at = Decide { session: "decide-race", check, channel: "live", lessons, tokens: 0, recorded: true };
3293 decide(Policy::Learned, applied, &at, stale, std::time::Duration::ZERO, |r, b| {
3294 both.wait();
3295 record_beside(lessons, applied, check, r, b)
3296 });
3297 });
3298 }
3299 });
3300 let mut got: Vec<(String, String, String, bool)> =
3301 graded_records(&lessons).into_iter().map(|o| (o.policy, o.source, o.reason, o.xk.is_some())).collect();
3302 got.sort();
3303 assert_eq!(got.len(), 2, "{name}: {got:?}");
3304 assert_eq!(got[0], ("fixed".to_string(), "fixed".to_string(), outcome::DECIDED_BUSY.to_string(), false), "{name}: the other took the fixed rule");
3305 let (policy, source, reason, drawn) = &got[1];
3306 assert!(policy == "learned" && sources.contains(&source.as_str()) && reason.is_empty(), "{name}: one decided under the lock: {got:?}");
3307 assert_eq!(*drawn, !sure, "{name}: the test's draw on its record alone");
3308 assert!(policy::remaining(&summary::refresh(&lessons).gate) >= -1e-9, "{name}: the gate never overspent");
3309 stores.push(lessons);
3310 }
3311 let stale = summary::refresh(&stores[0]);
3313 let gone = std::path::Path::new("/nonexistent/borg-decide/lessons.jsonl");
3314 let at = Decide { session: "decide-race", check: "0000000000c3", channel: "live", lessons: gone, tokens: 0, recorded: true };
3315 let mut why = "";
3316 let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |_, b| {
3317 why = b.why;
3318 true
3319 });
3320 assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_NO_LOCK));
3321 for lessons in stores {
3322 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3323 }
3324 }
3325
3326 #[test]
3330 fn a_policy_that_reads_no_gate_decides_by_itself_whoever_holds_the_lock() {
3331 use crate::select::{Ctx, Policy, graded_under, summary};
3332 let lessons = decide_store("nolock");
3333 let stale = summary::refresh(&lessons);
3334 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3335 let ls = [c("A"), c("B"), c("C"), c("D")];
3336 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 3, 2]).collect();
3337 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3338 let held = lock_within(&decide_lock(&lessons), std::time::Duration::ZERO).unwrap();
3339 let check = "0000000000e1";
3340 let at = Decide { session: "decide-nolock", check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
3341 let ctx = Ctx {
3342 session: at.session,
3343 check,
3344 channel: at.channel,
3345 lessons: &lessons,
3346 summary: &stale,
3347 tokens: 0,
3348 had: &[],
3349 alpha: crate::select::alpha(&lessons),
3350 };
3351 let fixed = ids(&graded_under(Policy::Fixed, &applied, &ctx).ask);
3352 for policy in [Policy::Fixed, Policy::Cap(1), Policy::Attention] {
3353 let own = graded_under(policy, &applied, &ctx);
3354 let mut got = None;
3355 let r = decide(policy, &applied, &at, &stale, std::time::Duration::ZERO, |r, b| {
3356 got = Some((r.log.policy, b.why, b.summary.key()));
3357 true
3358 });
3359 assert_eq!(got, Some((policy.name(), "", stale.key())), "{policy:?}");
3360 assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&own.ask), ids(&own.free)), "{policy:?}: its own rule");
3361 }
3362 assert_ne!(ids(&graded_under(Policy::Cap(1), &applied, &ctx).ask), fixed);
3364 let mut why = "";
3365 let r = decide(Policy::Learned, &applied, &at, &stale, std::time::Duration::ZERO, |_, b| {
3366 why = b.why;
3367 true
3368 });
3369 assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_BUSY), "the learned policy alone waits for the lock");
3370 drop(held);
3371 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3372 }
3373
3374 #[test]
3379 fn a_learned_decision_whose_record_cannot_land_takes_the_fixed_rule() {
3380 use crate::select::{Ctx, Policy, graded_under, policy, summary};
3381 let lessons = decide_store("unrecorded");
3382 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3383 let ls = [c("A"), c("B"), c("C"), c("D")];
3384 let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3385 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3386 let at = |check: &'static str, recorded: bool| Decide { session: "decide-unrecorded", check, channel: "live", lessons: &lessons, tokens: 0, recorded };
3387 let fixed = |check: &str, s: &summary::Summary| {
3388 let ctx = Ctx {
3389 session: "decide-unrecorded",
3390 check,
3391 channel: "live",
3392 lessons: &lessons,
3393 summary: s,
3394 tokens: 0,
3395 had: &[],
3396 alpha: crate::select::alpha(&lessons),
3397 };
3398 let r = graded_under(Policy::Fixed, &applied, &ctx);
3399 (ids(&r.ask), ids(&r.free), ids(&r.placebo))
3400 };
3401 let stale = summary::refresh(&lessons);
3403 let r = decide(Policy::Learned, &applied, &at("0000000000f1", true), &stale, std::time::Duration::ZERO, |r, b| {
3404 record_beside(&lessons, &applied, "0000000000f1", r, b)
3405 });
3406 assert_eq!(r.log.source, policy::EVIDENCE, "{:?}", r.log);
3407 let (ask, free, _) = fixed("0000000000f1", &stale);
3408 assert_ne!((ids(&r.ask), ids(&r.free)), (ask, free), "it deviated");
3409 let gate = summary::refresh(&lessons).gate;
3410 for check in ["0000000000f2", "0000000000f3"] {
3412 let stale = summary::refresh(&lessons);
3413 let mut tried = Vec::new();
3414 let r = decide(Policy::Learned, &applied, &at(check, false), &stale, std::time::Duration::ZERO, |r, b| {
3415 tried.push((r.log.policy, b.why));
3416 false
3417 });
3418 assert_eq!(tried, [("fixed", outcome::DECIDED_NO_RECORD)], "{check}");
3419 assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed(check, &stale), "{check}");
3420 }
3421 let stale = summary::refresh(&lessons);
3424 let mut tried = Vec::new();
3425 let r = decide(Policy::Learned, &applied, &at("0000000000f4", true), &stale, DECIDE_WAIT, |r, b| {
3427 tried.push((r.log.policy, b.why));
3428 false
3429 });
3430 assert_eq!(tried, [("learned", ""), ("fixed", outcome::DECIDED_NO_RECORD)]);
3431 assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed("0000000000f4", &stale));
3432 assert_eq!(graded_records(&lessons).len(), 1, "only the first check's record");
3433 assert_eq!(summary::refresh(&lessons).gate, gate, "no unrecorded decision moved the gate");
3434 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3435 }
3436
3437 #[test]
3442 fn a_lesson_waiting_in_the_queue_never_takes_a_free_or_placebo_slot() {
3443 use crate::select::{Policy, summary};
3444 let lessons = decide_store("queued");
3445 let stale = summary::refresh(&lessons);
3446 let session = format!("decide-queued-{}", std::process::id());
3447 let _ = std::fs::remove_file(state_file(&session));
3448 std::fs::write(queued_file(&session), "X @0000000000d0\nlive Y @0000000000d0\nplacebo Z @0000000000d0\n").unwrap();
3449 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3450 let named: Vec<String> = (1..=10).map(|i| format!("L{i:02}")).collect();
3451 let ls: Vec<conscience::Challenge> = ["X", "Y", "Z"].iter().map(|i| c(i)).chain(named.iter().map(|i| c(i))).collect();
3452 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3453 let first: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if ["X", "Y"].contains(&c.id.as_str()) { 2 } else { 1 })).collect();
3456 let last: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if c.id.starts_with('L') { 2 } else { 1 })).collect();
3457 let waits = |l: &[String]| l.iter().any(|i| i == "X" || i == "Y");
3458 for (check, applied) in [("0000000000d1", &first), ("0000000000d2", &last)] {
3459 let at = Decide { session: &session, check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
3460 let r = decide(Policy::Fixed, applied, &at, &stale, std::time::Duration::ZERO, |r, b| record_beside(&lessons, applied, check, r, b));
3461 let (free, placebo) = (ids(&r.free), ids(&r.placebo));
3462 assert_eq!(free.len(), conscience::CAP, "{check}: every free slot taken: {free:?}");
3463 assert!(!waits(&free) && !waits(&placebo), "{check}: free {free:?}, placebo {placebo:?}");
3464 }
3465 assert_eq!(graded_records(&lessons)[1].placebo, ["Z"], "waiting as a placebo, Z is still drawn");
3466 assert!(graded_records(&lessons)[0].free.contains(&"Z".to_string()), "and still goes free");
3467 let g = graded_records(&lessons);
3468 assert_eq!(g.iter().map(|o| o.nofree.clone()).collect::<Vec<_>>(), [["X", "Y"], ["X", "Y"]], "{g:?}");
3469 let _ = std::fs::remove_file(queued_file(&session));
3470 let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3471 }
3472
3473 #[test]
3474 fn only_an_adapter_reads_which_messages_an_answer_showed() {
3475 let out = json!({"hookSpecificOutput": {"additionalContext": "[borg] Message from s1: hi"}, "shown_ids": ["m0123456789ab"]});
3476 for name in ["claude", "codex"] {
3477 let h = harness::by_name(name).unwrap();
3478 assert_eq!(for_harness(h, out.clone()), json!({"hookSpecificOutput": out["hookSpecificOutput"]}), "{name}");
3479 }
3480 for name in ["omp", "opencode"] {
3481 assert_eq!(for_harness(harness::by_name(name).unwrap(), out.clone()), out, "{name}");
3482 }
3483 }
3484}