Skip to main content

borg/
hook.rs

1//! The hook: every harness's events, one canonical, Claude-shaped event each (session_id, cwd, prompt,
2//! tool_name, tool_input, error, last_assistant_message, transcript_path, stop_hook_active), answered
3//! Claude-shaped ({"decision":"block","reason"} at stop, `hookSpecificOutput` with context at a prompt
4//! or tool call); adapters translate both ways (harness/). What differs per harness comes from its
5//! [`Harness`]: capabilities, the turn's work and the drone's last message, never its name.
6//!
7//! `main` routes and records presence; [`dispatch`] has one function per event. Hooks always fail open:
8//! any error lets the drone continue untouched.
9
10use 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
27/// One line (or several, kept together) at the end of the state file, in one write: a check running in
28/// the background appends to the same file, and its lines never land between these.
29pub(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
35/// What a tool call touched, as text for matching: paths and search patterns.
36pub(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            // An edit of several files (a Codex patch) names the further ones in its `edits`.
49            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
60/// A shell command's text kept as its touch when it names no path (`touched`). Guard: bounds one
61/// state-file line (a heredoc would otherwise go in whole). borg-trace's `TOUCH_CHARS`, which a replay
62/// of this hook mirrors.
63const COMMAND_CHARS: usize = borg_trace::dialogue::TOUCH_CHARS;
64
65/// The files an edit names in its `edits` beside its own `file_path` (a Codex patch of several files;
66/// Claude's MultiEdit names none there). They reach `.ctx` and `.edits`; presence (`borg top`) shows
67/// one file per drone, the edit's own `file_path`.
68pub(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
76/// Counts one event of this session in the collective's statistics (stats.rs); `fill` adds details.
77fn 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    // The engineer as presence.rs already resolved it at this hook: no second `git config` call.
83    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
88/// The engineer's prompts this session (the queen's criterion 3; the state file mixes in touches).
89fn prompts_file(session: &str) -> PathBuf {
90    state_file(session).with_extension("prompts")
91}
92
93/// Which turn the session is in: the size of its prompts file, which every prompt grows. A live check
94/// records the turn it started in (live.rs `Mark`), so it covers that turn's stop only.
95fn turn_id(session: &str) -> u64 {
96    std::fs::metadata(prompts_file(session)).map_or(0, |m| m.len())
97}
98
99/// Where the session works: the repo it runs in and the paths it touched (`where_now` adds the latest
100/// prompt). Scopes are judged here only, so a system merely mentioned in a command's text or in an
101/// earlier part of the conversation is not a topic.
102fn where_file(session: &str) -> PathBuf {
103    state_file(session).with_extension("where")
104}
105
106/// Where the session works now: the repo and touched paths so far, plus the engineer's latest prompt
107/// (an earlier prompt's system is not this turn's work).
108fn 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
127/// A tool call refused without words, waiting for the engineer's next prompt (their reason).
128/// Pending only while the drone waits: a later successful tool call or the session's end clears it,
129/// because omp lets the model carry on after a denial and the next prompt may be about anything.
130fn denied_file(session: &str) -> PathBuf {
131    state_file(session).with_extension("denied")
132}
133
134/// A correcting prompt becomes an episode (task, the drone's last proposal, the correction);
135/// so does the first prompt after a wordless tool denial. Any other prompt becomes the session's task.
136fn 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
157/// A veto said in the session (veto.rs): its episode, the shown lessons' credit and its stat. A pending
158/// wordless denial is answered by it, so it never becomes a correction later.
159fn 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
178/// A rejected tool call or plan that carries the engineer's words becomes an episode; a wordless
179/// denial waits for the next prompt (`capture_prompt`).
180fn 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
200/// The drone's last proposal, its last `learn::PROPOSAL_CHARS` characters (`harness::last_message`: the
201/// adapter's, else the transcript's).
202fn 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
207/// The drone's whole last proposal (`proposal` keeps its end): the reasoning note
208/// redacts it before it cuts (notes.rs).
209fn 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
232/// The lessons the conscience reads (BORG_CONSCIENCE, the BORG_BEFORE cut-off) as `session` sees them:
233/// weighted by their memory with this session's usefulness sample (memory.rs), without the ones it may
234/// not deliver here (systems, reach, absorbed). Every hook path that hands lessons to a drone reads them
235/// here.
236fn lessons(session: &str) -> Vec<conscience::Challenge> {
237    loaded(session).0
238}
239
240/// Every lesson the judge gives a verdict on: `all` and the absorbed ones withheld from it (`loaded`).
241/// A delivery made before its lesson was absorbed is evidence about the lesson, not a delivery, so the
242/// judge still sees it (`Work::judged`).
243fn 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
247/// `lessons`, and the lessons the absorbed filter withheld from it (absorb.rs `withhold`).
248fn 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    // The routed collective's lessons and this engineer's personal ones of it (reach.rs), nothing else.
251    let mut all = conscience::load_store_for(&lessons_path(), before, Some(session));
252    // The system gate (systems.rs): where the repo says which system this is, lessons scoped to other
253    // systems score 0, however much the conversation names them.
254    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    // Reach (reach.rs): a tombstone is never delivered, a repo convention only in its repo.
260    all.retain(|c| crate::reach::deliverable(c, &repos));
261    // Absorbed (absorb.rs): held outside Borg now, so delivered on no path; every path reads this list.
262    let withheld = crate::absorb::withhold_beside(&lessons_path(), &mut all, before);
263    // Spike (concepts.rs): lessons sharing engineering concepts with the code written, the touched paths
264    // and the latest prompt rank higher; never a filter.
265    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
274/// The lessons file the conscience reads: BORG_CONSCIENCE, default $BORG_HOME/lessons.jsonl.
275fn lessons_path() -> PathBuf {
276    std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
277}
278
279/// What the drone did, for R1 (`pending`, `early_acted`): one line per Edit/Write/MultiEdit/NotebookEdit
280/// (path and the start of the new text); at each Stop block the blocked message and `#borg-asked <ids>`;
281/// the drone's answer at the turn end after it (`#borg-answer <message>`); `#borg-early <ids>` per early
282/// delivery. Kept out of the state file so the drone's code never feeds the conscience's matching.
283fn edits_file(session: &str) -> PathBuf {
284    state_file(session).with_extension("edits")
285}
286
287/// What Borg cost this session (R3b), one line per delivery: `block questions=N chars=M` for a Stop
288/// block (an extra round), `free event=E lessons=N chars=M` for context on a hook that forces no round.
289/// `borg status` sums them (prefilter.rs `tally`).
290fn cost_file(session: &str) -> PathBuf {
291    state_file(session).with_extension("cost")
292}
293
294/// BORG_STOP_GATE=edits: one line per tool call this turn that changed state (prefilter.rs `changes`);
295/// the Stop removes it.
296fn 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
306/// Lessons delivered early (`#borg-early` markers in the .edits file, and graded lessons handed over free,
307/// `#borg-queued`) that the drone acted on since: its work after the delivery brings in the lesson's
308/// words (memory.rs `resolve`, `final_msg` included). Stop leaves them alone; an early lesson not acted
309/// on can still be asked at Stop. A later delivery's own work line (`WORK`: a free delivery's, a live
310/// ask's, a placebo's) is neither: it is the work before that delivery, written whatever its route, so
311/// how a later lesson was routed never decides whether this one was acted on.
312fn 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
329/// Done in this session: asked as a question (at a stop or live), or handed over (early, free) and
330/// acted on since (`early_acted`, its `acted`). The one definition a check's pool leaves out (the
331/// stop's, a live check's) and a live check's ask is delivered by (`queued`): a lesson handed over but
332/// not acted on may be graded again, and when its grade asks it the question reaches the drone.
333fn 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
339/// Asked or handed over in this session, acted on or not: free context comes once per session, so
340/// none of these goes free again (`queued`, `early`), done or not.
341fn 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
348/// The .edits line before a live ask's, a free delivery's or a placebo's markers: the work so far
349/// (`work_so_far`), the evidence `pending` reads before it as a question's blocked message. Not work
350/// itself: `early_acted` and a judge's "work since" skip it, as every `#borg-` line, so an earlier
351/// delivery's evidence never depends on how a later lesson was routed (`note_work`).
352const WORK: &str = "#borg-work ";
353
354/// An edit tool's paths (every file it names, so the cut never drops one) and the start of its new
355/// text, on one line; None for other tools.
356pub(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
377/// An edit's line in the `.edits` and turn files (`edit_text`): its paths and the start of its new text.
378/// Guard: bounds one evidence line (a whole written file would otherwise go in).
379const EDIT_CHARS: usize = 600;
380
381/// The longest drone message one evidence line of the `.edits` file keeps: the blocked message before
382/// a question, the work so far before a live or free delivery, the answer after a block (`pending`).
383/// Guard: bounds a line of a file every judge call reads; at least what the judge reads after a
384/// delivery (`predict::JUDGE_PART` three times), so the cut never takes evidence the judge would see.
385/// Not a context budget: none of it is injected into the drone.
386const EVIDENCE_CHARS: usize = 4000;
387const _: () = assert!(EVIDENCE_CHARS >= 3 * predict::JUDGE_PART);
388
389/// `text` on one line, at most `n` characters.
390pub(crate) fn one_line(text: &str, n: usize) -> String {
391    text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
392}
393
394/// R1: the first turn end after a Stop block is the drone's answer to it. Its message (with any
395/// `<id>: applies|skip`) goes to the .edits file as `#borg-answer`: evidence for the judge (`pending`),
396/// never a label by itself.
397fn 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
409/// Pending lessons sent to the judge per call at most (the most recently delivered). Guard: bounds one
410/// call's judge section, whatever a session delivered; derived from one full block (conscience.rs
411/// `CAP`), whose lessons share their evidence in the prompt. A lesson it leaves out is logged
412/// (`#borg-unjudged <ids> why=slots`), so its hit rate is measured.
413const MAX_PENDING: usize = conscience::CAP;
414/// A lesson whose verdicts stay pending (unclear, missing) is sent to the judge this often at most;
415/// after that it waits for `borg learn` to settle it as `asked_not_corrected` (censored, reason
416/// `tries`). Guard: a lesson the judge never settles would otherwise ride on every later call. A failed
417/// call is no try: its lessons stay pending as they were (`#borg-unjudged … why=failed`).
418const MAX_JUDGE_TRIES: usize = 2;
419
420/// The markers of a delivery of lessons to the drone, in the state and .edits files: asked at a Stop or
421/// live (`#borg-asked`), delivered early, handed over free (`#borg-queued`); and a placebo
422/// (`#borg-placebo`), never shown but judged as a free delivery is. Each ends in ` @<check>`, the check
423/// that routed its lessons (select.rs `check_id`), once checks had ids.
424const DELIVERED: [&str; 4] = ["#borg-asked ", "#borg-early ", "#borg-queued ", "#borg-placebo "];
425
426/// Right after a stop's `#borg-asked` line: `#borg-routed <id>=<check>,…`, the lessons of the block
427/// another check routed (a live check's ask still waiting at the stop), where the block's own tag is
428/// the stop's check.
429const ROUTED: &str = "#borg-routed ";
430
431/// Whether `line` is part of a delivery's markers (`DELIVERED`, `#borg-live`, `ROUTED`): one delivery
432/// writes its work line once, then a marker per routing check.
433fn is_marker(line: &str) -> bool {
434    DELIVERED.iter().any(|m| line.starts_with(m)) || line.starts_with("#borg-live ") || line.starts_with(ROUTED)
435}
436
437/// Whether the judge sees question `lesson` of `check` without the drone's `<id>: applies|skip`: a
438/// fair coin from blake3 over both, so a retry strips alike and a replay knows it (outcome.rs
439/// `stripped`; a test predicts a judge prompt by it). Method: a random half, to measure what the
440/// declaration adds to a verdict.
441#[must_use]
442pub fn stripped(check: &str, lesson: &str) -> bool {
443    let mut h = blake3::Hasher::new();
444    h.update(b"borg-strip-v1\0");
445    h.update(format!("{check}\0{lesson}").as_bytes());
446    h.finalize().as_bytes()[0] & 1 == 1
447}
448
449/// What one judge call takes (`pending`): the pending lessons with their evidence, whether a question
450/// is among them, and the ids the call has no slot for (`MAX_PENDING`).
451struct Judging<'a> {
452    pending: Vec<predict::Pending<'a>>,
453    question: bool,
454    crowded: Vec<String>,
455}
456
457/// R1: lessons delivered earlier in this session (asked at a Stop or live, delivered early, handed over
458/// free, or a placebo) that have no verdict yet, with the evidence for the consequence model (predict.rs
459/// `Pending`), the same for every route (judge v2): the work before the delivery (at a Stop the blocked
460/// message, before a live or free delivery or a placebo the work so far), and after it "work since" (a
461/// question's answer, then the drone's edits and later messages) and this turn's message (`final_msg`),
462/// each capped by `predict::JUDGE_PART`. A random half of the questions (`stripped`) lose the drone's
463/// declaration from all of the call's evidence: their own, every sibling's, and this turn's message,
464/// which may be the answer itself (a live question answered at this stop; `check` takes it out of the
465/// work the call shows too). `#borg-judged <id>=<label>` lines in the state file record verdicts, each for
466/// the lesson's latest delivery before it: a lesson delivered early, judged, then asked at a Stop is
467/// judged again on the question. `mid_turn` (a live check) holds two kinds of delivery back for a later
468/// judge, without a try: a question the drone has not answered yet (no `#borg-answer` after it, written
469/// at the stop) waits for the stop's judge, which sees the answer, as in hook mode; a free delivery or a
470/// placebo with no work after it yet (no line but `#borg-` ones) waits for a later judge call: a live
471/// check after more work, or a stop that calls the judge (`deferred`). A delivery held back still is its lessons' latest: no earlier delivery of
472/// them is judged in its place, so a lesson handed over free, then asked live, never earns the free
473/// route what the question did. Of the `MAX_PENDING` slots, questions take theirs first (the newest),
474/// free deliveries what is left: a free hand-over never pushes a question out of its judge. `judging`:
475/// this call's check, the coin's seed for a delivery from before checks had ids.
476fn pending<'a>(session: &str, state: &str, all: &'a [conscience::Challenge], final_msg: &str, mid_turn: bool, judging: &str) -> Judging<'a> {
477    let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
478    let mut settled = std::collections::HashSet::new();
479    for l in state.lines() {
480        if let Some(l) = l.strip_prefix("#borg-judged ") {
481            let (id, label) = l.split_once('=').unwrap_or((l, ""));
482            *tries.entry(id).or_default() += 1;
483            if label.trim() != "pending" {
484                settled.insert(id);
485            }
486        } else if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
487            for id in ids_in(list) {
488                tries.remove(id);
489                settled.remove(id);
490            }
491        }
492    }
493    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
494    let lines: Vec<&str> = edits.lines().collect();
495    let part = predict::JUDGE_PART;
496    /// One delivery's evidence, unclipped, and its fresh lessons: (id, routing check, stripped).
497    struct Delivery<'l> {
498        channel: &'static str,
499        /// A question (`#borg-asked`), not a free delivery or a placebo.
500        question: bool,
501        before: &'l str,
502        answers: Vec<&'l str>,
503        work: Vec<&'l str>,
504        fresh: Vec<(&'l str, &'l str, bool)>,
505        /// Held back for a later judge (`deferred`): its lessons, none judged on an earlier delivery now.
506        held: Vec<&'l str>,
507    }
508    let mut deliveries: Vec<Delivery> = Vec::new();
509    for (p, l) in lines.iter().enumerate() {
510        let Some((marker, list)) = DELIVERED.iter().find_map(|m| l.strip_prefix(m).map(|rest| (*m, rest))) else { continue };
511        let question = marker == "#borg-asked ";
512        let later = &lines[p + 1..];
513        let channel = match marker {
514            "#borg-asked " if later.first().is_some_and(|n| n.starts_with("#borg-live ")) => "live",
515            "#borg-asked " => "stop",
516            "#borg-early " => "early",
517            "#borg-queued " => "queued",
518            _ => outcome::PLACEBO,
519        };
520        let answers: Vec<&str> = if question { later.iter().filter_map(|l| l.strip_prefix("#borg-answer ")).collect() } else { Vec::new() };
521        let work: Vec<&str> = later.iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
522        let (list, tag) = tagged(list);
523        if mid_turn && deferred(question, &answers, &work) {
524            let held = list.split(',').map(str::trim).filter(|i| !i.is_empty()).collect();
525            deliveries.push(Delivery { channel, question, before: "", answers, work, fresh: Vec::new(), held });
526            continue;
527        }
528        // The work before: the line before this delivery's markers, a delivery's own work line (`WORK`)
529        // read as its text; nothing when that is another bookkeeping line.
530        let before = match lines[..p].iter().rev().find(|b| !is_marker(b)) {
531            Some(b) if b.starts_with(WORK) => &b[WORK.len()..],
532            Some(b) if !b.starts_with("#borg-") => b,
533            _ => "",
534        };
535        let routed: std::collections::HashMap<&str, &str> =
536            later.first().and_then(|n| n.strip_prefix(ROUTED)).into_iter().flat_map(|r| r.split(',')).filter_map(|it| it.split_once('=')).collect();
537        let fresh: Vec<&str> = list
538            .split(',')
539            .map(str::trim)
540            .filter(|i| !i.is_empty() && !settled.contains(i) && tries.get(i).copied().unwrap_or(0) < MAX_JUDGE_TRIES)
541            .filter(|i| all.iter().any(|c| c.id == *i))
542            .collect();
543        let check_of = |id: &str| routed.get(id).copied().or(tag).unwrap_or_default();
544        let fresh =
545            fresh.into_iter().map(|id| (id, check_of(id), question && stripped(if check_of(id).is_empty() { judging } else { check_of(id) }, id))).collect();
546        deliveries.push(Delivery { channel, question, before, answers, work, fresh, held: Vec::new() });
547    }
548    // Every stripped question's declaration leaves all of this call's evidence before any cut could halve
549    // one: its own, a sibling's (a later blocked message may hold the answer) and this turn's message.
550    let strip: Vec<&str> = deliveries.iter().flat_map(|d| &d.fresh).filter(|f| f.2).map(|f| f.0).collect();
551    let blind = |text: &str| strip.iter().fold(text.to_string(), |t, id| crate::memory::strip_declaration(&t, id));
552    let turn = predict::clip(&one_line(&blind(final_msg), part + 1), part);
553    // Each with whether it was a question.
554    let mut out: Vec<(predict::Pending, bool)> = Vec::new();
555    for d in deliveries {
556        // A delivery held back is its lessons' latest all the same: an earlier one is not judged in its place.
557        out.retain(|x| !d.held.contains(&x.0.lesson.id.as_str()));
558        // One "after" for the whole delivery, so lessons delivered together share it.
559        let answer = blind(&d.answers.join(" "));
560        let said = answer.to_lowercase();
561        let since: Vec<&str> = std::iter::once(answer.as_str()).filter(|a| !a.is_empty()).chain(d.work.iter().copied()).collect();
562        let after = format!("work since: {}\nthis turn: {turn}", predict::clip(&blind(&since.join(" | ")), 2 * part));
563        let before = blind(d.before);
564        for (id, check, stripped) in d.fresh {
565            let Some(c) = all.iter().find(|c| c.id == id) else { continue };
566            // A later delivery of the same lesson is the one judged: its evidence replaces the earlier one's.
567            out.retain(|x| x.0.lesson.id != id);
568            let declared = if stripped { None } else { crate::memory::declared(&said, &id.to_lowercase()) };
569            let pending =
570                predict::Pending { lesson: c, before: before.clone(), after: after.clone(), declared, check: check.to_string(), channel: d.channel, stripped };
571            out.push((pending, d.question));
572        }
573    }
574    let questions = out.iter().filter(|x| x.1).count().min(MAX_PENDING);
575    let mut room = [MAX_PENDING - questions, questions];
576    let (mut kept, mut crowded): (Vec<(predict::Pending, bool)>, Vec<String>) = (Vec::new(), Vec::new());
577    for x in out.into_iter().rev() {
578        let r = &mut room[usize::from(x.1)];
579        if *r > 0 {
580            *r -= 1;
581            kept.push(x);
582        } else {
583            crowded.push(x.0.lesson.id.clone());
584        }
585    }
586    kept.reverse();
587    crowded.sort();
588    Judging { pending: kept.into_iter().map(|x| x.0).collect(), question: questions > 0, crowded }
589}
590
591/// Whether a live check (mid-turn) holds a delivery back for a later judge (`pending`): a question with no
592/// answer yet (`answers`), whose answer comes at the stop; a free delivery or a placebo with no work after
593/// it yet (`work`, its lines that are no `#borg-` bookkeeping), whose verdict would read only the work
594/// before it. Method (judge timing, 2026-10-31): 54% of free verdicts came within 2 minutes of the
595/// delivery and 74% of those were unclear, using up the lesson's tries (`MAX_JUDGE_TRIES`) before the
596/// drone had done anything. A stop's judge call holds nothing back, but not every stop makes one: a live
597/// stop the last live check covered skips (`stop_check`), a stop without the live guard judges nothing
598/// (`Work::judge`), and free deliveries alone pay for no call (`check`); a delivery still held then waits
599/// for the next turn's live check, or `borg learn` settles it `no_call`. Kill: revert if, over the next
600/// 150 free deliveries, the labelled share (changed or ignored over every free delivery, those settled
601/// `no_call` included) does not rise from 38% to at least 50%, or the unclear share does not fall by at
602/// least 15 points.
603fn deferred(question: bool, answers: &[&str], work: &[&str]) -> bool {
604    if question { answers.is_empty() } else { work.is_empty() }
605}
606
607/// `text` without the declarations of the stripped questions among `pending` (`stripped`): the work a
608/// judge call shows beside them, which may hold the answer to one (this turn's message at the stop).
609fn without_declarations<'t>(text: &'t str, pending: &[predict::Pending]) -> std::borrow::Cow<'t, str> {
610    pending.iter().filter(|p| p.stripped).fold(std::borrow::Cow::Borrowed(text), |t, p| crate::memory::strip_declaration(&t, &p.lesson.id).into())
611}
612
613/// `#borg-unjudged <ids> why=<reason>`: pending lessons a judge call did not judge, and why
614/// (outcome.rs `UNJUDGED_SLOTS`, `UNJUDGED_FAILED`), so `borg learn` can say why each settled without
615/// a verdict (`unjudged`). Nothing for no ids.
616fn note_unjudged(session: &str, ids: &[String], why: &str) {
617    if !ids.is_empty() {
618        append(session, &format!("#borg-unjudged {} why={why}", ids.join(",")));
619    }
620}
621
622/// Why each lesson delivered in a session's state file `state` has no verdict, as `borg learn` settles
623/// it (outcome.rs `settle_with`): its tries ran out on unclear or missing verdicts since its latest
624/// delivery (`tries`), else the last judge call that left it out (`slots`, `failed`), else no call came
625/// after its delivery (`no_call`).
626pub(crate) fn unjudged(state: &str) -> std::collections::HashMap<String, &'static str> {
627    let mut why: std::collections::HashMap<String, &'static str> = std::collections::HashMap::new();
628    let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
629    for l in state.lines() {
630        if let Some(list) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
631            for id in ids_in(list) {
632                tries.remove(id);
633                why.insert(id.to_string(), outcome::UNJUDGED_NO_CALL);
634            }
635        } else if let Some(l) = l.strip_prefix("#borg-judged ") {
636            let (id, label) = l.split_once('=').unwrap_or((l, ""));
637            if label.trim() == "pending" {
638                let n = tries.entry(id).or_default();
639                *n += 1;
640                if *n >= MAX_JUDGE_TRIES {
641                    why.insert(id.to_string(), outcome::UNJUDGED_TRIES);
642                }
643            }
644        } else if let Some(rest) = l.strip_prefix("#borg-unjudged ") {
645            let (list, reason) = rest.rsplit_once(" why=").unwrap_or((rest, ""));
646            let reason = match reason.trim() {
647                "slots" => outcome::UNJUDGED_SLOTS,
648                "failed" => outcome::UNJUDGED_FAILED,
649                _ => continue,
650            };
651            for id in list.split(',').map(str::trim).filter(|i| !i.is_empty()) {
652                if tries.get(id).copied().unwrap_or(0) < MAX_JUDGE_TRIES {
653                    why.insert(id.to_string(), reason);
654                }
655            }
656        }
657    }
658    why
659}
660
661/// R1: the consequence model's verdicts on the pending lessons it was shown (predict.rs `Judged`).
662/// `asked_then_changed` only for a change that avoids the consequence, `asked_ignored` for no change;
663/// an unclear verdict, or a change that still leads to the consequence, is `asked_unclear` (outcome.rs
664/// `UNCLEAR`, a measurement only) and stays pending, as a missing verdict does. Each result names its
665/// delivery's channel (outcome.rs `PLACEBO` for a placebo, never a result about the lesson), the check
666/// that routed it, the judge's version (predict.rs `JUDGE_VERSION`) and whether the declaration was
667/// stripped. Logs the call's judging cost in the .cost file: `judge call=<nth model call of the
668/// session> pending=N resolved=K chars=<prompt chars added> returned=<ids the answer named>
669/// unmatched=<those of them not pending>` (`judge_line`).
670fn record_verdicts(session: &str, input: &Value, pending: &[predict::Pending], pred: &predict::Prediction, chars: usize, call: usize) {
671    let resolved = &pred.resolved;
672    // One record per (kind, channel, check, stripped): the statistics split what the deliveries led to by
673    // how they were made.
674    let mut records: std::collections::BTreeMap<(&str, &str, &str, bool), Vec<String>> = std::collections::BTreeMap::new();
675    let mut settled = 0;
676    for p in pending {
677        let judged = resolved.iter().find(|j| j.id == p.lesson.id);
678        let label = judged.and_then(predict::Judged::label);
679        let word = match label {
680            Some(outcome::CHANGED) => "changed",
681            Some(_) => "ignored",
682            None => "pending",
683        };
684        append(session, &format!("#borg-judged {}={word}", p.lesson.id));
685        let kind = match (label, judged) {
686            (Some(kind), _) => kind,
687            (None, Some(_)) => outcome::UNCLEAR,
688            (None, None) => continue,
689        };
690        settled += usize::from(label.is_some());
691        records.entry((kind, p.channel, p.check.as_str(), p.stripped)).or_default().push(p.lesson.id.clone());
692    }
693    log_cost(session, &judge_line(call, pending, settled, chars, &pred.returned));
694    if learning() {
695        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
696        let (home, ms) = (learn::home(), learn::now_ms());
697        // The statistics count one event per kind and channel, whatever checks routed its lessons.
698        let mut counted: std::collections::BTreeMap<(&str, &str), Vec<String>> = std::collections::BTreeMap::new();
699        for ((kind, channel, check, stripped), lessons) in records {
700            counted.entry((kind, channel)).or_default().extend(lessons.iter().cloned());
701            let o = outcome::Outcome {
702                kind: kind.into(),
703                session: session.into(),
704                lessons,
705                ms,
706                repo: repo_of(cwd),
707                channel: channel.into(),
708                check: check.into(),
709                judge: predict::JUDGE_VERSION,
710                stripped,
711                ..Default::default()
712            };
713            let _ = learn::append_jsonl(&home.join("outcomes.jsonl"), &o);
714        }
715        for ((kind, channel), lessons) in counted {
716            outcome::count_result(
717                &home,
718                &outcome::Outcome {
719                    kind: kind.into(),
720                    session: session.into(),
721                    lessons,
722                    ms,
723                    repo: repo_of(cwd),
724                    channel: channel.into(),
725                    ..Default::default()
726                },
727            );
728        }
729    }
730}
731
732/// A judge call's cost line (`record_verdicts`): `judge call=… pending=N resolved=K chars=M`, then the ids
733/// the answer named (`returned=a,b`, predict.rs `Prediction::returned`) and those of them no pending
734/// lesson has (`unmatched=`), each empty for none. A pending lesson in neither list was left out by the
735/// model; one returned but not resolved got an unclear or malformed verdict. Zero tokens, no prompt text.
736fn judge_line(call: usize, pending: &[predict::Pending], resolved: usize, chars: usize, returned: &[String]) -> String {
737    let unmatched: Vec<&str> = returned.iter().map(String::as_str).filter(|id| !pending.iter().any(|p| p.lesson.id == *id)).collect();
738    format!("judge call={call} pending={} resolved={resolved} chars={chars} returned={} unmatched={}", pending.len(), returned.join(","), unmatched.join(","))
739}
740
741/// Ids listed on the state file's `marker` lines (`#borg-asked a,b @<check>`).
742pub(crate) fn listed<'a>(context: &'a str, marker: &str) -> std::collections::HashSet<&'a str> {
743    context.lines().filter_map(|l| l.strip_prefix(marker)).flat_map(ids_in).collect()
744}
745
746/// The ids of a marker's or a queue line's list, without its ` @<check>` tag (`tagged`).
747fn ids_in(list: &str) -> impl Iterator<Item = &str> {
748    tagged(list).0.split(',').map(str::trim).filter(|i| !i.is_empty())
749}
750
751/// ` @<check>` after a list, or nothing for a check without an id (a line of an older binary's state).
752fn tag(check: &str) -> String {
753    if check.is_empty() { String::new() } else { format!(" @{check}") }
754}
755
756/// Early delivery (conscience.rs `early`): once the session's prompts and touches name a lesson scope
757/// (a topic) and that scope's lessons score on them, the top ones go to the drone at once, as context,
758/// instead of waiting for the Stop checkpoint: at the prompt or the first touch on the topic (`text` is
759/// this event's, already in the state file). Each lesson once per session (`#borg-early`; Stop does not
760/// ask it again) and an `asked` outcome with channel early (`record_delivery`). A topic is settled (`#borg-topic`) once a delivery covered it or none of its lessons is
761/// left; until then each prompt or touch looks again. At most `MAX_EARLY_DELIVERIES` per session. Only
762/// where the harness takes it (`Caps::early_delivery`, Claude Code so far): omp and opencode take a prompt's
763/// and a tool call's context too (live mode), but early delivery to them waits for an evaluation, so for
764/// them the lessons stay with Stop. Standing lessons (ratified constraints, conscience.rs `standing_pool`)
765/// are no ranking guess and need no evaluation: they come on every harness that takes the context (a
766/// prompt's, or a tool call's for a named scope), whenever their scope is named (an unscoped one at a
767/// prompt), settled topic or not and past `MAX_EARLY_DELIVERIES`, until each came once. `BORG_EARLY=off`
768/// disables it; so do the eval modes BORG_ONLY and BORG_CRITIC.
769/// Early deliveries per session at most (each up to `conscience::render_early`'s lesson cap). Guard: a
770/// long session naming many systems would otherwise get a delivery at every touch until each topic's
771/// lessons ran out (standing lessons are exempt).
772const MAX_EARLY_DELIVERIES: usize = 3;
773
774fn early(h: &dyn Harness, so_far: &SoFar, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<String> {
775    if text.trim().is_empty()
776        || std::env::var("BORG_EARLY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
777        || std::env::var_os("BORG_ONLY").is_some()
778        || std::env::var_os("BORG_CRITIC").is_some()
779    {
780        return None;
781    }
782    let challenges = lessons(session);
783    let context = std::fs::read_to_string(state_file(session)).unwrap_or_default();
784    // Free, but not free of noise: a long session naming many systems would otherwise get a delivery
785    // at every touch until each topic's lessons ran out. Standing lessons are exempt.
786    let plain = h.caps().early_delivery && context.lines().filter(|l| l.starts_with("#borg-early ")).count() < MAX_EARLY_DELIVERIES;
787    let standing = challenges.iter().any(conscience::Challenge::standing);
788    if !standing && (!plain || challenges.iter().all(|c| c.scope.is_empty() && !c.consolidated)) {
789        return None;
790    }
791    let settled = listed(&context, "#borg-topic ");
792    let named = conscience::named_scopes(&challenges, &where_now(session));
793    let mut topics: Vec<String> = if plain { named.iter().filter(|t| !settled.contains(t.as_str())).cloned().collect() } else { Vec::new() };
794    // Consolidated lessons (memory.rs) need no scope: the first prompt is their topic (`ANY`).
795    if plain
796        && topics.is_empty()
797        && hook_event == "UserPromptSubmit"
798        && !settled.contains(conscience::ANY)
799        && challenges.iter().any(|c| c.consolidated && !c.standing())
800    {
801        topics.push(conscience::ANY.into());
802    }
803    // A standing lesson's topic is every named scope, settled or not, and any prompt for an unscoped one.
804    let mut standing_topics = named;
805    if hook_event == "UserPromptSubmit" {
806        standing_topics.push(conscience::ANY.into());
807    }
808    let done = had_in(&context);
809    let mut pool = if standing { conscience::standing_pool(&challenges, &standing_topics, &done) } else { Vec::new() };
810    if !topics.is_empty() {
811        pool.extend(conscience::early_pool(&challenges, &conscience::work_text(&context), &topics, &done));
812    }
813    // The header names what came: the plain topics, and the scopes of the standing lessons beside them.
814    let mut shown_topics = topics.clone();
815    for t in standing_topics.into_iter().filter(|t| pool.iter().any(|(_, c)| c.standing() && c.scope.contains(t))) {
816        if !shown_topics.contains(&t) {
817            shown_topics.push(t);
818        }
819    }
820    // Today's early caps (`EARLY_CAP`, `EARLY_CHARS`, `RELATIVE`), kept until a learned early rule has
821    // data: each early delivery's record carries every lesson's score relative to the best (`early_rel`).
822    let selected: Vec<&conscience::Challenge> = conscience::explored(pool.iter().copied()).into_iter().take(conscience::EARLY_CAP).collect();
823    let shown = conscience::render_early(&selected, &shown_topics);
824    let delivered = &selected[..shown.as_ref().map_or(0, |(_, n)| *n)];
825    // Plain topics only: a standing lesson left over (past `EARLY_CAP` or `EARLY_CHARS`) comes at the next
826    // prompt or touch on its scope whatever is settled.
827    let now_settled: Vec<&str> = topics
828        .iter()
829        .filter(|t| {
830            // One delivery per topic: whatever was delivered now covers every topic looked at now.
831            !delivered.is_empty()
832                || !challenges.iter().any(|c| {
833                    !c.standing()
834                        && (c.scope.contains(t) || (t.as_str() == conscience::ANY && c.consolidated))
835                        && !done.contains(c.id.as_str())
836                        && !delivered.iter().any(|d| d.id == c.id)
837                })
838        })
839        .map(String::as_str)
840        .collect();
841    if !now_settled.is_empty() {
842        append(session, &format!("#borg-topic {}", now_settled.join(",")));
843    }
844    let (text, _) = shown?;
845    let ids = delivered.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
846    // An early delivery is its own decision: its id ties its markers to its record.
847    let check = crate::select::check_id(session, "early", learn::now_ms(), std::process::id());
848    let line = format!("#borg-early {ids} @{check}");
849    append(session, &line);
850    note_work(session, so_far, &line);
851    let p = conscience::early_propensity(&pool, session, &shown_topics);
852    let p: Vec<f64> = delivered.iter().map(|c| p.get(&c.id).copied().unwrap_or(0.0)).collect();
853    record_delivery(session, input, delivered, "early", &check, p, early_rel(&challenges, &conscience::work_text(&context), &topics, delivered));
854    log_cost(session, &format!("free event={hook_event} lessons={} chars={}", delivered.len(), text.len()));
855    crate::stats::count(&learn::home(), crate::stats::EARLY, session, |e| {
856        e.n = delivered.len() as u64;
857        e.lessons = delivered.iter().map(|c| c.id.clone()).collect();
858        e.channel = "early".into();
859    });
860    Some(text)
861}
862
863/// Each of `delivered`'s word-match score (conscience.rs `rank` over `work`) as a share of the best
864/// on-topic match of `topics`, done or not (the match `RELATIVE` bars against: `early_pool` over no done
865/// lesson starts with it), rounded as a propensity is: what a learned early rule needs of each early
866/// delivery (outcome.rs `rel`). A standing lesson's too, though no score gates it (0 when it matches no
867/// word). Empty when no plain lesson is on topic to scale by (a standing lesson's delivery alone).
868fn early_rel(challenges: &[conscience::Challenge], work: &str, topics: &[String], delivered: &[&conscience::Challenge]) -> Vec<f64> {
869    let Some(&(top, _)) = conscience::early_pool(challenges, work, topics, &std::collections::HashSet::new()).first() else { return Vec::new() };
870    let ranked = conscience::rank(challenges, work);
871    delivered.iter().map(|c| outcome::round_p(ranked.iter().find(|(_, x)| x.id == c.id).map_or(0.0, |(s, _)| *s) / top)).collect()
872}
873
874/// A delivery's markers (`lines`: a live ask's, a free delivery's, a placebo's) in the .edits file, in
875/// one write after the work so far (`WORK`): the evidence before it is the same whatever the route
876/// (`pending`), and no route's line reads as work after an earlier delivery.
877fn note_work(session: &str, so_far: &SoFar, lines: &str) {
878    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
879        let _ = writeln!(f, "{WORK}{}\n{lines}", one_line(so_far.get(), EVIDENCE_CHARS));
880    }
881}
882
883/// The `asked` outcome of a free delivery (`channel` early or queued) of `delivered`, routed by `check`,
884/// with each one's propensity `p` and, early, its relative score `rel` (`early_rel`): delivered as
885/// context, the lessons wait for a result as a Stop's questions do. The next judge (`pending`) labels
886/// them changed or ignored, a merged correction corrected (outcome.rs `contradicted`), and `borg learn`
887/// settles the rest. Nothing with learning off.
888fn record_delivery(session: &str, input: &Value, delivered: &[&conscience::Challenge], channel: &str, check: &str, p: Vec<f64>, rel: Vec<f64>) {
889    if !learning() || delivered.is_empty() {
890        return;
891    }
892    let lessons: Vec<String> = delivered.iter().map(|c| c.id.clone()).collect();
893    let (grades, ranks, applied) = graded_of(session, &lessons);
894    let asked = outcome::Outcome {
895        kind: outcome::ASKED.into(),
896        session: session.into(),
897        lessons,
898        ms: learn::now_ms(),
899        repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
900        groups: conscience::disagreement_ids(delivered),
901        channel: channel.into(),
902        grades,
903        ranks,
904        applied,
905        p: p.into_iter().map(outcome::round_p).collect(),
906        check: check.into(),
907        rel,
908        ..Default::default()
909    };
910    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
911}
912
913/// One `delivery` record (outcome.rs `DELIVERY`): what delivery did with `lessons`, routed by `check`:
914/// `route` (outcome.rs `ROUTES`) and why. Nothing with learning off or without lessons.
915fn record_route(session: &str, input: &Value, check: &str, lessons: Vec<String>, channel: &str, route: &str, reason: &str) {
916    if !learning() || lessons.is_empty() {
917        return;
918    }
919    let o = outcome::Outcome {
920        kind: outcome::DELIVERY.into(),
921        session: session.into(),
922        lessons,
923        ms: learn::now_ms(),
924        repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
925        channel: channel.into(),
926        check: check.into(),
927        route: route.into(),
928        reason: reason.into(),
929        ..Default::default()
930    };
931    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &o);
932}
933
934/// `record_route` for each check of `routed` (lesson, check) pairs, in first-seen order.
935fn record_routes(session: &str, input: &Value, routed: &[(&conscience::Challenge, &str)], channel: &str, route: &str, reason: &str) {
936    for (check, ids) in by_check(routed) {
937        record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, route, reason);
938    }
939}
940
941/// `routed` (lesson, check) pairs grouped by check, checks in first-seen order, lessons in theirs.
942fn by_check<'a>(routed: &[(&'a conscience::Challenge, &'a str)]) -> Vec<(&'a str, Vec<&'a str>)> {
943    let mut out: Vec<(&str, Vec<&str>)> = Vec::new();
944    for (c, check) in routed {
945        match out.iter_mut().find(|(k, _)| k == check) {
946            Some((_, ids)) => ids.push(c.id.as_str()),
947            None => out.push((check, vec![c.id.as_str()])),
948        }
949    }
950    out
951}
952
953/// Graded Stop (default): what a graded check left for later, one line per check, waiting for the
954/// session's next prompt or touch (`queued`): `<ids> @<check>`, the 2s and 1s of a check that did not
955/// block, to hand over free; `placebo <ids> @<check>`, a check's placebo draws (select.rs `Routed`); and
956/// in live mode `live <ids> @<check>`, a live check's asks a stop asks while they wait (its check graded a
957/// 3), and `live-next <ids> @<check>`, those it never asks (`live_check`, `take_live`); `instead <ids>
958/// @<check>` and `instead-next <ids> @<check>`, a check's free lessons handed over in place of questions
959/// the fixed rule asks (`INSTEAD`, `INSTEAD_NEXT`). A line of an older binary has no tag.
960fn queued_file(session: &str) -> PathBuf {
961    state_file(session).with_extension("queued")
962}
963
964/// What a queue line routes its lessons to (`queued_file`), in the order delivery takes them (`queued`).
965#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
966enum Queued {
967    Ask,
968    /// Free, in place of a question the fixed rule asks (`INSTEAD`).
969    Instead,
970    Free,
971    Placebo,
972}
973
974/// Protocol: the prefix of a live check's free lessons that the fixed rule would have asked, drawn by
975/// the question-count test on a check with a 3 (select/policy.rs `test_routes`; `queued_file`). A free
976/// delivery carries them whatever its budget (`queued`, conscience.rs `render_owing`), as the questions
977/// they replace would have been carried, and what else waits free keeps the budget it had without them;
978/// a stop that comes before a delivery took them asks them in its block (`take_live`), as the check's
979/// own block there would have. So the test withholds nothing.
980const INSTEAD: &str = "instead ";
981
982/// Protocol: the prefix of the other free lessons a check hands over in place of questions the fixed
983/// rule asks (`queued_file`): the evidence rule's on a check with a 3 (select/policy.rs `EVIDENCE`),
984/// which valued them as hand-overs (their chance of arriving included), and any of a stop's. A free
985/// delivery carries them whatever its budget, as `INSTEAD`'s; no stop asks them.
986const INSTEAD_NEXT: &str = "instead-next ";
987
988/// Protocol: the prefix of a live check's asks no stop asks (`queued_file`): a tool call or the next
989/// prompt asks them, as it asks a `live` line's.
990const LIVE_NEXT: &str = "live-next ";
991
992/// Protocol: the `delivery` record's reason (outcome.rs `DELIVERY`) for a live check's hand-over in place
993/// of a question (`INSTEAD`) that a stop asked in its block because no delivery took it first
994/// (`stop_ask`): its route is the one that happened, `asked` on channel stop, not the check's `free`.
995const ASKED_INSTEAD: &str = "in place of a hand-over";
996
997/// A queue line's route, ids and check (empty on an older binary's line).
998fn queue_line(line: &str) -> (Queued, Vec<&str>, &str) {
999    let ask = line.strip_prefix("live ").or_else(|| line.strip_prefix(LIVE_NEXT));
1000    let instead = line.strip_prefix(INSTEAD).or_else(|| line.strip_prefix(INSTEAD_NEXT));
1001    let (route, list) = match (ask, line.strip_prefix("placebo "), instead) {
1002        (Some(l), _, _) => (Queued::Ask, l),
1003        (_, Some(l), _) => (Queued::Placebo, l),
1004        (_, _, Some(l)) => (Queued::Instead, l),
1005        _ => (Queued::Free, line),
1006    };
1007    (route, ids_in(list).collect(), tagged(list).1.unwrap_or_default())
1008}
1009
1010/// One line at the end of the `.queued` file, under its lock (live.rs `locked`): never into a file a
1011/// hook is taking at the same moment.
1012fn enqueue(session: &str, line: &str) {
1013    let q = queued_file(session);
1014    live::locked(&q, || {
1015        let _ = std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
1016    });
1017}
1018
1019/// The whole `.queued` file, taken under its lock (a rename, then read): what a check appends after
1020/// starts a new file, delivered next time.
1021fn take_queue(session: &str) -> Option<String> {
1022    let q = queued_file(session);
1023    let taken = state_file(session).with_extension(format!("queued.{}", std::process::id()));
1024    live::locked(&q, || std::fs::rename(&q, &taken)).ok()?;
1025    let text = std::fs::read_to_string(&taken).unwrap_or_default();
1026    let _ = std::fs::remove_file(&taken);
1027    Some(text)
1028}
1029
1030/// Live mode, at the stop: what the stop asks of the `.queued` file, each lesson with its check, taken out
1031/// of it: the live checks' asks still waiting (`live <ids>`), and the question-count test's hand-overs
1032/// in place of questions the fixed rule asks (`instead <ids>`, `INSTEAD`), which the fixed rule's block
1033/// would have asked here. The 2s and 1s, the asks of a check without a 3 (`live-next <ids>`,
1034/// `live_check`), the evidence rule's hand-overs (`INSTEAD_NEXT`) and the placebos stay for the next
1035/// prompt or touch.
1036fn take_live(session: &str) -> Taken {
1037    let Some(text) = take_queue(session) else { return Taken::default() };
1038    let mut taken = Taken::default();
1039    for line in text.lines() {
1040        let to = match (line.starts_with("live "), line.starts_with(INSTEAD)) {
1041            (true, _) => &mut taken.asks,
1042            (_, true) => &mut taken.instead,
1043            _ => {
1044                enqueue(session, line);
1045                continue;
1046            }
1047        };
1048        let (_, ids, check) = queue_line(line);
1049        to.extend(ids.into_iter().map(|i| (i.to_string(), check.to_string())));
1050    }
1051    taken
1052}
1053
1054/// What a stop took out of the `.queued` file to ask (`take_live`), each lesson with its check.
1055#[derive(Default)]
1056struct Taken {
1057    /// Live checks' asks.
1058    asks: Vec<(String, String)>,
1059    /// The question-count test's hand-overs in place of questions the fixed rule asks (`INSTEAD`).
1060    instead: Vec<(String, String)>,
1061}
1062
1063/// The lessons a `.queued` file's `text` holds to be asked or handed over (every line but a placebo's):
1064/// decided already by the check that queued them, so no other check decides them again (a live check's
1065/// pool, the stop's, `decide`'s `had`). A placebo waiting was routed nowhere: it stays eligible.
1066fn waiting_in(text: &str) -> impl Iterator<Item = &str> {
1067    text.lines().map(queue_line).filter(|(route, _, _)| *route != Queued::Placebo).flat_map(|(_, ids, _)| ids)
1068}
1069
1070/// Live mode, at the session's end: whatever still waits in the `.queued` file was never delivered
1071/// (`never_delivered`, outcome.rs `DELIVERY`). Read, not taken: a resumed session still gets it at its
1072/// next prompt, and that delivery's record supersedes this one.
1073fn note_never_delivered(session: &str, input: &Value) {
1074    let text = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1075    for line in text.lines() {
1076        let (route, ids, check) = queue_line(line);
1077        let channel = match route {
1078            Queued::Ask => "live",
1079            Queued::Instead | Queued::Free => "queued",
1080            Queued::Placebo => outcome::PLACEBO,
1081        };
1082        record_route(session, input, check, ids.into_iter().map(String::from).collect(), channel, outcome::ROUTE_NEVER, "session ended");
1083    }
1084}
1085
1086/// For each of `ids`, its value on the latest `prefix` line (`#borg-p `, `#borg-pf `) tagged `check`;
1087/// empty when any of them has none there (unknown, never a guess).
1088fn tagged_p(state: &str, prefix: &str, check: &str, ids: &[&str]) -> Vec<f64> {
1089    let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1090    for line in state.lines().filter_map(|l| l.strip_prefix(prefix)) {
1091        let (items, at) = tagged(line);
1092        if at != Some(check) {
1093            continue;
1094        }
1095        for (id, p) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1096            if let Ok(p) = p.trim().parse::<f64>() {
1097                seen.insert(id.trim(), p);
1098            }
1099        }
1100    }
1101    ids.iter().map(|i| seen.get(i).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1102}
1103
1104/// Lessons, each with the check that routed it.
1105type Pairs<'a> = Vec<(&'a conscience::Challenge, &'a str)>;
1106
1107/// Where a lesson the queue drops at delivery came from and why: its check, channel, route (outcome.rs
1108/// `ROUTES`) and reason.
1109type Drop<'a> = (&'a str, &'static str, &'static str, &'static str);
1110
1111/// The waiting graded lessons, once (`queued_file`): a live check's asks first, whatever order their
1112/// checks finished in, as questions to answer (`ask_live`), unless done (`done_in`, the definition its
1113/// check's pool used: a grade 3 on a lesson handed over but not acted on is asked, never dropped); then
1114/// the free lessons not asked or handed over in the session yet (`had_in`), those in place of a
1115/// question (`INSTEAD`) all and first, the others as many as one early delivery fits beside them
1116/// (`conscience::render_owing`); and last the placebos not shown meanwhile (asked or handed
1117/// over, now or before; one the budget cut stays a placebo), never shown but marked for the judge
1118/// (`#borg-placebo`, judged as a free delivery is). Free deliveries are marked `#borg-queued <ids>
1119/// @<check>` in the state and .edits files (after the work so far, `WORK`), so neither this queue nor
1120/// early delivery repeats them, and Stop treats them as early lessons (done once acted on); an `asked`
1121/// outcome per routing check with channel queued and that check's free propensity (`#borg-pf`,
1122/// `record_delivery`). Every lesson of the queue gets its `delivery` record: asked, free, placebo, or
1123/// dropped and why.
1124fn queued(so_far: &SoFar, hook_event: &str, session: &str, input: &Value) -> Option<String> {
1125    let text = take_queue(session)?;
1126    let all = lessons(session);
1127    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1128    let had = had_in(&state);
1129    let mut done: Option<std::collections::HashSet<&str>> = None;
1130    let mut lines: Vec<(Queued, Vec<&str>, &str)> = text.lines().map(queue_line).collect();
1131    // Asks, then free lessons in place of questions, then the other free ones, then placebos
1132    // (`Queued`'s order), each in its lines' order.
1133    lines.sort_by_key(|l| l.0);
1134    let placebos = lines.iter().position(|l| l.0 == Queued::Placebo).unwrap_or(lines.len());
1135    let (mut firm, mut wanted, mut placebo): (Pairs, Pairs, Pairs) = (Vec::new(), Vec::new(), Vec::new());
1136    let mut dropped: Vec<(Drop, &str)> = Vec::new();
1137    // The first `owed` of `wanted`: the free lessons in place of questions, which sort first.
1138    let mut owed = 0;
1139    for (route, ids, check) in &lines[..placebos] {
1140        let channel = if *route == Queued::Ask { "live" } else { "queued" };
1141        for &id in ids {
1142            let Some(c) = all.iter().find(|c| c.id == id) else {
1143                dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
1144                continue;
1145            };
1146            let why = if firm.iter().chain(&wanted).any(|(c, _)| c.id == id) {
1147                Some("queued twice")
1148            } else if *route == Queued::Ask {
1149                done.get_or_insert_with(|| done_in(&state, &early_acted(session, &all, ""))).contains(id).then_some("done")
1150            } else {
1151                had.contains(id).then_some("given before")
1152            };
1153            match why {
1154                Some(why) => dropped.push(((check, channel, outcome::ROUTE_DROPPED_DONE, why), id)),
1155                None if *route == Queued::Ask => firm.push((c, check)),
1156                None => {
1157                    owed += usize::from(*route == Queued::Instead);
1158                    wanted.push((c, check));
1159                }
1160            }
1161        }
1162    }
1163    let free: Vec<&conscience::Challenge> = wanted.iter().map(|(c, _)| *c).collect();
1164    let shown = conscience::render_owing(&free, &[], owed);
1165    let n = shown.as_ref().map_or(0, |(_, n)| *n);
1166    dropped.extend(wanted[n..].iter().map(|(c, check)| ((*check, "queued", outcome::ROUTE_DROPPED_CHARS, "budget"), c.id.as_str())));
1167    // A placebo is a lesson the drone was not shown: decided against what this delivery shows (the asks,
1168    // the free lessons within the budget) and what it was shown before.
1169    for (_, ids, check) in &lines[placebos..] {
1170        for &id in ids {
1171            let Some(c) = all.iter().find(|c| c.id == id) else {
1172                dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, "not deliverable"), id));
1173                continue;
1174            };
1175            let why = if placebo.iter().any(|(c, _)| c.id == id) {
1176                Some("queued twice")
1177            } else {
1178                (firm.iter().chain(&wanted[..n]).any(|(c, _)| c.id == id) || had.contains(id)).then_some("delivered")
1179            };
1180            match why {
1181                Some(why) => dropped.push(((check, outcome::PLACEBO, outcome::ROUTE_DROPPED_DONE, why), id)),
1182                None => placebo.push((c, check)),
1183            }
1184        }
1185    }
1186    let mut parts: Vec<String> = Vec::new();
1187    if !firm.is_empty() {
1188        parts.push(ask_live(so_far, hook_event, session, input, &mut firm));
1189    }
1190    let mut markers: Vec<String> = Vec::new();
1191    if let Some((text, n)) = shown {
1192        for (check, ids) in by_check(&wanted[..n]) {
1193            markers.push(format!("#borg-queued {}{}", ids.join(","), tag(check)));
1194            let delivered: Vec<&conscience::Challenge> = ids.iter().filter_map(|i| free.iter().find(|c| c.id == *i).copied()).collect();
1195            // The routing check's chance of handing each over free; 1 on an older binary's line (the
1196            // fixed rule's certain hand-over).
1197            let p = if check.is_empty() { vec![1.0; ids.len()] } else { tagged_p(&state, "#borg-pf ", check, &ids) };
1198            record_delivery(session, input, &delivered, "queued", check, p, Vec::new());
1199        }
1200        record_routes(session, input, &wanted[..n], "queued", outcome::ROUTE_FREE, "");
1201        log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
1202        crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
1203            e.n = n as u64;
1204            e.lessons = wanted[..n].iter().map(|(c, _)| c.id.clone()).collect();
1205            e.channel = "queued".into();
1206        });
1207        parts.push(text);
1208    }
1209    // Placebos: nothing reaches the drone; the next judge call sees each as it would a free delivery.
1210    for (check, ids) in by_check(&placebo) {
1211        markers.push(format!("#borg-placebo {}{}", ids.join(","), tag(check)));
1212    }
1213    record_routes(session, input, &placebo, outcome::PLACEBO, outcome::ROUTE_PLACEBO, "");
1214    if !markers.is_empty() {
1215        let markers = markers.join("\n");
1216        append(session, &markers);
1217        note_work(session, so_far, &markers);
1218    }
1219    let mut groups: Vec<(Drop, Vec<String>)> = Vec::new();
1220    for (key, id) in dropped {
1221        match groups.iter_mut().find(|(k, _)| *k == key) {
1222            Some((_, ids)) => ids.push(id.to_string()),
1223            None => groups.push((key, vec![id.to_string()])),
1224        }
1225    }
1226    for ((check, channel, route, why), ids) in groups {
1227        record_route(session, input, check, ids, channel, route, why);
1228    }
1229    (!parts.is_empty()).then(|| parts.join("\n\n"))
1230}
1231
1232/// Live mode: a live check's asks, delivered now (conscience.rs `render_live`), each with its check.
1233/// Asked is recorded here, at delivery, where the drone sees them: per check `#borg-asked <ids>
1234/// @<check>` and `#borg-live <ids> ms=<now>` in the state file, the same in .edits after the work so
1235/// far (`note_work`: the judge's evidence before them, `pending`), the outcome `asked` with channel live
1236/// and the p of its check's `#borg-p`, its `delivery` record, and the stat `live_ask`. No extra round: a
1237/// free delivery in the cost.
1238fn ask_live(so_far: &SoFar, hook_event: &str, session: &str, input: &Value, firm: &mut [(&conscience::Challenge, &str)]) -> String {
1239    crate::memory::shuffle(firm, session);
1240    let groups = by_check(firm);
1241    let now = learn::now_ms();
1242    // One write: `prefilter::turn` and E3 read each `#borg-live` line right after its `#borg-asked`.
1243    let lines: Vec<String> = groups
1244        .iter()
1245        .map(|(check, ids)| {
1246            let ids = ids.join(",");
1247            format!("#borg-asked {ids}{}\n#borg-live {ids} ms={now}", tag(check))
1248        })
1249        .collect();
1250    let lines = lines.join("\n");
1251    append(session, &lines);
1252    note_work(session, so_far, &lines);
1253    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1254    let lessons: Vec<&conscience::Challenge> = firm.iter().map(|(c, _)| *c).collect();
1255    if learning() {
1256        let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1257        for (check, ids) in &groups {
1258            let members: Vec<&conscience::Challenge> = lessons.iter().copied().filter(|c| ids.contains(&c.id.as_str())).collect();
1259            let ids: Vec<String> = members.iter().map(|c| c.id.clone()).collect();
1260            let (grades, ranks, applied) = graded_of(session, &ids);
1261            let p = if check.is_empty() {
1262                propensity_in(&state, &ids)
1263            } else {
1264                tagged_p(&state, "#borg-p ", check, &members.iter().map(|c| c.id.as_str()).collect::<Vec<_>>())
1265            };
1266            let (pool, base, scoped) = pool_in(&state, &ids);
1267            let asked = outcome::Outcome {
1268                kind: outcome::ASKED.into(),
1269                session: session.into(),
1270                lessons: ids,
1271                ms: now,
1272                repo: repo_of(cwd),
1273                groups: conscience::disagreement_ids(&members),
1274                channel: "live".into(),
1275                grades,
1276                ranks,
1277                applied,
1278                p,
1279                pool,
1280                base,
1281                scoped,
1282                check: check.to_string(),
1283                ..Default::default()
1284            };
1285            let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1286        }
1287    }
1288    record_routes(session, input, firm, "live", outcome::ROUTE_ASKED, "");
1289    let text = conscience::render_live(&lessons);
1290    log_cost(session, &format!("free event={hook_event} lessons={} chars={} live", lessons.len(), text.len()));
1291    stat(session, input, crate::stats::LIVE_ASK, |e| {
1292        e.n = lessons.len() as u64;
1293        e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
1294        e.channel = "live".into();
1295    });
1296    text
1297}
1298
1299/// Live mode: this turn's work so far (harness `work_so_far`): from the transcript where the harness
1300/// writes one, else the turn's tool calls (`turn_file`): no prose.
1301fn work_so_far(h: &dyn Harness, input: &Value) -> String {
1302    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1303    harness::work_so_far(h.caps(), input, &turn_file(session), LIVE_WORK_CHARS)
1304}
1305
1306/// One hook's work so far (`work_so_far`), read when a delivery first needs it and then shared: the live
1307/// asks, free deliveries and placebos of one prompt or tool call write the same work line (`note_work`)
1308/// from one read of the transcript, and a hook that delivers nothing reads none.
1309struct SoFar<'a> {
1310    h: &'a dyn Harness,
1311    input: &'a Value,
1312    text: std::cell::OnceCell<String>,
1313}
1314
1315impl<'a> SoFar<'a> {
1316    fn new(h: &'a dyn Harness, input: &'a Value) -> Self {
1317        SoFar { h, input, text: std::cell::OnceCell::new() }
1318    }
1319
1320    fn get(&self) -> &str {
1321        self.text.get_or_init(|| work_so_far(self.h, self.input))
1322    }
1323}
1324
1325/// This turn's tool calls, one `[tool] touch` line each, and their edit text: the work so far where the
1326/// harness sends no transcript. Emptied at each prompt.
1327fn turn_file(session: &str) -> PathBuf {
1328    state_file(session).with_extension("turn")
1329}
1330
1331/// How much of the turn so far a live check reads. Derived: as much as the blocked message `pending`
1332/// keeps (`EVIDENCE_CHARS`).
1333const LIVE_WORK_CHARS: usize = EVIDENCE_CHARS;
1334
1335/// One open queen question for the engineer (queen.rs `deliver`), at the first prompt of the session
1336/// where one of its work repos (the cwd's and the touched ones, systems.rs `work_repos`) has one: never
1337/// another repo's. Its commands run this binary in the session's `cwd`, wherever the drone's shell has
1338/// gone since. 0 model calls; fails open. Off with `BORG_QUEEN_ASK=off` or `BORG_LEARN=off` (nothing
1339/// recorded; eval drones cannot answer), and in the eval modes BORG_ONLY and BORG_CRITIC.
1340fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
1341    if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
1342        return None;
1343    }
1344    let repos = || {
1345        let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
1346        let here = repo_of(cwd);
1347        if !here.is_empty() && !repos.contains(&here) {
1348            repos.insert(0, here);
1349        }
1350        repos
1351    };
1352    let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
1353    let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
1354    if let Ok(exe) = std::env::current_exe() {
1355        q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
1356    }
1357    let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
1358    log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
1359    Some(text)
1360}
1361
1362/// The free context of a prompt or touch: waiting graded lessons first, then an early delivery.
1363fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
1364    free_context_with(h, hook_event, session, input, text, None)
1365}
1366
1367/// `free_context` plus `extra` (a queen question) last.
1368fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
1369    let so_far = SoFar::new(h, input);
1370    let parts: Vec<String> =
1371        [queued(&so_far, hook_event, session, input), early(h, &so_far, hook_event, session, input, text), extra].into_iter().flatten().collect();
1372    (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))
1373}
1374
1375/// What one model check of the work decided (`check`).
1376enum Verdict {
1377    /// The free gate skipped the model call: the score, or `noedit`.
1378    Skip(String),
1379    /// The model gave no answer: fail open.
1380    Failed,
1381    /// The ids to ask: in a block at the stop, with the next prompt or tool call in live mode; and
1382    /// whether a stop may ask them in its block while they wait: the check graded a 3, so the fixed
1383    /// rule's block there would have asked them too. Without a 3, a live check's asks (the randomised
1384    /// question-count test's, or the evidence rule's, valued at a hand-over's arrival rate, as live questions no stop asks,
1385    /// select/policy.rs `Valuer::ask`) wait for a tool call or the next prompt (`live_check`).
1386    Ask(Vec<String>, bool),
1387}
1388
1389/// The work a check judges, as the stop or a live check read it.
1390struct Work<'a> {
1391    /// Every lesson of the session it may deliver (the ungraded widening looks here).
1392    all: &'a [conscience::Challenge],
1393    /// `all` and the absorbed lessons withheld from it (`judged`): the judge's pending lessons look here,
1394    /// so a delivery made before an absorb still gets its verdict.
1395    judged: &'a [conscience::Challenge],
1396    /// The lessons that may be asked: not done (`done_in`), not decided already (waiting in the queue to
1397    /// be asked or handed over, `waiting_in`), not of another system.
1398    challenges: &'a [conscience::Challenge],
1399    done: &'a std::collections::HashSet<&'a str>,
1400    /// The state file as read before the check.
1401    state: &'a str,
1402    /// The state file plus the drone's work text: the gate finds the turn in it by its markers; the pool
1403    /// and the model read it without them (`conscience::work_text`).
1404    context: &'a str,
1405    scope_text: &'a str,
1406    /// The drone's message for the judge (`pending`): its final message at the stop, the turn so far live.
1407    message: &'a str,
1408    /// BORG_STOP_GATE=edits: whether the turn changed anything.
1409    changed: bool,
1410    /// Whether this check judges the pending lessons: not while another check (holding the session's
1411    /// lock) may be judging the same ones.
1412    judge: bool,
1413    /// The harness's stop budget (none: the model's own limit) and when the hook started: the model call
1414    /// gets what is left of it when it starts (live.rs `call_limit`).
1415    budget: Option<std::time::Duration>,
1416    started: std::time::Instant,
1417    /// This check's id (select.rs `check_id`), computed once when it started: on its `graded` record and
1418    /// on every marker of what it routed.
1419    check: &'a str,
1420    /// The selection summary beside the lessons file, read at the check's start (select/summary.rs
1421    /// `refresh`): what sizes the pool before the model call, and what a decision by a policy that reads
1422    /// no gate reads; a learned decision reads it again, under the store's decision lock (`decide`).
1423    summary: &'a crate::select::summary::Summary,
1424}
1425
1426/// What a check's `graded` record says of its pool and its model call (`record_graded`).
1427struct Call<'a> {
1428    /// Lessons the pool allowed, and those shown in the order they filled its budget (whole.rs
1429    /// `select`; the shortlist's order without the whole pool).
1430    eligible: usize,
1431    order: &'a [&'a conscience::Challenge],
1432    /// The tail sample shown after the pool (whole.rs `tail`): each lesson with its place in the full
1433    /// fill order (`order.len()` + its place among the lessons the budget dropped), and each one's
1434    /// chance of the draw (0 without a tail).
1435    tail: &'a [(usize, &'a conscience::Challenge)],
1436    pt: f64,
1437    /// The pool's token budget the check filled to (0 without the whole pool), outcome.rs `budget`.
1438    budget: usize,
1439    model: &'a str,
1440    prompt_hash: String,
1441    dur_ms: u64,
1442    /// The call's cost as the CLI reported it (0 for a failed call).
1443    usd: f64,
1444}
1445
1446/// The consequence model's check of `w`, shared by the stop and live mode's `borg check` (`live`): the
1447/// free gate, the pool (with its tail sample, whole.rs `tail`), one graded call that also judges the
1448/// pending lessons, and the graded split (select.rs `graded_under`), decided and recorded as one step
1449/// (`decide`: under the store's decision lock for the learned policy, which carries out a decision only
1450/// once its record landed where its gate is folded from). Its decision is logged before anything carries
1451/// it out (the `graded` record, then `#borg-graded`, `#borg-select`, `#borg-p`, `#borg-pf` in one write,
1452/// then the queue): a hook killed in between asks nothing and leaves no marker of an unlogged decision.
1453/// The free lessons and the placebos wait in the `.queued` file; what to ask is returned.
1454/// A live call is logged as `live_check` with a `live usd=… lessons=…` cost line, the stop's as `check`.
1455fn check(session: &str, input: &Value, w: &Work, live: bool) -> Verdict {
1456    // The free gate (prefilter.rs): a turn whose words touch no lesson costs no model call; the conscience
1457    // then asked nothing. BORG_STOP_GATE=edits instead: a turn that changed nothing costs no model call.
1458    // A live check has no gate: it runs only after a change, the debounce bounds it, and a turn half done
1459    // has too few words for the gate (the E3 smoke skipped every mid-turn check at 2.8 and 7.7 of 8).
1460    let skip = if live {
1461        None
1462    } else if prefilter::edits_gate() {
1463        (!w.changed).then(|| "noedit".to_string())
1464    } else {
1465        prefilter::skip(w.challenges, w.context).map(|score| format!("{score:.2}"))
1466    };
1467    if let Some(score) = skip {
1468        return Verdict::Skip(score);
1469    }
1470    // What the pool and the model read: the work, without the state file's bookkeeping lines.
1471    let text = conscience::work_text(w.context);
1472    let text = text.as_ref();
1473    // BM25's top 30, plus lessons the vector index finds that word matching missed (vectors.rs; here only,
1474    // where the model call dwarfs loading the embedder). Top 30 found as much as top 20 + vectors; both
1475    // together find the most (internal evaluation "vectors", not published).
1476    let started = std::time::Instant::now();
1477    let shortlist_now = || {
1478        let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
1479        if crate::vectors::enabled() {
1480            log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
1481        }
1482        list
1483    };
1484    // The whole pool (whole.rs, default): every lesson the Stop allows, up to a token budget; the
1485    // shortlist's order decides only when they do not all fit. BORG_STOP_FORMAT=classic asks about that
1486    // pool in the shortlist's layout (predict::prompt).
1487    let whole = crate::whole::on();
1488    let (shortlist, eligible, order, tail, pt, budget) = if whole {
1489        let ranked = conscience::rank_scoped(w.challenges, text, w.scope_text);
1490        let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
1491        let n = eligible.len();
1492        // The pool's budget: BORG_STOP_POOL_TOKENS when set, else the policy's (select.rs `pool_tokens`:
1493        // today's `whole::TOKENS` unless the learned policy's gate may risk a larger one that fits the
1494        // model call's limit).
1495        let tokens = match std::env::var_os("BORG_STOP_POOL_TOKENS") {
1496            Some(_) => crate::whole::tokens(),
1497            None => {
1498                let limit = live::call_limit(w.budget, w.started.elapsed());
1499                crate::select::pool_tokens(w.summary, Some(u64::try_from(limit.as_millis()).unwrap_or(u64::MAX)), session)
1500            }
1501        };
1502        let f = crate::whole::select_filled(eligible, shortlist_now, &ranked, tokens);
1503        // The tail sample (plan W3b "Pool"), under every policy: beside a pool the budget cut, a few of
1504        // the lessons it dropped, drawn by the check, shown after the pool (its stable prefix stays), so
1505        // the yield of fill positions past the budget is measured. Never beside an empty pool: the
1506        // sample alone would pay for a model call the budget does not.
1507        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)) };
1508        let tail: Vec<(usize, &conscience::Challenge)> = drawn.into_iter().map(|(i, c)| (f.order.len() + i, c)).collect();
1509        let sampled = if tail.is_empty() { String::new() } else { format!(" tail={}", tail.len()) };
1510        log_cost(session, &format!("pool mode=all eligible={n} shown={} budget={}{sampled}", f.shown.len(), if f.fell { "full" } else { "fits" }));
1511        let mut shown = f.shown;
1512        shown.extend(tail.iter().map(|(_, c)| *c));
1513        (shown, n, f.order, tail, pt, tokens)
1514    } else {
1515        let list = shortlist_now();
1516        (list.clone(), list.len(), list, Vec::new(), 0.0, 0)
1517    };
1518    let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
1519    // R1: the same call judges what the drone did with lessons delivered earlier this session.
1520    let judging = if w.judge {
1521        pending(session, w.state, w.judged, w.message, live, w.check)
1522    } else {
1523        Judging { pending: Vec::new(), question: false, crowded: Vec::new() }
1524    };
1525    let pend = &judging.pending;
1526    // Nothing to ask and no question to judge (no lesson in the pool, none pending): no model call. A live
1527    // check has no free gate, so without this every change of a session with no lessons paid for one.
1528    // Free deliveries alone never pay for a call: they wait for the next one, or `borg learn` settles them.
1529    if shortlist.is_empty() && !judging.question {
1530        return Verdict::Skip("empty".into());
1531    }
1532    let extra = predict::judge_section(pend).len();
1533    let graded = crate::whole::graded();
1534    let compact = whole && !crate::whole::classic();
1535    let timeout = live::call_limit(w.budget, w.started.elapsed());
1536    // The work the call shows holds no stripped question's declaration either: this turn's message may
1537    // be the drone's answer to it.
1538    let shown_work = without_declarations(text, pend);
1539    let called = std::time::Instant::now();
1540    let pred = if compact {
1541        predict::predict_whole(&shown_work, &shortlist, pend, &model, graded, timeout)
1542    } else {
1543        predict::predict(&shown_work, &shortlist, pend, &model, graded, timeout)
1544    };
1545    let dur_ms = called.elapsed().as_millis() as u64;
1546    stat(session, input, if live { crate::stats::LIVE_CHECK } else { crate::stats::CHECK }, |e| {
1547        e.n = shortlist.len() as u64;
1548        e.dur_ms = dur_ms;
1549        e.usd = pred.as_ref().map_or(0.0, |p| p.cost);
1550        if pred.is_none() {
1551            e.why = "failed".into();
1552        }
1553    });
1554    let usd = pred.as_ref().map_or(0.0, |p| p.cost);
1555    let call = Call { eligible, order: &order, tail: &tail, pt, budget, model: &model, prompt_hash: predict::template_hash(compact, graded), dur_ms, usd };
1556    note_unjudged(session, &judging.crowded, "slots");
1557    let Some(pred) = pred else {
1558        // Fail open: nothing asked, the pending lessons stay pending (no try counted).
1559        if graded {
1560            record_graded(session, input, w, live, &[], None, &call);
1561        }
1562        note_unjudged(session, &pend.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>(), "failed");
1563        return Verdict::Failed;
1564    };
1565    // Live calls log this line too: the judge's call numbering counts every model call of the session.
1566    append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
1567    if live {
1568        log_cost(session, &format!("live usd={:.4} lessons={}", pred.cost, shortlist.len()));
1569    }
1570    if !pend.is_empty() {
1571        let call = w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1;
1572        record_verdicts(session, input, pend, &pred, extra, call);
1573    }
1574    let applied: Vec<(&conscience::Challenge, u8)> =
1575        pred.applies.iter().zip(&pred.grades).filter_map(|(i, g)| shortlist.iter().find(|c| &c.id == i).map(|c| (*c, *g))).collect();
1576    let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1577    if graded {
1578        // Graded (default): select.rs decides what of the applies to ask, hand over free at the next
1579        // prompt or touch, or judge as a placebo (the fixed rule: ask only for a 3, else the 2s and 1s
1580        // free, conscience.rs `graded_without` with the session's `had`).
1581        let lessons = lessons_path();
1582        // Decided at the pool tokens the record logs, which the fit and the gate's ledger read; and, under
1583        // the learned policy, with the store's other checks one at a time (`decide`).
1584        let at = Decide {
1585            session,
1586            check: w.check,
1587            channel: if live { "live" } else { "stop" },
1588            lessons: &lessons,
1589            tokens: crate::select::shown_tokens(call.order),
1590            recorded: records_reach_gate(&lessons),
1591        };
1592        let r = decide(crate::select::policy(), &applied, &at, w.summary, DECIDE_WAIT, |r, basis| {
1593            record_graded(session, input, w, live, &applied, Some((r, basis)), &call)
1594        });
1595        let routed = |cs: &[&conscience::Challenge], p: &[f64]| -> Vec<(&str, f64)> {
1596            applied.iter().zip(p).filter(|((c, _), _)| cs.iter().any(|x| x.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)).collect()
1597        };
1598        let mut lines = vec![graded_line(&applied), r.log.line(applied.len(), w.check)];
1599        lines.extend(r.log.attention.clone());
1600        lines.extend(p_line("#borg-p", routed(&r.ask, &r.p), Some(w.check)));
1601        lines.extend(p_line("#borg-pf", routed(&r.free, &r.pf), Some(w.check)));
1602        append(session, &lines.join("\n"));
1603        let queue = !r.free.is_empty();
1604        log_cost(session, &format!("graded applied={} ask={} free={} {}", applied.len(), r.ask.len(), r.free.len(), if queue { "queued" } else { "dropped" }));
1605        if queue {
1606            // The learned policy's hand-overs of lessons the fixed rule asks (on a check with a 3: the
1607            // question-count test's, or the evidence rule's) are marked, so the free delivery's budget
1608            // never cuts what a question would have carried; and a live check's test draws are asked by
1609            // a stop that comes first (`INSTEAD`), the rest are not (`INSTEAD_NEXT`).
1610            let asks = if r.log.policy == crate::select::Policy::Learned.name() { conscience::graded_without(&applied, None, &[]).0 } else { Vec::new() };
1611            let (owed, rest): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = r.free.iter().partition(|c| asks.iter().any(|a| a.id == c.id));
1612            if !owed.is_empty() {
1613                let prefix = if live && r.log.source == crate::select::policy::EXPERIMENT { INSTEAD } else { INSTEAD_NEXT };
1614                enqueue(session, &format!("{prefix}{}{}", ids(&owed).join(","), tag(w.check)));
1615            }
1616            if !rest.is_empty() {
1617                enqueue(session, &format!("{}{}", ids(&rest).join(","), tag(w.check)));
1618            }
1619        }
1620        if !r.placebo.is_empty() {
1621            enqueue(session, &format!("placebo {}{}", ids(&r.placebo).join(","), tag(w.check)));
1622        }
1623        Verdict::Ask(ids(&r.ask), applied.iter().any(|(_, g)| *g == 3))
1624    } else {
1625        // R3b: the model decides whether to ask (at the stop, an extra round); once it does, the questions
1626        // also carry every other lesson over the low bar (conscience.rs `widen`).
1627        let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
1628        let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, text, w.scope_text), &predicted, w.done);
1629        let widened: Vec<&conscience::Challenge> =
1630            conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
1631        append_p_of(session, &pool, &widened, w.check);
1632        // Ungraded, the stop asks whatever it widens to: so it would have asked these.
1633        Verdict::Ask(ids(&widened), true)
1634    }
1635}
1636
1637/// Where a graded check decides (`decide`): the parts of its select.rs `Ctx` the summary does not hold.
1638struct Decide<'x> {
1639    session: &'x str,
1640    check: &'x str,
1641    channel: &'static str,
1642    lessons: &'x std::path::Path,
1643    /// The check's pool tokens (select.rs `Ctx::tokens`).
1644    tokens: u32,
1645    /// Whether the check's `graded` record lands in the log its gate is folded from
1646    /// (`records_reach_gate`): a learned decision whose record would not is never debited.
1647    recorded: bool,
1648}
1649
1650/// What a graded check's decision read (`decide`), for its record: the selection summary, the lessons
1651/// the session already had or has waiting in its queue (select.rs `Ctx::had`), and why it fell back to
1652/// the fixed rule (outcome.rs `DECIDED_BUSY`, `DECIDED_NO_LOCK`, `DECIDED_NO_RECORD`; empty when it
1653/// decided by the policy in force).
1654struct Basis<'s> {
1655    summary: &'s crate::select::summary::Summary,
1656    had: &'s [String],
1657    why: &'static str,
1658}
1659
1660/// How long a learned check waits for the store's decision lock (`decide`) at most; past it, the check
1661/// takes the fixed rule and its record says why. Guard: a check that holds the lock long (a summary
1662/// rebuilt from a long log, a stopped process) would otherwise hold up every other learned check's
1663/// answer. Derived: a third of
1664/// what the stop's model call leaves for the hook's records and answer (live.rs `AFTER_CALL`), so the
1665/// wait never costs a stop its answer.
1666const DECIDE_WAIT: std::time::Duration = std::time::Duration::from_millis(live::AFTER_CALL.as_millis() as u64 / 3);
1667
1668/// Method: how often a check waiting for the decision lock tries it again (`lock_within`).
1669const DECIDE_POLL: std::time::Duration = std::time::Duration::from_millis(5);
1670
1671/// The store's decision lock (`decide`), beside the selection summary it guards.
1672fn decide_lock(lessons: &std::path::Path) -> PathBuf {
1673    crate::select::summary::cache_path(lessons).with_extension("json.lock")
1674}
1675
1676/// `file` locked exclusively, tried every `DECIDE_POLL` for at most `wait` (once for none); unlocked when
1677/// the handle drops. Err with why when it stays held (`DECIDED_BUSY`) or cannot be taken at all
1678/// (`DECIDED_NO_LOCK`).
1679fn lock_within(file: &std::path::Path, wait: std::time::Duration) -> Result<std::fs::File, &'static str> {
1680    let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file).map_err(|_| outcome::DECIDED_NO_LOCK)?;
1681    let until = std::time::Instant::now() + wait;
1682    loop {
1683        match lock.try_lock() {
1684            Ok(()) => return Ok(lock),
1685            Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < until => std::thread::sleep(DECIDE_POLL),
1686            Err(std::fs::TryLockError::WouldBlock) => return Err(outcome::DECIDED_BUSY),
1687            Err(std::fs::TryLockError::Error(_)) => return Err(outcome::DECIDED_NO_LOCK),
1688        }
1689    }
1690}
1691
1692/// A graded check's decision and its record (`record`, which answers whether the record landed) as one
1693/// step. Only the learned policy reads the gate (select/policy.rs `choose`): its checks decide one at a
1694/// time under the store's decision lock (`decide_lock`), never during the model call, since learned
1695/// checks that decide at once (a stop and a live check of one session, the checks of several sessions on
1696/// one store) would each take all the gate may still risk. Under the lock the summary is loaded again
1697/// (select/summary.rs `refresh`: the log's tail since the check started, other checks' records and their
1698/// ledger entries included), decided on (select.rs `graded_under`) and recorded with that summary as the
1699/// one the decision read (outcome.rs `seen`); then released. A learned decision is carried out only once
1700/// its record landed in the log its gate is folded from, the gate's only debit: one whose record would
1701/// not land there (`Decide::recorded`) or did not (`record`'s answer) takes the fixed rule instead
1702/// (`DECIDED_NO_RECORD`), as one that cannot take the lock within `wait` does (on `stale`, the summary
1703/// it read at its start), and its record says why (`Basis::why`). The fixed rule and the evaluation
1704/// switches read no gate: they decide by themselves on `stale`, without the lock, whatever other checks
1705/// hold. Every way, the session's lessons delivered by now (`had_in`, read again: a tool call may have
1706/// handed one over during the model call) or waiting in its queue (`waiting_in`: a check that finished
1707/// meanwhile may have queued one, which delivery would drop as queued twice) never go free.
1708fn decide<'a>(
1709    policy: crate::select::Policy,
1710    applied: &[(&'a conscience::Challenge, u8)],
1711    at: &Decide,
1712    stale: &crate::select::summary::Summary,
1713    wait: std::time::Duration,
1714    mut record: impl FnMut(&crate::select::Routed<'a>, &Basis) -> bool,
1715) -> crate::select::Routed<'a> {
1716    use crate::select::Policy;
1717    let state = std::fs::read_to_string(state_file(at.session)).unwrap_or_default();
1718    let queue = std::fs::read_to_string(queued_file(at.session)).unwrap_or_default();
1719    let mut had: Vec<String> = had_in(&state).into_iter().chain(waiting_in(&queue)).map(str::to_string).collect();
1720    had.sort_unstable();
1721    had.dedup();
1722    // The collective's tolerance, read once: every way below decides and logs under it (select.rs `Ctx::alpha`).
1723    let alpha = crate::select::alpha(at.lessons);
1724    // Decided under `policy` on `summary` and recorded with `why`; whether the record landed.
1725    let mut on = |summary: &crate::select::summary::Summary, policy: Policy, why: &'static str| {
1726        let ctx =
1727            crate::select::Ctx { session: at.session, check: at.check, channel: at.channel, lessons: at.lessons, summary, tokens: at.tokens, had: &had, alpha };
1728        let r = crate::select::graded_under(policy, applied, &ctx);
1729        let landed = record(&r, &Basis { summary, had: &had, why });
1730        (r, landed)
1731    };
1732    if policy != Policy::Learned {
1733        return on(stale, policy, "").0;
1734    }
1735    if !at.recorded {
1736        return on(stale, Policy::Fixed, outcome::DECIDED_NO_RECORD).0;
1737    }
1738    match lock_within(&decide_lock(at.lessons), wait) {
1739        Ok(_held) => {
1740            let summary = crate::select::summary::refresh(at.lessons);
1741            match on(&summary, policy, "") {
1742                (r, true) => r,
1743                // Still under the lock: no other check decided on the gate this one left as it was.
1744                (_, false) => on(&summary, Policy::Fixed, outcome::DECIDED_NO_RECORD).0,
1745            }
1746        }
1747        Err(why) => on(stale, Policy::Fixed, why).0,
1748    }
1749}
1750
1751/// Whether a check's `graded` record (`record_graded`) lands in the log its gate is folded from, the
1752/// outcomes beside `lessons` (select/summary.rs `refresh`): learning on, and `lessons` in the store the
1753/// hook records to. A `BORG_CONSCIENCE` elsewhere reads the gate of its own store, which no record of
1754/// this hook reaches.
1755fn records_reach_gate(lessons: &std::path::Path) -> bool {
1756    learning() && outcome::path_beside(lessons) == graded_log()
1757}
1758
1759/// Where a check's `graded` record goes: beside the store's other outcomes.
1760fn graded_log() -> PathBuf {
1761    learn::home().join("outcomes.jsonl")
1762}
1763
1764/// The seed of a check's tail sample (whole.rs `tail`): its own stream of the check's seed (select.rs
1765/// `seed`, which reads only the session, channel and check; the tag spells "tailpool").
1766fn tail_seed(session: &str, check: &str, live: bool, summary: &crate::select::summary::Summary) -> u64 {
1767    let channel = if live { "live" } else { "stop" };
1768    let ctx =
1769        crate::select::Ctx { session, check, channel, lessons: std::path::Path::new(""), summary, tokens: 0, had: &[], alpha: crate::select::policy::ALPHA };
1770    crate::select::seed(&ctx) ^ u64::from_be_bytes(*b"tailpool")
1771}
1772
1773/// The `graded` record of one check (outcome.rs `GRADED`, `graded_outcome`), appended beside the store's
1774/// other outcomes (`graded_log`); whether it landed. Nothing with learning off.
1775fn record_graded(
1776    session: &str,
1777    input: &Value,
1778    w: &Work,
1779    live: bool,
1780    applied: &[(&conscience::Challenge, u8)],
1781    decided: Option<(&crate::select::Routed, &Basis)>,
1782    call: &Call,
1783) -> bool {
1784    if !learning() {
1785        return false;
1786    }
1787    let repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
1788    let o = graded_outcome(session, &repo, w.check, live, applied, decided, w.summary, call);
1789    outcome::append(&graded_log(), &o).is_ok()
1790}
1791
1792/// The `graded` record of one check: every check that called the model, one with no applies and a
1793/// failed one (`decided` none; its fit is that of `read`, the summary the check read at its start)
1794/// included, so each decision and each pool can be replayed. Per apply its grade, rank, fill position
1795/// (`Call::order`; a tail lesson's past the budget's, `Call::tail`), propensities to be asked (`p`) and
1796/// handed over free (`pf`); the routes (`chosen`, `free`, `placebo`, and ψ, each apply's chance of the
1797/// placebo draw among those routed nowhere, select.rs `Log::psi`); the applies that could not go free
1798/// because the session had them (`nofree`); the question-count test's k and s on a check it considered
1799/// (`xk`, `xs`, select.rs `Log::xk`); the tail sample (`tail`, `pt`, and after the applies' fill
1800/// positions each tail lesson's, outcome.rs `fill`); the policy, its fit, the summary the decision read
1801/// (`Basis`) and why it fell back to the fixed rule (`reason`); and the call.
1802#[allow(clippy::too_many_arguments)] // reason: one record of a check, from the parts the check holds apart
1803fn graded_outcome(
1804    session: &str,
1805    repo: &str,
1806    check: &str,
1807    live: bool,
1808    applied: &[(&conscience::Challenge, u8)],
1809    decided: Option<(&crate::select::Routed, &Basis)>,
1810    read: &crate::select::summary::Summary,
1811    call: &Call,
1812) -> outcome::Outcome {
1813    let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
1814    let rounded = |p: &[f64]| p.iter().map(|x| outcome::round_p(*x)).collect::<Vec<_>>();
1815    let (routed, basis) = (decided.map(|d| d.0), decided.map(|d| d.1));
1816    let shown: u32 = call.order.len().try_into().unwrap_or(u32::MAX);
1817    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));
1818    outcome::Outcome {
1819        kind: outcome::GRADED.into(),
1820        session: session.into(),
1821        lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
1822        ms: learn::now_ms(),
1823        repo: repo.into(),
1824        channel: if live { "live" } else { "stop" }.into(),
1825        grades: applied.iter().map(|(_, g)| *g).collect(),
1826        ranks: (0..applied.len()).map(|r| u16::try_from(r).unwrap_or(u16::MAX)).collect(),
1827        applied: u16::try_from(applied.len()).unwrap_or(u16::MAX),
1828        chosen: routed.map(|r| ids(&r.ask)).unwrap_or_default(),
1829        explore: routed.is_some_and(|r| r.log.source == "explore"),
1830        p: routed.map(|r| rounded(&r.p)).unwrap_or_default(),
1831        check: check.into(),
1832        policy: routed.map_or_else(|| crate::select::policy().name(), |r| r.log.policy).into(),
1833        fit: routed.map_or_else(|| read.fit.key.clone(), |r| r.log.fit.clone()),
1834        // The summary the decision's `Ctx` read (`decide`), not the log at this append.
1835        seen: basis.map(|b| b.summary.key()).unwrap_or_default(),
1836        source: routed.map(|r| r.log.source).unwrap_or_default().into(),
1837        free: routed.map(|r| ids(&r.free)).unwrap_or_default(),
1838        pf: routed.map(|r| rounded(&r.pf)).unwrap_or_default(),
1839        placebo: routed.map(|r| ids(&r.placebo)).unwrap_or_default(),
1840        psi: routed.map_or(0.0, |r| r.log.psi),
1841        nofree: basis.map(|b| crate::select::nofree(applied, b.had)).unwrap_or_default(),
1842        // The question-count test's k and s (select.rs `Log::xk`, `xs`): None outside it.
1843        xk: routed.and_then(|r| r.log.xk),
1844        xs: routed.and_then(|r| r.log.xs),
1845        // The tolerance it was decided under (select.rs `Log::alpha`): None at the default.
1846        alpha: routed.and_then(|r| r.log.alpha),
1847        tail: call.tail.iter().map(|(_, c)| c.id.clone()).collect(),
1848        pt: call.pt,
1849        reason: basis.map(|b| b.why).unwrap_or_default().into(),
1850        eligible: call.eligible.try_into().unwrap_or(u32::MAX),
1851        shown,
1852        tokens: crate::select::shown_tokens(call.order),
1853        budget: call.budget.try_into().unwrap_or(u32::MAX),
1854        // The applies' places, then each tail lesson's (aligned with `tail`, outcome.rs `fill`).
1855        fill: applied
1856            .iter()
1857            .map(|(c, _)| place(&c.id))
1858            .chain(call.tail.iter().map(|(at, _)| Some(*at)))
1859            .map(|i| i.map_or(u16::MAX, |i| u16::try_from(i).unwrap_or(u16::MAX)))
1860            .collect(),
1861        model: call.model.into(),
1862        prompt_hash: call.prompt_hash.clone(),
1863        harness: std::env::var("BORG_HARNESS").unwrap_or_default(),
1864        version: env!("CARGO_PKG_VERSION").into(),
1865        dur_ms: call.dur_ms,
1866        failed: routed.is_none(),
1867        usd: call.usd,
1868        ..Default::default()
1869    }
1870}
1871
1872/// Live mode (live.rs), in the PostToolUse hook: counts a state-changing tool call as a change and,
1873/// when no check runs, something changed since the last one and the debounce has passed, starts `borg
1874/// check` detached. The hook returns at once; the drone waits for nothing.
1875fn live_trigger(session: &str, input: &Value, tool: &str, changed: bool) {
1876    let ctx = state_file(session);
1877    if let Some(why) = live::unsupported() {
1878        // Said once per session: the hook would otherwise repeat it after every change.
1879        if std::fs::OpenOptions::new().write(true).create_new(true).open(ctx.with_extension("live.off")).is_ok() {
1880            eprintln!("borg: live mode needs the consequence model, which {why} replaces; this session stays in hook mode");
1881        }
1882        return;
1883    }
1884    if changed {
1885        live::bump(&ctx, tool);
1886    }
1887    let now = learn::now_ms();
1888    // A paused drone (steer.rs) is waiting for its engineer, not working: nothing to check.
1889    let home = learn::home();
1890    if crate::steer::active(&home) && crate::steer::paused(&home, session, crate::presence::read(&home, session).map(|p| p.started), now) {
1891        return;
1892    }
1893    let lock = live::lock_state(&ctx, now, live::stale_ms(), live::alive);
1894    if live::decide(live::Mode::Live, lock, live::count(&ctx), live::read_mark(&ctx), now, live::debounce_ms()).is_err() {
1895        return;
1896    }
1897    let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
1898    // As `end` starts `borg learn`: this binary, detached, inheriting the collective's store and state.
1899    if let Ok(exe) = std::env::current_exe() {
1900        let _ = std::process::Command::new(exe)
1901            .args(["check", "--session", session, "--transcript", &s("transcript_path"), "--cwd", &s("cwd")])
1902            .stdin(std::process::Stdio::null())
1903            .stdout(std::process::Stdio::null())
1904            .stderr(std::process::Stdio::null())
1905            .spawn();
1906    }
1907}
1908
1909/// `borg check` (live.rs): one check of the session's work so far, in the background. Takes the
1910/// session's lock (or leaves: a check runs), decides again under it, then runs the stop's check on this
1911/// turn so far: its asks wait in the `.queued` file as a `live <ids>` line when it graded a 3, which a stop
1912/// asks while they wait as its own block would have, else as a `live-next <ids>` line, which no stop
1913/// asks (`Verdict::Ask`); the free lessons as at the stop.
1914/// It writes no `#borg-asked` (that happens at delivery, `ask_live`) and no `#borg-skip` (that closes a
1915/// stop's turn): a gate skip is `#borg-live-skip`. The mark records the changes a completed check covered.
1916pub fn live_check(h: &dyn Harness, session: &str, input: &Value) {
1917    let ctx = state_file(session);
1918    let started = learn::now_ms();
1919    let Some(_lock) = live::acquire(&ctx, started) else { return };
1920    let n = live::count(&ctx);
1921    let mark = live::read_mark(&ctx);
1922    if live::unsupported().is_some() || live::decide(live::current(), live::Lock::Free, n, mark, started, live::debounce_ms()).is_err() {
1923        return;
1924    }
1925    let turn = turn_id(session);
1926    let (all, withheld) = loaded(session);
1927    let judged = judged(&all, withheld);
1928    let state = std::fs::read_to_string(&ctx).unwrap_or_default();
1929    let message = work_so_far(h, input);
1930    // Done as at the stop (`done_in`), and also what still waits to be asked or handed over: it is
1931    // decided already. A placebo waiting stays eligible: it was routed nowhere.
1932    let acted = early_acted(session, &judged, &message);
1933    let mut done = done_in(&state, &acted);
1934    let waiting = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1935    done.extend(waiting_in(&waiting));
1936    let challenges: Vec<conscience::Challenge> = all.iter().filter(|c| !done.contains(c.id.as_str()) && !c.off_system).cloned().collect();
1937    let context = format!("{state}{message}");
1938    let scope_text = format!("{}\n{message}", where_now(session));
1939    let check_id = crate::select::check_id(session, "live", started, std::process::id());
1940    // Folds the log's new tail and saves it, so the next check reads only what came after.
1941    let summary = crate::select::summary::refresh(&lessons_path());
1942    let work = Work {
1943        all: &all,
1944        judged: &judged,
1945        challenges: &challenges,
1946        done: &done,
1947        state: &state,
1948        context: &context,
1949        scope_text: &scope_text,
1950        message: &message,
1951        changed: true,
1952        judge: true,
1953        budget: None,
1954        started: std::time::Instant::now(),
1955        check: &check_id,
1956        summary: &summary,
1957    };
1958    let before = mark.map_or(0, |m| m.covered);
1959    let covered = match check(session, input, &work, true) {
1960        // A skipped or failed check covered nothing: the stop decides for itself.
1961        Verdict::Skip(score) => {
1962            append(session, &format!("#borg-live-skip {score}"));
1963            before
1964        }
1965        Verdict::Failed => before,
1966        Verdict::Ask(ids, blocks) => {
1967            if !ids.is_empty() {
1968                let prefix = if blocks { "live " } else { LIVE_NEXT };
1969                enqueue(session, &format!("{prefix}{}{}", ids.join(","), tag(&check_id)));
1970            }
1971            n
1972        }
1973    };
1974    live::write_mark(&ctx, live::Mark { covered, started_ms: started, turn: Some(turn) });
1975}
1976
1977/// One hook event, canonical already (harness `normalise`), for the drone `h` runs: what borg answers
1978/// (Claude-shaped), or None. One function per event.
1979pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
1980    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1981    match event {
1982        "prompt" => on_prompt(h, session, input),
1983        "post_tool_failure" => {
1984            capture_test(session, input, true);
1985            if h.caps().deny_capture {
1986                capture_rejection(h, session, input);
1987            }
1988            None
1989        }
1990        "end" => {
1991            on_end(h, session, input);
1992            None
1993        }
1994        "post_tool" => {
1995            capture_test(session, input, false);
1996            on_post_tool(h, session, input)
1997        }
1998        "stop" => on_stop(h, session, input),
1999        _ => None,
2000    }
2001}
2002
2003fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2004    let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
2005    append(session, prompt);
2006    if learning() {
2007        // Where the session started, once: its commits later get reasoning notes (notes.rs).
2008        crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
2009        crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
2010    }
2011    // The engineer's own words, separately from the drone's touches (the queen's criterion 3).
2012    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
2013        let _ = writeln!(f, "{prompt}");
2014    }
2015    // "borg no <reason>": the engineer's veto (veto.rs), never the session's task nor a correction.
2016    let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
2017    match &vetoed {
2018        Some(reason) => veto(h, session, input, reason),
2019        None => capture_prompt(h, session, input, prompt),
2020    }
2021    note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
2022    let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
2023    // A newer borg (update.rs): the daily release check, started detached when `update.json` is stale
2024    // (one read of it when fresh), and what the drone hears of it below.
2025    let checked = crate::update::maybe_check(learn::now_ms());
2026    // An adapter that will not wait for the answer (`"context": false`), or a harness whose model never
2027    // sees a prompt's context, gets none: what is handed over here is marked delivered and would be lost.
2028    let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
2029        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2030        let ask = queen_ask(h, session, cwd);
2031        // Once per session per state (`<session>.update`): a newer release, a member nothing syncs with,
2032        // a member on a newer borg; the routed store's mesh/ is read only when it exists.
2033        let update = crate::update::notice(checked.as_ref(), &learn::home(), &state_file(session).with_extension("update"));
2034        let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render), update].into_iter().flatten().collect();
2035        free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
2036    });
2037    // A new turn's work starts, after a live ask handed over here took the last turn's as its evidence.
2038    if harness::transcript(h.caps(), input).is_none() {
2039        let _ = std::fs::write(turn_file(session), "");
2040    }
2041    out.flatten()
2042}
2043
2044/// A shell command that ran tests leaves `fail <ms>` or `pass <ms>` in `<state>/<session>.tests`: the
2045/// regress `tests` signal (`notes/regress_signals.rs`) and `borg learn`'s `tested` credit, whether or not
2046/// the harness reports denials. A call that did not fail and shows no output (an adapter that sends
2047/// none) leaves nothing: its result is unknown, not a pass.
2048fn capture_test(session: &str, input: &Value, failed: bool) {
2049    if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
2050        return;
2051    }
2052    let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
2053    if !crate::notes::regress_signals::runs_tests(command) {
2054        return;
2055    }
2056    let output = crate::notes::regress_signals::tool_output(input);
2057    if !failed && output.trim().is_empty() {
2058        return;
2059    }
2060    if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
2061        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
2062    {
2063        let _ = writeln!(f, "{what} {}", learn::now_ms());
2064    }
2065}
2066
2067fn on_end(h: &dyn Harness, session: &str, input: &Value) {
2068    let _ = std::fs::remove_file(denied_file(session));
2069    if !learning() {
2070        return;
2071    }
2072    // The session's landed commits get a redacted reasoning note, in the background.
2073    crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
2074    crate::notes::mark(&crate::notes::state_dir(), session, "stop");
2075    // What still waits in the queue was never delivered (a resumed session's delivery supersedes it);
2076    // then the end itself, where the selection summary counts the session's deliveries (outcome.rs
2077    // `ENDED`), whatever it logged before.
2078    note_never_delivered(session, input);
2079    let end = outcome::Outcome {
2080        kind: outcome::ENDED.into(),
2081        session: session.into(),
2082        ms: learn::now_ms(),
2083        repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
2084        ..Default::default()
2085    };
2086    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &end);
2087    // Generalise this session's corrections in the background; never delay the drone. The notes job
2088    // starts it once its links are recorded, so learning sees their regret episodes.
2089    if !crate::notes::on_end(session, input)
2090        && let Ok(exe) = std::env::current_exe()
2091    {
2092        let _ = std::process::Command::new(exe)
2093            .args(["learn", "--session", session])
2094            .stdin(std::process::Stdio::null())
2095            .stdout(std::process::Stdio::null())
2096            .stderr(std::process::Stdio::null())
2097            .spawn();
2098    }
2099}
2100
2101fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2102    // The drone moved on from a denial: the next prompt is no longer the engineer's reason.
2103    let _ = std::fs::remove_file(denied_file(session));
2104    let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
2105    let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
2106    let touch = touched(tool_input, tool);
2107    append(session, &touch);
2108    // A path the drone touched says where it works; a command's free text does not.
2109    note_where(
2110        session,
2111        &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
2112    );
2113    // BORG_STOP_GATE=edits: this turn changed something, so its Stop asks the model.
2114    if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
2115        let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
2116    }
2117    let edit = edit_text(tool, tool_input);
2118    if let Some(edit) = &edit
2119        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
2120    {
2121        let _ = writeln!(f, "{edit}");
2122    }
2123    // No transcript (omp, opencode): the turn's tool calls are the work so far a live check reads.
2124    if harness::transcript(h.caps(), input).is_none()
2125        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
2126    {
2127        let _ = writeln!(f, "[{tool}] {touch}");
2128        if let Some(edit) = &edit {
2129            let _ = writeln!(f, "{edit}");
2130        }
2131    }
2132    // Live mode (live.rs): a change starts a check of the work so far, in the background. Any later
2133    // tool call starts one too while changes wait unchecked, so a change inside the debounce is
2134    // checked once it has passed, not only at the stop.
2135    if live::current() == live::Mode::Live {
2136        live_trigger(session, input, tool, prefilter::changes(tool, tool_input));
2137    }
2138    // Context only where the harness's model reads it after a tool call; otherwise it waits for the next
2139    // prompt or the stop. Never at a subagent's tool call: its context goes to the subagent, so what the
2140    // queue hands over there would be recorded delivered to a drone that never saw it.
2141    (h.caps().tool_context && !subagent(input)).then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
2142}
2143
2144/// Whether the event is a subagent's (`agent_id`, as msg.rs `deliver` reads it): the session's id, but
2145/// Claude gives the hook's context to the subagent, not to the session's drone.
2146fn subagent(input: &Value) -> bool {
2147    input.get("agent_id").and_then(Value::as_str).is_some_and(|a| !a.is_empty())
2148}
2149
2150/// Live mode at the stop: a check still running finishes first (bounded), so this stop reads what it
2151/// wrote. Then the stop holds the session's lock itself (`guard`, for the whole stop), so no check
2152/// starts beside it; when a check still holds it, the stop leaves the pending lessons to that check's
2153/// judge. The wait and the model call share the harness's budget (omp gives a stop 30 s).
2154struct StopLive {
2155    on: bool,
2156    budget: Option<std::time::Duration>,
2157    lock: live::Lock,
2158    guard: Option<live::Guard>,
2159    /// The asks of a live check that graded a 3 which no tool call delivered yet, each with its check:
2160    /// this stop asks them (its block would have). A check's asks without a 3 never block a stop: they
2161    /// stay in the queue for the next prompt or touch (`take_live`).
2162    waiting: Vec<(String, String)>,
2163    /// A live check's hand-overs in place of questions the fixed rule asks (`INSTEAD`) that no tool call
2164    /// delivered yet, each with its check: this stop asks them too, as the fixed rule's block would have.
2165    instead: Vec<(String, String)>,
2166}
2167
2168impl StopLive {
2169    fn take(h: &dyn Harness, session: &str) -> StopLive {
2170        let on = live::current() == live::Mode::Live;
2171        let budget = crate::expert_route::stop_budget(h);
2172        let lock = if on { live::wait_free(&state_file(session), live::stop_wait(budget).as_millis() as u64) } else { live::Lock::Free };
2173        let guard = if on && lock != live::Lock::Held { live::acquire(&state_file(session), learn::now_ms()) } else { None };
2174        let lock = if on && guard.is_none() { live::Lock::Held } else { lock };
2175        let Taken { asks: waiting, instead } = if on { take_live(session) } else { Taken::default() };
2176        StopLive { on, budget, lock, guard, waiting, instead }
2177    }
2178}
2179
2180fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
2181    let started = std::time::Instant::now();
2182    let started_ms = learn::now_ms();
2183    if learning() {
2184        // The drone's latest proposal, for the reasoning note of what it commits (notes.rs).
2185        crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
2186        crate::notes::mark(&crate::notes::state_dir(), session, "stop");
2187        // What this turn added to the harness's own transcript: its corrections and answers (sweep.rs).
2188        crate::sweep::turn_end(h, session, input);
2189    }
2190    let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
2191    if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
2192        return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
2193    }
2194    let (all, withheld) = loaded(session);
2195    let judged = judged(&all, withheld);
2196    // R1: the drone's answer to the last block's questions, at the first turn end after it: evidence
2197    // for the judge at the next model call (`pending`), not a label.
2198    note_answer(h, session, input);
2199    // BORG_STOP_GATE=edits: whether this turn changed anything; every Stop consumes the marker, so
2200    // the rework after a block does not count for the next turn.
2201    let changed = std::fs::remove_file(changes_file(session)).is_ok();
2202    // One round of self-questioning per turn (the harness's stop_hook_active); never loop.
2203    if second_round {
2204        return None;
2205    }
2206    stat(session, input, crate::stats::TURN_END, |_| {});
2207    let live = StopLive::take(h, session);
2208    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2209    // A lesson is asked once per session: the drone answered it at that turn end, and asking it again
2210    // at every later one is noise (and a model call spent on it). R3b: a lesson delivered early or
2211    // handed over free (`#borg-early`, `#borg-queued`) is done once the drone acted on it; one it did
2212    // not act on may still be asked (`done_in`).
2213    let proposal = full_proposal(h, input);
2214    let acted = early_acted(session, &judged, &proposal);
2215    let only = std::env::var_os("BORG_ONLY").is_some();
2216    let done = if only { std::collections::HashSet::new() } else { done_in(&state, &acted) };
2217    // What still waits is decided already: a live check's asks and its test's hand-overs in place of
2218    // questions (`stop_check` asks them unless done since) and the free lessons of a check whose line no delivery took yet (a live check that finished
2219    // during the turn's last change, an earlier stop's at a harness without prompt context; `queued`
2220    // hands them over). Out of this stop's pool, as a live check leaves them out of its own
2221    // (`waiting_in`), so one check decides each.
2222    let queue = if only { String::new() } else { std::fs::read_to_string(queued_file(session)).unwrap_or_default() };
2223    let waiting: std::collections::HashSet<&str> =
2224        if only { Default::default() } else { live.waiting.iter().chain(&live.instead).map(|(id, _)| id.as_str()).chain(waiting_in(&queue)).collect() };
2225    // Lessons of other systems (systems.rs) are neither shortlisted nor asked.
2226    let challenges: Vec<conscience::Challenge> =
2227        all.iter().filter(|c| !done.contains(c.id.as_str()) && !waiting.contains(c.id.as_str()) && !c.off_system).cloned().collect();
2228    let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
2229    let context = format!("{state}{message}");
2230    // Scopes at Stop: where the session works plus this turn's own message, not the whole history.
2231    let scope_text = format!("{}\n{message}", where_now(session));
2232    let check_id = crate::select::check_id(session, "stop", started_ms, std::process::id());
2233    // Folds the log's new tail and saves it, so the next check reads only what came after.
2234    let summary = crate::select::summary::refresh(&lessons_path());
2235    let work = Work {
2236        all: &all,
2237        judged: &judged,
2238        challenges: &challenges,
2239        done: &done,
2240        state: &state,
2241        context: &context,
2242        scope_text: &scope_text,
2243        message: &proposal,
2244        changed,
2245        judge: !live.on || live.guard.is_some(),
2246        budget: live.budget,
2247        started,
2248        check: &check_id,
2249        summary: &summary,
2250    };
2251    match stop_select(h, session, input, &work, &live) {
2252        Ok(selected) => stop_ask(h, session, input, selected, &check_id, &live.instead),
2253        Err(out) => out,
2254    }
2255}
2256
2257/// Lessons a stop asks, each with the check that routed it (this stop's, or a live check's whose ask
2258/// still waited).
2259type Asks<'a> = Vec<(&'a conscience::Challenge, String)>;
2260
2261/// The lessons this stop asks (`Asks`); `Err` with the hook's answer when it asks none (a gate skip still lets the
2262/// queen ask).
2263fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Asks<'a>, Option<Value>> {
2264    let own = |cs: Vec<&'a conscience::Challenge>| cs.into_iter().map(|c| (c, w.check.to_string())).collect();
2265    match std::env::var("BORG_ONLY") {
2266        // BORG_ONLY: eval oracle, perfect retrieval = exactly these challenge ids.
2267        Ok(ids) => Ok(own(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect())),
2268        // Word matching alone always finds *some* lesson, so it asked at every turn end; the
2269        // consequence model may abstain, and it is the mechanism the sealed eval measured.
2270        Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w, live),
2271        // R3b (conscience.rs `budget`): block only for a lesson near the best match not yet done;
2272        // then ask everything over the low bar, in-scope first.
2273        Err(_) => {
2274            let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
2275            let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
2276                .into_iter()
2277                .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
2278                .collect();
2279            append_p_of(session, &pool, &asked, w.check);
2280            Ok(own(asked))
2281        }
2282    }
2283}
2284
2285/// The consequence model's verdict at the stop, unless a live check covered the turn; a live check's
2286/// waiting asks and its test's hand-overs in place of questions (`StopLive::instead`) join it, each with
2287/// its own check (the stop's pool left them out, `on_stop`, so its own asks never hold one). A waiting
2288/// ask the stop finds done since, or not deliverable, is dropped with its `delivery` record
2289/// (`dropped_done`), never silently.
2290fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Asks<'a>, Option<Value>> {
2291    let skip = |why: &str| {
2292        append(session, &format!("#borg-skip {why}"));
2293        stat(session, input, crate::stats::GATE_SKIP, |e| e.why = why.to_string());
2294        let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
2295        Err(crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[]))
2296    };
2297    let mut waiting: Vec<(&conscience::Challenge, String)> = Vec::new();
2298    // The waiting asks, then the hand-overs in place of questions: each judged done as an ask is, since
2299    // the stop asks it as the fixed rule's block would have; a drop on the channel it waited for.
2300    let instead = live.instead.iter().map(|(id, check)| (id, check, "queued"));
2301    for (id, check, channel) in live.waiting.iter().map(|(id, check)| (id, check, "live")).chain(instead) {
2302        let why = match w.all.iter().find(|c| &c.id == id).filter(|c| !c.off_system) {
2303            Some(_) if waiting.iter().any(|(c, _)| &c.id == id) => "queued twice",
2304            Some(_) if w.done.contains(id.as_str()) => "done at the stop",
2305            Some(c) => {
2306                waiting.push((c, check.clone()));
2307                continue;
2308            }
2309            None => "not deliverable",
2310        };
2311        record_route(session, input, check, vec![id.clone()], channel, outcome::ROUTE_DROPPED_DONE, why);
2312    }
2313    // Live mode: a check that started in this turn covered every change and none came since: its
2314    // verdict stands (its waiting asks and hand-overs in place of questions are asked now), and this stop costs no model call (live.rs `covered`).
2315    let ctx = state_file(session);
2316    if live.on && live::covered(live.lock, live::count(&ctx), live::read_mark(&ctx), turn_id(session)) {
2317        return if waiting.is_empty() { skip("live_covered") } else { Ok(waiting) };
2318    }
2319    let mut asked: Asks = match check(session, input, w, false) {
2320        Verdict::Skip(_) | Verdict::Failed if !waiting.is_empty() => Vec::new(),
2321        Verdict::Skip(score) => return skip(&score),
2322        Verdict::Failed => return Err(None),
2323        Verdict::Ask(ids, _) => ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).map(|c| (c, w.check.to_string())).collect(),
2324    };
2325    // Disjoint: the stop's own asks come from its pool, which holds no waiting ask.
2326    asked.extend(waiting);
2327    Ok(asked)
2328}
2329
2330/// `#borg-graded n=<applied> <id>:<grade>:<rank>,…`: a graded check's applies in the model's order, so
2331/// the `asked` outcome can carry each asked lesson's grade and rank (`graded_of`).
2332fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
2333    let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
2334    format!("#borg-graded n={} {}", applied.len(), items.join(","))
2335}
2336
2337/// `#borg-p <id>=<p>,… @<check>`: the propensity (outcome.rs `p`) of each lesson a check chose to ask,
2338/// so every entry is above 0 and the latest entry per id is the check that asked it; the `asked`
2339/// outcome carries them (`tagged_p`, `propensity_in`). A graded check's `#borg-pf` line is the same for
2340/// what it hands over free. `check` is the check's id (select.rs `check_id`); none on an older
2341/// binary's line. Nothing when no lesson was chosen.
2342fn p_line<'a>(prefix: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) -> Option<String> {
2343    let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
2344    (!items.is_empty()).then(|| format!("{prefix} {}{}", items.join(","), tag(check.unwrap_or_default())))
2345}
2346
2347/// `p_line` for lessons `budget`/`widen` asked from `pool`: their chance of a slot under the
2348/// session's posterior (conscience.rs `propensity`). Then the pool itself, `#borg-pool
2349/// <id>=<base>[:s],… @<check>` in ranking order (`:s` a scoped lesson, base conscience.rs `pool_base`,
2350/// unrounded), so the `asked` record can carry it (`pool_in`) and `borg debug ope` can replay the draw. Both
2351/// lines carry the check that drew (its id, one draw per check), which ties the p of an asked lesson
2352/// to the pool its draw was over.
2353fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge], check: &str) {
2354    if asked.is_empty() {
2355        return;
2356    }
2357    let p = conscience::propensity(pool, session);
2358    let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
2359    let items: Vec<String> =
2360        pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
2361    let pool = format!("#borg-pool {}{}", items.join(","), tag(check));
2362    append(session, &p_line("#borg-p", chosen, Some(check)).map_or(pool.clone(), |p| format!("{p}\n{pool}")));
2363}
2364
2365/// Splits `line` into its items and its ` @<check>` tag (None without one).
2366fn tagged(line: &str) -> (&str, Option<&str>) {
2367    match line.rsplit_once(" @") {
2368        Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
2369        _ => (line, None),
2370    }
2371}
2372
2373/// The pool behind `ids` (`append_p_of`): the `#borg-pool` line of the check that wrote the latest
2374/// `#borg-p` entry of every one of them, as (ids, bases, scoped); empty when their latest entries come
2375/// from different checks, from a check without a pool, or from a line before checks were tagged (never
2376/// a guess: the p of one draw is never paired with another draw's pool).
2377fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
2378    let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
2379        let mut out = (Vec::new(), Vec::new(), Vec::new());
2380        for item in line.split(',') {
2381            let (id, rest) = item.rsplit_once('=')?;
2382            let (base, scoped) = match rest.strip_suffix(":s") {
2383                Some(b) => (b, true),
2384                None => (rest, false),
2385            };
2386            out.0.push(id.trim().to_string());
2387            out.1.push(base.trim().parse::<f64>().ok()?);
2388            out.2.push(scoped);
2389        }
2390        Some(out)
2391    };
2392    // The check of each id's latest p entry.
2393    let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
2394    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
2395        let (items, tag) = tagged(line);
2396        for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
2397            check.insert(id.trim(), tag);
2398        }
2399    }
2400    let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
2401    if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
2402        return Default::default();
2403    }
2404    state
2405        .lines()
2406        .rev()
2407        .filter_map(|l| l.strip_prefix("#borg-pool "))
2408        .map(tagged)
2409        .filter(|(_, t)| *t == Some(tag))
2410        .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
2411        .unwrap_or_default()
2412}
2413
2414/// For each of `ids`, its propensity in the state file's latest `#borg-p` entry for it, whatever check
2415/// wrote it (a line of an older binary has no tag); empty when any of them has none (unknown, never a
2416/// guess).
2417fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
2418    let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
2419    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
2420        for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
2421            if let Ok(p) = p.trim().parse::<f64>() {
2422                seen.insert(id.trim(), p);
2423            }
2424        }
2425    }
2426    ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
2427}
2428
2429/// For each of `ids`: its grade and rank in the session's latest graded check that graded it, and that
2430/// check's number of applies (the largest when the ids come from several checks); (0, 0) and 0 when none.
2431fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
2432    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2433    let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
2434    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
2435        let Some((n, items)) = line.split_once(' ') else { continue };
2436        let n: u16 = n.parse().unwrap_or(0);
2437        for it in items.split(',') {
2438            let mut p = it.rsplitn(3, ':');
2439            let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
2440            seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
2441        }
2442    }
2443    let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
2444    (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
2445}
2446
2447/// The stop asks `selected`, each with the check that routed it: one block (the queen's, when it asks
2448/// too), `#borg-asked <ids> @<check>` with this stop's `check`, and right after it `#borg-routed
2449/// <id>=<check>,…` for the lessons another check routed (a live check's waiting ask, or its hand-over
2450/// in place of a question, `instead`); per routing check its `asked` record and its `delivery` record,
2451/// a hand-over's apart: its check chose no question, so its record carries no p and no pool (unknown,
2452/// never another draw's), and its delivery says it was asked in place of a hand-over (`ASKED_INSTEAD`).
2453fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut selected: Asks, check: &str, instead: &[(String, String)]) -> Option<Value> {
2454    // Shown in an order stable within the session but different across sessions: no lesson always
2455    // sits first (position bias; memory.rs `shuffle`).
2456    crate::memory::shuffle(&mut selected, session);
2457    let lessons: Vec<&conscience::Challenge> = selected.iter().map(|(c, _)| *c).collect();
2458    let log = lessons.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
2459    let others: Vec<String> = selected.iter().filter(|(_, k)| k != check).map(|(c, k)| format!("{}={k}", c.id)).collect();
2460    // An empty `#borg-asked` still ends the turn (prefilter.rs `turn`): it carries no tag.
2461    let mut marker = if lessons.is_empty() { "#borg-asked ".to_string() } else { format!("#borg-asked {log}{}", tag(check)) };
2462    if !others.is_empty() {
2463        marker.push_str(&format!("\n{ROUTED}{}", others.join(",")));
2464    }
2465    append(session, &marker);
2466    if !lessons.is_empty()
2467        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
2468    {
2469        // The blocked message is the work before the questions (R1 `pending`).
2470        let _ = writeln!(f, "{}\n{marker}", one_line(&full_proposal(h, input), EVIDENCE_CHARS));
2471    }
2472    // L4 (queen.rs): a rare, budgeted question to the engineer when the conscience had nothing
2473    // or its lessons contradict each other (its block then carries the lessons too).
2474    let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
2475    let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &lessons);
2476    if lessons.is_empty() {
2477        return queen;
2478    }
2479    if learning() {
2480        // Outcome tracking (outcome.rs): `borg learn` later sees whether a correction followed. One
2481        // `asked` record per routing check, as `ask_live` writes them: each names its check and carries
2482        // that check's p and pool, so a verdict and the settle land on the check that decided the lesson.
2483        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2484        let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
2485        let ms = learn::now_ms();
2486        // The block's disagreements, on each record holding one of their sides (outcome.rs `contradicted`
2487        // reads them across the session's records).
2488        let disagree = conscience::disagreement_ids(&lessons);
2489        let (handed, routed): (Vec<(&conscience::Challenge, &str)>, Vec<_>) =
2490            selected.iter().map(|(c, k)| (*c, k.as_str())).partition(|(c, k)| instead.iter().any(|(id, at)| *id == c.id && at == k));
2491        let groups = by_check(&routed).into_iter().map(|g| (g, false)).chain(by_check(&handed).into_iter().map(|g| (g, true)));
2492        for ((k, members), handed) in groups {
2493            let ids: Vec<String> = members.iter().map(|i| (*i).to_string()).collect();
2494            let (grades, ranks, applied) = graded_of(session, &ids);
2495            let p = match handed {
2496                true => Vec::new(),
2497                false if k.is_empty() => propensity_in(&state, &ids),
2498                false => tagged_p(&state, "#borg-p ", k, &members),
2499            };
2500            let (pool, base, scoped) = if handed { Default::default() } else { pool_in(&state, &ids) };
2501            let asked = outcome::Outcome {
2502                kind: outcome::ASKED.into(),
2503                session: session.into(),
2504                lessons: ids.clone(),
2505                ms,
2506                repo: repo_of(cwd),
2507                groups: disagree.iter().filter(|g| g.iter().any(|x| ids.contains(x))).cloned().collect(),
2508                channel: "stop".into(),
2509                grades,
2510                ranks,
2511                applied,
2512                p,
2513                pool,
2514                base,
2515                scoped,
2516                check: k.into(),
2517                ..Default::default()
2518            };
2519            let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
2520            let reason = match (handed, k == check) {
2521                (true, _) => ASKED_INSTEAD,
2522                (false, true) => "",
2523                (false, false) => "at the stop",
2524            };
2525            record_route(session, input, k, ids, "stop", outcome::ROUTE_ASKED, reason);
2526        }
2527    }
2528    let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&lessons)})));
2529    let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
2530    log_cost(session, &format!("block questions={} chars={chars}", lessons.len()));
2531    stat(session, input, crate::stats::BLOCK, |e| {
2532        e.n = lessons.len() as u64;
2533        e.lessons = lessons.iter().map(|c| c.id.clone()).collect();
2534        e.channel = "stop".into();
2535    });
2536    out
2537}
2538
2539/// `borg hook <harness> <event>`, the event on stdin: routes the repo to its collective (an off repo
2540/// leaves no trace), records presence, then answers through steering, the experts and [`dispatch`],
2541/// with the messages from other sessions (msg.rs).
2542/// An unknown harness, like any failure here, lets the drone continue untouched.
2543pub fn main(name: &str, event: &str) {
2544    let Some(h) = harness::by_name(name) else { return };
2545    let mut raw = String::new();
2546    let _ = std::io::stdin().read_to_string(&mut raw);
2547    let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
2548    // A pid only from a harness that sends its own: presence reads a gone one as an ended drone.
2549    if !h.caps().sends_pid
2550        && let Some(m) = input.as_object_mut()
2551    {
2552        m.shift_remove("pid");
2553    }
2554    // Which mind is this repo part of? Decided before anything is read or written: an off repo
2555    // (or a broken config) leaves no trace at all.
2556    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
2557    let cfg = collective::load();
2558    let route = match &cfg {
2559        Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
2560        Err(_) => collective::Route::Off,
2561    };
2562    let collective::Route::Collective { home, name, .. } = route else { return };
2563    // The delivery mode (live.rs): BORG_MODE, else the collective's `mode`, else live. Exported as the
2564    // mode this drone runs in, which the statistics record.
2565    let mode = live::of(cfg.as_ref().ok().and_then(Option::as_ref), &name).mode;
2566    // SAFETY: single-threaded at this point; everything below (and the background `borg learn`
2567    // or `borg check` it spawns) inherits the collective's own store, scratch state and mode.
2568    unsafe {
2569        std::env::set_var("BORG_HOME", &home);
2570        std::env::set_var("BORG_MODE", mode.name());
2571        // borg's own model calls go through the drone's harness (runner.rs); `borg check` reads it too.
2572        std::env::set_var("BORG_HARNESS", h.name());
2573        if std::env::var_os("BORG_STATE").is_none() {
2574            std::env::set_var("BORG_STATE", home.join("state"));
2575        }
2576    }
2577    // Who is working where (presence.rs, for `borg top`): one small atomic write per hook.
2578    let now = learn::now_ms();
2579    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
2580    // The turn of a session that listens for wakes (listen.rs): one stat of its opt-in for any other, at
2581    // a prompt, a tool call's start or end, a halted turn and the end. With steering's stat below, an idle
2582    // tool call costs two failed stats: the opt-in is per session, the admissions are per store.
2583    crate::listen::touch(&home, event, session, now);
2584    // A session's start (a hook of an older install) and a halted turn read and write nothing more.
2585    if matches!(event, "start" | "halted") {
2586        return;
2587    }
2588    // Not at pre_tool (every tool call): there only steering reads, once someone steered (steer.rs
2589    // `active`, the second stat).
2590    let me = match event {
2591        "pre_tool" => {
2592            if !crate::steer::active(&home) {
2593                return;
2594            }
2595            crate::presence::read(&home, session)
2596        }
2597        _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
2598    };
2599    // Steering (steer.rs) wraps the answer: a pause refuses tools and lets the stop wait.
2600    // At a stop, the routed collective's experts join the conscience's block (expert_route.rs).
2601    let started = me.map(|p| p.started);
2602    // A listening session's stop holds its wakes off until its answer is out (listen.rs).
2603    let stopping = crate::listen::stopping(&home, event, session, now);
2604    let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
2605    // A listening session's stop starts its waiter, or asks for it (listen.rs).
2606    let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
2607    // An adapter that asked learns its wait budgets from the answer (harness.rs `Budgets`).
2608    let out = harness::with_budgets(h, &input, out);
2609    // Messages from other sessions (msg.rs) join a prompt's or tool call's context, each once.
2610    if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
2611        println!("{}", for_harness(h, out));
2612    }
2613}
2614
2615/// The answer as `h` reads it: `"shown_ids"` (which messages it showed) is for an adapter, which drops a
2616/// held wake (listen.rs) once every message it names was shown; a harness that parses the answer itself
2617/// (Claude Code, Codex) gets only the keys it defines.
2618fn for_harness(h: &dyn Harness, mut out: Value) -> Value {
2619    if matches!(h.wiring(), harness::Wiring::CommandHooks { .. })
2620        && let Some(o) = out.as_object_mut()
2621    {
2622        o.shift_remove("shown_ids");
2623    }
2624    out
2625}
2626
2627#[cfg(test)]
2628mod tests {
2629    use super::*;
2630
2631    #[test]
2632    fn every_file_of_an_edit_survives_the_cut_of_its_text() {
2633        let long = "x".repeat(700);
2634        let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
2635        let text = edit_text("Edit", &input).unwrap();
2636        assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
2637        assert_eq!(text.chars().count(), 600);
2638        let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
2639        assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
2640        assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
2641    }
2642
2643    #[test]
2644    fn propensity_in_takes_the_latest_entry_and_nothing_when_one_is_missing() {
2645        let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
2646        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2647        assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
2648        assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
2649        assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
2650        assert!(propensity_in("", &ids(&["A"])).is_empty());
2651    }
2652
2653    #[test]
2654    fn pool_in_takes_the_pool_of_the_check_that_logged_each_p() {
2655        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";
2656        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2657        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");
2658        assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
2659        assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
2660        assert_eq!(pool_in("", &ids(&["A"])), Default::default());
2661        assert_eq!(pool_in(state, &[]), Default::default());
2662        // A graded check's p has no pool: a later pool that happens to hold the lesson is not its.
2663        let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
2664        assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
2665        assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
2666        // A state file from before checks were tagged: no pool.
2667        assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
2668    }
2669
2670    #[test]
2671    fn a_queue_line_names_its_route_its_ids_and_its_check() {
2672        let c = "0123456789ab";
2673        assert_eq!(queue_line(&format!("A,B @{c}")), (Queued::Free, vec!["A", "B"], c));
2674        assert_eq!(queue_line(&format!("live A @{c}")), (Queued::Ask, vec!["A"], c));
2675        assert_eq!(queue_line(&format!("placebo X,Y @{c}")), (Queued::Placebo, vec!["X", "Y"], c));
2676        assert_eq!(queue_line(&format!("instead G0,G1 @{c}")), (Queued::Instead, vec!["G0", "G1"], c));
2677        assert_eq!(queue_line(&format!("instead-next G2 @{c}")), (Queued::Instead, vec!["G2"], c));
2678        // An older binary's lines: no check.
2679        assert_eq!(queue_line("A,B"), (Queued::Free, vec!["A", "B"], ""));
2680        assert_eq!(queue_line("live A"), (Queued::Ask, vec!["A"], ""));
2681        assert_eq!(queue_line(&format!("live-next A,B @{c}")), (Queued::Ask, vec!["A", "B"], c));
2682        assert_eq!(listed(&format!("#borg-asked A,B @{c}\n#borg-asked C\n"), "#borg-asked "), ["A", "B", "C"].into_iter().collect());
2683    }
2684
2685    #[test]
2686    fn a_tagged_propensity_is_its_checks_never_another_checks() {
2687        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";
2688        assert_eq!(tagged_p(state, "#borg-pf ", "c1", &["A", "B"]), [1.0, 0.5]);
2689        assert_eq!(tagged_p(state, "#borg-pf ", "c2", &["A"]), [0.25]);
2690        assert!(tagged_p(state, "#borg-pf ", "c2", &["A", "B"]).is_empty(), "B has none at c2: no guess");
2691        assert_eq!(tagged_p(state, "#borg-p ", "c1", &["A"]), [0.75], "#borg-p and #borg-pf are separate lines");
2692        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"));
2693        assert_eq!(p_line("#borg-p", [], Some("c1")), None);
2694    }
2695
2696    #[test]
2697    fn unjudged_says_why_each_delivered_lesson_has_no_verdict() {
2698        let c = "0123456789ab";
2699        let state = format!(
2700            "#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\
2701             #borg-queued E @{c}\n#borg-judged B=pending\n#borg-asked D\n"
2702        );
2703        let why = unjudged(&state);
2704        assert_eq!(why["A"], outcome::UNJUDGED_TRIES, "two unclear tries");
2705        assert_eq!(why["B"], outcome::UNJUDGED_SLOTS, "one try is not all of them; the last call left it out");
2706        assert_eq!(why["C"], outcome::UNJUDGED_NO_CALL);
2707        assert_eq!(why["D"], outcome::UNJUDGED_NO_CALL, "delivered again after its failed call");
2708        assert_eq!(why["E"], outcome::UNJUDGED_NO_CALL);
2709    }
2710
2711    #[test]
2712    fn the_declaration_coin_is_fixed_per_check_and_lesson_and_fair() {
2713        assert_eq!(stripped("0123456789ab", "L1"), stripped("0123456789ab", "L1"));
2714        let heads = (0..1000).filter(|i| stripped("0123456789ab", &format!("L{i}"))).count();
2715        assert!((400..600).contains(&heads), "{heads} of 1000");
2716        let checks = (0..1000).filter(|i| stripped(&format!("{i:012x}"), "L1")).count();
2717        assert!((400..600).contains(&checks), "{checks} of 1000");
2718    }
2719
2720    /// Judge v2's evidence: a free delivery and a placebo get the work so far before them and the work
2721    /// since after, as a question does; each lesson carries the check that routed it (a stop's block may
2722    /// carry a live check's waiting ask); a stripped declaration is gone from the shared evidence.
2723    #[test]
2724    fn pending_gives_every_route_the_same_evidence_and_its_own_check() {
2725        let session = format!("pending-v2-{}", std::process::id());
2726        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2727        let all = [c("A"), c("B"), c("C"), c("D"), c("E")];
2728        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\
2729                     edit one\n#borg-work the work so far\n#borg-queued C @0000000000c3\n#borg-placebo D @0000000000c4\nedit two\n\
2730                     #borg-work later work\n#borg-asked E @0000000000c5\n";
2731        let _ = std::fs::write(edits_file(&session), edits);
2732        let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
2733        let j = pending(&session, &state, &all, "the final message", false, "00000000cafe");
2734        let got: Vec<(&str, &str, &str)> = j.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel, p.check.as_str())).collect();
2735        assert_eq!(
2736            got,
2737            [
2738                ("A", "stop", "0000000000c1"),
2739                ("B", "stop", "0000000000c2"),
2740                ("C", "queued", "0000000000c3"),
2741                ("D", outcome::PLACEBO, "0000000000c4"),
2742                ("E", "stop", "0000000000c5")
2743            ]
2744        );
2745        let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
2746        assert_eq!(
2747            (p("A").before.as_str(), p("C").before.as_str(), p("D").before.as_str()),
2748            ("Plan: the blocked message", "the work so far", "the work so far")
2749        );
2750        assert_eq!(p("C").after, "work since: edit two\nthis turn: the final message");
2751        assert_eq!(p("D").after, p("C").after, "a placebo is judged as a free delivery");
2752        assert!(p("A").after.starts_with("work since: ") && p("A").after.contains("edit one | edit two"), "{}", p("A").after);
2753        assert_eq!(p("A").after, p("B").after, "delivered together, one shared evidence");
2754        for (id, word) in [("A", "applies"), ("B", "skip")] {
2755            let x = p(id);
2756            assert_eq!(x.stripped, stripped(&x.check, id));
2757            match x.stripped {
2758                true => assert!(x.declared.is_none() && !x.after.contains(&format!("{id}: {word}")), "{x:?}"),
2759                false => assert!(x.declared.is_some() && x.after.contains(&format!("{id}: {word}")), "{x:?}"),
2760            }
2761        }
2762        assert!(!p("C").stripped && !p("D").stripped, "only questions are stripped");
2763        // A live check judges free deliveries and placebos now, questions once answered.
2764        let live = pending(&session, &state, &all, "", true, "00000000cafe");
2765        let ids: Vec<&str> = live.pending.iter().map(|p| p.lesson.id.as_str()).collect();
2766        assert_eq!(ids, ["A", "B", "C", "D"], "E is not answered yet");
2767        assert!(j.question && j.crowded.is_empty());
2768        let _ = std::fs::remove_file(edits_file(&session));
2769    }
2770
2771    /// A lesson handed over free at check A, then asked live at check B: until the drone answers, a live
2772    /// check judges neither delivery (the free one would earn what the question did), and once it has,
2773    /// the lesson is judged once, on the question.
2774    #[test]
2775    fn a_later_question_not_yet_answered_keeps_the_earlier_free_delivery_from_its_judge() {
2776        let session = format!("pending-later-{}", std::process::id());
2777        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2778        let all = [c("L"), c("M")];
2779        let (a, b) = ("00000000000a", "00000000000b");
2780        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");
2781        let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
2782        let _ = std::fs::write(edits_file(&session), &asked);
2783        let live = pending(&session, &state_of(&asked), &all, "", true, "00000000cafe");
2784        let got: Vec<(&str, &str)> = live.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel)).collect();
2785        assert_eq!(got, [("M", "queued")], "L waits for its answer; M, handed over alone, is judged");
2786        assert!(!live.question && live.crowded.is_empty(), "nothing held back takes a slot or a try");
2787        let answered = format!("{asked}#borg-answer L: applies\n");
2788        let _ = std::fs::write(edits_file(&session), &answered);
2789        for mid_turn in [true, false] {
2790            let j = pending(&session, &state_of(&answered), &all, "", mid_turn, "00000000cafe");
2791            let got: Vec<(&str, &str, &str)> = j.pending.iter().map(|p| (p.lesson.id.as_str(), p.channel, p.check.as_str())).collect();
2792            assert_eq!(got, [("M", "queued", a), ("L", "live", b)], "mid-turn {mid_turn}");
2793        }
2794        let _ = std::fs::remove_file(edits_file(&session));
2795    }
2796
2797    /// Judge timing: a live check judges a free delivery or a placebo only once the drone worked after
2798    /// it; a stop's judge call judges it whatever (a stop that makes none leaves it waiting, `deferred`).
2799    /// A question is judged once answered, work or not.
2800    #[test]
2801    fn a_free_delivery_or_placebo_waits_for_work_after_it_or_a_stops_judge() {
2802        let session = format!("pending-work-{}", std::process::id());
2803        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2804        let all = [c("A"), c("C"), c("D")];
2805        let state_of = |edits: &str| edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect::<String>();
2806        let ids = |j: &Judging| j.pending.iter().map(|p| p.lesson.id.clone()).collect::<Vec<_>>();
2807        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";
2808        let _ = std::fs::write(edits_file(&session), fresh);
2809        let live = pending(&session, &state_of(fresh), &all, "", true, "00000000cafe");
2810        assert_eq!(ids(&live), ["A"], "nothing done since C and D were delivered");
2811        assert!(live.crowded.is_empty());
2812        assert_eq!(ids(&pending(&session, &state_of(fresh), &all, "", false, "00000000cafe")), ["A", "C", "D"], "the stop judges them");
2813        let worked = format!("{fresh}edit one\n");
2814        let _ = std::fs::write(edits_file(&session), &worked);
2815        assert_eq!(ids(&pending(&session, &state_of(&worked), &all, "", true, "00000000cafe")), ["A", "C", "D"]);
2816        // Its own work line and a later delivery's markers are no work after it.
2817        let marked = format!("{fresh}#borg-work later\n#borg-early A @0000000000c5\n");
2818        let _ = std::fs::write(edits_file(&session), &marked);
2819        assert!(ids(&pending(&session, &state_of(&marked), &all, "", true, "00000000cafe")).is_empty(), "C, D and A's early delivery wait");
2820        let _ = std::fs::remove_file(edits_file(&session));
2821    }
2822
2823    /// The judge's cost line names the ids the answer returned and those that matched no pending lesson,
2824    /// after the fields `borg status` sums (prefilter.rs `tally`).
2825    #[test]
2826    fn the_judge_cost_line_names_the_returned_and_unmatched_ids() {
2827        fn p(lesson: &conscience::Challenge) -> predict::Pending<'_> {
2828            predict::Pending { lesson, before: String::new(), after: String::new(), declared: None, check: String::new(), channel: "stop", stripped: false }
2829        }
2830        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2831        let (a, b) = (c("La"), c("Lb"));
2832        let pend = [p(&a), p(&b)];
2833        let returned = ["La".to_string(), "Lz".to_string()];
2834        assert_eq!(judge_line(3, &pend, 1, 900, &returned), "judge call=3 pending=2 resolved=1 chars=900 returned=La,Lz unmatched=Lz");
2835        assert_eq!(judge_line(4, &pend, 0, 10, &[]), "judge call=4 pending=2 resolved=0 chars=10 returned= unmatched=");
2836    }
2837
2838    /// A live question answered at the stop: the stop's own message is the answer, so a stripped
2839    /// question's declaration must leave "this turn" too; and when that stop blocks with a new question,
2840    /// its blocked message (that question's work before, and work since for the live one) as well. A
2841    /// question that keeps its declaration keeps it.
2842    #[test]
2843    fn a_stripped_declaration_leaves_every_evidence_of_the_call() {
2844        let session = format!("pending-strip-{}", std::process::id());
2845        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2846        let all = [c("A"), c("F"), c("G")];
2847        let check = |lesson: &str, strip: bool| (0u64..).map(|i| format!("{i:012x}")).find(|k| stripped(k, lesson) == strip).unwrap();
2848        let (ca, cf, cg) = (check("A", false), check("F", true), check("G", false));
2849        let answer = "Fixed the arc. F: applies";
2850        let edits = format!(
2851            "Plan: the blocked message\n#borg-asked A @{ca}\n#borg-answer A: applies\nedit one\n#borg-work the work so far\n\
2852             #borg-asked F @{cf}\n#borg-live F ms=1\n#borg-answer {answer}\n{answer}\n#borg-asked G @{cg}\n"
2853        );
2854        let _ = std::fs::write(edits_file(&session), &edits);
2855        let state: String = edits.lines().filter(|l| DELIVERED.iter().any(|m| l.starts_with(m))).map(|l| format!("{l}\n")).collect();
2856        let j = pending(&session, &state, &all, answer, false, "00000000cafe");
2857        let p = |id: &str| j.pending.iter().find(|p| p.lesson.id == id).unwrap();
2858        assert!(p("F").stripped && p("F").declared.is_none(), "{:?}", p("F"));
2859        for x in &j.pending {
2860            assert!(!format!("{} {}", x.before, x.after).to_lowercase().contains("f: applies"), "{x:?}");
2861        }
2862        assert!(p("F").after.contains("this turn: Fixed the arc."), "the rest of the message stays: {}", p("F").after);
2863        assert!(!p("A").stripped && p("A").declared == Some(true) && p("A").after.contains("A: applies"), "{:?}", p("A"));
2864        // The work a call shows loses it too.
2865        assert_eq!(without_declarations(&format!("work\n{answer}"), &j.pending), "work\nFixed the arc. ");
2866        assert_eq!(without_declarations("A: applies", &j.pending), "A: applies");
2867        let _ = std::fs::remove_file(edits_file(&session));
2868    }
2869
2870    /// Track R's merged ask: a live check drew A and B from one pool, a stop check drew A again from
2871    /// another, and the stop's block holds the waiting B beside its A. The block's `asked` records are
2872    /// one per routing check (`stop_ask`), each with its own draw's pool; ids of two draws together
2873    /// carry none (counted as no_pool by `borg debug ope`), never one draw's pool with both p.
2874    #[test]
2875    fn a_merged_ask_of_two_draws_carries_no_pool() {
2876        let session = format!("pool-merge-{}", std::process::id());
2877        let _ = std::fs::remove_file(state_file(&session));
2878        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2879        let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
2880        let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
2881        append_p_of(&session, &live, &[&a, &b], "c1");
2882        let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
2883        append_p_of(&session, &stop, &[&a], "c2");
2884        let state = std::fs::read_to_string(state_file(&session)).unwrap_or_default();
2885        let merged = vec!["A".to_string(), "B".to_string()];
2886        assert_eq!(propensity_in(&state, &merged).len(), 2, "each id has its latest p");
2887        assert_eq!(pool_in(&state, &merged), Default::default());
2888        assert_eq!(pool_in(&state, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
2889        assert_eq!(pool_in(&state, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
2890        assert_eq!(tagged_p(&state, "#borg-p ", "c1", &["B"]).len(), 1, "B's record carries its own check's p");
2891        let _ = std::fs::remove_file(state_file(&session));
2892    }
2893
2894    /// An early delivery's relative scores (`early_rel`): each lesson's match as a share of the best
2895    /// on-topic match, which a lesson done already may hold; a standing lesson's on the same scale (0
2896    /// without a word in common); nothing to scale by without a plain lesson on the topic.
2897    #[test]
2898    fn an_early_lessons_relative_score_is_its_share_of_the_best_on_topic_match() {
2899        let c = |id: &str, q: &str| conscience::Challenge { id: id.into(), question: q.into(), scope: vec!["Orion".into()], ..Default::default() };
2900        let standing = conscience::Challenge { source: conscience::CONSTRAINT.into(), ..c("S", "harpsichord tuning") };
2901        let all = vec![c("A", "orion budget place reset arc drain"), c("B", "orion budget place"), c("C", "telescope glaciers"), standing];
2902        let work = "drain the orion budget place, never with a reset arc";
2903        let topics = vec!["Orion".to_string()];
2904        let ranked = conscience::rank(&all, work);
2905        let score = |id: &str| ranked.iter().find(|(_, x)| x.id == id).map_or(0.0, |(s, _)| *s);
2906        assert!(score("A") > score("B") && score("B") > 0.0, "{ranked:?}");
2907        // A, the best match, was delivered before (done): B's share is of A's score all the same.
2908        let rel = early_rel(&all, work, &topics, &[&all[1], &all[3]]);
2909        assert_eq!(rel, [outcome::round_p(score("B") / score("A")), 0.0]);
2910        assert!(rel[0] < 1.0);
2911        assert_eq!(early_rel(&all, work, &topics, &[&all[0]]), [1.0]);
2912        // Only a standing lesson on a topic no plain lesson matches: nothing to scale by.
2913        assert!(early_rel(&all, work, &["Beacon".to_string()], &[&all[3]]).is_empty());
2914    }
2915
2916    /// A store for `decide`: its lessons file (empty) and outcomes beside it, and a saved selection summary
2917    /// whose fit is sure that handing over beats asking, with a gate open wide: a learned live check
2918    /// deviates on evidence, and its record moves the gate's ledger.
2919    fn decide_store(name: &str) -> std::path::PathBuf {
2920        decide_store_with(name, true)
2921    }
2922
2923    /// `decide_store`, its fit `sure` that handing over beats asking, or (not `sure`) with no evidence
2924    /// either way, asking and handing over worth the same: a learned live check then keeps the fixed rule
2925    /// on evidence and runs the question-count test, which the open gate admits.
2926    fn decide_store_with(name: &str, sure: bool) -> std::path::PathBuf {
2927        use crate::select::{policy, summary};
2928        let d = std::env::temp_dir().join(format!("borg-decide-{name}-{}", std::process::id()));
2929        let _ = std::fs::remove_dir_all(&d);
2930        std::fs::create_dir_all(&d).unwrap();
2931        let lessons = d.join("lessons.jsonl");
2932        std::fs::write(&lessons, "").unwrap();
2933        std::fs::write(outcome::path_beside(&lessons), "").unwrap();
2934        let at = |name: &str| policy::NAMES.iter().position(|n| *n == name).unwrap();
2935        let mut beta = vec![0.0; policy::P];
2936        beta[at("intercept")] = -0.5;
2937        if sure {
2938            beta[at("ask")] = -3.0;
2939            beta[at("not")] = -1.0;
2940        }
2941        let mut chol = vec![0.0; policy::P * policy::P];
2942        for k in 0..policy::P {
2943            chol[k * policy::P + k] = 0.05;
2944        }
2945        let model = policy::Model { beta, chol, sd: vec![1.0; policy::P], spread: [0.1; 3], ..policy::Model::default() };
2946        let rates = policy::Rates { free_stop: 1.0, free_live: 1.0, later: 0.0, wait: 0.0, ask_live: 1.0 };
2947        let fit = policy::Fit { key: "9-abcdef01".into(), model: Some(model), rates, ..policy::Fit::default() };
2948        let gate = summary::Gate { gain: vec![0.0; policy::DRAWS], allowance: 5.0, ..summary::Gate::default() };
2949        summary::save(&lessons, &summary::Summary { fit, gate, ..summary::Summary::default() }).unwrap();
2950        lessons
2951    }
2952
2953    /// `record` for `decide` in a test: the hook's own graded record (`graded_outcome`), beside `lessons`;
2954    /// whether it landed, as `record_graded` answers.
2955    fn record_beside(lessons: &std::path::Path, applied: &[(&conscience::Challenge, u8)], check: &str, r: &crate::select::Routed, basis: &Basis) -> bool {
2956        let order: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
2957        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 };
2958        let o = graded_outcome("decide-s", "r", check, true, applied, Some((r, basis)), basis.summary, &call);
2959        outcome::append(&outcome::path_beside(lessons), &o).is_ok()
2960    }
2961
2962    /// The question-count test's k and s reach the graded record from the decision's log, the s = 0
2963    /// draw included; a check outside the test and a failed one log neither.
2964    #[test]
2965    fn the_graded_record_carries_the_question_count_tests_draw() {
2966        use crate::select::{Log, Routed, policy, summary};
2967        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
2968        let ls = [c("A"), c("B")];
2969        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([2, 1]).collect();
2970        let order: Vec<&conscience::Challenge> = ls.iter().collect();
2971        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 };
2972        let s = summary::Summary::default();
2973        let basis = Basis { summary: &s, had: &[], why: "" };
2974        let routed = |source: &'static str, xk: Option<u32>, xs: Option<u32>| Routed {
2975            ask: Vec::new(),
2976            free: vec![&ls[0], &ls[1]],
2977            placebo: Vec::new(),
2978            p: vec![0.0, 0.0],
2979            pf: vec![1.0, 1.0],
2980            log: Log { policy: "learned", source, xk, xs, ..Log::default() },
2981        };
2982        let record = |r: Option<&Routed>| graded_outcome("s", "r", "0123456789ab", true, &applied, r.map(|r| (r, &basis)), &s, &call);
2983        let drawn = record(Some(&routed(policy::EXPERIMENT_CONTROL, Some(2), Some(0))));
2984        assert_eq!((drawn.source.as_str(), drawn.xk, drawn.xs), (policy::EXPERIMENT_CONTROL, Some(2), Some(0)));
2985        let outside = record(Some(&routed(policy::EVIDENCE, None, None)));
2986        assert_eq!((outside.source.as_str(), outside.xk, outside.xs), (policy::EVIDENCE, None, None));
2987        let failed = record(None);
2988        assert_eq!((failed.failed, failed.xk, failed.xs), (true, None, None));
2989        // The pool budget the check filled to, whatever it decided.
2990        assert_eq!((drawn.budget, failed.budget), (16_000, 16_000));
2991    }
2992
2993    fn graded_records(lessons: &std::path::Path) -> Vec<outcome::Outcome> {
2994        outcome::read(&outcome::path_beside(lessons)).into_iter().filter(|o| o.kind == outcome::GRADED).collect()
2995    }
2996
2997    /// A check decides on the summary it loads under the store's decision lock, never on the one it read
2998    /// at its start: B started before A decided, yet B's decision reads A's ledger entry and says so.
2999    #[test]
3000    fn a_check_decides_on_the_summary_under_the_lock_with_every_decision_before_it() {
3001        use crate::select::{Policy, policy, summary};
3002        let lessons = decide_store("fresh");
3003        // What both checks read at their start, before either decided.
3004        let stale = summary::refresh(&lessons);
3005        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3006        let ls = [c("A"), c("B"), c("C"), c("D")];
3007        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3008        let mut gates = Vec::new();
3009        // One after the other, with the hook's own wait: a process spawned by another test while the
3010        // first held the lock keeps a copy of its descriptor, so the flock, until it execs.
3011        for check in ["0000000000a1", "0000000000b2"] {
3012            let at = Decide { session: "decide-fresh", check, channel: "live", lessons: &lessons, tokens: 0, recorded: true };
3013            let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |r, b| record_beside(&lessons, &applied, check, r, b));
3014            gates.push(r.log.gate);
3015        }
3016        let g = graded_records(&lessons);
3017        assert_eq!(g.len(), 2);
3018        assert!(g.iter().all(|o| o.policy == "learned" && o.reason.is_empty()), "{g:?}");
3019        assert_ne!(g[0].free, Vec::<String>::new(), "the first deviates: everything free at a live check, {:?}", g[0]);
3020        assert_eq!(g[0].seen, stale.key(), "nothing was decided before the first");
3021        // The second's summary is the start's with the first's record folded in, its ledger entry too.
3022        let log = std::fs::read_to_string(outcome::path_beside(&lessons)).unwrap();
3023        let first = log.lines().next().unwrap();
3024        let after = summary::fold(&format!("{first}\n"), stale.clone());
3025        assert_eq!(after.gate.checks, stale.gate.checks + 1, "the first's decision is in the gate's ledger");
3026        assert_eq!(g[1].seen, after.key(), "decided on the summary loaded under the lock, not the one read at its start");
3027        assert_eq!(gates[1], policy::remaining(&after.gate).max(0.0), "against the gate with the first's entry");
3028        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3029    }
3030
3031    /// Two checks deciding at once take turns: while one holds the store's decision lock (deciding and
3032    /// recording), the other, past its wait, takes the fixed rule, which reads no gate, and says why;
3033    /// never both against the same gate. So for a deviation on evidence and for the question-count test
3034    /// alike (its draw logged on the learned record only). A lock that cannot be taken at all is the
3035    /// fixed rule too.
3036    #[test]
3037    fn two_checks_deciding_at_once_never_both_take_the_gate() {
3038        use crate::select::{Policy, policy, summary};
3039        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3040        let ls = [c("A"), c("B"), c("C"), c("D")];
3041        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3042        let mut stores = Vec::new();
3043        for (name, sure, sources) in [("race", true, &[policy::EVIDENCE][..]), ("race-test", false, &[policy::EXPERIMENT, policy::EXPERIMENT_CONTROL][..])] {
3044            let lessons = decide_store_with(name, sure);
3045            let stale = summary::refresh(&lessons);
3046            // Each records only once both have decided: the first to take the lock holds it until the
3047            // other decided without it.
3048            let both = std::sync::Barrier::new(2);
3049            std::thread::scope(|s| {
3050                for check in ["0000000000c1", "0000000000c2"] {
3051                    let (applied, lessons, stale, both) = (&applied, &lessons, &stale, &both);
3052                    s.spawn(move || {
3053                        let at = Decide { session: "decide-race", check, channel: "live", lessons, tokens: 0, recorded: true };
3054                        decide(Policy::Learned, applied, &at, stale, std::time::Duration::ZERO, |r, b| {
3055                            both.wait();
3056                            record_beside(lessons, applied, check, r, b)
3057                        });
3058                    });
3059                }
3060            });
3061            let mut got: Vec<(String, String, String, bool)> =
3062                graded_records(&lessons).into_iter().map(|o| (o.policy, o.source, o.reason, o.xk.is_some())).collect();
3063            got.sort();
3064            assert_eq!(got.len(), 2, "{name}: {got:?}");
3065            assert_eq!(got[0], ("fixed".to_string(), "fixed".to_string(), outcome::DECIDED_BUSY.to_string(), false), "{name}: the other took the fixed rule");
3066            let (policy, source, reason, drawn) = &got[1];
3067            assert!(policy == "learned" && sources.contains(&source.as_str()) && reason.is_empty(), "{name}: one decided under the lock: {got:?}");
3068            assert_eq!(*drawn, !sure, "{name}: the test's draw on its record alone");
3069            assert!(policy::remaining(&summary::refresh(&lessons).gate) >= -1e-9, "{name}: the gate never overspent");
3070            stores.push(lessons);
3071        }
3072        // No lock to be had (the store's directory is gone): the fixed rule, and why.
3073        let stale = summary::refresh(&stores[0]);
3074        let gone = std::path::Path::new("/nonexistent/borg-decide/lessons.jsonl");
3075        let at = Decide { session: "decide-race", check: "0000000000c3", channel: "live", lessons: gone, tokens: 0, recorded: true };
3076        let mut why = "";
3077        let r = decide(Policy::Learned, &applied, &at, &stale, DECIDE_WAIT, |_, b| {
3078            why = b.why;
3079            true
3080        });
3081        assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_NO_LOCK));
3082        for lessons in stores {
3083            let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3084        }
3085    }
3086
3087    /// The fixed rule and the evaluation switches read no gate: while another check holds the store's
3088    /// decision lock, each decides by itself at once, on the summary its check read at its start, and its
3089    /// record gives no reason; only a learned check waits for the lock or falls back.
3090    #[test]
3091    fn a_policy_that_reads_no_gate_decides_by_itself_whoever_holds_the_lock() {
3092        use crate::select::{Ctx, Policy, graded_under, summary};
3093        let lessons = decide_store("nolock");
3094        let stale = summary::refresh(&lessons);
3095        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3096        let ls = [c("A"), c("B"), c("C"), c("D")];
3097        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 3, 2]).collect();
3098        let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3099        let held = lock_within(&decide_lock(&lessons), std::time::Duration::ZERO).unwrap();
3100        let check = "0000000000e1";
3101        let at = Decide { session: "decide-nolock", check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
3102        let ctx = Ctx {
3103            session: at.session,
3104            check,
3105            channel: at.channel,
3106            lessons: &lessons,
3107            summary: &stale,
3108            tokens: 0,
3109            had: &[],
3110            alpha: crate::select::alpha(&lessons),
3111        };
3112        let fixed = ids(&graded_under(Policy::Fixed, &applied, &ctx).ask);
3113        for policy in [Policy::Fixed, Policy::Cap(1), Policy::Attention] {
3114            let own = graded_under(policy, &applied, &ctx);
3115            let mut got = None;
3116            let r = decide(policy, &applied, &at, &stale, std::time::Duration::ZERO, |r, b| {
3117                got = Some((r.log.policy, b.why, b.summary.key()));
3118                true
3119            });
3120            assert_eq!(got, Some((policy.name(), "", stale.key())), "{policy:?}");
3121            assert_eq!((ids(&r.ask), ids(&r.free)), (ids(&own.ask), ids(&own.free)), "{policy:?}: its own rule");
3122        }
3123        // Not vacuous: the cap's block differs from the fixed rule's, which a fallback would have asked.
3124        assert_ne!(ids(&graded_under(Policy::Cap(1), &applied, &ctx).ask), fixed);
3125        let mut why = "";
3126        let r = decide(Policy::Learned, &applied, &at, &stale, std::time::Duration::ZERO, |_, b| {
3127            why = b.why;
3128            true
3129        });
3130        assert_eq!((r.log.policy, why), ("fixed", outcome::DECIDED_BUSY), "the learned policy alone waits for the lock");
3131        drop(held);
3132        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3133    }
3134
3135    /// A learned decision is carried out only once its record landed in the log its gate is folded from,
3136    /// the gate's only debit. One whose record would not land there (learning off, the lessons of
3137    /// another store: `Decide::recorded`) or did not (a failed append) takes the fixed rule and says why,
3138    /// so checks one after another never each risk the whole allowance of a gate none of them debited.
3139    #[test]
3140    fn a_learned_decision_whose_record_cannot_land_takes_the_fixed_rule() {
3141        use crate::select::{Ctx, Policy, graded_under, policy, summary};
3142        let lessons = decide_store("unrecorded");
3143        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3144        let ls = [c("A"), c("B"), c("C"), c("D")];
3145        let applied: Vec<(&conscience::Challenge, u8)> = ls.iter().zip([3, 1, 2, 1]).collect();
3146        let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3147        let at = |check: &'static str, recorded: bool| Decide { session: "decide-unrecorded", check, channel: "live", lessons: &lessons, tokens: 0, recorded };
3148        let fixed = |check: &str, s: &summary::Summary| {
3149            let ctx = Ctx {
3150                session: "decide-unrecorded",
3151                check,
3152                channel: "live",
3153                lessons: &lessons,
3154                summary: s,
3155                tokens: 0,
3156                had: &[],
3157                alpha: crate::select::alpha(&lessons),
3158            };
3159            let r = graded_under(Policy::Fixed, &applied, &ctx);
3160            (ids(&r.ask), ids(&r.free), ids(&r.placebo))
3161        };
3162        // Its record lands: the learned policy deviates (not vacuous).
3163        let stale = summary::refresh(&lessons);
3164        let r = decide(Policy::Learned, &applied, &at("0000000000f1", true), &stale, std::time::Duration::ZERO, |r, b| {
3165            record_beside(&lessons, &applied, "0000000000f1", r, b)
3166        });
3167        assert_eq!(r.log.source, policy::EVIDENCE, "{:?}", r.log);
3168        let (ask, free, _) = fixed("0000000000f1", &stale);
3169        assert_ne!((ids(&r.ask), ids(&r.free)), (ask, free), "it deviated");
3170        let gate = summary::refresh(&lessons).gate;
3171        // Two checks whose records would not land, one after the other: both the fixed rule, never tried.
3172        for check in ["0000000000f2", "0000000000f3"] {
3173            let stale = summary::refresh(&lessons);
3174            let mut tried = Vec::new();
3175            let r = decide(Policy::Learned, &applied, &at(check, false), &stale, std::time::Duration::ZERO, |r, b| {
3176                tried.push((r.log.policy, b.why));
3177                false
3178            });
3179            assert_eq!(tried, [("fixed", outcome::DECIDED_NO_RECORD)], "{check}");
3180            assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed(check, &stale), "{check}");
3181        }
3182        // A check whose record failed to append: decided again by the fixed rule under the lock, and that
3183        // carried out; the store's log and its gate are as they were.
3184        let stale = summary::refresh(&lessons);
3185        let mut tried = Vec::new();
3186        // After the first check's lock, so with the hook's wait (as in the test above).
3187        let r = decide(Policy::Learned, &applied, &at("0000000000f4", true), &stale, DECIDE_WAIT, |r, b| {
3188            tried.push((r.log.policy, b.why));
3189            false
3190        });
3191        assert_eq!(tried, [("learned", ""), ("fixed", outcome::DECIDED_NO_RECORD)]);
3192        assert_eq!((ids(&r.ask), ids(&r.free), ids(&r.placebo)), fixed("0000000000f4", &stale));
3193        assert_eq!(graded_records(&lessons).len(), 1, "only the first check's record");
3194        assert_eq!(summary::refresh(&lessons).gate, gate, "no unrecorded decision moved the gate");
3195        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3196    }
3197
3198    /// A lesson another check queued to be asked or handed over, not delivered yet, is had at a decision
3199    /// as a delivered one is (`waiting_in`): it never takes a free slot (the next lesson does) nor a
3200    /// placebo's, and the record names it (`nofree`), so delivery never drops a second copy as queued
3201    /// twice. A lesson waiting as a placebo was routed nowhere: it may still go free.
3202    #[test]
3203    fn a_lesson_waiting_in_the_queue_never_takes_a_free_or_placebo_slot() {
3204        use crate::select::{Policy, summary};
3205        let lessons = decide_store("queued");
3206        let stale = summary::refresh(&lessons);
3207        let session = format!("decide-queued-{}", std::process::id());
3208        let _ = std::fs::remove_file(state_file(&session));
3209        std::fs::write(queued_file(&session), "X @0000000000d0\nlive Y @0000000000d0\nplacebo Z @0000000000d0\n").unwrap();
3210        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
3211        let named: Vec<String> = (1..=10).map(|i| format!("L{i:02}")).collect();
3212        let ls: Vec<conscience::Challenge> = ["X", "Y", "Z"].iter().map(|i| c(i)).chain(named.iter().map(|i| c(i))).collect();
3213        let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
3214        // No 3, so the fixed rule hands over up to `CAP`: X and Y first in line (the slots they would take
3215        // go to the next), or last, past ten 2s, where a placebo is drawn among what is left.
3216        let first: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if ["X", "Y"].contains(&c.id.as_str()) { 2 } else { 1 })).collect();
3217        let last: Vec<(&conscience::Challenge, u8)> = ls.iter().map(|c| (c, if c.id.starts_with('L') { 2 } else { 1 })).collect();
3218        let waits = |l: &[String]| l.iter().any(|i| i == "X" || i == "Y");
3219        for (check, applied) in [("0000000000d1", &first), ("0000000000d2", &last)] {
3220            let at = Decide { session: &session, check, channel: "stop", lessons: &lessons, tokens: 0, recorded: true };
3221            let r = decide(Policy::Fixed, applied, &at, &stale, std::time::Duration::ZERO, |r, b| record_beside(&lessons, applied, check, r, b));
3222            let (free, placebo) = (ids(&r.free), ids(&r.placebo));
3223            assert_eq!(free.len(), conscience::CAP, "{check}: every free slot taken: {free:?}");
3224            assert!(!waits(&free) && !waits(&placebo), "{check}: free {free:?}, placebo {placebo:?}");
3225        }
3226        assert_eq!(graded_records(&lessons)[1].placebo, ["Z"], "waiting as a placebo, Z is still drawn");
3227        assert!(graded_records(&lessons)[0].free.contains(&"Z".to_string()), "and still goes free");
3228        let g = graded_records(&lessons);
3229        assert_eq!(g.iter().map(|o| o.nofree.clone()).collect::<Vec<_>>(), [["X", "Y"], ["X", "Y"]], "{g:?}");
3230        let _ = std::fs::remove_file(queued_file(&session));
3231        let _ = std::fs::remove_dir_all(lessons.parent().unwrap());
3232    }
3233
3234    #[test]
3235    fn only_an_adapter_reads_which_messages_an_answer_showed() {
3236        let out = json!({"hookSpecificOutput": {"additionalContext": "[borg] Message from s1: hi"}, "shown_ids": ["m0123456789ab"]});
3237        for name in ["claude", "codex"] {
3238            let h = harness::by_name(name).unwrap();
3239            assert_eq!(for_harness(h, out.clone()), json!({"hookSpecificOutput": out["hookSpecificOutput"]}), "{name}");
3240        }
3241        for name in ["omp", "opencode"] {
3242            assert_eq!(for_harness(harness::by_name(name).unwrap(), out.clone()), out, "{name}");
3243        }
3244    }
3245}