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(200).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/// The files an edit names in its `edits` beside its own `file_path` (a Codex patch of several files;
61/// Claude's MultiEdit names none there). They reach `.ctx` and `.edits`; presence (`borg top`) shows
62/// one file per drone, the edit's own `file_path`.
63pub(crate) fn further_files(input: &Value) -> impl Iterator<Item = &str> {
64    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())
65}
66
67pub(crate) fn repo_of(cwd: &str) -> String {
68    borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
69}
70
71/// Counts one event of this session in the collective's statistics (stats.rs); `fill` adds details.
72fn stat(session: &str, input: &Value, kind: &str, fill: impl FnOnce(&mut crate::stats::Event)) {
73    let home = learn::home();
74    let mut ev = crate::stats::Event::new(kind, learn::now_ms());
75    ev.session = session.into();
76    ev.repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
77    // The engineer as presence.rs already resolved it at this hook: no second `git config` call.
78    ev.engineer = crate::presence::read(&home, session).map(|p| p.engineer).unwrap_or_default();
79    fill(&mut ev);
80    crate::stats::record(&home, &ev);
81}
82
83/// The engineer's prompts this session (the queen's criterion 3; the state file mixes in touches).
84fn prompts_file(session: &str) -> PathBuf {
85    state_file(session).with_extension("prompts")
86}
87
88/// Which turn the session is in: the size of its prompts file, which every prompt grows. A live check
89/// records the turn it started in (live.rs `Mark`), so it covers that turn's stop only.
90fn turn_id(session: &str) -> u64 {
91    std::fs::metadata(prompts_file(session)).map_or(0, |m| m.len())
92}
93
94/// Where the session works: the repo it runs in and the paths it touched (`where_now` adds the latest
95/// prompt). Scopes are judged here only, so a system merely mentioned in a command's text or in an
96/// earlier part of the conversation is not a topic.
97fn where_file(session: &str) -> PathBuf {
98    state_file(session).with_extension("where")
99}
100
101/// Where the session works now: the repo and touched paths so far, plus the engineer's latest prompt
102/// (an earlier prompt's system is not this turn's work).
103fn where_now(session: &str) -> String {
104    let paths = std::fs::read_to_string(where_file(session)).unwrap_or_default();
105    let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
106    format!("{paths}\n{latest}")
107}
108
109fn note_where(session: &str, text: &str) {
110    if text.trim().is_empty() {
111        return;
112    }
113    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(where_file(session)) {
114        let _ = writeln!(f, "{text}");
115    }
116}
117
118fn task_file(session: &str) -> PathBuf {
119    state_file(session).with_extension("task")
120}
121
122/// A tool call refused without words, waiting for the engineer's next prompt (their reason).
123/// Pending only while the drone waits: a later successful tool call or the session's end clears it,
124/// because omp lets the model carry on after a denial and the next prompt may be about anything.
125fn denied_file(session: &str) -> PathBuf {
126    state_file(session).with_extension("denied")
127}
128
129/// A correcting prompt becomes an episode (task, the drone's last proposal, the correction);
130/// so does the first prompt after a wordless tool denial. Any other prompt becomes the session's task.
131fn capture_prompt(h: &dyn Harness, session: &str, input: &Value, prompt: &str) {
132    if !learning() {
133        return;
134    }
135    let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
136    if let Ok(tool) = std::fs::read_to_string(denied_file(session)) {
137        let _ = std::fs::remove_file(denied_file(session));
138        let said = prompt.trim();
139        if said.len() > 3 && !said.starts_with('<') {
140            record(session, input, task, proposal(h, input), Some(tool), said.chars().take(1500).collect());
141            return;
142        }
143    }
144    if !learn::is_correction(prompt) || task.is_empty() {
145        let task: String = prompt.chars().take(3000).collect();
146        let _ = std::fs::write(task_file(session), task);
147        return;
148    }
149    record(session, input, task, proposal(h, input), None, prompt.chars().take(1500).collect());
150}
151
152/// A veto said in the session (veto.rs): its episode, the shown lessons' credit and its stat. A pending
153/// wordless denial is answered by it, so it never becomes a correction later.
154fn veto(h: &dyn Harness, session: &str, input: &Value, reason: &str) {
155    let _ = std::fs::remove_file(denied_file(session));
156    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
157    let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
158    let said = crate::veto::Said {
159        session,
160        repo: &repo_of(cwd),
161        engineer: &learn::engineer(cwd),
162        task: &task,
163        agent: &proposal(h, input),
164        reason,
165        ms: learn::now_ms(),
166        via: "prompt",
167    };
168    if let Ok(v) = crate::veto::record(&learn::home(), &said) {
169        append(session, &format!("#borg-episode {}", v.episode));
170    }
171}
172
173/// A rejected tool call or plan that carries the engineer's words becomes an episode; a wordless
174/// denial waits for the next prompt (`capture_prompt`).
175fn capture_rejection(h: &dyn Harness, session: &str, input: &Value) {
176    if !learning() {
177        return;
178    }
179    let error = input.get("error").and_then(Value::as_str).unwrap_or_default();
180    let tool = format!(
181        "{} {}",
182        input.get("tool_name").and_then(Value::as_str).unwrap_or_default(),
183        input.get("tool_input").map(|v| v.to_string()).unwrap_or_default().chars().take(1500).collect::<String>()
184    );
185    let Some(said) = learn::rejection(error) else {
186        if learn::denied(error) {
187            let _ = std::fs::write(denied_file(session), tool);
188        }
189        return;
190    };
191    let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
192    record(session, input, task, proposal(h, input), Some(tool), said);
193}
194
195/// The drone's last proposal, its last 2000 characters (`harness::last_message`: the adapter's, else
196/// the transcript's).
197fn proposal(h: &dyn Harness, input: &Value) -> String {
198    let t = full_proposal(h, input);
199    t.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect()
200}
201
202/// The drone's whole last proposal (`proposal` keeps its last 2000 characters): the reasoning note
203/// redacts it before it cuts (notes.rs).
204fn full_proposal(h: &dyn Harness, input: &Value) -> String {
205    harness::last_message(h.caps(), input)
206}
207
208fn record(session: &str, input: &Value, task: String, agent: String, rejected_tool: Option<String>, correction: String) {
209    let ms = learn::now_ms();
210    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
211    let e = learn::Episode {
212        id: format!("{}-{ms}", session.chars().take(8).collect::<String>()),
213        ms,
214        repo: repo_of(cwd),
215        session: session.into(),
216        task,
217        agent,
218        rejected_tool,
219        correction,
220        engineer: learn::engineer(cwd),
221        ..learn::Episode::default()
222    };
223    let _ = learn::record_episode(&e);
224    append(session, &format!("#borg-episode {}", e.id));
225}
226
227/// The lessons the conscience reads (BORG_CONSCIENCE, the BORG_BEFORE cut-off) as `session` sees them:
228/// weighted by their memory with this session's usefulness sample (memory.rs).
229fn lessons(session: &str) -> Vec<conscience::Challenge> {
230    let before = std::env::var("BORG_BEFORE").ok().and_then(|v| v.parse().ok());
231    let mut all = conscience::load_for(&lessons_path(), before, Some(session));
232    // The system gate (systems.rs): where the repo says which system this is, lessons scoped to other
233    // systems score 0, however much the conversation names them.
234    let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
235    let repos = crate::systems::work_repos(&where_text);
236    if crate::systems::on() {
237        crate::systems::gate(&mut all, &repos);
238    }
239    // Reach (reach.rs): a withdrawn personal lesson is never delivered, a repo convention only in its repo.
240    all.retain(|c| crate::reach::deliverable(c, &repos));
241    // Spike (concepts.rs): lessons sharing engineering concepts with the code written, the touched paths
242    // and the latest prompt rank higher; never a filter.
243    if crate::concepts::on() {
244        let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
245        let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
246        let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
247        crate::concepts::apply(&mut all, &crate::concepts::of_work(&edits, &where_text, &latest), crate::concepts::beta());
248    }
249    all
250}
251
252/// The lessons file the conscience reads: BORG_CONSCIENCE, default $BORG_HOME/lessons.jsonl.
253fn lessons_path() -> PathBuf {
254    std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
255}
256
257/// What the drone did, for R1 (`pending`, `early_acted`): one line per Edit/Write/MultiEdit/NotebookEdit
258/// (path and the start of the new text); at each Stop block the blocked message and `#borg-asked <ids>`;
259/// the drone's answer at the turn end after it (`#borg-answer <message>`); `#borg-early <ids>` per early
260/// delivery. Kept out of the state file so the drone's code never feeds the conscience's matching.
261fn edits_file(session: &str) -> PathBuf {
262    state_file(session).with_extension("edits")
263}
264
265/// What Borg cost this session (R3b), one line per delivery: `block questions=N chars=M` for a Stop
266/// block (an extra round), `free event=E lessons=N chars=M` for context on a hook that forces no round.
267/// `borg status` sums them (prefilter.rs `tally`).
268fn cost_file(session: &str) -> PathBuf {
269    state_file(session).with_extension("cost")
270}
271
272/// BORG_STOP_GATE=edits: one line per tool call this turn that changed state (prefilter.rs `changes`);
273/// the Stop removes it.
274fn changes_file(session: &str) -> PathBuf {
275    state_file(session).with_extension("changes")
276}
277
278fn log_cost(session: &str, line: &str) {
279    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(cost_file(session)) {
280        let _ = writeln!(f, "{line}");
281    }
282}
283
284/// Lessons delivered early (`#borg-early` markers in the .edits file, and graded lessons handed over free,
285/// `#borg-queued`) that the drone acted on since: its work after the delivery brings in the lesson's
286/// words (memory.rs `resolve`, `final_msg` included). Stop leaves them alone; an early lesson not acted
287/// on can still be asked at Stop.
288fn early_acted(session: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
289    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
290    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
291    let lines: Vec<&str> = edits.lines().collect();
292    let mut out = std::collections::HashSet::new();
293    for (p, l) in lines.iter().enumerate() {
294        let Some(ids) = l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")) else { continue };
295        let ids: std::collections::HashSet<&str> = ids.split(',').collect();
296        let asked: Vec<&conscience::Challenge> = all.iter().filter(|c| ids.contains(c.id.as_str())).collect();
297        let after: Vec<&str> = lines[p + 1..].iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
298        let upto = state.find(&format!("{l}\n")).map_or(state.as_str(), |i| &state[..i]);
299        let before = format!("{upto}\n{}", lines[..p].join("\n"));
300        out.extend(crate::memory::resolve(&asked, all, &before, &after.join("\n"), final_msg, crate::memory::NOVEL_WORDS).0);
301    }
302    out
303}
304
305/// An edit tool's paths (every file it names, so the cut never drops one) and the start of its new
306/// text, on one line; None for other tools.
307pub(crate) fn edit_text(tool: &str, input: &Value) -> Option<String> {
308    if !["edit", "write", "multiedit", "notebookedit"].contains(&tool.to_ascii_lowercase().as_str()) {
309        return None;
310    }
311    let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
312    let mut text = [s(input, "file_path"), s(input, "notebook_path")].join(" ");
313    for p in further_files(input) {
314        text.push(' ');
315        text.push_str(p);
316    }
317    for k in ["new_string", "content", "new_source"] {
318        text.push(' ');
319        text.push_str(&s(input, k));
320    }
321    for e in input.get("edits").and_then(Value::as_array).into_iter().flatten() {
322        text.push(' ');
323        text.push_str(&s(e, "new_string"));
324    }
325    Some(text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(600).collect())
326}
327
328/// `text` on one line, at most `n` characters.
329pub(crate) fn one_line(text: &str, n: usize) -> String {
330    text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
331}
332
333/// R1: the first turn end after a Stop block is the drone's answer to it. Its message (with any
334/// `<id>: applies|skip`) goes to the .edits file as `#borg-answer`: evidence for the judge (`pending`),
335/// never a label by itself.
336fn note_answer(h: &dyn Harness, session: &str, input: &Value) {
337    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
338    let lines: Vec<&str> = edits.lines().collect();
339    let Some(i) = lines.iter().rposition(|l| l.starts_with("#borg-asked ")) else { return };
340    if lines[i + 1..].iter().any(|l| l.starts_with("#borg-answer ")) {
341        return;
342    }
343    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
344        let _ = writeln!(f, "#borg-answer {}", one_line(&full_proposal(h, input), 4000));
345    }
346}
347
348/// Pending lessons sent to the judge per call at most (the most recently asked): one full block
349/// (conscience.rs caps a block at 10), whose lessons share their evidence in the prompt.
350const MAX_PENDING: usize = 10;
351/// A lesson whose verdicts stay pending (unclear, missing) is sent to the judge this often at most;
352/// after that it waits for `borg learn` to settle it as `asked_not_corrected`.
353const MAX_JUDGE_TRIES: usize = 2;
354
355/// The markers of a delivery of lessons to the drone, in the state and .edits files: asked at a Stop or
356/// live (`#borg-asked`), delivered early, handed over free (`#borg-queued`).
357const DELIVERED: [&str; 3] = ["#borg-asked ", "#borg-early ", "#borg-queued "];
358
359/// R1: lessons delivered earlier in this session (asked at a Stop or live, delivered early, handed over
360/// free) that have no verdict yet, with the evidence for the consequence model (predict.rs `Pending`):
361/// the work before the delivery (at a Stop, the blocked message), and after it the drone's answer (a
362/// question's only: a free delivery asks nothing), its edits and later messages since, and this turn's
363/// message (`final_msg`), each part capped at `predict::JUDGE_PART` characters. `#borg-judged
364/// <id>=<label>` lines in the state file record verdicts, each for the lesson's latest delivery before
365/// it: a lesson delivered early, judged, then asked at a Stop is judged again on the question.
366/// `answered_only` (a live check, mid-turn): questions the drone has not answered yet (no `#borg-answer`
367/// after them, written at the stop) wait for the stop's judge, which sees the answer, as in hook mode;
368/// free deliveries wait for it too. Of the `MAX_PENDING` slots, questions take theirs first (the newest),
369/// free deliveries what is left: a free hand-over never pushes a question out of its judge. The flag:
370/// whether a question is among them.
371fn pending<'a>(session: &str, state: &str, all: &'a [conscience::Challenge], final_msg: &str, answered_only: bool) -> (Vec<predict::Pending<'a>>, bool) {
372    let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
373    let mut settled = std::collections::HashSet::new();
374    for l in state.lines() {
375        if let Some(l) = l.strip_prefix("#borg-judged ") {
376            let (id, label) = l.split_once('=').unwrap_or((l, ""));
377            *tries.entry(id).or_default() += 1;
378            if label.trim() != "pending" {
379                settled.insert(id);
380            }
381        } else if let Some(ids) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
382            for id in ids.split(',').map(str::trim) {
383                tries.remove(id);
384                settled.remove(id);
385            }
386        }
387    }
388    let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
389    let lines: Vec<&str> = edits.lines().collect();
390    let part = predict::JUDGE_PART;
391    // Each with whether it was a question (`#borg-asked`), not a free delivery.
392    let mut out: Vec<(predict::Pending, bool)> = Vec::new();
393    for (p, l) in lines.iter().enumerate() {
394        let Some((marker, ids)) = DELIVERED.iter().find_map(|m| l.strip_prefix(m).map(|ids| (*m, ids))) else { continue };
395        let before = p.checked_sub(1).map(|b| lines[b]).filter(|b| !b.starts_with("#borg-")).unwrap_or_default();
396        let later = &lines[p + 1..];
397        let answers: Vec<&str> = if marker == "#borg-asked " { later.iter().filter_map(|l| l.strip_prefix("#borg-answer ")).collect() } else { Vec::new() };
398        if answered_only && answers.is_empty() {
399            continue;
400        }
401        let work: Vec<&str> = later.iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
402        let after = format!(
403            "answer: {}\nwork since: {}\nthis turn: {}",
404            predict::clip(&answers.join(" "), part),
405            predict::clip(&work.join(" | "), part),
406            predict::clip(&one_line(final_msg, part + 1), part)
407        );
408        let said = answers.join("\n").to_lowercase();
409        for id in ids.split(',').map(str::trim).filter(|i| !i.is_empty()) {
410            if settled.contains(id) || tries.get(id).copied().unwrap_or(0) >= MAX_JUDGE_TRIES {
411                continue;
412            }
413            let Some(c) = all.iter().find(|c| c.id == id) else { continue };
414            // A later delivery of the same lesson is the one judged: its evidence replaces the earlier one's.
415            out.retain(|x| x.0.lesson.id != id);
416            let declared = crate::memory::declared(&said, &id.to_lowercase());
417            out.push((predict::Pending { lesson: c, before: before.to_string(), after: after.clone(), declared }, marker == "#borg-asked "));
418        }
419    }
420    let questions = out.iter().filter(|x| x.1).count().min(MAX_PENDING);
421    let mut room = [MAX_PENDING - questions, questions];
422    let mut kept: Vec<(predict::Pending, bool)> = Vec::new();
423    for x in out.into_iter().rev() {
424        let r = &mut room[usize::from(x.1)];
425        if *r > 0 {
426            *r -= 1;
427            kept.push(x);
428        }
429    }
430    kept.reverse();
431    (kept.into_iter().map(|x| x.0).collect(), questions > 0)
432}
433
434/// R1: the consequence model's verdicts on the pending lessons it was shown (predict.rs `Judged`).
435/// `asked_then_changed` only for a change that avoids the consequence, `asked_ignored` for no change;
436/// unclear or missing verdicts stay pending. Logs the call's judging cost in the .cost file:
437/// `judge call=<nth model call of the session> pending=N resolved=K chars=<prompt chars added>`.
438fn record_verdicts(session: &str, input: &Value, pending: &[predict::Pending], resolved: &[predict::Judged], chars: usize, call: usize) {
439    let (mut changed, mut ignored) = (Vec::new(), Vec::new());
440    for p in pending {
441        let label = resolved.iter().find(|j| j.id == p.lesson.id).and_then(predict::Judged::label);
442        let word = match label {
443            Some(outcome::CHANGED) => "changed",
444            Some(_) => "ignored",
445            None => "pending",
446        };
447        append(session, &format!("#borg-judged {}={word}", p.lesson.id));
448        match label {
449            Some(outcome::CHANGED) => changed.push(p.lesson.id.clone()),
450            Some(_) => ignored.push(p.lesson.id.clone()),
451            None => {}
452        }
453    }
454    log_cost(session, &format!("judge call={call} pending={} resolved={} chars={chars}", pending.len(), changed.len() + ignored.len()));
455    if learning() {
456        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
457        // One record per channel: the statistics split what the deliveries led to by how they were made.
458        let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
459        let by = channels(&edits);
460        for (kind, lessons) in [(outcome::CHANGED, changed), (outcome::IGNORED, ignored)] {
461            for channel in ["live", "stop", "early", "queued"] {
462                let lessons: Vec<String> = lessons.iter().filter(|id| by.get(id.as_str()).copied().unwrap_or("stop") == channel).cloned().collect();
463                if !lessons.is_empty() {
464                    let o = outcome::Outcome {
465                        kind: kind.into(),
466                        session: session.into(),
467                        lessons,
468                        ms: learn::now_ms(),
469                        repo: repo_of(cwd),
470                        channel: channel.into(),
471                        ..Default::default()
472                    };
473                    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &o);
474                }
475            }
476        }
477    }
478}
479
480/// The channel of each lesson's latest delivery in the .edits file `text`: `stop` (`#borg-asked`),
481/// `live` (a live check's, `#borg-live <ids> ms=<epoch ms>` right after its `#borg-asked` line), `early`
482/// or `queued`.
483fn channels(text: &str) -> std::collections::HashMap<&str, &'static str> {
484    let mut out = std::collections::HashMap::new();
485    for l in text.lines() {
486        let (ids, channel) = if let Some(ids) = l.strip_prefix("#borg-asked ") {
487            (ids, "stop")
488        } else if let Some(rest) = l.strip_prefix("#borg-live ") {
489            (rest.split_whitespace().next().unwrap_or_default(), "live")
490        } else if let Some(ids) = l.strip_prefix("#borg-early ") {
491            (ids, "early")
492        } else if let Some(ids) = l.strip_prefix("#borg-queued ") {
493            (ids, "queued")
494        } else {
495            continue;
496        };
497        for id in ids.split(',').map(str::trim).filter(|i| !i.is_empty()) {
498            out.insert(id, channel);
499        }
500    }
501    out
502}
503
504/// Ids listed on the state file's `marker` lines (`#borg-asked a,b`).
505pub(crate) fn listed<'a>(context: &'a str, marker: &str) -> std::collections::HashSet<&'a str> {
506    context.lines().filter_map(|l| l.strip_prefix(marker)).flat_map(|ids| ids.split(',')).map(str::trim).filter(|i| !i.is_empty()).collect()
507}
508
509/// Early delivery (conscience.rs `early`): once the session's prompts and touches name a lesson scope
510/// (a topic) and that scope's lessons score on them, the top ones go to the drone at once, as context,
511/// instead of waiting for the Stop checkpoint: at the prompt or the first touch on the topic (`text` is
512/// this event's, already in the state file). Each lesson once per session (`#borg-early`; Stop does not
513/// 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
514/// left; until then each prompt or touch looks again. At most `MAX_EARLY_DELIVERIES` per session. Only
515/// where the harness takes it (`Caps::early_delivery`, Claude Code so far): omp and opencode take a prompt's
516/// and a tool call's context too (live mode), but early delivery to them waits for an evaluation, so for
517/// them the lessons stay with Stop. Standing lessons (ratified constraints, conscience.rs `standing_pool`)
518/// are no ranking guess and need no evaluation: they come on every harness that takes the context (a
519/// prompt's, or a tool call's for a named scope), whenever their scope is named (an unscoped one at a
520/// prompt), settled topic or not and past `MAX_EARLY_DELIVERIES`, until each came once. `BORG_EARLY=off`
521/// disables it; so do the eval modes BORG_ONLY and BORG_CRITIC.
522/// Early deliveries per session at most (each up to `conscience::render_early`'s lesson cap).
523const MAX_EARLY_DELIVERIES: usize = 3;
524
525fn early(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<String> {
526    if text.trim().is_empty()
527        || std::env::var("BORG_EARLY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
528        || std::env::var_os("BORG_ONLY").is_some()
529        || std::env::var_os("BORG_CRITIC").is_some()
530    {
531        return None;
532    }
533    let challenges = lessons(session);
534    let context = std::fs::read_to_string(state_file(session)).unwrap_or_default();
535    // Free, but not free of noise: a long session naming many systems would otherwise get a delivery
536    // at every touch until each topic's lessons ran out. Standing lessons are exempt.
537    let plain = h.caps().early_delivery && context.lines().filter(|l| l.starts_with("#borg-early ")).count() < MAX_EARLY_DELIVERIES;
538    let standing = challenges.iter().any(conscience::Challenge::standing);
539    if !standing && (!plain || challenges.iter().all(|c| c.scope.is_empty() && !c.consolidated)) {
540        return None;
541    }
542    let settled = listed(&context, "#borg-topic ");
543    let named = conscience::named_scopes(&challenges, &where_now(session));
544    let mut topics: Vec<String> = if plain { named.iter().filter(|t| !settled.contains(t.as_str())).cloned().collect() } else { Vec::new() };
545    // Consolidated lessons (memory.rs) need no scope: the first prompt is their topic (`ANY`).
546    if plain
547        && topics.is_empty()
548        && hook_event == "UserPromptSubmit"
549        && !settled.contains(conscience::ANY)
550        && challenges.iter().any(|c| c.consolidated && !c.standing())
551    {
552        topics.push(conscience::ANY.into());
553    }
554    // A standing lesson's topic is every named scope, settled or not, and any prompt for an unscoped one.
555    let mut standing_topics = named;
556    if hook_event == "UserPromptSubmit" {
557        standing_topics.push(conscience::ANY.into());
558    }
559    let mut done = listed(&context, "#borg-asked ");
560    done.extend(listed(&context, "#borg-early "));
561    done.extend(listed(&context, "#borg-queued "));
562    let mut pool = if standing { conscience::standing_pool(&challenges, &standing_topics, &done) } else { Vec::new() };
563    if !topics.is_empty() {
564        pool.extend(conscience::early_pool(&challenges, &conscience::work_text(&context), &topics, &done));
565    }
566    // The header names what came: the plain topics, and the scopes of the standing lessons beside them.
567    let mut shown_topics = topics.clone();
568    for t in standing_topics.into_iter().filter(|t| pool.iter().any(|(_, c)| c.standing() && c.scope.contains(t))) {
569        if !shown_topics.contains(&t) {
570            shown_topics.push(t);
571        }
572    }
573    let selected: Vec<&conscience::Challenge> = conscience::explored(pool.iter().copied()).into_iter().take(conscience::EARLY_CAP).collect();
574    let shown = conscience::render_early(&selected, &shown_topics);
575    let delivered = &selected[..shown.as_ref().map_or(0, |(_, n)| *n)];
576    // Plain topics only: a standing lesson left over (past `EARLY_CAP` or `EARLY_CHARS`) comes at the next
577    // prompt or touch on its scope whatever is settled.
578    let now_settled: Vec<&str> = topics
579        .iter()
580        .filter(|t| {
581            // One delivery per topic: whatever was delivered now covers every topic looked at now.
582            !delivered.is_empty()
583                || !challenges.iter().any(|c| {
584                    !c.standing()
585                        && (c.scope.contains(t) || (t.as_str() == conscience::ANY && c.consolidated))
586                        && !done.contains(c.id.as_str())
587                        && !delivered.iter().any(|d| d.id == c.id)
588                })
589        })
590        .map(String::as_str)
591        .collect();
592    if !now_settled.is_empty() {
593        append(session, &format!("#borg-topic {}", now_settled.join(",")));
594    }
595    let (text, _) = shown?;
596    let ids = delivered.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
597    append(session, &format!("#borg-early {ids}"));
598    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
599        let _ = writeln!(f, "#borg-early {ids}");
600    }
601    let p = conscience::early_propensity(&pool, session, &shown_topics);
602    record_delivery(session, input, delivered, "early", delivered.iter().map(|c| p.get(&c.id).copied().unwrap_or(0.0)).collect());
603    log_cost(session, &format!("free event={hook_event} lessons={} chars={}", delivered.len(), text.len()));
604    crate::stats::count(&learn::home(), crate::stats::EARLY, session, |e| {
605        e.n = delivered.len() as u64;
606        e.lessons = delivered.iter().map(|c| c.id.clone()).collect();
607        e.channel = "early".into();
608    });
609    Some(text)
610}
611
612/// The `asked` outcome of a free delivery (`channel` early or queued) of `delivered`, with each one's
613/// propensity `p`: delivered as context, the lessons wait for a result as a Stop's questions do. The
614/// next Stop's judge (`pending`) labels them changed or ignored, a merged correction corrected
615/// (outcome.rs `contradicted`), and `borg learn` settles the rest. Nothing with learning off.
616fn record_delivery(session: &str, input: &Value, delivered: &[&conscience::Challenge], channel: &str, p: Vec<f64>) {
617    if !learning() || delivered.is_empty() {
618        return;
619    }
620    let lessons: Vec<String> = delivered.iter().map(|c| c.id.clone()).collect();
621    let (grades, ranks, applied) = graded_of(session, &lessons);
622    let asked = outcome::Outcome {
623        kind: outcome::ASKED.into(),
624        session: session.into(),
625        lessons,
626        ms: learn::now_ms(),
627        repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
628        groups: conscience::disagreement_ids(delivered),
629        channel: channel.into(),
630        grades,
631        ranks,
632        applied,
633        p: p.into_iter().map(outcome::round_p).collect(),
634        ..Default::default()
635    };
636    let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
637}
638
639/// Graded Stop (default): ids graded 2 or 1 at a turn end that did not block, one comma-separated line
640/// per such Stop, waiting for the session's next prompt or touch (`queued`). In live mode a check during
641/// the turn (`live_check`) adds its 2s and 1s the same way, and its 3s as a `live <ids>` line.
642fn queued_file(session: &str) -> PathBuf {
643    state_file(session).with_extension("queued")
644}
645
646/// One line at the end of the `.queued` file, under its lock (live.rs `locked`): never into a file a
647/// hook is taking at the same moment.
648fn enqueue(session: &str, line: &str) {
649    let q = queued_file(session);
650    live::locked(&q, || {
651        let _ = std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
652    });
653}
654
655/// The whole `.queued` file, taken under its lock (a rename, then read): what a check appends after
656/// starts a new file, delivered next time.
657fn take_queue(session: &str) -> Option<String> {
658    let q = queued_file(session);
659    let taken = state_file(session).with_extension(format!("queued.{}", std::process::id()));
660    live::locked(&q, || std::fs::rename(&q, &taken)).ok()?;
661    let text = std::fs::read_to_string(&taken).unwrap_or_default();
662    let _ = std::fs::remove_file(&taken);
663    Some(text)
664}
665
666/// Live mode, at the stop: the live checks' asks still waiting in the `.queued` file (`live <ids>`), taken
667/// out of it; the 2s and 1s stay for the next prompt or touch.
668fn take_live(session: &str) -> Vec<String> {
669    let Some(text) = take_queue(session) else { return Vec::new() };
670    let (live, rest): (Vec<&str>, Vec<&str>) = text.lines().partition(|l| l.starts_with("live "));
671    for line in rest {
672        enqueue(session, line);
673    }
674    live.iter().flat_map(|l| l["live ".len()..].split(',')).map(str::trim).filter(|i| !i.is_empty()).map(String::from).collect()
675}
676
677/// The waiting graded lessons as free context, once: those not asked, delivered early or handed over
678/// since, as many as one early delivery fits (`conscience::render_early`); the rest are dropped. Marked
679/// `#borg-queued` in the state and .edits files, so neither this queue nor early delivery repeats them,
680/// and Stop treats them as early lessons (done once acted on); an `asked` outcome with channel queued
681/// (`record_delivery`). A live check's asks (`live` lines) come
682/// first, as questions to answer (`ask_live`).
683fn queued(h: &dyn Harness, hook_event: &str, session: &str, input: &Value) -> Option<String> {
684    let ids = take_queue(session)?;
685    let all = lessons(session);
686    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
687    let mut done = listed(&state, "#borg-asked ");
688    done.extend(listed(&state, "#borg-early "));
689    done.extend(listed(&state, "#borg-queued "));
690    let (mut firm, mut wanted): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = (Vec::new(), Vec::new());
691    for line in ids.lines() {
692        let (live, list) = line.strip_prefix("live ").map_or((false, line), |l| (true, l));
693        for id in list.split(',').map(str::trim) {
694            if let Some(c) = all.iter().find(|c| c.id == id)
695                && !done.contains(id)
696                && !firm.iter().chain(&wanted).any(|w| w.id == id)
697            {
698                if live { firm.push(c) } else { wanted.push(c) }
699            }
700        }
701    }
702    let mut parts: Vec<String> = Vec::new();
703    if !firm.is_empty() {
704        parts.push(ask_live(h, hook_event, session, input, &mut firm));
705    }
706    if let Some((text, n)) = conscience::render_early(&wanted, &[]) {
707        let ids = wanted[..n].iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
708        append(session, &format!("#borg-queued {ids}"));
709        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
710            let _ = writeln!(f, "#borg-queued {ids}");
711        }
712        // Handed over as the graded check left them: no draw, so each was certain to come (p 1).
713        record_delivery(session, input, &wanted[..n], "queued", vec![1.0; n]);
714        log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
715        crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
716            e.n = n as u64;
717            e.lessons = wanted[..n].iter().map(|c| c.id.clone()).collect();
718            e.channel = "queued".into();
719        });
720        parts.push(text);
721    }
722    (!parts.is_empty()).then(|| parts.join("\n\n"))
723}
724
725/// Live mode: a live check's asks, delivered now (conscience.rs `render_live`). Asked is recorded here, at
726/// delivery, where the drone sees them: `#borg-asked <ids>` and `#borg-live <ids> ms=<now>` in the state
727/// file, the same in .edits after the work so far (the judge's evidence before them, `pending`), the
728/// outcome `asked` with channel live, and the stat `live_ask`. No extra round: a free delivery in the cost.
729fn ask_live(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, firm: &mut [&conscience::Challenge]) -> String {
730    crate::memory::shuffle(firm, session);
731    let ids = firm.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
732    let now = learn::now_ms();
733    // One write: `prefilter::turn` and E3 read the `#borg-live` line right after its `#borg-asked`.
734    append(session, &format!("#borg-asked {ids}\n#borg-live {ids} ms={now}"));
735    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
736        let _ = writeln!(f, "{}\n#borg-asked {ids}\n#borg-live {ids} ms={now}", one_line(&work_so_far(h, input), 4000));
737    }
738    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
739    if learning() {
740        let lessons: Vec<String> = firm.iter().map(|c| c.id.clone()).collect();
741        let (grades, ranks, applied) = graded_of(session, &lessons);
742        let p = propensity_of(session, &lessons);
743        let (pool, base, scoped) = pool_of(session, &lessons);
744        let asked = outcome::Outcome {
745            kind: outcome::ASKED.into(),
746            session: session.into(),
747            lessons,
748            ms: now,
749            repo: repo_of(cwd),
750            groups: conscience::disagreement_ids(firm),
751            channel: "live".into(),
752            grades,
753            ranks,
754            applied,
755            p,
756            pool,
757            base,
758            scoped,
759            ..Default::default()
760        };
761        let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
762    }
763    let text = conscience::render_live(firm);
764    log_cost(session, &format!("free event={hook_event} lessons={} chars={} live", firm.len(), text.len()));
765    stat(session, input, crate::stats::LIVE_ASK, |e| {
766        e.n = firm.len() as u64;
767        e.lessons = firm.iter().map(|c| c.id.clone()).collect();
768        e.channel = "live".into();
769    });
770    text
771}
772
773/// Live mode: this turn's work so far (harness `work_so_far`): from the transcript where the harness
774/// writes one, else the turn's tool calls (`turn_file`): no prose.
775fn work_so_far(h: &dyn Harness, input: &Value) -> String {
776    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
777    harness::work_so_far(h.caps(), input, &turn_file(session), LIVE_WORK_CHARS)
778}
779
780/// This turn's tool calls, one `[tool] touch` line each, and their edit text: the work so far where the
781/// harness sends no transcript. Emptied at each prompt.
782fn turn_file(session: &str) -> PathBuf {
783    state_file(session).with_extension("turn")
784}
785
786/// How much of the turn so far a live check reads: as much as the blocked message `pending` keeps.
787const LIVE_WORK_CHARS: usize = 4000;
788
789/// One open queen question for the engineer (queen.rs `deliver`), at the first prompt of the session
790/// where one of its work repos (the cwd's and the touched ones, systems.rs `work_repos`) has one: never
791/// another repo's. Its commands run this binary in the session's `cwd`, wherever the drone's shell has
792/// gone since. 0 model calls; fails open. Off with `BORG_QUEEN_ASK=off` or `BORG_LEARN=off` (nothing
793/// recorded; eval drones cannot answer), and in the eval modes BORG_ONLY and BORG_CRITIC.
794fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
795    if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
796        return None;
797    }
798    let repos = || {
799        let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
800        let here = repo_of(cwd);
801        if !here.is_empty() && !repos.contains(&here) {
802            repos.insert(0, here);
803        }
804        repos
805    };
806    let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
807    let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
808    if let Ok(exe) = std::env::current_exe() {
809        q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
810    }
811    let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
812    log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
813    Some(text)
814}
815
816/// The free context of a prompt or touch: waiting graded lessons first, then an early delivery.
817fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
818    free_context_with(h, hook_event, session, input, text, None)
819}
820
821/// `free_context` plus `extra` (a queen question) last.
822fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
823    let parts: Vec<String> = [queued(h, hook_event, session, input), early(h, hook_event, session, input, text), extra].into_iter().flatten().collect();
824    (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))
825}
826
827/// What one model check of the work decided (`check`).
828enum Verdict {
829    /// The free gate skipped the model call: the score, or `noedit`.
830    Skip(String),
831    /// The model gave no answer: fail open.
832    Failed,
833    /// The ids to ask: in a block at the stop, with the next prompt or tool call in live mode.
834    Ask(Vec<String>),
835}
836
837/// The work a check judges, as the stop or a live check read it.
838struct Work<'a> {
839    /// Every lesson of the session (the judge's pending lessons and the ungraded widening look here).
840    all: &'a [conscience::Challenge],
841    /// The lessons that may be asked: not done, not of another system.
842    challenges: &'a [conscience::Challenge],
843    done: &'a std::collections::HashSet<&'a str>,
844    /// The state file as read before the check.
845    state: &'a str,
846    /// The state file plus the drone's work text: the gate finds the turn in it by its markers; the pool
847    /// and the model read it without them (`conscience::work_text`).
848    context: &'a str,
849    scope_text: &'a str,
850    /// The drone's message for the judge (`pending`): its final message at the stop, the turn so far live.
851    message: &'a str,
852    /// BORG_STOP_GATE=edits: whether the turn changed anything.
853    changed: bool,
854    /// Whether this check judges the pending lessons: not while another check (holding the session's
855    /// lock) may be judging the same ones.
856    judge: bool,
857    /// The harness's stop budget (none: the model's own limit) and when the hook started: the model call
858    /// gets what is left of it when it starts (live.rs `call_limit`).
859    budget: Option<std::time::Duration>,
860    started: std::time::Instant,
861}
862
863/// The consequence model's check of `w`, shared by the stop and live mode's `borg check` (`live`): the
864/// free gate, the pool, one graded call that also judges the pending lessons, and the graded split. The
865/// 2s and 1s of a graded call wait in the `.queued` file; what to ask is returned.
866/// A live call is logged as `live_check` with a `live usd=… lessons=…` cost line, the stop's as `check`.
867fn check(session: &str, input: &Value, w: &Work, live: bool) -> Verdict {
868    // The free gate (prefilter.rs): a turn whose words touch no lesson costs no model call; the conscience
869    // then asked nothing. BORG_STOP_GATE=edits instead: a turn that changed nothing costs no model call.
870    // A live check has no gate: it runs only after a change, the debounce bounds it, and a turn half done
871    // has too few words for the gate (the E3 smoke skipped every mid-turn check at 2.8 and 7.7 of 8).
872    let skip = if live {
873        None
874    } else if prefilter::edits_gate() {
875        (!w.changed).then(|| "noedit".to_string())
876    } else {
877        prefilter::skip(w.challenges, w.context).map(|score| format!("{score:.2}"))
878    };
879    if let Some(score) = skip {
880        return Verdict::Skip(score);
881    }
882    // What the pool and the model read: the work, without the state file's bookkeeping lines.
883    let text = conscience::work_text(w.context);
884    let text = text.as_ref();
885    // BM25's top 30, plus lessons the vector index finds that word matching missed (vectors.rs; here only,
886    // where the model call dwarfs loading the embedder). Top 30 found as much as top 20 + vectors; both
887    // together find the most (internal evaluation "vectors", not published).
888    let started = std::time::Instant::now();
889    let shortlist_now = || {
890        let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
891        if crate::vectors::enabled() {
892            log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
893        }
894        list
895    };
896    // The whole pool (whole.rs, default): every lesson the Stop allows, up to a token budget; the
897    // shortlist's order decides only when they do not all fit. BORG_STOP_FORMAT=classic asks about that
898    // pool in the shortlist's layout (predict::prompt).
899    let whole = crate::whole::on();
900    let shortlist = if whole {
901        let ranked = conscience::rank_scoped(w.challenges, text, w.scope_text);
902        let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
903        let n = eligible.len();
904        let (shown, fell) = crate::whole::select(eligible, shortlist_now, &ranked, crate::whole::tokens());
905        log_cost(session, &format!("pool mode=all eligible={n} shown={} budget={}", shown.len(), if fell { "full" } else { "fits" }));
906        shown
907    } else {
908        shortlist_now()
909    };
910    let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
911    // R1: the same call judges what the drone did with lessons asked earlier this session.
912    let (pend, question) = if w.judge { pending(session, w.state, w.all, w.message, live) } else { (Vec::new(), false) };
913    // Nothing to ask and no question to judge (no lesson in the pool, none pending): no model call. A live
914    // check has no free gate, so without this every change of a session with no lessons paid for one.
915    // Free deliveries alone never pay for a call: they wait for the next one, or `borg learn` settles them.
916    if shortlist.is_empty() && !question {
917        return Verdict::Skip("empty".into());
918    }
919    let extra = predict::judge_section(&pend).len();
920    let graded = crate::whole::graded();
921    let timeout = live::call_limit(w.budget, w.started.elapsed());
922    let called = std::time::Instant::now();
923    let pred = if whole && !crate::whole::classic() {
924        predict::predict_whole(text, &shortlist, &pend, &model, graded, timeout)
925    } else {
926        predict::predict(text, &shortlist, &pend, &model, graded, timeout)
927    };
928    stat(session, input, if live { crate::stats::LIVE_CHECK } else { crate::stats::CHECK }, |e| {
929        e.n = shortlist.len() as u64;
930        e.dur_ms = called.elapsed().as_millis() as u64;
931        e.usd = pred.as_ref().map_or(0.0, |p| p.cost);
932        if pred.is_none() {
933            e.why = "failed".into();
934        }
935    });
936    let Some(pred) = pred else { return Verdict::Failed };
937    // Live calls log this line too: the judge's call numbering counts every model call of the session.
938    append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
939    if live {
940        log_cost(session, &format!("live usd={:.4} lessons={}", pred.cost, shortlist.len()));
941    }
942    if !pend.is_empty() {
943        let call = w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1;
944        record_verdicts(session, input, &pend, &pred.resolved, extra, call);
945    }
946    let applied: Vec<(&conscience::Challenge, u8)> =
947        pred.applies.iter().zip(&pred.grades).filter_map(|(i, g)| shortlist.iter().find(|c| &c.id == i).map(|c| (*c, *g))).collect();
948    let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
949    if graded {
950        // Graded (default): ask only for a lesson graded 3; a check without one hands its 2s and 1s over
951        // free at the next prompt or touch (conscience.rs `graded`).
952        let (ask, free, learned, p) = crate::attention::graded(&applied, &learn::home());
953        append(session, &graded_line(&applied));
954        append_p(session, applied.iter().zip(&p).filter(|((c, _), _)| ask.iter().any(|a| a.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)), None);
955        if learning() && !applied.is_empty() {
956            let graded = outcome::Outcome {
957                kind: outcome::GRADED.into(),
958                session: session.into(),
959                lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
960                ms: learn::now_ms(),
961                repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
962                channel: if live { "live" } else { "stop" }.into(),
963                grades: applied.iter().map(|(_, g)| *g).collect(),
964                ranks: (0..applied.len() as u16).collect(),
965                applied: applied.len() as u16,
966                chosen: ids(&ask),
967                explore: learned.as_ref().is_some_and(|c| c.source == "explore"),
968                p: p.iter().map(|x| outcome::round_p(*x)).collect(),
969                ..Default::default()
970            };
971            let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &graded);
972        }
973        if let Some(cut) = &learned {
974            append(session, &crate::attention::line(cut, applied.len()));
975        }
976        let queue = !free.is_empty();
977        log_cost(session, &format!("graded applied={} ask={} free={} {}", applied.len(), ask.len(), free.len(), if queue { "queued" } else { "dropped" }));
978        if queue {
979            enqueue(session, &free.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(","));
980        }
981        Verdict::Ask(ids(&ask))
982    } else {
983        // R3b: the model decides whether to ask (at the stop, an extra round); once it does, the questions
984        // also carry every other lesson over the low bar (conscience.rs `widen`).
985        let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
986        let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, text, w.scope_text), &predicted, w.done);
987        let widened: Vec<&conscience::Challenge> =
988            conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
989        append_p_of(session, &pool, &widened);
990        Verdict::Ask(ids(&widened))
991    }
992}
993
994/// Live mode (live.rs), in the PostToolUse hook: counts a state-changing tool call as a change and,
995/// when no check runs, something changed since the last one and the debounce has passed, starts `borg
996/// check` detached. The hook returns at once; the drone waits for nothing.
997fn live_trigger(session: &str, input: &Value, tool: &str, changed: bool) {
998    let ctx = state_file(session);
999    if let Some(why) = live::unsupported() {
1000        // Said once per session: the hook would otherwise repeat it after every change.
1001        if std::fs::OpenOptions::new().write(true).create_new(true).open(ctx.with_extension("live.off")).is_ok() {
1002            eprintln!("borg: live mode needs the consequence model, which {why} replaces; this session stays in hook mode");
1003        }
1004        return;
1005    }
1006    if changed {
1007        live::bump(&ctx, tool);
1008    }
1009    let now = learn::now_ms();
1010    // A paused drone (steer.rs) is waiting for its engineer, not working: nothing to check.
1011    let home = learn::home();
1012    if crate::steer::active(&home) && crate::steer::paused(&home, session, crate::presence::read(&home, session).map(|p| p.started), now) {
1013        return;
1014    }
1015    let lock = live::lock_state(&ctx, now, live::stale_ms(), live::alive);
1016    if live::decide(live::Mode::Live, lock, live::count(&ctx), live::read_mark(&ctx), now, live::debounce_ms()).is_err() {
1017        return;
1018    }
1019    let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
1020    // As `end` starts `borg learn`: this binary, detached, inheriting the collective's store and state.
1021    if let Ok(exe) = std::env::current_exe() {
1022        let _ = std::process::Command::new(exe)
1023            .args(["check", "--session", session, "--transcript", &s("transcript_path"), "--cwd", &s("cwd")])
1024            .stdin(std::process::Stdio::null())
1025            .stdout(std::process::Stdio::null())
1026            .stderr(std::process::Stdio::null())
1027            .spawn();
1028    }
1029}
1030
1031/// `borg check` (live.rs): one check of the session's work so far, in the background. Takes the
1032/// session's lock (or leaves: a check runs), decides again under it, then runs the stop's check on this
1033/// turn so far: the 3s wait in the `.queued` file as a `live <ids>` line, the 2s and 1s as at the stop.
1034/// It writes no `#borg-asked` (that happens at delivery, `ask_live`) and no `#borg-skip` (that closes a
1035/// stop's turn): a gate skip is `#borg-live-skip`. The mark records the changes a completed check covered.
1036pub fn live_check(h: &dyn Harness, session: &str, input: &Value) {
1037    let ctx = state_file(session);
1038    let started = learn::now_ms();
1039    let Some(_lock) = live::acquire(&ctx, started) else { return };
1040    let n = live::count(&ctx);
1041    let mark = live::read_mark(&ctx);
1042    if live::unsupported().is_some() || live::decide(live::current(), live::Lock::Free, n, mark, started, live::debounce_ms()).is_err() {
1043        return;
1044    }
1045    let turn = turn_id(session);
1046    let all = lessons(session);
1047    let state = std::fs::read_to_string(&ctx).unwrap_or_default();
1048    let message = work_so_far(h, input);
1049    // Done as at the stop, and also what still waits for delivery: it is decided already.
1050    let acted = early_acted(session, &all, &message);
1051    let mut done = listed(&state, "#borg-asked ");
1052    done.extend(listed(&state, "#borg-early ").into_iter().chain(listed(&state, "#borg-queued ")).filter(|i| acted.contains(*i)));
1053    let waiting = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1054    done.extend(waiting.lines().flat_map(|l| l.strip_prefix("live ").unwrap_or(l).split(',')).map(str::trim).filter(|i| !i.is_empty()));
1055    let challenges: Vec<conscience::Challenge> = all.iter().filter(|c| !done.contains(c.id.as_str()) && !c.off_system).cloned().collect();
1056    let context = format!("{state}{message}");
1057    let scope_text = format!("{}\n{message}", where_now(session));
1058    let work = Work {
1059        all: &all,
1060        challenges: &challenges,
1061        done: &done,
1062        state: &state,
1063        context: &context,
1064        scope_text: &scope_text,
1065        message: &message,
1066        changed: true,
1067        judge: true,
1068        budget: None,
1069        started: std::time::Instant::now(),
1070    };
1071    let before = mark.map_or(0, |m| m.covered);
1072    let covered = match check(session, input, &work, true) {
1073        // A skipped or failed check covered nothing: the stop decides for itself.
1074        Verdict::Skip(score) => {
1075            append(session, &format!("#borg-live-skip {score}"));
1076            before
1077        }
1078        Verdict::Failed => before,
1079        Verdict::Ask(ids) => {
1080            if !ids.is_empty() {
1081                enqueue(session, &format!("live {}", ids.join(",")));
1082            }
1083            n
1084        }
1085    };
1086    live::write_mark(&ctx, live::Mark { covered, started_ms: started, turn: Some(turn) });
1087}
1088
1089/// One hook event, canonical already (harness `normalise`), for the drone `h` runs: what borg answers
1090/// (Claude-shaped), or None. One function per event.
1091pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
1092    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1093    match event {
1094        "prompt" => on_prompt(h, session, input),
1095        "post_tool_failure" => {
1096            capture_test(session, input, true);
1097            if h.caps().deny_capture {
1098                capture_rejection(h, session, input);
1099            }
1100            None
1101        }
1102        "end" => {
1103            on_end(h, session, input);
1104            None
1105        }
1106        "post_tool" => {
1107            capture_test(session, input, false);
1108            on_post_tool(h, session, input)
1109        }
1110        "stop" => on_stop(h, session, input),
1111        _ => None,
1112    }
1113}
1114
1115fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
1116    let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
1117    append(session, prompt);
1118    if learning() {
1119        // Where the session started, once: its commits later get reasoning notes (notes.rs).
1120        crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
1121        crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
1122    }
1123    // The engineer's own words, separately from the drone's touches (the queen's criterion 3).
1124    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
1125        let _ = writeln!(f, "{prompt}");
1126    }
1127    // "borg no <reason>": the engineer's veto (veto.rs), never the session's task nor a correction.
1128    let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
1129    match &vetoed {
1130        Some(reason) => veto(h, session, input, reason),
1131        None => capture_prompt(h, session, input, prompt),
1132    }
1133    note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
1134    let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
1135    // An adapter that will not wait for the answer (`"context": false`), or a harness whose model never
1136    // sees a prompt's context, gets none: what is handed over here is marked delivered and would be lost.
1137    let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
1138        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1139        let ask = queen_ask(h, session, cwd);
1140        let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render)].into_iter().flatten().collect();
1141        free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
1142    });
1143    // A new turn's work starts, after a live ask handed over here took the last turn's as its evidence.
1144    if harness::transcript(h.caps(), input).is_none() {
1145        let _ = std::fs::write(turn_file(session), "");
1146    }
1147    out.flatten()
1148}
1149
1150/// A shell command that ran tests leaves `fail <ms>` or `pass <ms>` in `<state>/<session>.tests`: the
1151/// regress `tests` signal (`notes/regress_signals.rs`) and `borg learn`'s `tested` credit, whether or not
1152/// the harness reports denials. A call that did not fail and shows no output (an adapter that sends
1153/// none) leaves nothing: its result is unknown, not a pass.
1154fn capture_test(session: &str, input: &Value, failed: bool) {
1155    if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
1156        return;
1157    }
1158    let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
1159    if !crate::notes::regress_signals::runs_tests(command) {
1160        return;
1161    }
1162    let output = crate::notes::regress_signals::tool_output(input);
1163    if !failed && output.trim().is_empty() {
1164        return;
1165    }
1166    if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
1167        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
1168    {
1169        let _ = writeln!(f, "{what} {}", learn::now_ms());
1170    }
1171}
1172
1173fn on_end(h: &dyn Harness, session: &str, input: &Value) {
1174    let _ = std::fs::remove_file(denied_file(session));
1175    if !learning() {
1176        return;
1177    }
1178    // The session's landed commits get a redacted reasoning note, in the background.
1179    crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
1180    crate::notes::mark(&crate::notes::state_dir(), session, "stop");
1181    // Generalise this session's corrections in the background; never delay the drone. The notes job
1182    // starts it once its links are recorded, so learning sees their regret episodes.
1183    if !crate::notes::on_end(session, input)
1184        && let Ok(exe) = std::env::current_exe()
1185    {
1186        let _ = std::process::Command::new(exe)
1187            .args(["learn", "--session", session])
1188            .stdin(std::process::Stdio::null())
1189            .stdout(std::process::Stdio::null())
1190            .stderr(std::process::Stdio::null())
1191            .spawn();
1192    }
1193}
1194
1195fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
1196    // The drone moved on from a denial: the next prompt is no longer the engineer's reason.
1197    let _ = std::fs::remove_file(denied_file(session));
1198    let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
1199    let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
1200    let touch = touched(tool_input, tool);
1201    append(session, &touch);
1202    // A path the drone touched says where it works; a command's free text does not.
1203    note_where(
1204        session,
1205        &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
1206    );
1207    // BORG_STOP_GATE=edits: this turn changed something, so its Stop asks the model.
1208    if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
1209        let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
1210    }
1211    let edit = edit_text(tool, tool_input);
1212    if let Some(edit) = &edit
1213        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
1214    {
1215        let _ = writeln!(f, "{edit}");
1216    }
1217    // No transcript (omp, opencode): the turn's tool calls are the work so far a live check reads.
1218    if harness::transcript(h.caps(), input).is_none()
1219        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
1220    {
1221        let _ = writeln!(f, "[{tool}] {touch}");
1222        if let Some(edit) = &edit {
1223            let _ = writeln!(f, "{edit}");
1224        }
1225    }
1226    // Live mode (live.rs): a change starts a check of the work so far, in the background. Any later
1227    // tool call starts one too while changes wait unchecked, so a change inside the debounce is
1228    // checked once it has passed, not only at the stop.
1229    if live::current() == live::Mode::Live {
1230        live_trigger(session, input, tool, prefilter::changes(tool, tool_input));
1231    }
1232    // Context only where the harness's model reads it after a tool call; otherwise it waits for the next
1233    // prompt or the stop.
1234    h.caps().tool_context.then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
1235}
1236
1237/// Live mode at the stop: a check still running finishes first (bounded), so this stop reads what it
1238/// wrote. Then the stop holds the session's lock itself (`guard`, for the whole stop), so no check
1239/// starts beside it; when a check still holds it, the stop leaves the pending lessons to that check's
1240/// judge. The wait and the model call share the harness's budget (omp gives a stop 30 s).
1241struct StopLive {
1242    on: bool,
1243    budget: Option<std::time::Duration>,
1244    lock: live::Lock,
1245    guard: Option<live::Guard>,
1246    /// A live check's 3s that no tool call delivered yet: this stop asks them (it would have).
1247    waiting: Vec<String>,
1248}
1249
1250impl StopLive {
1251    fn take(h: &dyn Harness, session: &str) -> StopLive {
1252        let on = live::current() == live::Mode::Live;
1253        let budget = crate::expert_route::stop_budget(h);
1254        let lock = if on { live::wait_free(&state_file(session), live::stop_wait(budget).as_millis() as u64) } else { live::Lock::Free };
1255        let guard = if on && lock != live::Lock::Held { live::acquire(&state_file(session), learn::now_ms()) } else { None };
1256        let lock = if on && guard.is_none() { live::Lock::Held } else { lock };
1257        let waiting = if on { take_live(session) } else { Vec::new() };
1258        StopLive { on, budget, lock, guard, waiting }
1259    }
1260}
1261
1262fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
1263    let started = std::time::Instant::now();
1264    if learning() {
1265        // The drone's latest proposal, for the reasoning note of what it commits (notes.rs).
1266        crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
1267        crate::notes::mark(&crate::notes::state_dir(), session, "stop");
1268    }
1269    let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
1270    if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
1271        return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
1272    }
1273    let all = lessons(session);
1274    // R1: the drone's answer to the last block's questions, at the first turn end after it: evidence
1275    // for the judge at the next model call (`pending`), not a label.
1276    note_answer(h, session, input);
1277    // BORG_STOP_GATE=edits: whether this turn changed anything; every Stop consumes the marker, so
1278    // the rework after a block does not count for the next turn.
1279    let changed = std::fs::remove_file(changes_file(session)).is_ok();
1280    // One round of self-questioning per turn (the harness's stop_hook_active); never loop.
1281    if second_round {
1282        return None;
1283    }
1284    stat(session, input, crate::stats::TURN_END, |_| {});
1285    let live = StopLive::take(h, session);
1286    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1287    // A lesson is asked once per session: the drone answered it at that turn end, and asking it again
1288    // at every later one is noise (and a model call spent on it). A lesson delivered early
1289    // (`#borg-early`) is not asked again either.
1290    let mut done = std::collections::HashSet::new();
1291    // R3b: an early lesson the drone acted on is done; one it did not act on may still be asked.
1292    let proposal = full_proposal(h, input);
1293    let acted = early_acted(session, &all, &proposal);
1294    if std::env::var_os("BORG_ONLY").is_none() {
1295        done = listed(&state, "#borg-asked ");
1296        done.extend(listed(&state, "#borg-early ").into_iter().chain(listed(&state, "#borg-queued ")).filter(|i| acted.contains(*i)));
1297    }
1298    // Lessons of other systems (systems.rs) are neither shortlisted nor asked.
1299    let challenges: Vec<conscience::Challenge> = all.iter().filter(|c| !done.contains(c.id.as_str()) && !c.off_system).cloned().collect();
1300    let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
1301    let context = format!("{state}{message}");
1302    // Scopes at Stop: where the session works plus this turn's own message, not the whole history.
1303    let scope_text = format!("{}\n{message}", where_now(session));
1304    let work = Work {
1305        all: &all,
1306        challenges: &challenges,
1307        done: &done,
1308        state: &state,
1309        context: &context,
1310        scope_text: &scope_text,
1311        message: &proposal,
1312        changed,
1313        judge: !live.on || live.guard.is_some(),
1314        budget: live.budget,
1315        started,
1316    };
1317    match stop_select(h, session, input, &work, &live) {
1318        Ok(selected) => stop_ask(h, session, input, selected),
1319        Err(out) => out,
1320    }
1321}
1322
1323/// The lessons this stop asks; `Err` with the hook's answer when it asks none (a gate skip still lets
1324/// the queen ask).
1325fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
1326    match std::env::var("BORG_ONLY") {
1327        // BORG_ONLY: eval oracle, perfect retrieval = exactly these challenge ids.
1328        Ok(ids) => Ok(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect()),
1329        // Word matching alone always finds *some* lesson, so it asked at every turn end; the
1330        // consequence model may abstain, and it is the mechanism the sealed eval measured.
1331        Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w, live),
1332        // R3b (conscience.rs `budget`): block only for a lesson near the best match not yet done;
1333        // then ask everything over the low bar, in-scope first.
1334        Err(_) => {
1335            let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
1336            let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
1337                .into_iter()
1338                .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
1339                .collect();
1340            append_p_of(session, &pool, &asked);
1341            Ok(asked)
1342        }
1343    }
1344}
1345
1346/// The consequence model's verdict at the stop, unless a live check covered the turn; a live check's
1347/// waiting asks join it.
1348fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
1349    let skip = |why: &str| {
1350        append(session, &format!("#borg-skip {why}"));
1351        stat(session, input, crate::stats::GATE_SKIP, |e| e.why = why.to_string());
1352        let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
1353        Err(crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[]))
1354    };
1355    let waiting: Vec<&conscience::Challenge> = live.waiting.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect();
1356    // Live mode: a check that started in this turn covered every change and none came since: its
1357    // verdict stands (its waiting asks are asked now), and this stop costs no model call (live.rs `covered`).
1358    let ctx = state_file(session);
1359    if live.on && live::covered(live.lock, live::count(&ctx), live::read_mark(&ctx), turn_id(session)) {
1360        return if waiting.is_empty() { skip("live_covered") } else { Ok(waiting) };
1361    }
1362    let mut asked: Vec<&conscience::Challenge> = match check(session, input, w, false) {
1363        Verdict::Skip(_) | Verdict::Failed if !waiting.is_empty() => Vec::new(),
1364        Verdict::Skip(score) => return skip(&score),
1365        Verdict::Failed => return Err(None),
1366        Verdict::Ask(ids) => ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect(),
1367    };
1368    for c in waiting {
1369        if !asked.iter().any(|a| a.id == c.id) {
1370            asked.push(c);
1371        }
1372    }
1373    Ok(asked)
1374}
1375
1376/// `#borg-graded n=<applied> <id>:<grade>:<rank>,…`: a graded check's applies in the model's order, so
1377/// the `asked` outcome can carry each asked lesson's grade and rank (`graded_of`).
1378fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
1379    let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
1380    format!("#borg-graded n={} {}", applied.len(), items.join(","))
1381}
1382
1383/// `#borg-p <id>=<p>,…[ @<check>]`: the propensity (outcome.rs `p`) of each lesson a check chose to ask,
1384/// so every entry is above 0 and the latest entry per id is the check that asked it; the `asked`
1385/// outcome carries them (`propensity_of`). `check` names the pool line the draw was over (`append_p_of`);
1386/// none for a graded or learned-cut check. Nothing when no lesson was chosen.
1387fn append_p<'a>(session: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) {
1388    let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
1389    if !items.is_empty() {
1390        let tag = check.map_or_else(String::new, |c| format!(" @{c}"));
1391        append(session, &format!("#borg-p {}{tag}", items.join(",")));
1392    }
1393}
1394
1395/// `append_p` for lessons `budget`/`widen` asked from `pool`: their chance of a slot under the
1396/// session's posterior (conscience.rs `propensity`). Then the pool itself, `#borg-pool
1397/// <id>=<base>[:s],… @<check>` in ranking order (`:s` a scoped lesson, base conscience.rs `pool_base`,
1398/// unrounded), so the `asked` record can carry it (`pool_of`) and `borg debug ope` can replay the draw. Both
1399/// lines carry the same `check` (a hash of the pool line and its p line), which ties the p of an asked
1400/// lesson to the pool its draw was over.
1401fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge]) {
1402    if asked.is_empty() {
1403        return;
1404    }
1405    let p = conscience::propensity(pool, session);
1406    let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
1407    let items: Vec<String> =
1408        pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
1409    let items = items.join(",");
1410    let mut h = blake3::Hasher::new();
1411    h.update(items.as_bytes());
1412    for (id, p) in &chosen {
1413        h.update(format!("\0{id}={}", outcome::round_p(*p)).as_bytes());
1414    }
1415    let check = h.finalize().to_hex()[..12].to_string();
1416    append_p(session, chosen, Some(&check));
1417    append(session, &format!("#borg-pool {items} @{check}"));
1418}
1419
1420/// Splits `line` into its items and its ` @<check>` tag (None without one).
1421fn tagged(line: &str) -> (&str, Option<&str>) {
1422    match line.rsplit_once(" @") {
1423        Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
1424        _ => (line, None),
1425    }
1426}
1427
1428/// The pool behind `ids` (`append_p_of`): the `#borg-pool` line of the check that wrote the latest
1429/// `#borg-p` entry of every one of them, as (ids, bases, scoped); empty when their latest entries come
1430/// from different checks, from a check without a pool, or from a line before checks were tagged (never
1431/// a guess: the p of one draw is never paired with another draw's pool).
1432fn pool_of(session: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
1433    pool_in(&std::fs::read_to_string(state_file(session)).unwrap_or_default(), ids)
1434}
1435
1436fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
1437    let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
1438        let mut out = (Vec::new(), Vec::new(), Vec::new());
1439        for item in line.split(',') {
1440            let (id, rest) = item.rsplit_once('=')?;
1441            let (base, scoped) = match rest.strip_suffix(":s") {
1442                Some(b) => (b, true),
1443                None => (rest, false),
1444            };
1445            out.0.push(id.trim().to_string());
1446            out.1.push(base.trim().parse::<f64>().ok()?);
1447            out.2.push(scoped);
1448        }
1449        Some(out)
1450    };
1451    // The check of each id's latest p entry.
1452    let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
1453    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
1454        let (items, tag) = tagged(line);
1455        for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1456            check.insert(id.trim(), tag);
1457        }
1458    }
1459    let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
1460    if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
1461        return Default::default();
1462    }
1463    state
1464        .lines()
1465        .rev()
1466        .filter_map(|l| l.strip_prefix("#borg-pool "))
1467        .map(tagged)
1468        .filter(|(_, t)| *t == Some(tag))
1469        .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
1470        .unwrap_or_default()
1471}
1472
1473/// For each of `ids`, its propensity in the session's latest `#borg-p` entry for it; empty when any of
1474/// them has none (unknown, never a guess).
1475fn propensity_of(session: &str, ids: &[String]) -> Vec<f64> {
1476    propensity_in(&std::fs::read_to_string(state_file(session)).unwrap_or_default(), ids)
1477}
1478
1479fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
1480    let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1481    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
1482        for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
1483            if let Ok(p) = p.trim().parse::<f64>() {
1484                seen.insert(id.trim(), p);
1485            }
1486        }
1487    }
1488    ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1489}
1490
1491/// For each of `ids`: its grade and rank in the session's latest graded check that graded it, and that
1492/// check's number of applies (the largest when the ids come from several checks); (0, 0) and 0 when none.
1493fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
1494    let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1495    let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
1496    for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
1497        let Some((n, items)) = line.split_once(' ') else { continue };
1498        let n: u16 = n.parse().unwrap_or(0);
1499        for it in items.split(',') {
1500            let mut p = it.rsplitn(3, ':');
1501            let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
1502            seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
1503        }
1504    }
1505    let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
1506    (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
1507}
1508
1509/// The stop asks `selected`: one block (the queen's, when it asks too), recorded as asked.
1510fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut selected: Vec<&conscience::Challenge>) -> Option<Value> {
1511    // Shown in an order stable within the session but different across sessions: no lesson always
1512    // sits first (position bias; memory.rs `shuffle`).
1513    crate::memory::shuffle(&mut selected, session);
1514    let log = selected.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
1515    append(session, &format!("#borg-asked {log}"));
1516    if !selected.is_empty()
1517        && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
1518    {
1519        // The blocked message is the work before the questions (R1 `pending`).
1520        let _ = writeln!(f, "{}\n#borg-asked {log}", one_line(&full_proposal(h, input), 4000));
1521    }
1522    // L4 (queen.rs): a rare, budgeted question to the engineer when the conscience had nothing
1523    // or its lessons contradict each other (its block then carries the lessons too).
1524    let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
1525    let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &selected);
1526    if selected.is_empty() {
1527        return queen;
1528    }
1529    if learning() {
1530        // Outcome tracking (outcome.rs): `borg learn` later sees whether a correction followed.
1531        let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1532        let lessons: Vec<String> = selected.iter().map(|c| c.id.clone()).collect();
1533        let (grades, ranks, applied) = graded_of(session, &lessons);
1534        let p = propensity_of(session, &lessons);
1535        let (pool, base, scoped) = pool_of(session, &lessons);
1536        let asked = outcome::Outcome {
1537            kind: outcome::ASKED.into(),
1538            session: session.into(),
1539            lessons,
1540            ms: learn::now_ms(),
1541            repo: repo_of(cwd),
1542            groups: conscience::disagreement_ids(&selected),
1543            channel: "stop".into(),
1544            grades,
1545            ranks,
1546            applied,
1547            p,
1548            pool,
1549            base,
1550            scoped,
1551            ..Default::default()
1552        };
1553        let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1554    }
1555    let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&selected)})));
1556    let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
1557    log_cost(session, &format!("block questions={} chars={chars}", selected.len()));
1558    stat(session, input, crate::stats::BLOCK, |e| {
1559        e.n = selected.len() as u64;
1560        e.lessons = selected.iter().map(|c| c.id.clone()).collect();
1561        e.channel = "stop".into();
1562    });
1563    out
1564}
1565
1566/// `borg hook <harness> <event>`, the event on stdin: routes the repo to its collective (an off repo
1567/// leaves no trace), records presence, then answers through steering, the experts and [`dispatch`],
1568/// with the messages from other sessions (msg.rs).
1569/// An unknown harness, like any failure here, lets the drone continue untouched.
1570pub fn main(name: &str, event: &str) {
1571    let Some(h) = harness::by_name(name) else { return };
1572    let mut raw = String::new();
1573    let _ = std::io::stdin().read_to_string(&mut raw);
1574    let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
1575    // A pid only from a harness that sends its own: presence reads a gone one as an ended drone.
1576    if !h.caps().sends_pid
1577        && let Some(m) = input.as_object_mut()
1578    {
1579        m.shift_remove("pid");
1580    }
1581    // Which mind is this repo part of? Decided before anything is read or written: an off repo
1582    // (or a broken config) leaves no trace at all.
1583    let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1584    let cfg = collective::load();
1585    let route = match &cfg {
1586        Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
1587        Err(_) => collective::Route::Off,
1588    };
1589    let collective::Route::Collective { home, name, .. } = route else { return };
1590    // The delivery mode (live.rs): BORG_MODE, else the collective's `mode`, else live. Exported as the
1591    // mode this drone runs in, which the statistics record.
1592    let mode = live::of(cfg.as_ref().ok().and_then(Option::as_ref), &name).mode;
1593    // SAFETY: single-threaded at this point; everything below (and the background `borg learn`
1594    // or `borg check` it spawns) inherits the collective's own store, scratch state and mode.
1595    unsafe {
1596        std::env::set_var("BORG_HOME", &home);
1597        std::env::set_var("BORG_MODE", mode.name());
1598        // borg's own model calls go through the drone's harness (runner.rs); `borg check` reads it too.
1599        std::env::set_var("BORG_HARNESS", h.name());
1600        if std::env::var_os("BORG_STATE").is_none() {
1601            std::env::set_var("BORG_STATE", home.join("state"));
1602        }
1603    }
1604    // Who is working where (presence.rs, for `borg top`): one small atomic write per hook.
1605    let now = learn::now_ms();
1606    let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1607    // The turn of a session that listens for wakes (listen.rs): one stat of its opt-in for any other, at
1608    // a prompt, a tool call's start or end, a halted turn and the end. With steering's stat below, an idle
1609    // tool call costs two failed stats: the opt-in is per session, the admissions are per store.
1610    crate::listen::touch(&home, event, session, now);
1611    // A session's start (a hook of an older install) and a halted turn read and write nothing more.
1612    if matches!(event, "start" | "halted") {
1613        return;
1614    }
1615    // Not at pre_tool (every tool call): there only steering reads, once someone steered (steer.rs
1616    // `active`, the second stat).
1617    let me = match event {
1618        "pre_tool" => {
1619            if !crate::steer::active(&home) {
1620                return;
1621            }
1622            crate::presence::read(&home, session)
1623        }
1624        _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
1625    };
1626    // Steering (steer.rs) wraps the answer: a pause refuses tools and lets the stop wait.
1627    // At a stop, the routed collective's experts join the conscience's block (expert_route.rs).
1628    let started = me.map(|p| p.started);
1629    // A listening session's stop holds its wakes off until its answer is out (listen.rs).
1630    let stopping = crate::listen::stopping(&home, event, session, now);
1631    let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
1632    // A listening session's stop starts its waiter, or asks for it (listen.rs).
1633    let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
1634    // Messages from other sessions (msg.rs) join a prompt's or tool call's context, each once.
1635    if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
1636        println!("{out}");
1637    }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642    use super::*;
1643
1644    #[test]
1645    fn every_file_of_an_edit_survives_the_cut_of_its_text() {
1646        let long = "x".repeat(700);
1647        let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
1648        let text = edit_text("Edit", &input).unwrap();
1649        assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
1650        assert_eq!(text.chars().count(), 600);
1651        let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
1652        assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
1653        assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
1654    }
1655
1656    #[test]
1657    fn propensity_of_takes_the_latest_entry_and_nothing_when_one_is_missing() {
1658        let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
1659        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1660        assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
1661        assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
1662        assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
1663        assert!(propensity_in("", &ids(&["A"])).is_empty());
1664    }
1665
1666    #[test]
1667    fn pool_of_takes_the_pool_of_the_check_that_logged_each_p() {
1668        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";
1669        let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1670        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");
1671        assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
1672        assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
1673        assert_eq!(pool_in("", &ids(&["A"])), Default::default());
1674        assert_eq!(pool_in(state, &[]), Default::default());
1675        // A graded check's p has no pool: a later pool that happens to hold the lesson is not its.
1676        let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
1677        assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
1678        assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
1679        // A state file from before checks were tagged: no pool.
1680        assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
1681    }
1682
1683    /// Track R's merged ask: a live check drew A and B from one pool, a stop check drew A again from
1684    /// another; the stop merges the waiting B into its ask of A. Their p come from two draws, so the
1685    /// record carries no pool (counted as no_pool by `borg debug ope`), never one draw's pool with both p.
1686    #[test]
1687    fn a_merged_ask_of_two_draws_carries_no_pool() {
1688        let session = format!("pool-merge-{}", std::process::id());
1689        let _ = std::fs::remove_file(state_file(&session));
1690        let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
1691        let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
1692        let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
1693        append_p_of(&session, &live, &[&a, &b]);
1694        let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
1695        append_p_of(&session, &stop, &[&a]);
1696        let merged = vec!["A".to_string(), "B".to_string()];
1697        assert_eq!(propensity_of(&session, &merged).len(), 2, "each id has its latest p");
1698        assert_eq!(pool_of(&session, &merged), Default::default());
1699        assert_eq!(pool_of(&session, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
1700        assert_eq!(pool_of(&session, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
1701        let _ = std::fs::remove_file(state_file(&session));
1702    }
1703}