Skip to main content

borg/
veto.rs

1//! `borg no`: the engineer's veto, the strongest negative signal.
2//!
3//! An engineer says no to a drone's work, with a reason: `borg no <reason>` from a terminal (the drone
4//! of the current repo, or the one `--session` names), or a prompt that starts with "borg no" inside the
5//! session. Saying it costs 0 tokens and has three effects:
6//! - **Credit.** Every lesson shown in the session so far (asked at a stop or live, handed over early or
7//!   queued) gets a `vetoed` outcome (outcome.rs), which memory.rs weighs above `asked_then_corrected`.
8//! - **A lesson.** The reason is an episode of kind `veto`; `borg learn` generalises it with its own
9//!   prompt at the end of any session, so a veto from a terminal never waits for its session. A veto
10//!   merged into a lesson an earlier veto made is counted as `veto_repeat`: the same "no" said twice,
11//!   the rate learning is to bring to zero.
12//! - **A proposed constraint.** When the reason names an invariant of the system, `borg learn` queues it
13//!   as a queen question (`Kind::Constraint`). Only an engineer's answer ratifies it into
14//!   `<store>/constraints.jsonl`; learning never applies a constraint. A ratified constraint is a
15//!   standing lesson (conscience.rs `standing`): every session gets it early once its scope is in play
16//!   (an unscoped one at the first prompt), whatever the match, and the mesh syncs the file signed like
17//!   the lessons. Nothing blocks an edit or a stop on a constraint yet.
18//!
19//! - **A missed escalation**, when the vetoed work had already merged and the queen never escalated
20//!   from that session (escalation.rs, the queen's attention reward; logged and shown, never optimised).
21//!
22//! **Across the mesh.** A drone on another node is vetoed by naming it (`--session`, a designation or a
23//! session id, as `borg pause` takes it): `borg no` then sends a `veto` message to that session of that
24//! node (msg.rs `Kind::Veto`). It counts there only under a steering role the mesh admitted this node
25//! with (steer.rs), as any pause. The drone's node records it once when it arrives (`apply_messages`):
26//! the credit, the episode and the stat land where the shown lessons and the session are, and its
27//! drone is told at its next prompt or tool call, labelled with the sender and role. The issuing node
28//! records nothing but `steer_issued`; the `vetoed` outcomes reach it as team outcomes (outcome.rs
29//! `SHARED_KINDS`), so both nodes' beliefs count the veto once.
30//!
31//! In the session the drone is told to stop the vetoed approach and not argue it back. A veto prompt is
32//! never the session's task and never doubles as a correction. BORG_VETO=off makes such a prompt an
33//! ordinary one (the command still works).
34
35use std::path::{Path, PathBuf};
36use std::sync::LazyLock;
37
38use regex::Regex;
39use serde_json::json;
40
41use crate::msg::{self, Address, Kind, Message};
42use crate::outcome::{self, Outcome};
43use crate::presence::State;
44use crate::{collective, escalation, learn, presence, queen, stats, steer, top};
45
46/// `Episode::kind` of a veto.
47pub const KIND: &str = "veto";
48
49/// The veto prompt's opening; `learn::schema_for` picks the veto schema by it.
50pub const OPENING: &str = "An engineer vetoed a coding agent's work: they said no to what it was doing, with the reason below. \
51Decide whether the reason expresses a reusable engineering lesson: a critical concern about quality, design, correctness, reuse, \
52spec conformance or root cause that this engineer would plausibly raise again about similar future work (a veto is rarely \
53operational; \"stop\", \"later\" are NOT lessons).";
54
55/// The extra field line in the veto prompt.
56pub const INVARIANT: &str = "- invariant: if the reason names an invariant of the system (a property that must always hold, e.g. \
57\"merge-to-main lookups go through the review API, never the commit log\"), state it as one rule; otherwise empty. Never invent one.";
58
59/// The answers a constraint question offers: the default never applies anything.
60pub const KEEP_OPTION: &str = "keep it a lesson only";
61pub const RATIFY_OPTION: &str = "ratify: make it a constraint (only an engineer can)";
62
63static PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)^\s*borg\s+no\b[\s:,.-]*(.*)$").unwrap());
64
65/// The most characters of a veto's reason that its episode keeps. Guard: bounds the episode, as a
66/// correction's text is bounded where the hook captures it (hook.rs `capture_prompt`): the same bound.
67pub const REASON_CHARS: usize = crate::learn::EXCERPT_CHARS;
68/// A shorter reason is no veto (a stray "borg no"). Guard: an accidental prompt does not become one.
69const REASON_MIN: usize = 4;
70/// Display: the characters of the veto's words a proposed constraint shows as its evidence, beside the
71/// invariant quoted whole: enough to recognise the veto, the episode keeps it all (`REASON_CHARS`).
72const EVIDENCE_CHARS: usize = 200;
73
74/// False when BORG_VETO=off: a prompt starting "borg no" is an ordinary prompt.
75pub fn enabled() -> bool {
76    std::env::var("BORG_VETO").as_deref() != Ok("off")
77}
78
79/// The reason of a prompt that is a veto ("borg no: never …"), trimmed, at most `REASON_CHARS`; None
80/// when it is not a veto or its reason is shorter than `REASON_MIN` characters.
81pub fn reason_in(prompt: &str) -> Option<String> {
82    let reason = PREFIX.captures(prompt)?.get(1)?.as_str().trim();
83    (reason.chars().count() >= REASON_MIN).then(|| reason.chars().take(REASON_CHARS).collect())
84}
85
86/// The lessons the drone was shown in `session` so far, sorted and unique: its `asked` outcomes (stop
87/// or live) and its stats events of the delivering kinds (early, queued, live asks, blocks).
88pub fn shown(home: &Path, session: &str) -> Vec<String> {
89    const DELIVERED: [&str; 4] = [stats::EARLY, stats::QUEUED_FREE, stats::LIVE_ASK, stats::BLOCK];
90    let asked = outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == outcome::ASKED && o.session == session).flat_map(|o| o.lessons);
91    let delivered = stats::events(home).into_iter().filter(|e| e.session == session && DELIVERED.contains(&e.kind.as_str())).flat_map(|e| e.lessons);
92    let mut out: Vec<String> = asked.chain(delivered).filter(|l| !l.is_empty()).collect();
93    out.sort();
94    out.dedup();
95    out
96}
97
98/// One veto as said.
99pub struct Said<'a> {
100    pub session: &'a str,
101    pub repo: &'a str,
102    pub engineer: &'a str,
103    /// The session's task, as the hooks kept it.
104    pub task: &'a str,
105    /// The drone's last proposal; empty from a terminal (no transcript there).
106    pub agent: &'a str,
107    pub reason: &'a str,
108    pub ms: i64,
109    /// `cli`, `prompt` or `mesh` (a signed directive from another node, `apply`).
110    pub via: &'static str,
111}
112
113/// What `record` wrote: the episode's id, the lessons credited `vetoed` now, and whether the veto
114/// counted as a missed escalation (escalation.rs).
115#[derive(Debug, Clone, PartialEq)]
116pub struct Veto {
117    pub episode: String,
118    pub credited: Vec<String>,
119    pub missed: bool,
120}
121
122/// Records one veto in `home`: the episode, a `vetoed` outcome per shown lesson not yet credited in
123/// the session (one per lesson keeps `outcome::has` idempotent; nothing when none, so a session with
124/// nothing shown adds no clock tick), and one `veto` stat for every call. A veto of work that had
125/// already merged, from a session the queen never escalated, is also a missed escalation
126/// (escalation.rs `missed`). The caller checks `learn::learning()` first.
127///
128/// # Errors
129/// When the episode or an outcome cannot be appended.
130pub fn record(home: &Path, s: &Said) -> std::io::Result<Veto> {
131    let e = learn::Episode {
132        id: format!("no-{}-{}", s.session.chars().take(8).collect::<String>(), s.ms),
133        ms: s.ms,
134        repo: s.repo.into(),
135        session: s.session.into(),
136        task: s.task.into(),
137        agent: s.agent.into(),
138        rejected_tool: None,
139        correction: s.reason.into(),
140        engineer: s.engineer.into(),
141        kind: KIND.into(),
142        ..learn::Episode::default()
143    };
144    learn::record_episode_in(home, &e)?;
145    let path = home.join("outcomes.jsonl");
146    let outcomes = outcome::read(&path);
147    let credited: Vec<String> = shown(home, s.session).into_iter().filter(|l| !outcome::has(&outcomes, outcome::VETOED, s.session, l)).collect();
148    for l in &credited {
149        let o =
150            Outcome { kind: outcome::VETOED.into(), session: s.session.into(), lessons: vec![l.clone()], ms: s.ms, repo: s.repo.into(), ..Default::default() };
151        outcome::append(&path, &o)?;
152    }
153    stats::count(home, stats::VETO, s.session, |ev| {
154        ev.ms = s.ms;
155        ev.engineer = s.engineer.into();
156        ev.repo = s.repo.into();
157        ev.lessons = credited.clone();
158        ev.n = credited.len() as u64;
159        ev.why = s.via.into();
160        ev.text = stats::excerpt(s.reason, stats::FEED_CHARS);
161    });
162    let missed = escalation::merged(&crate::notes::state_dir(), s.session) && escalation::missed(home, s.session, KIND, &e.id, s.repo, s.ms);
163    Ok(Veto { episode: e.id, credited, missed })
164}
165
166/// The session's task as the hooks kept it (`<state>/<session>.task`), else `fallback` (its last prompt).
167fn task_of(home: &Path, session: &str, fallback: &str) -> String {
168    let state = std::env::var_os("BORG_STATE").map_or_else(|| home.join("state"), PathBuf::from);
169    std::fs::read_to_string(state.join(format!("{}.task", presence::safe(session))))
170        .ok()
171        .filter(|t| !t.trim().is_empty())
172        .unwrap_or_else(|| fallback.to_string())
173}
174
175/// Records the veto a message carries (msg.rs `Kind::Veto`), once, on the node `here` whose session it
176/// names: `borg no` for a drone on another machine sends it from there (`remote`), the mesh carries it
177/// here like any message, and this node holds what the veto credits (the lessons the session was
178/// shown) and learns its episode, so its beliefs and its repeat-veto count see it. The caller checks
179/// that it counts (steer.rs `Roles`). The issuing node records nothing but its `steer_issued` stat;
180/// the `vetoed` outcomes reach it as team outcomes. Once: `<store>/messages/vetoed/<id>` is created
181/// exclusively first (removed again if recording fails, so the next round retries). None for any other
182/// message, one for another node, or one already recorded.
183///
184/// # Errors
185/// When the marker or the veto cannot be written.
186pub fn apply(home: &Path, m: &Message, here: &str) -> std::io::Result<Option<Veto>> {
187    let Some(Address::Session { id: session, node: Some(node) }) = Address::parse(&m.to) else { return Ok(None) };
188    if m.kind != Kind::Veto || node != here || m.text.trim().is_empty() {
189        return Ok(None);
190    }
191    // A session unknown here is another node's, or none.
192    let session = session.as_str();
193    let p = presence::read(home, session);
194    if p.is_none() && !outcome::read(&home.join("outcomes.jsonl")).iter().any(|o| o.session == session) {
195        return Ok(None);
196    }
197    let dir = msg::dir(home).join("vetoed");
198    std::fs::create_dir_all(&dir)?;
199    let marker = dir.join(presence::safe(&m.id));
200    match std::fs::OpenOptions::new().write(true).create_new(true).open(&marker) {
201        Ok(_) => {}
202        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
203        Err(e) => return Err(e),
204    }
205    let repo = p.as_ref().map(|p| p.repo.clone()).unwrap_or_default();
206    let task = task_of(home, session, p.as_ref().map_or("", |p| p.prompt.as_str()));
207    let reason: String = m.text.trim().chars().take(REASON_CHARS).collect();
208    // The engineer's email when an engineer sent it, else the sending session's name.
209    let engineer = match Address::parse(&m.by) {
210        Some(Address::Engineer { email }) if !email.is_empty() => email,
211        _ => m.name.clone(),
212    };
213    let said = Said { session, repo: &repo, engineer: &engineer, task: &task, agent: "", reason: &reason, ms: m.ms, via: "mesh" };
214    record(home, &said).map(Some).inspect_err(|_| {
215        let _ = std::fs::remove_file(&marker);
216    })
217}
218
219/// Records every admitted veto message for a session of this node not recorded yet (`apply`); the mesh
220/// calls it after it admits a round's messages (steer.rs `admit`) and at every `enforce`, which
221/// retries what failed. Nothing with learning off or while nobody steers. Returns how many it recorded.
222pub fn apply_messages(home: &Path, now: i64) -> usize {
223    if !learn::learning() {
224        return 0;
225    }
226    let roles = steer::roles(home);
227    if roles.is_empty() {
228        return 0;
229    }
230    let here = msg::node(home);
231    let lines: Vec<Message> = crate::jsonl::read_lines(&msg::path(home));
232    lines
233        .iter()
234        .filter(|m| m.kind == Kind::Veto && roles.has(&m.id) && m.well_formed() && roles.of(m, &here, now).is_some())
235        .filter_map(|m| apply(home, m, &here).ok().flatten())
236        .count()
237}
238
239/// What the drone is told when the engineer vetoes in the session.
240pub fn render(reason: &str) -> String {
241    format!(
242        "[borg] The engineer said no to this work: \"{reason}\". Stop the vetoed approach now; do not argue it back. If the reason is unclear, ask one short clarifying question, then redo the work within it."
243    )
244}
245
246/// A session as the terminal names it beside its drone: its first characters (msg.rs `short_session`).
247fn short(session: &str) -> String {
248    crate::msg::short_session(session)
249}
250
251/// The drone a terminal veto names (pure). `named`: a designation, number or session id (prefix), as
252/// `borg pause` takes it, of a local drone or one on another node (`main` then sends it as a veto
253/// message). Otherwise the local drone working in `repo`: the one not ended, else the most recently
254/// seen ended one (a veto right after the session ended); several active ones are an error, never a
255/// guess. A remote drone is never picked unnamed.
256///
257/// # Errors
258/// What to name instead.
259pub fn target<'a>(drones: &'a [top::Drone], repo: &str, named: Option<&str>) -> Result<&'a top::Drone, String> {
260    if let Some(n) = named {
261        return match top::resolve(drones, n)? {
262            None => Err("a veto names one drone, not local, collective or all".into()),
263            Some(d) => Ok(d),
264        };
265    }
266    let here: Vec<&top::Drone> = drones.iter().filter(|d| d.node.is_empty() && d.p.repo == repo).collect();
267    let active: Vec<&top::Drone> = here.iter().copied().filter(|d| d.state != State::Ended).collect();
268    match active.as_slice() {
269        [d] => Ok(d),
270        [] => here.into_iter().max_by_key(|d| d.p.ms).ok_or_else(|| format!("no drone in {repo} (`borg top`): name a session with --session")),
271        many => {
272            let names: Vec<String> = many.iter().map(|d| format!("{} {}", d.designation, short(&d.p.session))).collect();
273            Err(format!("{} drones work in {repo}: name one with --session ({})", many.len(), names.join(", ")))
274        }
275    }
276}
277
278/// Queues the invariant a veto's lesson named as a `Kind::Constraint` question, never a constraint: the
279/// default keeps it a lesson only. Dedups by key and near-duplicate (`queen::seed`). Returns whether it
280/// was queued.
281///
282/// # Errors
283/// When the queue cannot be appended.
284pub fn propose(home: &Path, lesson: &learn::Lesson, invariant: &str, e: &learn::Episode, now: i64) -> std::io::Result<bool> {
285    let key = queen::constraint_key(&lesson.repo, invariant);
286    let entry = queen::Entry {
287        id: format!("q{}", &key[..8]),
288        decision_key: key,
289        kind: queen::Kind::Constraint,
290        question: format!("Make this a constraint of the collective? \"{}\"", stats::excerpt(invariant, queen::QUOTE_CHARS)),
291        options: vec![KEEP_OPTION.into(), RATIFY_OPTION.into()],
292        default: KEEP_OPTION.into(),
293        evidence: vec![format!("invariant: {invariant}"), format!("lesson {}", lesson.id), format!("veto: {}", stats::excerpt(&e.correction, EVIDENCE_CHARS))],
294        session: e.session.clone(),
295        cwd: String::new(),
296        repo: lesson.repo.clone(),
297        engineer: e.engineer.clone(),
298        ms: now,
299        answer: None,
300        answered_by: None,
301        answered_ms: None,
302        disagreeing: Vec::new(),
303        dismissed: None,
304        dismissed_ms: None,
305        payload: None,
306    };
307    queen::seed(home, &entry)
308}
309
310/// The ratified constraints of a store, beside its lessons (`constraints_path`).
311pub const FILE: &str = "constraints.jsonl";
312
313/// The constraints file of a store. Written on ratification, read with the lessons as standing ones
314/// (conscience.rs `load`) and synced by the mesh (borg-mesh lines.rs `SYNCED`).
315pub fn constraints_path(store: &Path) -> PathBuf {
316    store.join(FILE)
317}
318
319/// The answer to a constraint question (queen.rs `answer`): an answer starting with "ratify" (option
320/// b) appends the invariant to `constraints.jsonl` and returns true; anything else writes nothing.
321/// The line syncs to the team, so it carries what a lesson does and no more: the invariant (redacted,
322/// as a lesson is generalised), the lesson it came from and who ratified it, never the veto's raw words.
323/// It also carries who should hear it: the lesson's scope, repo and reach (reach.rs), or, the lesson
324/// unknown here, the question's repo with reach `repo`, so a node without the lesson never widens it.
325///
326/// # Errors
327/// When the constraints file cannot be appended.
328pub fn ratify(store: &Path, e: &queen::Entry, answer: &str, author: &str, now: i64) -> Result<bool, String> {
329    if !answer.trim().to_lowercase().starts_with("ratify") {
330        return Ok(false);
331    }
332    let field = |prefix: &str| e.evidence.iter().find_map(|x| x.strip_prefix(prefix)).unwrap_or_default().to_string();
333    let lesson = field("lesson ");
334    let from = crate::conscience::load_unweighted(&store.join("lessons.jsonl"), None).into_iter().find(|l| l.id == lesson);
335    let (scope, repo, reach) = match from {
336        Some(l) => (l.scope, if l.repo.is_empty() { e.repo.clone() } else { l.repo }, l.reach),
337        None => (Vec::new(), e.repo.clone(), crate::reach::REPO.to_string()),
338    };
339    let line = json!({"id": format!("c{}", &e.decision_key[..8.min(e.decision_key.len())]), "text": crate::notes::redact(&field("invariant: ")),
340        "lesson": lesson, "scope": scope, "repo": repo, "reach": reach, "by": author, "ms": now, "source": "veto", "check": ""});
341    learn::append_jsonl(&constraints_path(store), &line).map_err(|err| err.to_string())?;
342    Ok(true)
343}
344
345/// `borg no`'s flags (completions.rs).
346pub const FLAGS: &[&str] = &["--session", "--dir", "--json"];
347
348const USAGE: &str = "usage: borg no [--session ID|DRONE] [--dir DIR] [--json] <reason…>
349  says no to a drone's work: the lessons it was shown lose credit, and the reason becomes a lesson";
350
351/// A veto of the drone `d` on another node: a veto message to its session of its node (the drone's node
352/// records it, `apply_messages`).
353fn remote(home: &Path, drones: &[top::Drone], d: &top::Drone, cwd: &str, reason: &str, now: i64) -> i32 {
354    let to = Address::Session { id: d.p.session.clone(), node: Some(d.node.clone()) };
355    let sent = msg::sender(home, drones, None, cwd).and_then(|from| msg::send(home, Message::new(Kind::Veto, &from, &to.to_string(), None, now, reason), now));
356    match sent {
357        Ok(m) => {
358            println!(
359                "vetoed {} ({}) on another machine: {}, recorded there after the next mesh round (`borg mesh serve`)",
360                d.designation,
361                short(&d.p.session),
362                m.id
363            );
364            0
365        }
366        Err(e) => {
367            eprintln!("borg no: cannot send the veto: {e}");
368            1
369        }
370    }
371}
372
373/// `borg no [--session ID|DRONE] [--dir DIR] [--json] <reason…>`.
374pub fn main(args: &[&str]) -> i32 {
375    let value = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
376    let mut words = Vec::new();
377    let mut i = 0;
378    while i < args.len() {
379        match args[i] {
380            "--session" | "--dir" => i += 2,
381            "--json" => i += 1,
382            w => {
383                words.push(w);
384                i += 1;
385            }
386        }
387    }
388    let reason = words.join(" ").trim().to_string();
389    if reason.is_empty() {
390        eprintln!("{USAGE}");
391        return 2;
392    }
393    // Routing compares path text, so a relative --dir is made absolute first, as hooks' cwd is.
394    let dir = value("--dir").map_or_else(|| std::env::current_dir().unwrap_or_default(), PathBuf::from);
395    let cwd = std::path::absolute(&dir).unwrap_or(dir).display().to_string();
396    let route = match collective::load() {
397        Ok(cfg) => collective::route(cfg.as_ref(), &cwd, &learn::home()),
398        Err(e) => {
399            eprintln!("borg no: invalid collectives config: {e}");
400            return 1;
401        }
402    };
403    let collective::Route::Collective { home, .. } = &route else {
404        eprintln!("off: {cwd} belongs to no collective; borg records nothing here");
405        return 1;
406    };
407    if !learn::learning() {
408        println!("learning is off (BORG_LEARN=off): nothing recorded");
409        return 0;
410    }
411    let now = learn::now_ms();
412    // The same drones and designations as `borg top`; remote ones are listed but never targeted.
413    let drones = top::drones(home, collective::may_sync(&route), now);
414    let d = match target(&drones, &queen::repo_name(&cwd), value("--session")) {
415        Ok(d) => d,
416        Err(e) => {
417            eprintln!("borg no: {e}");
418            return 1;
419        }
420    };
421    let session = d.p.session.as_str();
422    if !d.node.is_empty() {
423        return remote(home, &drones, d, &cwd, &reason, now);
424    }
425    let task = task_of(home, session, &d.p.prompt);
426    let engineer = learn::engineer(&cwd);
427    let said = Said { session, repo: &d.p.repo, engineer: &engineer, task: &task, agent: "", reason: &reason, ms: now, via: "cli" };
428    let v = match record(home, &said) {
429        Ok(v) => v,
430        Err(e) => {
431            eprintln!("borg no: cannot record the veto: {e}");
432            return 1;
433        }
434    };
435    if args.contains(&"--json") {
436        println!("{}", json!({"session": session, "episode": v.episode, "credited": v.credited}));
437        return 0;
438    }
439    let lost = match v.credited.len() {
440        0 => "no shown lessons lose credit".to_string(),
441        n => format!("{n} shown lesson(s) lose credit ({})", v.credited.join(", ")),
442    };
443    println!("vetoed {} ({}): {lost}; the reason becomes a lesson when borg learn runs", d.designation, short(session));
444    println!("to stop it now: borg pause {}", short(session));
445    0
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::presence::Presence;
452
453    fn temp(name: &str) -> PathBuf {
454        let d = std::env::temp_dir().join(format!("borg-veto-{name}-{}", std::process::id()));
455        let _ = std::fs::remove_dir_all(&d);
456        std::fs::create_dir_all(&d).unwrap();
457        d
458    }
459
460    #[test]
461    fn a_veto_prompt_starts_with_borg_no_and_carries_a_reason() {
462        assert_eq!(reason_in("borg no: never read the commit log").as_deref(), Some("never read the commit log"));
463        assert_eq!(reason_in("Borg no, don't").as_deref(), Some("don't"));
464        assert_eq!(reason_in("  BORG NO\nthe schema is public").as_deref(), Some("the schema is public"));
465        assert_eq!(reason_in("borg now run the tests"), None);
466        assert_eq!(reason_in("no, use X"), None, "a correction, not a veto");
467        assert_eq!(reason_in("borg no"), None);
468        assert_eq!(reason_in("borg no: ok"), None, "too short a reason");
469        assert_eq!(reason_in(&format!("borg no {}", "x".repeat(2000))).map(|r| r.chars().count()), Some(1500));
470    }
471
472    fn drone(session: &str, repo: &str, state: State, ms: i64, node: &str, number: usize) -> top::Drone {
473        top::Drone {
474            designation: format!("{} of Five", top::word(number)),
475            number,
476            node: node.into(),
477            state,
478            paused: false,
479            p: Presence {
480                session: session.into(),
481                harness: "claude".into(),
482                engineer: "a@x".into(),
483                repo: repo.into(),
484                collective: "corp".into(),
485                prompt: "p".into(),
486                files: vec![],
487                event: "prompt".into(),
488                ms,
489                started: ms,
490                pid: None,
491            },
492        }
493    }
494
495    #[test]
496    fn a_terminal_veto_targets_the_one_local_drone_of_the_repo_or_asks() {
497        let one = vec![drone("aaaa1111", "app", State::Working, 10, "", 1), drone("bbbb2222", "other", State::Working, 20, "", 2)];
498        assert_eq!(target(&one, "app", None).unwrap().p.session, "aaaa1111");
499        assert!(target(&one, "nope", None).unwrap_err().contains("no drone in nope"));
500        let two = vec![drone("aaaa1111", "app", State::Working, 10, "", 1), drone("cccc3333", "app", State::Idle, 30, "", 2)];
501        let err = target(&two, "app", None).unwrap_err();
502        assert!(err.contains("2 drones work in app") && err.contains("aaaa1111") && err.contains("cccc3333") && err.contains("One of Five"), "{err}");
503        assert_eq!(target(&two, "app", Some("Two")).unwrap().p.session, "cccc3333");
504        assert_eq!(target(&two, "app", Some("aaaa")).unwrap().p.session, "aaaa1111");
505        assert!(["all", "collective", "local"].iter().all(|w| target(&two, "app", Some(w)).is_err()), "a veto names one drone");
506        // Only ended ones: the most recently seen (a veto right after the session ended).
507        let ended = vec![drone("aaaa1111", "app", State::Ended, 50, "", 1), drone("cccc3333", "app", State::Ended, 40, "", 2)];
508        assert_eq!(target(&ended, "app", None).unwrap().p.session, "aaaa1111");
509        // A remote drone is never picked unnamed; named, it is (`main` signs the veto for its node).
510        let remote = vec![drone("dddd4444", "app", State::Working, 10, "beef", 1)];
511        assert!(target(&remote, "app", None).is_err());
512        assert_eq!(target(&remote, "app", Some("dddd")).unwrap().node, "beef");
513    }
514
515    fn asked(home: &Path, session: &str, lessons: &[&str]) {
516        let o = Outcome {
517            kind: outcome::ASKED.into(),
518            session: session.into(),
519            lessons: lessons.iter().map(|l| l.to_string()).collect(),
520            ms: 1,
521            ..Default::default()
522        };
523        outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
524    }
525
526    fn event(home: &Path, kind: &str, session: &str, lessons: &[&str]) {
527        let ev =
528            stats::Event { kind: kind.into(), ms: 2, session: session.into(), lessons: lessons.iter().map(|l| l.to_string()).collect(), ..Default::default() };
529        stats::record(home, &ev);
530    }
531
532    #[test]
533    fn shown_is_every_lesson_the_session_was_shown() {
534        let home = temp("shown");
535        asked(&home, "s1", &["C", "A"]);
536        asked(&home, "s2", &["X"]);
537        event(&home, stats::EARLY, "s1", &["B", "A"]);
538        event(&home, stats::QUEUED_FREE, "s1", &["D"]);
539        event(&home, stats::EARLY, "s2", &["Y"]);
540        event(&home, stats::CHECK, "s1", &["Z"]);
541        assert_eq!(shown(&home, "s1"), ["A", "B", "C", "D"]);
542        assert!(shown(&home, "s3").is_empty());
543        let _ = std::fs::remove_dir_all(home);
544    }
545
546    fn said<'a>(session: &'a str, reason: &'a str, ms: i64) -> Said<'a> {
547        Said { session, repo: "app", engineer: "a@x", task: "fix the merge lookup", agent: "", reason, ms, via: "cli" }
548    }
549
550    #[test]
551    fn a_veto_records_its_episode_credits_shown_lessons_once_and_counts_every_call() {
552        let home = temp("record");
553        asked(&home, "s1", &["A"]);
554        event(&home, stats::EARLY, "s1", &["B"]);
555        let v = record(&home, &said("s1", "never read the commit log for merges", 100)).unwrap();
556        assert_eq!(v, Veto { episode: "no-s1-100".into(), credited: vec!["A".into(), "B".into()], missed: false });
557        let eps: Vec<learn::Episode> =
558            std::fs::read_to_string(home.join("episodes.jsonl")).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
559        assert_eq!((eps.len(), eps[0].kind.as_str(), eps[0].correction.as_str()), (1, KIND, "never read the commit log for merges"));
560        let vetoed = || outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == outcome::VETOED).count();
561        assert_eq!(vetoed(), 2);
562        // A second veto in the same session: no duplicate credit, but a second veto stat.
563        let again = record(&home, &said("s1", "and not the reflog either", 200)).unwrap();
564        assert!(again.credited.is_empty());
565        assert_eq!(vetoed(), 2);
566        let vetoes: Vec<stats::Event> = stats::events(&home).into_iter().filter(|e| e.kind == stats::VETO).collect();
567        assert_eq!(vetoes.len(), 2);
568        assert_eq!((vetoes[0].lessons.clone(), vetoes[0].n, vetoes[0].why.as_str()), (vec!["A".to_string(), "B".to_string()], 2, "cli"));
569        // No correction is counted for a veto: it has its own stat.
570        assert!(!stats::events(&home).iter().any(|e| e.kind == stats::CORRECTION));
571        // A session that was shown nothing adds no outcome line (no tick on the memory clock).
572        let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
573        record(&home, &said("s9", "never deploy on fridays", 300)).unwrap();
574        assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
575        let _ = std::fs::remove_dir_all(home);
576    }
577
578    const PEER: &str = "abababababababababababababababababababababababababababababababab";
579
580    /// A message from the peer node `PEER`, merged into the log as the mesh would, admitted under
581    /// `role` (`None`: not admitted).
582    fn arrive(home: &Path, kind: Kind, to: &str, text: &str, ms: i64, role: Option<steer::Role>) -> Message {
583        let from = msg::Sender { by: "engineer:lead@x".into(), name: "lead@x".into(), harness: "terminal".into(), node: PEER.into() };
584        let m = Message::new(kind, &from, to, None, ms, text);
585        learn::append_line(&msg::path(home), &serde_json::to_string(&m).unwrap()).unwrap();
586        if let Some(r) = role {
587            steer::admit(home, &[steer::Admission::of(&m, r)], ms).unwrap();
588        }
589        m
590    }
591
592    #[test]
593    fn a_veto_from_another_node_is_recorded_once_where_its_drone_runs() {
594        let home = temp("apply");
595        asked(&home, "s1", &["A"]);
596        let here = msg::node(&home);
597        let s1 = format!("s1@{here}");
598        let v = arrive(&home, Kind::Veto, &s1, "never read the commit log for merges", 100, Some(steer::Role::Steer));
599        let got = apply(&home, &v, &here).unwrap().unwrap();
600        assert_eq!((got.episode.as_str(), got.credited.clone(), got.missed), ("no-s1-100", vec!["A".to_string()], false));
601        assert_eq!(apply(&home, &v, &here).unwrap(), None, "the same message is recorded once");
602        // Through the log (the mesh's way in): already recorded, and a tell or an unadmitted veto never records.
603        arrive(&home, Kind::Tell, &s1, "run the tests", 110, Some(steer::Role::Steer));
604        arrive(&home, Kind::Veto, &s1, "not admitted", 120, None);
605        assert_eq!(apply_messages(&home, 200), 0);
606        let eps = std::fs::read_to_string(home.join("episodes.jsonl")).unwrap();
607        assert_eq!(eps.lines().count(), 1);
608        let e: learn::Episode = serde_json::from_str(eps.lines().next().unwrap()).unwrap();
609        assert_eq!((e.kind.as_str(), e.engineer.as_str(), e.session.as_str()), (KIND, "lead@x", "s1"), "the sender's veto, of its session");
610        let vetoes: Vec<stats::Event> = stats::events(&home).into_iter().filter(|e| e.kind == stats::VETO).collect();
611        assert_eq!((vetoes.len(), vetoes[0].why.as_str()), (1, "mesh"));
612        assert_eq!(outcome::read(&home.join("outcomes.jsonl")).iter().filter(|o| o.kind == outcome::VETOED).count(), 1);
613        // A new admitted one through the log is recorded there, once; past the TTL it no longer counts.
614        arrive(&home, Kind::Veto, &s1, "and not the reflog either", 130, Some(steer::Role::Admin));
615        assert_eq!(apply_messages(&home, 200), 1);
616        assert_eq!(apply_messages(&home, 200), 0);
617        arrive(&home, Kind::Veto, &s1, "too late", 140, Some(steer::Role::Admin));
618        assert_eq!(apply_messages(&home, 140 + steer::TTL_MS + 1), 0);
619        // A veto of a session this node never held, or of another node's, is not this node's to learn.
620        let stranger = arrive(&home, Kind::Veto, &format!("zz@{here}"), "not here", 150, Some(steer::Role::Steer));
621        assert_eq!(apply(&home, &stranger, &here).unwrap(), None);
622        let elsewhere = arrive(&home, Kind::Veto, "s1@n2", "elsewhere", 160, Some(steer::Role::Steer));
623        assert_eq!(apply(&home, &elsewhere, &here).unwrap(), None);
624        assert_eq!(std::fs::read_to_string(home.join("episodes.jsonl")).unwrap().lines().count(), 2);
625        let _ = std::fs::remove_dir_all(home);
626    }
627
628    #[test]
629    fn an_invariant_is_proposed_and_only_ratifying_writes_a_constraint() {
630        let home = temp("propose");
631        let l = learn::Lesson { id: "Lno-s1-1".into(), repo: "app".into(), question: "q".into(), ..Default::default() };
632        let e = learn::Episode {
633            id: "no-s1-1".into(),
634            session: "s1".into(),
635            engineer: "a@x".into(),
636            correction: "never the commit log".into(),
637            ..Default::default()
638        };
639        let rule = "merge lookups go through the review API";
640        assert!(propose(&home, &l, rule, &e, 5).unwrap());
641        assert!(!propose(&home, &l, "Merge lookups go through the review API.", &e, 6).unwrap(), "the same invariant once");
642        let q = queen::entries(&home).into_iter().find(|x| x.kind == queen::Kind::Constraint).unwrap();
643        assert_eq!((q.default.as_str(), q.options.len(), q.repo.as_str()), (KEEP_OPTION, 2, "app"));
644        assert!(q.question.contains(rule) && q.evidence[0] == format!("invariant: {rule}"));
645        assert!(!constraints_path(&home).exists(), "proposing never applies");
646        // Keeping it a lesson writes nothing; ratifying writes exactly one constraint.
647        assert!(!ratify(&home, &q, KEEP_OPTION, "lead@x", 7).unwrap());
648        assert!(!constraints_path(&home).exists());
649        assert!(ratify(&home, &q, RATIFY_OPTION, "lead@x", 8).unwrap());
650        let text = std::fs::read_to_string(constraints_path(&home)).unwrap();
651        assert_eq!(text.lines().count(), 1);
652        let c: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
653        assert_eq!(
654            (c["text"].as_str(), c["lesson"].as_str(), c["by"].as_str(), c["source"].as_str()),
655            (Some(rule), Some("Lno-s1-1"), Some("lead@x"), Some("veto"))
656        );
657        // The lesson unknown here: bound to the question's repo, never everywhere.
658        assert_eq!((c["repo"].as_str(), c["reach"].as_str()), (Some("app"), Some(crate::reach::REPO)));
659        // The lesson known: its scope, repo and reach.
660        let lesson = serde_json::json!({"id": "Lno-s1-1", "origin_ms": 1, "question": "q", "repo": "app", "scope": ["Orion"]});
661        std::fs::write(home.join("lessons.jsonl"), format!("{lesson}\n")).unwrap();
662        std::fs::remove_file(constraints_path(&home)).unwrap();
663        assert!(ratify(&home, &q, RATIFY_OPTION, "lead@x", 9).unwrap());
664        let c: serde_json::Value = serde_json::from_str(std::fs::read_to_string(constraints_path(&home)).unwrap().trim()).unwrap();
665        assert_eq!((c["scope"].clone(), c["repo"].as_str(), c["reach"].as_str()), (serde_json::json!(["Orion"]), Some("app"), Some("")));
666        let _ = std::fs::remove_dir_all(home);
667    }
668}