Skip to main content

borg/
queen.rs

1//! The queen: the rare, budgeted escalation to a human engineer (PLAN.md "Expertise and
2//! the queen"; internal evaluation "expertise" §2, L4, not published).
3//!
4//! At the Stop checkpoint, after the conscience decided, the drone's plan goes to the queen only if
5//! all four hold:
6//! 1. **High consequence**: a plan sentence makes an irreversible change, an external mutation, a
7//!    public API/schema change or a cross-repo/cross-team decision (`classify`, deterministic, 0 tokens).
8//! 2. **The collective cannot resolve it**: either the conscience asked nothing and the collective's
9//!    lessons carrying this decision key are absent or disagree, or the lessons the conscience selected
10//!    contradict each other on this decision (same situations, different consequences: `conflicts`).
11//! 3. **The inputs do not answer it**: the engineer's prompts in this session do not already state it.
12//! 4. **Budget**: at most one queen message (≤ `MAX_QUESTIONS` questions) per engineer per
13//!    `BUDGET_SESSIONS` sessions, counted in the collective's own store.
14//!
15//! An asked question goes to `<store>/queen/queue.jsonl`; the drone is told once which default it
16//! proceeds with and is never held up. A decision (its key, or a reworded near-duplicate of an asked
17//! question or a queen lesson) is asked at most once, ever. `borg queen answer`
18//! turns the engineer's answer into a lesson of the collective, so later drones get it from the
19//! conscience (L1) instead of the queen.
20//!
21//! Off by default: the queen test (internal evaluation "queen-test", rounds 1–3, not published) failed its pre-registered bar,
22//! so the Stop hook asks the queen only with `BORG_QUEEN=on`. `borg queen` (list, answer) works either way.
23//!
24//! Answering the queue: `borg queen` numbers the open questions (asking order) for the terminal, `--json`
25//! gives drones a stable schema (`Question`), `dismiss` closes a question without a lesson, `skip`
26//! holds it back for `SKIP_MS`, and a near-duplicate of a queued question of the same repo
27//! is never queued (`duplicate`; `dedupe` cleans old queues). The prompt hook delivers at most one
28//! question per session, only of the session's own repos (`deliver`, `render_delivery`;
29//! `BORG_QUEEN_ASK=off` disables it), and newly queued questions get one desktop notification (notify.rs).
30//!
31//! Fails open: any error means the drone proceeds untouched.
32
33use std::collections::{HashMap, HashSet};
34use std::io::Write;
35use std::path::{Path, PathBuf};
36use std::sync::LazyLock;
37
38use regex::Regex;
39use serde::{Deserialize, Serialize};
40use serde_json::{Value, json};
41
42use crate::conscience::Challenge;
43use crate::{collective, conscience, learn};
44
45/// At most one queen message per engineer per this many sessions. Guard: the engineer's attention, not
46/// money; a queen that finds something in every session cannot interrupt every session.
47/// Design-chosen, not tuned (escalation.rs measures what the messages yield).
48pub const BUDGET_SESSIONS: usize = 5;
49/// At most this many questions in one message (one message per checkpoint). Guard: as
50/// `BUDGET_SESSIONS`, one message stays short enough to answer.
51pub const MAX_QUESTIONS: usize = 2;
52/// Of a decision's significant words, this share already in the engineer's prompts means the inputs
53/// state the decision (criterion 3). Baseline: design-chosen, not tuned; the same 0.6 as
54/// `DUPLICATE` by coincidence, a different question.
55pub(crate) const STATED: f64 = 0.6;
56/// Display: the characters of the text a queen question quotes (a plan's sentence here, a lesson's
57/// question in reach.rs, an invariant in veto.rs): one sentence whole in nearly every case, short
58/// enough that the question still reads as one on the queen's screen and in borg web's queue.
59pub const QUOTE_CHARS: usize = 300;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Kind {
64    ExternalMutation,
65    Irreversible,
66    PublicApi,
67    CrossRepo,
68    /// A decision the team's own documents leave open ("Your decision", "Open conflict"), seeded into the
69    /// queue by `borg assimilate --from-dir` (assimilate_dir.rs). Never raised by a checkpoint and not a
70    /// message to anyone, so it does not use up an engineer's budget.
71    OpenDecision,
72    /// Who should hear an existing lesson (reach.rs `borg reach`): the team, its repo, or only the
73    /// engineer. Queued by a cleanup, not a checkpoint; answering it moves the lesson, writes none.
74    Reach,
75    /// An invariant an engineer's veto named (veto.rs `propose`), proposed as a constraint of the
76    /// collective. Queued by `borg learn`, not a checkpoint; only an engineer's answer ratifies it, and
77    /// the default keeps it a lesson only.
78    Constraint,
79}
80
81impl Kind {
82    /// Whether the queen chose to raise it: the four kinds a checkpoint escalates (`classify`). The
83    /// others are proposals or gates every engineer answers whatever the queen thinks, so the queen's
84    /// attention reward (escalation.rs) leaves them out.
85    pub fn escalates(self) -> bool {
86        matches!(self, Kind::ExternalMutation | Kind::Irreversible | Kind::PublicApi | Kind::CrossRepo)
87    }
88
89    /// The option that lets the drone's plan go ahead: the "yes, fine" of an escalation. An external
90    /// mutation's default holds it, so its alternative is the plan; every other escalation proceeds by
91    /// default. Empty for a kind that does not escalate.
92    pub fn proceeds(self) -> &'static str {
93        match self {
94            Kind::ExternalMutation => self.options().1,
95            k if k.escalates() => k.options().0,
96            _ => "",
97        }
98    }
99
100    pub fn label(self) -> &'static str {
101        match self {
102            Kind::ExternalMutation => "an external mutation",
103            Kind::Irreversible => "an irreversible change",
104            Kind::PublicApi => "a public API or schema change",
105            Kind::CrossRepo => "a cross-repo or cross-team decision",
106            Kind::OpenDecision => "a decision the team's documents leave open",
107            Kind::Reach => "who should hear a lesson",
108            Kind::Constraint => "an invariant proposed as a constraint",
109        }
110    }
111
112    /// (the drone's default, the alternative). External mutations stay approval-gated: their default
113    /// is to hold, everything else proceeds as planned.
114    pub(crate) fn options(self) -> (&'static str, &'static str) {
115        match self {
116            Kind::ExternalMutation => ("hold: prepare it, but do not run it until the engineer approves", "run it as planned"),
117            Kind::Irreversible => ("proceed as planned", "make it reversible first (backup, soft delete, a revert path)"),
118            Kind::PublicApi => ("proceed as planned", "keep the old contract working (additive change, versioning)"),
119            Kind::CrossRepo => ("proceed as planned", "leave the other repo's or team's side to its owners"),
120            Kind::OpenDecision => ("keep the documented interim rule", "decide it now"),
121            Kind::Reach => (crate::reach::TEAM_OPTION, crate::reach::PERSONAL_OPTION),
122            Kind::Constraint => (crate::veto::KEEP_OPTION, crate::veto::RATIFY_OPTION),
123        }
124    }
125}
126
127/// Trigger phrases per kind. Each is a move, anchored on a present/future verb or on a phrase that
128/// characterises the change; bare noun mentions ("the prod database", "the public API") and past
129/// tenses ("deployed", "bumped": at Stop usually a summary of work already done) do not trigger.
130static KINDS: LazyLock<Vec<(Kind, Regex)>> = LazyLock::new(|| {
131    let r = |s: &str| Regex::new(s).unwrap();
132    vec![
133        (
134            Kind::ExternalMutation,
135            r(
136                r"\b(deploy(ing)? (to|the)|(cargo|npm|pnpm|yarn|twine|gem) publish|publish(ing)? (the |a )?(package|crate|release|artifact|image)|push(ing)? (it |this )?to (origin|main|master|the remote|upstream|production)|merg(e|ing) (the |this )?(pr|pull request)|terraform apply|kubectl (apply|delete)|helm (install|upgrade)|send(ing)? (an |the )?(email|e-mail|notification)|post(ing)? to slack|(creat|clos|transition)(e|ing) (a |the )?(jira|ticket)|(writ(e|ing)( to)?|migrat(e|ing)|updat(e|ing)|modify(ing)?|delet(e|ing) from|insert(ing)? into|alter(ing)?) (the )?prod(uction)? (database|db)|tag(ging)? (a |the )?release|cut(ting)? (a |the )?release)\b",
137            ),
138        ),
139        // A push to a shared branch: the refspec's target is `main`, `master` or `prod(uction)` exactly
140        // ("HEAD:main" is, "main-fix" and "feature/main" are not).
141        (Kind::ExternalMutation, r(r"\bgit push ([-\w./=@]+ ){0,3}?((origin|upstream) )?([\w./-]+:)?(main|master|prod(uction)?)([^-/\w]|$)")),
142        (
143            Kind::Irreversible,
144            r(
145                r"\b(drop(ping)? (the )?(\w+ )?(table|column|database|schema|index)|truncat(e|ing) (the )?(table|\w+ table)|rm -rf|force[- ]push(ing)?|push(ing)? --force|reset --hard|hard reset|(delete|deleting) (the )?(\w+ )?(branch|bucket|database|table|history|backups?)|permanently delet(e|ing)|purg(e|ing) (the |all )?(\w+ )?(data|records|rows|bucket|cache|queue|history|backups?|users?|tables?|database)|irreversibl[ey]|cannot be undone|wip(e|ing) (the )?\w+|rewrit(e|ing) (the )?(git )?history)\b",
146            ),
147        ),
148        (
149            Kind::PublicApi,
150            r(
151                r"\b((is|introduces?|introducing|makes?|making)( this| it)? (a |the )?(major[- ])?breaking change|(is|makes?|making) (it |this )?backwards?[- ]incompatible|alter table|(run|runn?ing|apply|applying|add|adding|write|writing) (a |the )?(database |schema )migration|chang(e|ing) the (\w+ )?schema|(renam|remov)(e|ing) (the |a )?(public |exported )?(endpoint|api|field|column|route)|(chang(e|es|ing)|break(s|ing)?|renam(e|es|ing)|remov(e|es|ing)|bump(s|ing)?) (the |a |our )?(public api|protobuf( schema| messages?)?|openapi( spec)?|graphql schema|wire format|major version|semver major)|bump(s|ing)? to (a |the )?(new )?major( version)?)\b",
152            ),
153        ),
154        (
155            Kind::CrossRepo,
156            r(
157                r"\b(chang(e|es|ing)|patch(es|ing)?|modify(ing)?|edit(s|ing)?|updat(e|es|ing)|fork(s|ing)?)( \S+){0,3}? (the |a )?(other|another|upstream|shared|vendor|cross[- ]repo)[- ](repo(sitory|s)?|library|project|crate|module|infra(structure)?|config(uration)?|team'?s?|api)\b",
158            ),
159        ),
160    ]
161});
162
163static SENTENCES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[.!?]\s+|\n|;").unwrap());
164
165/// A sentence that only suggests a move to the engineer, hands it to them ("you: force-push ..."), or
166/// says something stays as it is, makes none.
167static NOT_A_MOVE: LazyLock<Regex> = LazyLock::new(|| {
168    Regex::new(r"\b(would be|could|might|you (can|may|should|could|might|want)|if you|suggest(ed|ion)?|recommend(ed|ation)?|optionally|unchanged|untouched|no changes?|nothing (is |was )?(changed|touched)|should i|shall i|do you want|want me to|when you'?re ready|whoever|an admin|(stays|is|lies) with you|up to you|your call)\b|\byou:").unwrap()
169});
170
171/// The start of a described clause: what a job, script or convention does ("that job runs ...", "which
172/// calls ...", "the convention is ..."). A move after it is described, not made; a move before it is
173/// still made ("I'm force-pushing main now, which triggers the deploy").
174static DESCRIBES: LazyLock<Regex> = LazyLock::new(|| {
175    Regex::new(r"\b((which|that|it|job|script|pipeline|workflow|step|hook|command|target) (runs|does|calls|triggers|executes|invokes|ends (in|with))|convention (is|says))\b").unwrap()
176});
177
178/// The shared ref or environment a sentence names, for telling two sentences about one move apart.
179static REF: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b(main|master|prod|production|staging)\b").unwrap());
180
181/// The target ref or environment of a move ("to origin/main" -> "main").
182static TARGET: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\S+(?:\s+\S+){0,6}?\s+to\s+(?:the\s+)?(?:(?:origin|upstream)[/\s]+)?([\w./-]+)").unwrap());
183
184const NEGATIONS: [&str; 18] = [
185    "no",
186    "not",
187    "never",
188    "without",
189    "avoid",
190    "instead",
191    "skip",
192    "nor",
193    "dont",
194    "wont",
195    "didnt",
196    "doesnt",
197    "isnt",
198    "wasnt",
199    "shouldnt",
200    "cant",
201    "cannot",
202    "unchanged",
203];
204
205fn negation(word: &str) -> bool {
206    let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
207    w.ends_with("n't") || NEGATIONS.contains(&w)
208}
209
210const STOP: [&str; 24] = [
211    "the", "and", "will", "then", "this", "that", "with", "for", "from", "into", "our", "now", "also", "i'll", "we'll", "let", "lets", "can", "should",
212    "would", "first", "after", "before", "all",
213];
214
215/// A high-consequence move of the plan: its kind and the sentence that makes it.
216#[derive(Debug, Clone, PartialEq)]
217pub struct Decision {
218    pub kind: Kind,
219    pub sentence: String,
220    /// The move itself, normalised ("force push main"): what the key is computed from.
221    pub what: String,
222    /// Stable hash of the repo and the normalised move (`decision_key`).
223    pub key: String,
224}
225
226/// Significant words of `text`: tokens of 3+ characters minus filler, sorted and unique.
227pub(crate) fn words(text: &str) -> Vec<String> {
228    let mut w: Vec<String> = conscience::tokens(text).into_iter().filter(|t| !STOP.contains(&t.as_str())).collect();
229    w.sort();
230    w.dedup();
231    w
232}
233
234/// Share of `need`'s words that `have` contains; 0 for fewer than `min` words.
235pub(crate) fn overlap(need: &[String], have: &HashSet<String>, min: usize) -> f64 {
236    match need.len() >= min {
237        true => need.iter().filter(|w| have.contains(*w)).count() as f64 / need.len() as f64,
238        false => 0.0,
239    }
240}
241
242/// FNV-1a 64: stable across builds and platforms, no dependency in the hook binary.
243fn fnv(s: &str) -> String {
244    format!("{:016x}", s.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3)))
245}
246
247/// A move's words, punctuation and articles ignored ("force-push the" -> "force push").
248fn norm(text: &str) -> String {
249    text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty() && !["the", "a", "an", "this", "it"].contains(w)).collect::<Vec<_>>().join(" ")
250}
251
252/// The decision key: the kind, the repo and the normalised move, so a reworded sentence making the
253/// same move in the same repo is the same decision.
254pub fn decision_key(kind: Kind, repo: &str, what: &str) -> String {
255    fnv(&format!("{kind:?}:{repo}:{what}"))
256}
257
258/// The trimmed sentences of `text`, each with whether it ends in a question mark.
259fn sentences(text: &str) -> Vec<(&str, bool)> {
260    let mut out = Vec::new();
261    let mut at = 0;
262    for m in SENTENCES.find_iter(text) {
263        let s = &text[at..m.start()];
264        out.push((s.trim(), m.as_str().starts_with('?') || s.trim_end().ends_with('?')));
265        at = m.end();
266    }
267    let s = &text[at..];
268    out.push((s.trim(), s.trim_end().ends_with('?')));
269    out
270}
271
272/// Criterion 1: every high-consequence move the plan makes (not ones it rules out, suggests or says
273/// it left unchanged), one per sentence (its earliest move), first occurrence of each decision key.
274pub fn classify(plan: &str, repo: &str) -> Vec<Decision> {
275    let lower = plan.to_lowercase().replace(['\u{2019}', '\u{2018}'], "'");
276    let mut seen = HashSet::new();
277    let mut out = Vec::new();
278    for (sentence, asks) in sentences(&lower) {
279        // A question: the drone already puts this to the engineer itself.
280        if sentence.is_empty() || asks || NOT_A_MOVE.is_match(sentence) {
281            continue;
282        }
283        let described = DESCRIBES.find(sentence).map_or(usize::MAX, |m| m.end());
284        let hit = KINDS
285            .iter()
286            .flat_map(|(kind, re)| re.find_iter(sentence).map(move |m| (*kind, m)))
287            .filter(|(_, m)| m.start() < described)
288            .filter(|(_, m)| !sentence[..m.start()].split_whitespace().rev().take(4).any(negation))
289            .min_by_key(|(_, m)| m.start());
290        let Some((kind, m)) = hit else { continue };
291        let mut what = norm(m.as_str());
292        if let Some(t) = TARGET.captures(&sentence[m.start()..]).and_then(|c| c.get(1)) {
293            let t = norm(t.as_str().trim_end_matches(['.', ',']).rsplit('/').next().unwrap_or_default());
294            if !t.is_empty() && !what.split(' ').any(|w| w == t) {
295                what = format!("{what} {t}");
296            }
297        }
298        let key = decision_key(kind, repo, &what);
299        if seen.insert(key.clone()) {
300            let sentence: String = sentence.chars().take(QUOTE_CHARS).collect();
301            out.push(Decision { kind, sentence, what, key });
302        }
303    }
304    out
305}
306
307/// Criterion 3: the engineer's inputs (their prompts, not the drone's own tool calls) already state
308/// this decision: they name the same move (a move of its kind with most of its words: "force-push"
309/// states "force push main", "wipe the cache" does not), or contain most of the sentence's words.
310pub fn stated(d: &Decision, inputs: &str) -> bool {
311    let lower = inputs.to_lowercase().replace(['\u{2019}', '\u{2018}'], "'");
312    let move_words = words(&d.what);
313    let same = |m: &str| {
314        let have: HashSet<String> = words(m).into_iter().collect();
315        move_words.is_empty() || overlap(&move_words, &have, 1) >= STATED
316    };
317    if KINDS.iter().any(|(k, re)| *k == d.kind && re.find_iter(&lower).any(|m| same(m.as_str()))) {
318        return true;
319    }
320    let have: HashSet<String> = words(&lower).into_iter().collect();
321    overlap(&words(&d.sentence), &have, 3) >= STATED
322}
323
324/// A reworded duplicate of an earlier decision question: most of the decision's significant words
325/// (at least 4 of them) are in it. Catches rewordings the key does not.
326fn near(d: &Decision, earlier: &str) -> bool {
327    let have: HashSet<String> = words(earlier).into_iter().collect();
328    overlap(&words(&d.sentence), &have, 4) >= STATED
329}
330
331/// Two decisions of one plan that are one move: the same key, either sentence a reworded duplicate of
332/// the other, or the same kind on the same named ref ("`git push -u origin main`" and "they push to
333/// the remote `origin` and the branch `main`"). One message asks each move once.
334fn same_move(a: &Decision, b: &Decision) -> bool {
335    let target = |d: &Decision| REF.find(&d.sentence).map(|m| m.as_str().replace("production", "prod"));
336    a.key == b.key || near(a, &b.sentence) || near(b, &a.sentence) || (a.kind == b.kind && target(a).is_some() && target(a) == target(b))
337}
338
339/// What the collective knows about one decision key (criterion 2).
340#[derive(Debug, Clone, PartialEq)]
341pub enum Known {
342    /// No lesson carries this key.
343    Nothing,
344    /// The lessons carrying it agree: the collective resolves it.
345    Resolved,
346    /// Lessons carrying it give different consequences, none superseding the others.
347    Disagree(Vec<String>),
348}
349
350/// The latest version of every lesson line as raw JSON (fields `learn::Lesson` does not know survive).
351fn lesson_lines(path: &Path) -> Vec<Value> {
352    let mut by: HashMap<String, Value> = HashMap::new();
353    let mut order = Vec::new();
354    for v in read_lines::<Value>(path) {
355        let Some(id) = v["id"].as_str().map(str::to_string) else { continue };
356        let ver = |v: &Value| v["version"].as_u64().unwrap_or(0);
357        if by.get(&id).is_none_or(|o| ver(o) <= ver(&v)) {
358            if !by.contains_key(&id) {
359                order.push(id.clone());
360            }
361            by.insert(id, v);
362        }
363    }
364    order.into_iter().filter_map(|id| by.remove(&id)).collect()
365}
366
367pub fn known(lessons: &[Value], key: &str) -> Known {
368    let with: Vec<&Value> = lessons.iter().filter(|l| l["decision_key"].as_str() == Some(key)).collect();
369    let superseded: HashSet<&str> = with.iter().flat_map(|l| l["supersedes"].as_array().into_iter().flatten().filter_map(Value::as_str)).collect();
370    let live: Vec<&&Value> = with.iter().filter(|l| !superseded.contains(l["id"].as_str().unwrap_or_default())).collect();
371    let answers: HashSet<String> = live.iter().map(|l| l["consequence"].as_str().unwrap_or_default().trim().to_lowercase()).collect();
372    match answers.len() {
373        0 => Known::Nothing,
374        1 => Known::Resolved,
375        _ => Known::Disagree(live.iter().filter_map(|l| l["id"].as_str().map(str::to_string)).collect()),
376    }
377}
378
379/// One queen question (a line of queue.jsonl). Answering appends the same id with the answer set;
380/// the latest line per id wins.
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
382pub struct Entry {
383    pub id: String,
384    pub decision_key: String,
385    pub kind: Kind,
386    pub question: String,
387    pub options: Vec<String>,
388    pub default: String,
389    pub evidence: Vec<String>,
390    pub session: String,
391    pub cwd: String,
392    /// The repo the question is about (`repo_name` of its cwd at queueing); empty on older lines.
393    #[serde(default, skip_serializing_if = "String::is_empty")]
394    pub repo: String,
395    pub engineer: String,
396    pub ms: i64,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub answer: Option<String>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub answered_by: Option<String>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub answered_ms: Option<i64>,
403    /// Lessons whose disagreement led to the question; the answer supersedes them.
404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
405    pub disagreeing: Vec<String>,
406    /// Why the question was dismissed (`borg queen dismiss`, `dedupe`): it is no longer open, never
407    /// delivered, and no lesson is written.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub dismissed: Option<String>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub dismissed_ms: Option<i64>,
412    /// What a 0.3.0 hive task's question carried (its goal and task, `legacy_hive`); None on every
413    /// other line.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub payload: Option<Value>,
416}
417
418pub fn dir(store: &Path) -> PathBuf {
419    store.join("queen")
420}
421
422fn queue_path(store: &Path) -> PathBuf {
423    dir(store).join("queue.jsonl")
424}
425
426fn sessions_path(store: &Path) -> PathBuf {
427    dir(store).join("sessions.jsonl")
428}
429
430fn read_lines<T: for<'de> Deserialize<'de>>(path: &Path) -> Vec<T> {
431    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
432}
433
434fn append_line<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
435    if let Some(d) = path.parent() {
436        std::fs::create_dir_all(d)?;
437    }
438    let mut line = serde_json::to_string(v).map_err(std::io::Error::other)?;
439    line.push('\n');
440    // One write(2) per record: concurrent appenders (parallel sessions' hooks) never interleave a line.
441    std::fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(line.as_bytes())
442}
443
444/// Every question ever asked of this collective, latest version per id, in asking order.
445pub fn entries(store: &Path) -> Vec<Entry> {
446    let mut out: Vec<Entry> = Vec::new();
447    for e in read_lines::<Entry>(&queue_path(store)) {
448        match out.iter_mut().find(|o| o.id == e.id) {
449            Some(o) => *o = e,
450            None => out.push(e),
451        }
452    }
453    out
454}
455
456/// The key of an open decision found in a team document: its source repo and normalised question.
457pub fn open_decision_key(repo: &str, question: &str) -> String {
458    decision_key(Kind::OpenDecision, repo, &norm(&question.to_lowercase()))
459}
460
461/// The key of a constraint proposed from a veto: its repo and normalised invariant.
462pub fn constraint_key(repo: &str, text: &str) -> String {
463    decision_key(Kind::Constraint, repo, &norm(&text.to_lowercase()))
464}
465
466/// Queue `e` (an imported open decision) unless its decision, or a near-duplicate question of its repo
467/// (`duplicate`), was ever queued in this collective. Returns whether it was added.
468pub fn seed(store: &Path, e: &Entry) -> std::io::Result<bool> {
469    if entries(store).iter().any(|x| x.decision_key == e.decision_key || duplicate(x, e)) {
470        return Ok(false);
471    }
472    append_line(&queue_path(store), e)?;
473    counted(store, crate::stats::QUEEN_QUEUED, e, e.ms, |_| {});
474    Ok(true)
475}
476
477/// The queen's queue in the usage statistics: one event about entry `e` at `ms`.
478fn counted(store: &Path, kind: &str, e: &Entry, ms: i64, fill: impl FnOnce(&mut crate::stats::Event)) {
479    crate::stats::count(store, kind, &e.session, |ev| {
480        ev.ms = ms;
481        ev.engineer = e.engineer.clone();
482        ev.repo = e.repo.clone();
483        ev.lessons = vec![e.id.clone()];
484        ev.text = crate::stats::excerpt(&e.question, crate::stats::FEED_CHARS);
485        fill(ev);
486    });
487}
488
489/// Questions still waiting for an answer (neither answered nor dismissed), in asking order: `borg
490/// queen` numbers them 1.. in this order.
491pub fn open(store: &Path) -> Vec<Entry> {
492    entries(store).into_iter().filter(Entry::is_open).collect()
493}
494
495impl Entry {
496    pub fn is_open(&self) -> bool {
497        self.answer.is_none() && self.dismissed.is_none()
498    }
499}
500
501/// A session the queen saw a checkpoint of (a line of sessions.jsonl), in first-seen order.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct Seen {
504    pub engineer: String,
505    pub session: String,
506    pub ms: i64,
507}
508
509/// Count this checkpoint's session for its engineer (once per session).
510fn note_session(store: &Path, engineer: &str, session: &str, now: i64) -> std::io::Result<()> {
511    let seen: Vec<Seen> = read_lines(&sessions_path(store));
512    if seen.iter().any(|s| s.engineer == engineer && s.session == session) {
513        return Ok(());
514    }
515    append_line(&sessions_path(store), &Seen { engineer: engineer.into(), session: session.into(), ms: now })
516}
517
518/// Criterion 4: the engineer was never asked, or at least `BUDGET_SESSIONS` of their sessions began
519/// since the session in which they were last asked (that one counted as the first).
520pub fn within_budget(store: &Path, engineer: &str, session: &str) -> bool {
521    budget_allows(&entries(store), &read_lines::<Seen>(&sessions_path(store)), engineer, session, collective::aliases())
522}
523
524/// Criterion 4, pure: `asked` every question so far, `seen` every session in first-seen order. An
525/// engineer's emails under `aliases` share one budget; open decisions imported from documents, reach
526/// questions and proposed constraints use none.
527pub fn budget_allows(asked: &[Entry], seen: &[Seen], engineer: &str, session: &str, aliases: &collective::Aliases) -> bool {
528    let me = aliases.canon(engineer);
529    let sent = asked.iter().filter(|e| e.kind.escalates());
530    let Some(last) = sent.filter(|e| aliases.canon(&e.engineer) == me).max_by_key(|e| e.ms) else { return true };
531    if last.session == session {
532        return false; // one message per session at most
533    }
534    let mine: Vec<&Seen> = seen.iter().filter(|s| aliases.canon(&s.engineer) == me).collect();
535    let now = mine.iter().position(|s| s.session == session);
536    let then = mine.iter().position(|s| s.session == last.session);
537    let since = match (then, now) {
538        (Some(t), Some(n)) => n.saturating_sub(t),
539        // The asking session's record is gone: count sessions first seen after the question.
540        _ => mine.iter().filter(|s| s.ms > last.ms).count(),
541    };
542    since >= BUDGET_SESSIONS
543}
544
545/// One Stop checkpoint as the queen sees it.
546pub struct Checkpoint<'a> {
547    /// The routed collective's store.
548    pub store: &'a Path,
549    pub session: &'a str,
550    pub cwd: &'a str,
551    pub engineer: &'a str,
552    /// The drone's plan / last proposal.
553    pub plan: &'a str,
554    /// What the engineer gave the drone this session: their prompts (not the drone's own tool calls,
555    /// which would otherwise "state" any move the drone already ran).
556    pub inputs: &'a str,
557    /// The lessons the conscience asked at this checkpoint.
558    pub selected: &'a [&'a Challenge],
559    pub now: i64,
560}
561
562fn question(d: &Decision) -> String {
563    format!("The drone's plan says: \"{}\". That is {}. Is this the right call here, or should it be done differently?", d.sentence, d.kind.label())
564}
565
566/// A lesson is about a move when its question, situations and consequence name most (`STATED`) of
567/// the move's significant words ("force push main").
568pub fn covers(c: &Challenge, d: &Decision) -> bool {
569    let have: HashSet<String> = words(&format!("{} {} {}", c.question, c.situations.join(" "), c.consequence)).into_iter().collect();
570    overlap(&words(&d.what), &have, 1) >= STATED
571}
572
573/// Lessons among `selected` that contradict another one (`conflict_pairs`). Ids in selection order.
574pub fn conflicts(selected: &[&Challenge]) -> Vec<String> {
575    let pairs = conflict_pairs(selected);
576    (0..selected.len()).filter(|i| pairs.iter().any(|&(a, b)| a == *i || b == *i)).map(|i| selected[i].id.clone()).collect()
577}
578
579/// Pairs (i < j, indices into `selected`) of lessons that contradict each other: their situations
580/// mostly agree (≥ `STATED` of the shorter one's words) and their consequences mostly do not.
581pub fn conflict_pairs(selected: &[&Challenge]) -> Vec<(usize, usize)> {
582    let set = |v: &[String]| words(&v.join(" ")).into_iter().collect::<HashSet<String>>();
583    let same = |a: &HashSet<String>, b: &HashSet<String>| {
584        let n = a.len().min(b.len());
585        n > 0 && a.intersection(b).count() as f64 / n as f64 >= STATED
586    };
587    let form: Vec<(HashSet<String>, HashSet<String>)> = selected.iter().map(|c| (set(&c.situations), set(std::slice::from_ref(&c.consequence)))).collect();
588    let mut out = Vec::new();
589    for i in 0..selected.len() {
590        for j in i + 1..selected.len() {
591            if !form[i].1.is_empty() && !form[j].1.is_empty() && same(&form[i].0, &form[j].0) && !same(&form[i].1, &form[j].1) {
592                out.push((i, j));
593            }
594        }
595    }
596    out
597}
598
599/// Decide whether to ask the queen and, if so, queue the questions. Returns the asked entries
600/// (empty: nothing asked). Errors only on I/O; the caller fails open. Cheap checks first: the
601/// lessons file is read only when a candidate decision survives them and the budget. A question that
602/// near-duplicates one already queued for its repo (`duplicate`) is not queued again. Newly queued
603/// questions get one desktop notification (notify.rs).
604pub fn consider(cp: &Checkpoint) -> std::io::Result<Vec<Entry>> {
605    note_session(cp.store, cp.engineer, cp.session, cp.now)?;
606    let asked = entries(cp.store);
607    let mut ask: Vec<Entry> = Vec::new();
608    for e in decide(cp, &asked, || within_budget(cp.store, cp.engineer, cp.session), || lesson_lines(&cp.store.join("lessons.jsonl"))) {
609        if !asked.iter().chain(&ask).any(|x| duplicate(x, &e)) {
610            ask.push(e);
611        }
612    }
613    for e in &ask {
614        append_line(&queue_path(cp.store), e)?;
615        counted(cp.store, crate::stats::QUEEN_QUEUED, e, e.ms, |_| {});
616    }
617    crate::notify::queued(&ask);
618    Ok(ask)
619}
620
621/// The queen's decision at one checkpoint, without I/O (`consider` is this plus the store; the
622/// replay eval calls it directly): `asked` is every question asked so far, `budget` criterion 4 and
623/// `lessons` the collective's lessons as raw JSON, both evaluated only once a candidate survives
624/// criteria 1-3. `cp.store` is not read.
625pub fn decide(cp: &Checkpoint, asked: &[Entry], budget: impl FnOnce() -> bool, lessons: impl FnOnce() -> Vec<Value>) -> Vec<Entry> {
626    let repo = repo_name(cp.cwd);
627    let ever: HashSet<&str> = asked.iter().map(|e| e.decision_key.as_str()).collect();
628    let mut candidates: Vec<(Decision, Vec<String>)> = Vec::new();
629    for d in classify(cp.plan, &repo) {
630        let masks = |e: &Entry| !legacy_hive(e) || same_repo(&self::repo(e), &repo);
631        if ever.contains(d.key.as_str()) || stated(&d, cp.inputs) || asked.iter().any(|e| masks(e) && near(&d, &e.question)) {
632            continue;
633        }
634        // Criterion 2, L1 side: the lessons the conscience asked that are about this move. Agreeing,
635        // they answered it; contradicting each other, the collective cannot resolve it. Lessons about
636        // other things do not answer it (the conscience selects something for almost any plan).
637        let about: Vec<&Challenge> = cp.selected.iter().copied().filter(|c| covers(c, &d)).collect();
638        let clashing = conflicts(&about);
639        if !about.is_empty() && clashing.is_empty() {
640            continue;
641        }
642        candidates.push((d, clashing));
643    }
644    if candidates.is_empty() || !budget() {
645        return Vec::new();
646    }
647    let lessons = lessons();
648    let mut ask = Vec::new();
649    let mut picked: Vec<Decision> = Vec::new();
650    for (d, relevant) in candidates {
651        if ask.len() == MAX_QUESTIONS {
652            break;
653        }
654        if picked.iter().any(|p| same_move(&d, p)) {
655            continue;
656        }
657        let answered = lessons.iter().any(|l| l["source"] == "queen" && near(&d, l["decision_question"].as_str().unwrap_or_default()));
658        let disagreeing = match (known(&lessons, &d.key), answered) {
659            (Known::Resolved, _) | (Known::Nothing, true) => continue,
660            (Known::Nothing, false) => relevant,
661            (Known::Disagree(ids), _) => ids.into_iter().chain(relevant).collect::<Vec<_>>(),
662        };
663        let (default, other) = d.kind.options();
664        let mut evidence = vec![format!("plan: \"{}\"", d.sentence), format!("high consequence: {}", d.kind.label())];
665        evidence.push(match disagreeing.is_empty() {
666            true => "no lesson of this collective is about this move".into(),
667            false => format!("lessons {} disagree", disagreeing.join(", ")),
668        });
669        evidence.push("the engineer's prompts this session do not state this decision".into());
670        picked.push(d.clone());
671        ask.push(Entry {
672            id: format!("q{}", &d.key[..8]),
673            decision_key: d.key.clone(),
674            kind: d.kind,
675            question: question(&d),
676            options: vec![default.into(), other.into()],
677            default: default.into(),
678            evidence,
679            session: cp.session.into(),
680            cwd: cp.cwd.into(),
681            repo: repo.clone(),
682            engineer: cp.engineer.into(),
683            ms: cp.now,
684            answer: None,
685            answered_by: None,
686            answered_ms: None,
687            disagreeing,
688            dismissed: None,
689            dismissed_ms: None,
690            payload: None,
691        });
692    }
693    ask
694}
695
696/// What the drone is told, once: whom was asked what, and which default it proceeds with.
697pub fn render(asked: &[Entry]) -> String {
698    let who = asked.first().map(|e| e.engineer.as_str()).filter(|e| !e.is_empty()).unwrap_or("the engineer");
699    let mut s = format!(
700        "Borg asked the engineer ({who}) about a high-consequence decision this collective has no settled lesson for. \
701Do not wait for the answer and do not ask again: proceed with the default below unless an answer arrives. \
702Mention the open question and the default you took in your final message.\n"
703    );
704    for (i, e) in asked.iter().enumerate() {
705        s.push_str(&format!("\n{}. [{}] {}\n   proceed with default: {}", i + 1, e.id, e.question, e.default));
706    }
707    s
708}
709
710/// Whether the Stop hook asks the queen: only with `BORG_QUEEN=on` (off by default since the queen test).
711pub fn enabled() -> bool {
712    std::env::var("BORG_QUEEN").is_ok_and(|v| v.trim().eq_ignore_ascii_case("on"))
713}
714
715/// The Stop hook's single call, after the conscience decided. `plan` reads the drone's last message
716/// (`harness::plan`), only once the queen is on; `prompts` the engineer's prompts this session, `selected` the lessons the
717/// conscience asked. Some(block once) when the queen was asked; its reason then carries the
718/// conscience's questions too.
719pub fn stop(session: &str, input: &Value, plan: &dyn Fn() -> String, prompts: &str, selected: &[&Challenge]) -> Option<Value> {
720    if !learn::learning() || !enabled() {
721        return None;
722    }
723    // `plan` reads a transcript: inside the guard too.
724    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
725        let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default();
726        let plan = &plan();
727        let store = learn::home();
728        let engineer = learn::engineer(s("cwd"));
729        let cp = Checkpoint { store: &store, session, cwd: s("cwd"), engineer: &engineer, plan, inputs: prompts, selected, now: learn::now_ms() };
730        let asked = consider(&cp).ok()?;
731        let reason = match selected.is_empty() {
732            true => render(&asked),
733            false => format!("{}\n\n{}", conscience::render(selected), render(&asked)),
734        };
735        (!asked.is_empty()).then(|| json!({"decision": "block", "reason": reason}))
736    }))
737    .ok()
738    .flatten()
739}
740
741/// The normalised consequence `known` compares answers by.
742fn said(text: &str) -> String {
743    text.trim().to_lowercase()
744}
745
746/// The lesson an answer becomes: the decision question with the engineer's answer, keyed by the
747/// decision so the queen is never asked it again. It supersedes only the disagreeing lessons the
748/// answer overrules (their consequence differs from it, as `known` compares), never the side it
749/// confirms, and takes the area most of the disagreeing lessons (`lessons`, latest lines) share.
750pub fn lesson(e: &Entry, answer: &str, author: &str, now: i64, lessons: &[Value]) -> Value {
751    let repo = Path::new(&e.cwd).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
752    let sides: Vec<&Value> = e.disagreeing.iter().filter_map(|id| lessons.iter().find(|l| l["id"].as_str() == Some(id))).collect();
753    // A disagreeing lesson that is gone from the store cannot be compared: it stays superseded.
754    let supersedes: Vec<&String> = e
755        .disagreeing
756        .iter()
757        .filter(|id| !sides.iter().any(|l| l["id"].as_str() == Some(id) && said(l["consequence"].as_str().unwrap_or_default()) == said(answer)))
758        .collect();
759    let mut areas: Vec<(&str, usize)> = Vec::new();
760    for a in sides.iter().map(|l| crate::trust::area(l["area"].as_str().unwrap_or_default())) {
761        match areas.iter_mut().find(|(x, _)| *x == a) {
762            Some((_, n)) => *n += 1,
763            None => areas.push((a, 1)),
764        }
765    }
766    // Most shared area; ties go to the first disagreeing lesson's.
767    let area = areas.iter().fold(None, |best: Option<(&str, usize)>, &(a, n)| match best {
768        Some((_, m)) if m >= n => best,
769        _ => Some((a, n)),
770    });
771    json!({
772        "id": format!("queen-{}", e.decision_key),
773        "origin_ms": now,
774        "repo": repo,
775        // The conscience shows only the question, so it carries the answer too.
776        "question": format!("{} The engineer ({author}) decided: \"{answer}\"", e.question),
777        "situations": [format!("{} under a plan that says: {}", e.kind.label(), e.evidence.first().map(String::as_str).unwrap_or_default())],
778        "consequence": answer,
779        "support": 1,
780        "episodes": [],
781        "history": [],
782        "version": 1,
783        "engineers": if author.is_empty() { vec![] } else { vec![author.to_string()] },
784        "author": author,
785        "decision_key": e.decision_key,
786        "decision_question": e.question,
787        "area": area.map_or(crate::trust::OTHER, |(a, _)| a),
788        "supersedes": supersedes,
789        "source": "queen",
790    })
791}
792
793/// Record the engineer's answer to `id` and add it to the collective's lessons.
794pub fn answer(store: &Path, id: &str, text: &str, author: &str, now: i64) -> Result<Entry, String> {
795    let text = text.trim();
796    if text.is_empty() {
797        return Err("the answer is empty".into());
798    }
799    let Some(mut e) = entries(store).into_iter().find(|e| e.id == id) else {
800        return Err(format!("no queen question {id} in this collective"));
801    };
802    if e.answer.is_some() {
803        return Err(format!("{id} is already answered"));
804    }
805    if e.dismissed.is_some() {
806        return Err(format!("{id} was dismissed"));
807    }
808    // The lesson first: a question marked answered without its lesson would be lost. A reach question
809    // writes none: its answer moves the lesson it is about (reach.rs). Nor does a proposed constraint:
810    // its veto is a lesson already, and ratifying it writes the constraint (veto.rs).
811    if e.kind == Kind::Constraint {
812        crate::veto::ratify(store, &e, text, author, now)?;
813    } else if e.kind == Kind::Reach {
814        crate::reach::answer(store, &e, text)?;
815    } else {
816        let lessons = lesson_lines(&store.join("lessons.jsonl"));
817        append_line(&store.join("lessons.jsonl"), &lesson(&e, text, author, now, &lessons)).map_err(|err| err.to_string())?;
818    }
819    e.answer = Some(text.into());
820    e.answered_by = Some(author.into());
821    e.answered_ms = Some(now);
822    append_line(&queue_path(store), &e).map_err(|err| err.to_string())?;
823    counted(store, crate::stats::QUEEN_ANSWERED, &e, now, |ev| {
824        ev.engineer = author.into();
825        ev.dur_ms = u64::try_from(now - e.ms).unwrap_or(0);
826        ev.text = crate::stats::excerpt(text, crate::stats::FEED_CHARS);
827    });
828    crate::escalation::answered(store, &e, text, author, now);
829    if !matches!(e.kind, Kind::Reach | Kind::Constraint) {
830        counted(store, crate::stats::LESSON_NEW, &e, now, |ev| {
831            ev.engineer = author.into();
832            ev.why = "queen".into();
833        });
834    }
835    Ok(e)
836}
837
838// ---------------------------------------------------------------------------------------------------
839// Answering the queue: numbering, dismissing, skipping, deduplicating, the machine interface and the
840// in-harness delivery (one question per session, in the session's own repo).
841
842/// A skipped question is not delivered again for this long (3 days). Guard: a skip is respected, and
843/// the question still comes back while its decision may matter. Derived from clock.rs `DAY_MS`.
844pub const SKIP_MS: i64 = 3 * crate::clock::DAY_MS;
845/// A question delivered but neither answered nor skipped is offered again only once more than this many
846/// sessions (with open questions of their repos) began after the delivering one. Guard: an ignored
847/// question does not come back at every session.
848pub const REOFFER_SESSIONS: usize = 3;
849/// Two questions of one repo whose significant words overlap this much (Jaccard) are one question.
850/// Method: a dedupe threshold, design-chosen.
851pub const DUPLICATE: f64 = 0.6;
852
853fn skips_path(store: &Path) -> PathBuf {
854    dir(store).join("skips.jsonl")
855}
856
857fn delivered_path(store: &Path) -> PathBuf {
858    dir(store).join("delivered.jsonl")
859}
860
861fn visits_path(store: &Path) -> PathBuf {
862    dir(store).join("visits.jsonl")
863}
864
865/// The repo of a directory as the hooks name it: its git root's base name, a worktree folded onto its
866/// repo (`borg_trace::repo_key`); the directory's own name outside git.
867pub fn repo_name(cwd: &str) -> String {
868    Path::new(&borg_trace::repo_key(cwd)).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
869}
870
871/// The repo a question is about: the one recorded when it was queued, else `repo_name` of the
872/// directory it was asked (or imported) in.
873pub fn repo(e: &Entry) -> String {
874    if e.repo.is_empty() { repo_name(&e.cwd) } else { e.repo.clone() }
875}
876
877fn same_repo(a: &str, b: &str) -> bool {
878    !a.is_empty() && a.eq_ignore_ascii_case(b)
879}
880
881/// What a question is compared and summarised by: a checkpoint question's quoted plan sentence (its
882/// frame is the same for every move), else the question itself.
883pub fn gist(e: &Entry) -> &str {
884    e.question.strip_prefix("The drone's plan says: \"").and_then(|r| r.split_once("\". That is ")).map_or(e.question.as_str(), |(s, _)| s)
885}
886
887/// `text` trimmed and cut to `max` characters, an ellipsis marking the cut.
888pub fn clip(text: &str, max: usize) -> String {
889    let t = text.trim();
890    if t.chars().count() <= max {
891        return t.to_string();
892    }
893    let mut s: String = t.chars().take(max.saturating_sub(1)).collect::<String>().trim_end().to_string();
894    s.push('…');
895    s
896}
897
898/// Jaccard similarity of the significant words of `a` and `b` (0 when both have none).
899pub fn jaccard(a: &str, b: &str) -> f64 {
900    let (a, b): (HashSet<String>, HashSet<String>) = (words(a).into_iter().collect(), words(b).into_iter().collect());
901    match a.union(&b).count() {
902        0 => 0.0,
903        n => a.intersection(&b).count() as f64 / n as f64,
904    }
905}
906
907/// Two questions are one: the same repo and `DUPLICATE` of their words (`gist`) shared.
908pub fn duplicate(a: &Entry, b: &Entry) -> bool {
909    repo(a).eq_ignore_ascii_case(&repo(b)) && jaccard(gist(a), gist(b)) >= DUPLICATE
910}
911
912/// A question marked in a session: a skip (a line of skips.jsonl; session "cli" from the terminal) or a
913/// delivery by the prompt hook (a line of delivered.jsonl).
914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
915pub struct Mark {
916    pub id: String,
917    pub session: String,
918    pub ms: i64,
919}
920
921/// A session the delivery saw a prompt of while its repos had open questions (a line of visits.jsonl),
922/// in first-seen order: what "3 more sessions" counts.
923#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
924pub struct Visit {
925    pub session: String,
926    pub ms: i64,
927}
928
929pub fn skips(store: &Path) -> Vec<Mark> {
930    read_lines(&skips_path(store))
931}
932
933pub fn deliveries(store: &Path) -> Vec<Mark> {
934    read_lines(&delivered_path(store))
935}
936
937/// `id` was skipped less than `SKIP_MS` before `now`.
938pub fn skipped(skips: &[Mark], id: &str, now: i64) -> bool {
939    skips.iter().any(|s| s.id == id && now.saturating_sub(s.ms) < SKIP_MS)
940}
941
942/// The open question `id`, or why it is not one.
943fn find_open(store: &Path, id: &str) -> Result<Entry, String> {
944    let Some(e) = entries(store).into_iter().find(|e| e.id == id) else {
945        return Err(format!("no queen question {id} in this collective"));
946    };
947    match (&e.answer, &e.dismissed) {
948        (Some(_), _) => Err(format!("{id} is already answered")),
949        (_, Some(_)) => Err(format!("{id} was dismissed")),
950        _ => Ok(e),
951    }
952}
953
954/// Dismiss `id`: no longer open, never delivered, no lesson written. The latest queue line wins.
955pub fn dismiss(store: &Path, id: &str, reason: &str, now: i64) -> Result<Entry, String> {
956    let mut e = find_open(store, id)?;
957    e.dismissed = Some(Some(reason.trim()).filter(|r| !r.is_empty()).unwrap_or("dismissed").to_string());
958    e.dismissed_ms = Some(now);
959    append_line(&queue_path(store), &e).map_err(|err| err.to_string())?;
960    counted(store, crate::stats::QUEEN_DISMISSED, &e, now, |ev| ev.why = e.dismissed.clone().unwrap_or_default());
961    Ok(e)
962}
963
964/// Skip `id` in `session`: it stays open but is not delivered again for `SKIP_MS`.
965pub fn skip(store: &Path, id: &str, session: &str, now: i64) -> Result<Entry, String> {
966    let e = find_open(store, id)?;
967    append_line(&skips_path(store), &Mark { id: e.id.clone(), session: session.into(), ms: now }).map_err(|err| err.to_string())?;
968    counted(store, crate::stats::QUEEN_SKIPPED, &e, now, |ev| ev.session = session.into());
969    Ok(e)
970}
971
972/// `key` as `borg queen` takes it: a listing number n (1-based, into `open` in asking order), else the
973/// id of any question in `all`.
974pub fn resolve<'a>(open: &'a [Entry], all: &'a [Entry], key: &str) -> Option<&'a Entry> {
975    let key = key.trim();
976    match key.parse::<usize>() {
977        Ok(n) => n.checked_sub(1).and_then(|i| open.get(i)),
978        Err(_) => all.iter().find(|e| e.id == key),
979    }
980}
981
982/// An answer as typed: exactly an option's letter ("a", "b") or number ("1", "2") stands for that
983/// option's text; a lone letter or number that names no option is refused (a typo, never recorded as
984/// a lesson) when the question has options; anything else is the answer itself.
985pub fn option_answer(e: &Entry, text: &str) -> Result<String, String> {
986    let t = text.trim();
987    let i = match t.as_bytes() {
988        [c @ b'a'..=b'z'] => Some((c - b'a') as usize),
989        [c @ b'A'..=b'Z'] => Some((c - b'A') as usize),
990        _ => t.parse::<usize>().ok().map(|n| n.wrapping_sub(1)),
991    };
992    match (i, e.options.len()) {
993        (Some(i), n) if i < n => Ok(e.options[i].clone()),
994        (Some(_), n) if n > 0 => Err(format!("no option {t} (a-{} or 1-{n}); to answer in your own words, write more than a letter or number", letter(n - 1))),
995        _ => Ok(t.to_string()),
996    }
997}
998
999/// Open questions that near-duplicate an earlier, not dismissed question of their repo (`duplicate`):
1000/// (later id, earlier id) in asking order. A duplicate found here is never the earlier side.
1001pub fn duplicates(all: &[Entry]) -> Vec<(String, String)> {
1002    let mut out: Vec<(String, String)> = Vec::new();
1003    for (j, e) in all.iter().enumerate().filter(|(_, e)| e.is_open()) {
1004        let first = all[..j].iter().find(|x| x.dismissed.is_none() && !out.iter().any(|(d, _)| *d == x.id) && duplicate(x, e));
1005        if let Some(x) = first {
1006            out.push((e.id.clone(), x.id.clone()));
1007        }
1008    }
1009    out
1010}
1011
1012/// `borg queen dedupe`: the duplicates; with `apply`, each dismissed as "duplicate of <id>".
1013pub fn dedupe(store: &Path, apply: bool, now: i64) -> Result<Vec<(String, String)>, String> {
1014    let found = duplicates(&entries(store));
1015    if apply {
1016        for (id, of) in &found {
1017            dismiss(store, id, &format!("duplicate of {of}"), now)?;
1018        }
1019    }
1020    Ok(found)
1021}
1022
1023/// One open question for a drone or a script: `borg queen --json` (an array) and the prompt hook. The
1024/// schema is stable: `id`, `n` (its number in `borg queen`), `repo`, `kind` (snake_case `Kind`),
1025/// `question`, `options`, `default`, `evidence`, `ms` (asked, epoch ms) and three exact shell commands:
1026/// `answer_cmd` (replace `<answer>` inside its single quotes), `skip_cmd` and `dismiss_cmd` (a reason
1027/// may follow).
1028#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1029pub struct Question {
1030    pub id: String,
1031    pub n: usize,
1032    pub repo: String,
1033    pub kind: Kind,
1034    pub question: String,
1035    pub options: Vec<String>,
1036    pub default: String,
1037    pub evidence: Vec<String>,
1038    pub ms: i64,
1039    pub answer_cmd: String,
1040    pub skip_cmd: String,
1041    pub dismiss_cmd: String,
1042}
1043
1044/// `s` single-quoted for a POSIX shell.
1045pub fn sh_quote(s: &str) -> String {
1046    format!("'{}'", s.replace('\'', r"'\''"))
1047}
1048
1049impl Question {
1050    /// `e` as listing number `n`. `scope` follows each command (" --collective 'team'"); empty, the
1051    /// command routes by the directory it runs in.
1052    pub fn of(e: &Entry, n: usize, scope: &str) -> Question {
1053        let id = sh_quote(&e.id);
1054        Question {
1055            id: e.id.clone(),
1056            n,
1057            repo: repo(e),
1058            kind: e.kind,
1059            question: e.question.clone(),
1060            options: e.options.clone(),
1061            default: e.default.clone(),
1062            evidence: e.evidence.clone(),
1063            ms: e.ms,
1064            answer_cmd: format!("borg queen answer {id} '<answer>'{scope}"),
1065            skip_cmd: format!("borg queen skip {id}{scope}"),
1066            dismiss_cmd: format!("borg queen dismiss {id}{scope}"),
1067        }
1068    }
1069
1070    /// `self` with its commands run by `bin` (a shell word, e.g. the hook's own sh-quoted exe) rather
1071    /// than by `borg` on PATH.
1072    pub fn run_by(mut self, bin: &str) -> Question {
1073        for c in [&mut self.answer_cmd, &mut self.skip_cmd, &mut self.dismiss_cmd] {
1074            if let Some(rest) = c.strip_prefix("borg ") {
1075                *c = format!("{bin} {rest}");
1076            }
1077        }
1078        self
1079    }
1080}
1081
1082/// The open questions as `borg queen --json` lists them, only `only`'s repo when given.
1083pub fn questions(open: &[Entry], only: Option<&str>, scope: &str) -> Vec<Question> {
1084    open.iter().enumerate().filter(|(_, e)| only.is_none_or(|r| same_repo(r, &repo(e)))).map(|(i, e)| Question::of(e, i + 1, scope)).collect()
1085}
1086
1087/// Whether the prompt hook delivers queen questions: `BORG_QUEEN_ASK` is not "off" (default on).
1088pub fn ask_enabled() -> bool {
1089    ask_on(std::env::var("BORG_QUEEN_ASK").ok().as_deref())
1090}
1091
1092/// `ask_enabled` for a given `BORG_QUEEN_ASK` value.
1093pub fn ask_on(value: Option<&str>) -> bool {
1094    !value.is_some_and(|v| v.trim().eq_ignore_ascii_case("off"))
1095}
1096
1097/// The question to deliver in `session`, pure. None once `delivered` has one for this session (one per
1098/// session). Else an open question of one of `repos` (never another repo's), not skipped within
1099/// `SKIP_MS`, and, if delivered before, only once more than `REOFFER_SESSIONS` sessions of `visits`
1100/// (first-seen order) began since. The engineer's decisions about the work come first: a 0.3.0 hive
1101/// task's question (`legacy_hive`) only when none of them is eligible. Then
1102/// never-delivered questions first, then asking order. The index is into `open`.
1103pub fn eligible<'a>(
1104    open: &'a [Entry],
1105    skips: &[Mark],
1106    delivered: &[Mark],
1107    visits: &[Visit],
1108    session: &str,
1109    repos: &[String],
1110    now: i64,
1111) -> Option<(usize, &'a Entry)> {
1112    if delivered.iter().any(|d| d.session == session) {
1113        return None;
1114    }
1115    let at = |s: &str| visits.iter().position(|v| v.session == s);
1116    open.iter()
1117        .enumerate()
1118        .filter(|(_, e)| repos.iter().any(|r| same_repo(r, &repo(e))) && !skipped(skips, &e.id, now))
1119        .filter_map(|(i, e)| {
1120            let Some(last) = delivered.iter().filter(|d| d.id == e.id).max_by_key(|d| d.ms) else { return Some((legacy_hive(e), false, i, e)) };
1121            let since = match (at(&last.session), at(session)) {
1122                (Some(t), Some(n)) => n.saturating_sub(t),
1123                // The delivering session's record is gone: count sessions first seen after the delivery.
1124                _ => visits.iter().filter(|v| v.ms > last.ms).count(),
1125            };
1126            (since > REOFFER_SESSIONS).then_some((legacy_hive(e), true, i, e))
1127        })
1128        .min_by_key(|&(proposal, again, i, _)| (proposal, again, i))
1129        .map(|(_, _, i, e)| (i, e))
1130}
1131
1132/// The prompt hook's call (0 model calls): the one question to ask in `session`, whose work is in
1133/// `repos` (computed only once the session has none delivered and the queue has open questions),
1134/// recorded as delivered (delivered.jsonl). Its commands end with `scope`. None when nothing is
1135/// eligible (`eligible`) or the store cannot record it: fails open. `BORG_QUEEN_ASK` is the caller's.
1136pub fn deliver(store: &Path, session: &str, repos: impl FnOnce() -> Vec<String>, scope: &str, now: i64) -> Option<Question> {
1137    if session.is_empty() {
1138        return None;
1139    }
1140    let delivered = deliveries(store);
1141    if delivered.iter().any(|d| d.session == session) {
1142        return None;
1143    }
1144    let open = open(store);
1145    if open.is_empty() {
1146        return None;
1147    }
1148    let repos = repos();
1149    if !open.iter().any(|e| repos.iter().any(|r| same_repo(r, &repo(e)))) {
1150        return None;
1151    }
1152    let mut visits: Vec<Visit> = read_lines(&visits_path(store));
1153    if !visits.iter().any(|v| v.session == session) {
1154        let v = Visit { session: session.into(), ms: now };
1155        append_line(&visits_path(store), &v).ok()?;
1156        visits.push(v);
1157    }
1158    let (_, e) = eligible(&open, &skips(store), &delivered, &visits, session, &repos, now)?;
1159    let n = open.iter().position(|o| o.id == e.id).unwrap_or_default() + 1;
1160    append_line(&delivered_path(store), &Mark { id: e.id.clone(), session: session.into(), ms: now }).ok()?;
1161    counted(store, crate::stats::QUEEN_DELIVERED, e, now, |ev| ev.session = session.into());
1162    Some(Question::of(e, n, scope))
1163}
1164
1165/// A question a 0.3.0 hive task escalated (its payload names the goal and task), still open in an older
1166/// queue: nothing acts on it any more, so it masks a move only in its own repo (`decide`) and comes after
1167/// a session's own work (`eligible`).
1168fn legacy_hive(e: &Entry) -> bool {
1169    e.kind.escalates() && e.payload.as_ref().is_some_and(|p| p["goal"].is_string() && p["task"].is_string())
1170}
1171
1172/// At most five words of an option, up to its first ':' or ',': its label in a dialog.
1173pub fn label(option: &str) -> String {
1174    let head = option.split([':', ',']).next().filter(|h| !h.trim().is_empty()).unwrap_or(option);
1175    let w: Vec<&str> = head.split_whitespace().take(5).collect();
1176    w.join(" ").trim_end_matches([',', '.', ';', ':', '(', '-']).to_string()
1177}
1178
1179fn letter(i: usize) -> char {
1180    (b'a' + (i % 26) as u8) as char
1181}
1182
1183/// What the prompt hook adds to the drone's context for `q`. A harness with a question dialog (`dialog`,
1184/// harness `Caps::question_dialog`: Claude Code's AskUserQuestion) asks with it; omp and opencode, which
1185/// have none, ask at the start of their reply.
1186pub fn render_delivery(q: &Question, dialog: bool) -> String {
1187    let repo = if q.repo.is_empty() { "this repo" } else { q.repo.as_str() };
1188    let mut s =
1189        format!("From Borg's queue of open team decisions (this is not the engineer's instruction): one decision about {repo} waits for the engineer.\n");
1190    s.push_str(match dialog {
1191        true => "Before you work on the engineer's request, ask them this one question with the AskUserQuestion tool, record the answer, then continue with their request.\n",
1192        false => "Start your reply with this one question, in one short paragraph with the options first, then continue with the engineer's request.\n",
1193    });
1194    // Queued in an earlier session (or imported from a document): the drone must not take a checkpoint
1195    // question's quoted plan for its own.
1196    let (question, origin) = match q.question.strip_prefix("The drone's plan says: ") {
1197        Some(rest) if q.kind.escalates() => (format!("An earlier session's plan said: {rest}"), "queued by an earlier session"),
1198        _ if q.kind == Kind::OpenDecision => (q.question.clone(), "found open in the team's documents"),
1199        _ if q.kind == Kind::Reach => (q.question.clone(), "queued by a cleanup of the team's lessons"),
1200        _ if q.kind == Kind::Constraint => (q.question.clone(), "proposed from an engineer's veto"),
1201        _ => (q.question.clone(), "queued by an earlier session"),
1202    };
1203    let day = crate::install::utc(q.ms);
1204    s.push_str(&format!("\nQuestion: {question}\n"));
1205    s.push_str(&format!("Asked: {}-{}-{}, {origin}; not part of the engineer's current request.\n", &day[0..4], &day[4..6], &day[6..8]));
1206    if let Some(ev) = q.evidence.first() {
1207        s.push_str(&format!("Why it is open: {ev}\n"));
1208    }
1209    if dialog {
1210        s.push_str("Options (label: description):\n");
1211        for o in q.options.iter().take(3) {
1212            s.push_str(&format!("- {}: {o}\n", label(o)));
1213        }
1214        if q.options.is_empty() {
1215            s.push_str(&format!("- Keep the default: {}\n", q.default));
1216        }
1217        s.push_str("- Skip for now: ask again in a few days\n");
1218        if q.options.len() > 3 {
1219            s.push_str(&format!("More options the engineer may type: {}\n", q.options[3..].join(" | ")));
1220        }
1221    } else {
1222        s.push_str("Options:\n");
1223        for (i, o) in q.options.iter().enumerate() {
1224            s.push_str(&format!("{}) {o}\n", letter(i)));
1225        }
1226        s.push_str("or skip it for now.\n");
1227    }
1228    if !q.default.is_empty() {
1229        s.push_str(&format!("Default while nobody decides: {}\n", q.default));
1230    }
1231    s.push_str("The engineer can always answer in their own words.\n");
1232    s.push_str(&format!(
1233        "\n{} record it with exactly:\n  {}\nreplacing <answer> with the chosen option's full text or the engineer's own words, inside the single quotes (write a ' as '\\'').\n",
1234        if dialog { "Then" } else { "When the engineer answers," },
1235        q.answer_cmd
1236    ));
1237    s.push_str(&format!("If the engineer skips it: {}\nIf they say it no longer applies: {} '<reason>'\n", q.skip_cmd, q.dismiss_cmd));
1238    s.push_str("Ask it only once in this session.");
1239    s
1240}
1241
1242fn paint(color: bool, code: &str, s: &str) -> String {
1243    match color {
1244        true => format!("\x1b[{code}m{s}\x1b[0m"),
1245        false => s.to_string(),
1246    }
1247}
1248
1249/// The options of `e` on one line: "a) … · b) … (default)", the default appended when no option is it.
1250fn options_line(e: &Entry) -> String {
1251    let mut parts: Vec<String> =
1252        e.options.iter().enumerate().map(|(i, o)| format!("{}) {o}{}", letter(i), if *o == e.default { " (default)" } else { "" })).collect();
1253    if !e.options.contains(&e.default) && !e.default.is_empty() {
1254        parts.push(format!("default: {}", e.default));
1255    }
1256    parts.join(" · ")
1257}
1258
1259const HINT: &str = "answer: borg queen ask · borg queen answer <n> <text|a|b> · borg queen show <n>";
1260
1261/// `borg queen`: the open questions (only `only`'s repo when given) numbered in asking order, grouped by
1262/// repo (repos in order of their first question), each line cut to `width` characters; ANSI colors
1263/// when `color`.
1264pub fn render_list(open: &[Entry], only: Option<&str>, width: usize, color: bool) -> String {
1265    let mut repos: Vec<String> = Vec::new();
1266    for r in open.iter().map(repo).filter(|r| only.is_none_or(|o| same_repo(o, r))) {
1267        if !repos.contains(&r) {
1268            repos.push(r);
1269        }
1270    }
1271    let body = width.saturating_sub(5).max(10);
1272    let mut s = String::new();
1273    for r in &repos {
1274        let mine: Vec<(usize, &Entry)> = open.iter().enumerate().filter(|(_, e)| repo(e) == *r).collect();
1275        let name = if r.is_empty() { "(no repo)" } else { r };
1276        s.push_str(&format!("{}\n", paint(color, "1", &format!("{name} ({})", mine.len()))));
1277        for (i, e) in mine {
1278            s.push_str(&format!("{}  {}\n", paint(color, "1;36", &format!("{:>3}", i + 1)), clip(&e.question, body)));
1279            let opts = options_line(e);
1280            if !opts.is_empty() {
1281                s.push_str(&format!("     {}\n", paint(color, "2", &clip(&opts, body))));
1282            }
1283        }
1284        s.push('\n');
1285    }
1286    s.push_str(&paint(color, "2", &clip(HINT, width)));
1287    s.push('\n');
1288    s
1289}
1290
1291/// `borg queen show`: everything about one question; `n` its listing number when open.
1292pub fn render_show(e: &Entry, n: Option<usize>) -> String {
1293    let who = |s: &str| if s.is_empty() { "?".to_string() } else { s.to_string() };
1294    let mut s = match n {
1295        Some(n) => format!("#{n}  {}\n", e.id),
1296        None => format!("{}\n", e.id),
1297    };
1298    s.push_str(&format!("{}\n\n", e.question));
1299    s.push_str(&format!("kind:     {}\n", e.kind.label()));
1300    s.push_str(&format!("repo:     {} ({})\n", repo(e), e.cwd));
1301    s.push_str(&format!("asked:    {} by {} (session {})\n", crate::install::utc(e.ms), who(&e.engineer), e.session));
1302    if !e.options.is_empty() {
1303        s.push_str("options:\n");
1304        for (i, o) in e.options.iter().enumerate() {
1305            s.push_str(&format!("  {}) {o}{}\n", letter(i), if *o == e.default { " (default)" } else { "" }));
1306        }
1307    }
1308    s.push_str(&format!("default:  {}\n", e.default));
1309    if !e.evidence.is_empty() {
1310        s.push_str("evidence:\n");
1311        for ev in &e.evidence {
1312            s.push_str(&format!("  - {ev}\n"));
1313        }
1314    }
1315    let status = match (&e.answer, &e.dismissed) {
1316        (Some(a), _) => {
1317            format!("answered by {} at {}: {a}", who(e.answered_by.as_deref().unwrap_or_default()), crate::install::utc(e.answered_ms.unwrap_or(0)))
1318        }
1319        (_, Some(r)) => format!("dismissed at {}: {r}", crate::install::utc(e.dismissed_ms.unwrap_or(0))),
1320        _ => "open".into(),
1321    };
1322    s.push_str(&format!("status:   {status}\n"));
1323    s
1324}
1325
1326/// What `borg queen ask` did: (answered, skipped, dismissed).
1327pub type Asked = (usize, usize, usize);
1328
1329/// `borg queen ask` over any input: each open question with one evidence line and numbered options. An
1330/// option's number or letter answers with that option, a lone number or letter naming no option asks
1331/// again, other text is the answer; "s" skips, "d" dismisses, "q" or the end of input quits, an empty
1332/// line asks again.
1333pub fn ask_loop(store: &Path, input: &mut dyn std::io::BufRead, out: &mut dyn Write, author: &str, now: &dyn Fn() -> i64) -> std::io::Result<Asked> {
1334    let open = open(store);
1335    let mut done: Asked = (0, 0, 0);
1336    'questions: for (i, e) in open.iter().enumerate() {
1337        writeln!(out, "\n[{}/{}] {}  {}", i + 1, open.len(), repo(e), e.question)?;
1338        if let Some(ev) = e.evidence.first() {
1339            writeln!(out, "  evidence: {ev}")?;
1340        }
1341        for (k, o) in e.options.iter().enumerate() {
1342            writeln!(out, "  {}) {o}{}", k + 1, if *o == e.default { " (default)" } else { "" })?;
1343        }
1344        if !e.options.contains(&e.default) {
1345            writeln!(out, "  default: {}", e.default)?;
1346        }
1347        loop {
1348            write!(out, "answer (option number, or your own words; s skip, d dismiss, q quit): ")?;
1349            out.flush()?;
1350            let mut line = String::new();
1351            if input.read_line(&mut line)? == 0 {
1352                break 'questions;
1353            }
1354            let said = match line.trim() {
1355                "" => continue,
1356                "q" => break 'questions,
1357                "s" => skip(store, &e.id, "cli", now()).map(|_| {
1358                    done.1 += 1;
1359                    "skipped for 3 days".to_string()
1360                }),
1361                "d" => dismiss(store, &e.id, "dismissed in borg queen ask", now()).map(|_| {
1362                    done.2 += 1;
1363                    "dismissed".to_string()
1364                }),
1365                t => match option_answer(e, t) {
1366                    Err(err) => {
1367                        writeln!(out, "  {err}")?;
1368                        continue;
1369                    }
1370                    Ok(a) => answer(store, &e.id, &a, author, now()).map(|x| {
1371                        done.0 += 1;
1372                        format!("answered: {}", x.answer.unwrap_or_default())
1373                    }),
1374                },
1375            };
1376            match said {
1377                Ok(m) => writeln!(out, "  {m}")?,
1378                Err(err) => writeln!(out, "  borg queen: {err}")?,
1379            }
1380            break;
1381        }
1382    }
1383    writeln!(out, "\n{} answered, {} skipped, {} dismissed", done.0, done.1, done.2)?;
1384    Ok(done)
1385}
1386
1387/// The terminal's width in columns: `COLUMNS`, else the terminal of stdout, else none (no cut).
1388pub(crate) fn width() -> usize {
1389    if let Some(w) = std::env::var("COLUMNS").ok().and_then(|c| c.trim().parse::<usize>().ok()).filter(|w| *w > 20) {
1390        return w;
1391    }
1392    #[cfg(unix)]
1393    {
1394        // SAFETY: TIOCGWINSZ only writes a winsize into the zeroed struct we own.
1395        let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
1396        if unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0 && ws.ws_col > 20 {
1397            return ws.ws_col as usize;
1398        }
1399    }
1400    usize::MAX
1401}
1402
1403/// The store `borg queen` works on: the collective named `named`, else the one `cwd` routes to. None:
1404/// `cwd` routes off. Err: (message, exit code).
1405fn locate(cwd: &str, named: Option<&str>) -> Result<Option<PathBuf>, (String, i32)> {
1406    let cfg = collective::load().map_err(|e| (format!("invalid collectives config ({e}); everything is off"), 1))?;
1407    let base = learn::home();
1408    let Some(n) = named else {
1409        return Ok(match collective::route(cfg.as_ref(), cwd, &base) {
1410            collective::Route::Collective { home, .. } => Some(home),
1411            collective::Route::Off => None,
1412        });
1413    };
1414    match &cfg {
1415        None if n == "default" => Ok(Some(base)),
1416        None => Err((format!("no collective named {n:?}: there is no collectives config"), 2)),
1417        Some(c) => {
1418            c.collectives.iter().find(|e| e.name == n).map(|e| Some(collective::store(&base, &e.name))).ok_or_else(|| (format!("no collective named {n:?}"), 2))
1419        }
1420    }
1421}
1422
1423fn print_json<T: Serialize>(v: &T) -> i32 {
1424    println!("{}", serde_json::to_string_pretty(v).unwrap_or_else(|_| "null".into()));
1425    0
1426}
1427
1428fn fail(msg: impl std::fmt::Display) -> i32 {
1429    eprintln!("borg queen: {msg}");
1430    1
1431}
1432
1433/// `borg queen`: list, show, ask, answer, dismiss, skip, dedupe (see `usage`). `--dir DIR`
1434/// (default: the current directory) or `--collective NAME` picks the collective.
1435pub fn main(args: &[&str]) -> i32 {
1436    use std::io::IsTerminal;
1437    let mut rest: Vec<&str> = Vec::new();
1438    let (mut dir, mut named, mut only, mut json, mut apply) = (None, None, None, false, false);
1439    let mut it = args.iter();
1440    while let Some(a) = it.next() {
1441        match *a {
1442            "--dir" | "--collective" | "--repo" => {
1443                let Some(v) = it.next().map(|v| v.to_string()) else { return usage() };
1444                match *a {
1445                    "--dir" => dir = Some(v),
1446                    "--collective" => named = Some(v),
1447                    _ => only = Some(v),
1448                }
1449            }
1450            "--json" => json = true,
1451            "--apply" => apply = true,
1452            a => rest.push(a),
1453        }
1454    }
1455    // An unknown command fails everywhere, also in a repo that routes off.
1456    if !matches!(rest.first(), None | Some(&("show" | "ask" | "answer" | "dismiss" | "skip" | "dedupe"))) {
1457        return usage();
1458    }
1459    let cwd = dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
1460    let store = match locate(&cwd, named.as_deref()) {
1461        Ok(Some(s)) => s,
1462        Ok(None) => {
1463            match json {
1464                true => println!("[]"),
1465                false => println!("off: {cwd} belongs to no collective; the queen is never asked here"),
1466            }
1467            return 0;
1468        }
1469        Err((msg, code)) => {
1470            eprintln!("borg queen: {msg}");
1471            return code;
1472        }
1473    };
1474    let scope = match (&named, &dir) {
1475        (Some(n), _) => format!(" --collective {}", sh_quote(n)),
1476        (None, Some(d)) => format!(" --dir {}", sh_quote(d)),
1477        _ => String::new(),
1478    };
1479    let now = learn::now_ms();
1480    let all = entries(&store);
1481    let open: Vec<Entry> = all.iter().filter(|e| e.is_open()).cloned().collect();
1482    let find = |key: &str| resolve(&open, &all, key).cloned().ok_or_else(|| format!("no queen question {key} in this collective (see borg queen)"));
1483    match rest.as_slice() {
1484        [] if json => print_json(&questions(&open, only.as_deref(), &scope)),
1485        [] => {
1486            if !open.iter().any(|e| only.as_deref().is_none_or(|r| same_repo(r, &repo(e)))) {
1487                println!("no open queen questions ({})", store.display());
1488                return 0;
1489            }
1490            print!(
1491                "{}",
1492                render_list(&open, only.as_deref(), width(), std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty()))
1493            );
1494            0
1495        }
1496        ["show", key] => match find(key) {
1497            Ok(e) => {
1498                let n = open.iter().position(|o| o.id == e.id).map(|i| i + 1);
1499                match json {
1500                    true => print_json(&n.map_or_else(|| serde_json::to_value(&e).unwrap_or_default(), |n| json!(Question::of(&e, n, &scope)))),
1501                    false => {
1502                        print!("{}", render_show(&e, n));
1503                        0
1504                    }
1505                }
1506            }
1507            Err(err) => fail(err),
1508        },
1509        ["ask"] => {
1510            if !std::io::stdin().is_terminal() {
1511                eprintln!("borg queen ask needs a terminal; otherwise: borg queen answer <n> <text|a|b>");
1512                return 2;
1513            }
1514            let author = learn::engineer(&cwd);
1515            match ask_loop(&store, &mut std::io::stdin().lock(), &mut std::io::stdout(), &author, &learn::now_ms) {
1516                Ok(_) => 0,
1517                Err(err) => fail(err),
1518            }
1519        }
1520        ["answer", key, text @ ..] if !text.is_empty() => {
1521            let author = learn::engineer(&cwd);
1522            match find(key).and_then(|e| answer(&store, &e.id, &option_answer(&e, &text.join(" "))?, &author, now)) {
1523                Ok(e) if e.kind == Kind::Reach => {
1524                    println!("{} answered: {}", e.id, e.answer.as_deref().unwrap_or_default());
1525                    0
1526                }
1527                Ok(e) if e.kind == Kind::Constraint => {
1528                    match e.answer.as_deref().is_some_and(|a| a.trim().to_lowercase().starts_with("ratify")) {
1529                        true => println!("{} answered: constraint added to {}", e.id, crate::veto::constraints_path(&store).display()),
1530                        false => println!("{} answered: kept as a lesson only", e.id),
1531                    }
1532                    0
1533                }
1534                Ok(e) => {
1535                    println!("{} answered; lesson queen-{} added to {}", e.id, e.decision_key, store.join("lessons.jsonl").display());
1536                    0
1537                }
1538                Err(err) => fail(err),
1539            }
1540        }
1541        ["dismiss", key, reason @ ..] => match find(key).and_then(|e| dismiss(&store, &e.id, &reason.join(" "), now)) {
1542            Ok(e) => {
1543                println!("{} dismissed ({}); no lesson written", e.id, e.dismissed.unwrap_or_default());
1544                0
1545            }
1546            Err(err) => fail(err),
1547        },
1548        ["skip", key] => match find(key).and_then(|e| skip(&store, &e.id, "cli", now)) {
1549            Ok(e) => {
1550                println!("{} skipped; not delivered again for 3 days", e.id);
1551                0
1552            }
1553            Err(err) => fail(err),
1554        },
1555        ["dedupe"] => match dedupe(&store, apply, now) {
1556            Ok(found) => {
1557                for (id, of) in &found {
1558                    let q = all.iter().find(|e| e.id == *id).map(|e| clip(gist(e), 100)).unwrap_or_default();
1559                    println!("{} {id} (duplicate of {of}): {q}", if apply { "dismissed" } else { "would dismiss" });
1560                }
1561                match (found.is_empty(), apply) {
1562                    (true, _) => println!("no duplicate open questions"),
1563                    (false, false) => println!("{} duplicates; dry run, --apply dismisses them", found.len()),
1564                    (false, true) => println!("{} duplicates dismissed", found.len()),
1565                }
1566                0
1567            }
1568            Err(err) => fail(err),
1569        },
1570        _ => usage(),
1571    }
1572}
1573
1574fn usage() -> i32 {
1575    eprintln!(
1576        "usage: borg queen [--json] [--repo NAME]            open questions, numbered
1577       borg queen show <n|id>                     one question in full
1578       borg queen ask                             answer them one by one (a terminal)
1579       borg queen answer <n|id> <text|a|b|1|2>    answer one (an option letter or number picks it)
1580       borg queen dismiss <n|id> [reason]         drop one: no lesson, never delivered
1581       borg queen skip <n|id>                     not delivered again for 3 days
1582       borg queen dedupe [--apply]                dismiss later near-duplicates (dry run by default)
1583  each takes --dir DIR (default: the current directory) or --collective NAME"
1584    );
1585    2
1586}
1587
1588#[cfg(test)]
1589mod tests {
1590    use super::*;
1591
1592    fn tmp(name: &str) -> PathBuf {
1593        let d = std::env::temp_dir().join(format!("borg-queen-{name}-{}", std::process::id()));
1594        let _ = std::fs::remove_dir_all(&d);
1595        std::fs::create_dir_all(&d).unwrap();
1596        d
1597    }
1598
1599    const PUSH: &str = "Plan: rewrite the commit messages, then force-push the rewritten history to origin main.";
1600    const TASK: &str = "Clean up the commit messages on this branch.";
1601
1602    fn cp<'a>(store: &'a Path, session: &'a str, plan: &'a str, inputs: &'a str) -> Checkpoint<'a> {
1603        Checkpoint { store, session, cwd: "/w/app", engineer: "eng@x", plan, inputs, selected: &[], now: 1 }
1604    }
1605
1606    fn lesson_ch(id: &str, situation: &str, consequence: &str) -> Challenge {
1607        Challenge {
1608            id: id.into(),
1609            origin_ms: 1,
1610            question: format!("When you {situation}, what happens?"),
1611            situations: vec![situation.into()],
1612            consequence: consequence.into(),
1613            ..Default::default()
1614        }
1615    }
1616
1617    #[test]
1618    fn criterion_1_only_high_consequence_moves_are_asked() {
1619        let store = tmp("c1");
1620        let low = "Plan: rename the local helper and add a unit test for the parser.";
1621        assert!(consider(&cp(&store, "s1", low, TASK)).unwrap().is_empty());
1622        let asked = consider(&cp(&store, "s1", PUSH, TASK)).unwrap();
1623        assert_eq!(asked.len(), 1);
1624        assert_eq!(asked[0].kind, Kind::Irreversible);
1625        // A move the plan rules out is not one it makes.
1626        assert!(classify("We will not drop the users table; we add a nullable column instead.", "").iter().all(|d| d.kind != Kind::Irreversible));
1627        let kinds = |p: &str| classify(p, "").into_iter().map(|d| d.kind).collect::<Vec<_>>();
1628        assert_eq!(kinds("Then npm publish the package."), [Kind::ExternalMutation]);
1629        assert_eq!(kinds("This is a breaking change to the public API."), [Kind::PublicApi]);
1630        assert_eq!(kinds("Patch the shared library in the other repo."), [Kind::CrossRepo]);
1631        assert_eq!(kinds("Next, deploy to production."), [Kind::ExternalMutation]);
1632        assert_eq!(kinds("I'll write to the prod database to backfill the column."), [Kind::ExternalMutation]);
1633        assert_eq!(kinds("Rename the public API endpoint /v1/users."), [Kind::PublicApi]);
1634        assert_eq!(kinds("Bump the major version and publish the crate."), [Kind::PublicApi]);
1635        assert_eq!(kinds("We keep nothing of it: drop the users table."), [Kind::Irreversible]);
1636    }
1637
1638    #[test]
1639    fn criterion_1_mentions_completed_negated_and_suggested_moves_are_not_moves() {
1640        for p in [
1641            "Done. The public API is unchanged.",
1642            "This change doesn't touch the production database.",
1643            "This isn't a breaking change.",
1644            "I didn't force-push anything.",
1645            "We don\u{2019}t drop the users table.",
1646            "I deployed to staging and verified it.",
1647            "I read rows from the prod database to check counts.",
1648            "Updated the protobuf comments",
1649            "Bumped the major version in the changelog notes",
1650            "Next step would be to git push.",
1651            "You can now force-push to main if you want.",
1652            "No changes to the openapi spec.",
1653            "I force-pushed the branch earlier.",
1654            "Talked to the other team about the shared config.",
1655        ] {
1656            assert_eq!(classify(p, ""), vec![], "{p}");
1657        }
1658    }
1659
1660    /// Rules tuned on the development half of replayed history (internal evaluation "queen-replay", not published).
1661    #[test]
1662    fn criterion_1_handed_over_asked_described_and_routine_moves_are_not_asked() {
1663        for p in [
1664            "You:** force-push the corrected tip to `main` and tell me.",
1665            "Should I run git push origin main?",
1666            "The force-push to `main` stays with you and the teams.",
1667            "Run it when you're ready: git push origin main",
1668            "That job runs `terraform apply -auto-approve` on 3_github.",
1669            "To push, the repo's convention is `git push origin main`.",
1670            "Then git push origin feature/lumen-120.",
1671            "- the 30-day purge, backups included: crypto-shredding",
1672            "Truncate the product title with an ellipsis.",
1673            "- **a breaking change:** `TerminationReason` gets a new value.",
1674            "Added a changelog entry that flags the breaking change.",
1675        ] {
1676            assert_eq!(classify(p, ""), vec![], "{p}");
1677        }
1678        let kinds = |p: &str| classify(p, "").into_iter().map(|d| d.kind).collect::<Vec<_>>();
1679        assert_eq!(kinds("Then I'll git push origin main."), [Kind::ExternalMutation]);
1680        assert_eq!(kinds("Then git push -u origin main."), [Kind::ExternalMutation]);
1681        assert_eq!(kinds("Truncate the audit table before the import."), [Kind::Irreversible]);
1682        assert_eq!(kinds("Purge the old backups."), [Kind::Irreversible]);
1683        assert_eq!(kinds("Adding variants to the enum is a major-breaking change."), [Kind::PublicApi]);
1684    }
1685
1686    /// Review of the tuned rules: a description excludes only the move inside the described clause,
1687    /// and a push targets `main` only when its refspec ends there.
1688    #[test]
1689    fn criterion_1_a_trailing_clause_or_a_convention_mention_does_not_hide_a_move() {
1690        let kinds = |p: &str| classify(p, "").into_iter().map(|d| d.kind).collect::<Vec<_>>();
1691        assert_eq!(kinds("I'm force-pushing main now, which triggers the deploy."), [Kind::Irreversible]);
1692        assert_eq!(kinds("Next I run terraform apply on prod, it runs about 4 minutes."), [Kind::ExternalMutation]);
1693        assert_eq!(kinds("I will drop the users table, per the team convention."), [Kind::Irreversible]);
1694        assert_eq!(kinds("The deploy step runs `kubectl apply -f prod.yaml` nightly."), []);
1695        assert_eq!(kinds("Then git push origin HEAD:main."), [Kind::ExternalMutation]);
1696        assert_eq!(kinds("Then `git push origin main:main`"), [Kind::ExternalMutation]);
1697        assert_eq!(kinds("Then git push origin main-fix."), []);
1698        assert_eq!(kinds("Then git push origin feature/main."), []);
1699        assert_eq!(kinds("Then git push origin production"), [Kind::ExternalMutation]);
1700    }
1701
1702    #[test]
1703    fn one_message_asks_one_move_once() {
1704        let store = tmp("onemove");
1705        // The command and a sentence describing it: two sentences, one push.
1706        let plan = "Next:\n`git push -u origin main`\nSo we push to the remote `origin` and its branch `main`, no force.";
1707        assert_eq!(classify(plan, "app").len(), 2, "{:?}", classify(plan, "app"));
1708        let asked = consider(&cp(&store, "s1", plan, TASK)).unwrap();
1709        assert_eq!(asked.len(), 1, "{asked:?}");
1710        // Two different moves in one plan are still both asked.
1711        let store = tmp("twomoves");
1712        let asked = consider(&cp(&store, "s1", "Drop the audit table.\nThen git push origin main.", TASK)).unwrap();
1713        assert_eq!(asked.len(), 2, "{asked:?}");
1714    }
1715
1716    #[test]
1717    fn criterion_3_the_inputs_must_state_the_same_move_not_just_its_kind() {
1718        let d = classify("Then force-push the rewritten history to origin main.", "app").remove(0);
1719        // A synthetic prompt (the held-out half's wording must not pin a tuned rule).
1720        assert!(stated(&d, "go ahead and force-push it once the messages read well"));
1721        assert!(!stated(&d, "coordinate a history wipe among the teams"), "another irreversible move");
1722        assert!(!stated(&d, "wipe the build cache first"));
1723    }
1724
1725    #[test]
1726    fn criterion_2_a_resolving_lesson_or_agreeing_conscience_lessons_stop_the_queen() {
1727        let store = tmp("c2");
1728        let agree = [lesson_ch("x", "force-push to main", "never force-push main")];
1729        let sel: Vec<&Challenge> = agree.iter().collect();
1730        let mut c = cp(&store, "s1", PUSH, TASK);
1731        c.selected = &sel;
1732        assert!(consider(&c).unwrap().is_empty(), "the conscience asked one lesson: L1 answered");
1733        let key = classify(PUSH, "app").remove(0).key;
1734        let line = |id: &str, cons: &str| format!("{}\n", json!({"id": id, "origin_ms": 1, "question": "q", "consequence": cons, "decision_key": key}));
1735        std::fs::write(store.join("lessons.jsonl"), line("a", "never force-push main")).unwrap();
1736        assert!(consider(&cp(&store, "s2", PUSH, TASK)).unwrap().is_empty(), "one lesson resolves it");
1737        // Two engineers' lessons disagree: the collective cannot resolve it.
1738        // (Same key: two team peers each answered it and the mesh synced both lessons.)
1739        std::fs::write(store.join("lessons.jsonl"), line("a", "never force-push main") + &line("b", "force-push is fine on main")).unwrap();
1740        let asked = consider(&cp(&store, "s3", PUSH, TASK)).unwrap();
1741        assert_eq!(asked[0].disagreeing, ["a", "b"]);
1742        // A lesson superseding the other agrees with itself again.
1743        let lessons =
1744            vec![json!({"id": "a", "consequence": "x", "decision_key": "k"}), json!({"id": "b", "consequence": "y", "decision_key": "k", "supersedes": ["a"]})];
1745        assert_eq!(known(&lessons, "k"), Known::Resolved);
1746    }
1747
1748    #[test]
1749    fn criterion_2_contradicting_lessons_the_conscience_selected_go_to_the_queen() {
1750        let store = tmp("c2conflict");
1751        let ls = [
1752            lesson_ch("a", "force-push the rewritten history to origin main", "the team loses commits; never do it"),
1753            lesson_ch("b", "force-push rewritten history to main", "fine after a squash; CI rebuilds cleanly"),
1754            lesson_ch("c", "rename a parser helper", "callers break"),
1755        ];
1756        let sel: Vec<&Challenge> = ls.iter().collect();
1757        assert_eq!(conflicts(&sel), ["a", "b"]);
1758        // Same situation, same consequence in other words: no contradiction.
1759        let same = [lesson_ch("a", "force-push to main", "never force-push main"), lesson_ch("b", "force-push to main", "never force-push to main")];
1760        assert!(conflicts(&same.iter().collect::<Vec<_>>()).is_empty());
1761        let mut c = cp(&store, "s1", PUSH, TASK);
1762        c.selected = &sel;
1763        let asked = consider(&c).unwrap();
1764        assert_eq!(asked.len(), 1);
1765        assert_eq!(asked[0].disagreeing, ["a", "b"]);
1766        assert!(asked[0].evidence.iter().any(|e| e.contains("lessons a, b disagree")), "{:?}", asked[0].evidence);
1767        // The contradiction must be about the plan's move: unrelated clashing lessons are not a
1768        // disagreement about it, and do not answer it either (no lesson is about this move).
1769        let store = tmp("c2unrelated");
1770        let other = [lesson_ch("p", "cache the parser tables", "stale reads"), lesson_ch("q", "cache parser tables", "faster startup, no issue")];
1771        let sel: Vec<&Challenge> = other.iter().collect();
1772        assert_eq!(conflicts(&sel), ["p", "q"]);
1773        let mut c = cp(&store, "s1", PUSH, TASK);
1774        c.selected = &sel;
1775        let asked = consider(&c).unwrap();
1776        assert_eq!(asked.len(), 1);
1777        assert!(asked[0].disagreeing.is_empty());
1778        assert!(asked[0].evidence.iter().any(|e| e.contains("no lesson of this collective is about this move")), "{:?}", asked[0].evidence);
1779        // An unrelated lesson the conscience asked does not settle the move either.
1780        let store = tmp("c2unrelated1");
1781        let one = [lesson_ch("p", "cache the parser tables", "stale reads")];
1782        let sel: Vec<&Challenge> = one.iter().collect();
1783        let mut c = cp(&store, "s1", PUSH, TASK);
1784        c.selected = &sel;
1785        assert_eq!(consider(&c).unwrap().len(), 1);
1786    }
1787
1788    #[test]
1789    fn criterion_3_a_decision_the_inputs_state_is_not_asked() {
1790        let store = tmp("c3");
1791        let inputs = "Squash the branch and force-push it to origin main, I approve.";
1792        assert!(consider(&cp(&store, "s1", PUSH, inputs)).unwrap().is_empty());
1793        // Same words, other phrasing: the inputs state it too.
1794        let d =
1795            Decision { kind: Kind::Irreversible, sentence: "drop the legacy orders table".into(), what: "drop legacy orders table".into(), key: "k".into() };
1796        assert!(stated(&d, "please remove the legacy orders table"));
1797        assert!(!stated(&d, "speed up the checkout page"));
1798    }
1799
1800    #[test]
1801    fn criterion_4_budget_is_one_message_per_engineer_per_five_sessions() {
1802        let store = tmp("c4");
1803        let plans = ["Then npm publish the package.", "Drop the audit table.", "Run terraform apply on prod.", "This is a breaking change to the public API."];
1804        assert_eq!(consider(&cp(&store, "s0", plans[0], TASK)).unwrap().len(), 1);
1805        // Same session again, and the next four sessions: over budget.
1806        for s in ["s0", "s1", "s2", "s3", "s4"] {
1807            assert!(consider(&cp(&store, s, plans[1], TASK)).unwrap().is_empty(), "{s}");
1808        }
1809        // Another engineer has their own budget.
1810        let mut other = cp(&store, "t0", plans[2], TASK);
1811        other.engineer = "other@x";
1812        assert_eq!(consider(&other).unwrap().len(), 1);
1813        // The fifth session after the asking one may ask again; a revisited old session counts once.
1814        assert!(consider(&cp(&store, "s2", plans[1], TASK)).unwrap().is_empty());
1815        assert_eq!(consider(&cp(&store, "s5", plans[1], TASK)).unwrap().len(), 1);
1816        assert!(consider(&cp(&store, "s6", plans[3], TASK)).unwrap().is_empty());
1817    }
1818
1819    #[test]
1820    fn batched_at_most_two_questions_in_one_message() {
1821        let store = tmp("batch");
1822        let plan = "Drop the audit table.\nThen npm publish the package.\nPatch the shared library in the other repo.";
1823        let asked = consider(&cp(&store, "s1", plan, TASK)).unwrap();
1824        assert_eq!(asked.len(), MAX_QUESTIONS);
1825        assert_eq!(open(&store).len(), 2);
1826        let reason = render(&asked);
1827        assert!(reason.contains("eng@x") && reason.contains("proceed with default: proceed as planned"), "{reason}");
1828        assert!(reason.contains("hold: prepare it"), "external mutations default to holding: {reason}");
1829    }
1830
1831    #[test]
1832    fn the_same_decision_is_never_asked_twice_even_after_its_answer() {
1833        let store = tmp("twice");
1834        let asked = consider(&cp(&store, "s0", PUSH, TASK)).unwrap();
1835        assert_eq!(asked.len(), 1);
1836        for i in 1..=6 {
1837            let s = format!("s{i}");
1838            assert!(consider(&cp(&store, &s, PUSH, TASK)).unwrap().is_empty(), "asked again in {s}");
1839        }
1840        answer(&store, &asked[0].id, "Never force-push main; open a new branch.", "lead@x", 9).unwrap();
1841        // The engineer changed course: the escalation earned the queen its "no" (escalation.rs), once.
1842        let rewarded: Vec<String> = crate::stats::events(&store)
1843            .into_iter()
1844            .filter(|e| e.kind.starts_with("queen_") && e.kind != crate::stats::QUEEN_ANSWERED && e.kind != crate::stats::QUEEN_QUEUED)
1845            .map(|e| e.kind)
1846            .collect();
1847        assert_eq!(rewarded, [crate::stats::QUEEN_NO]);
1848        // The answer's lesson would be filtered by the conscience first; even without it, the key is spent.
1849        std::fs::remove_file(store.join("lessons.jsonl")).unwrap();
1850        assert!(consider(&cp(&store, "s9", PUSH, TASK)).unwrap().is_empty());
1851        assert!(answer(&store, &asked[0].id, "again", "lead@x", 10).is_err());
1852        assert!(answer(&store, "qnope", "x", "lead@x", 10).is_err());
1853    }
1854
1855    #[test]
1856    fn an_answer_becomes_a_lesson_the_conscience_recalls() {
1857        let store = tmp("answer");
1858        let asked = consider(&cp(&store, "s0", PUSH, TASK)).unwrap();
1859        let e = answer(&store, &asked[0].id, "Never force-push main; open a new branch.", "lead@x", 9).unwrap();
1860        assert_eq!(e.answer.as_deref(), Some("Never force-push main; open a new branch."));
1861        assert!(open(&store).is_empty());
1862        let v: Value = serde_json::from_str(std::fs::read_to_string(store.join("lessons.jsonl")).unwrap().trim()).unwrap();
1863        assert_eq!((v["author"].as_str(), v["decision_key"].as_str()), (Some("lead@x"), Some(asked[0].decision_key.as_str())));
1864        assert_eq!(v["consequence"], "Never force-push main; open a new branch.");
1865        // `borg learn` still reads it as a lesson.
1866        assert_eq!(learn::lessons(&store.join("lessons.jsonl")).len(), 1);
1867        // A later drone with the same plan: the conscience asks it, so the queen stays quiet.
1868        let cs = conscience::load(&store.join("lessons.jsonl"), None);
1869        let later = format!("{TASK}\n{PUSH}");
1870        let sel = conscience::select(&cs, &later, 5);
1871        assert_eq!(sel.len(), 1);
1872        assert!(conscience::render(&sel).contains("Never force-push main"));
1873        assert_eq!(known(&lesson_lines(&store.join("lessons.jsonl")), &asked[0].decision_key), Known::Resolved);
1874    }
1875
1876    #[test]
1877    fn an_answer_supersedes_only_the_side_it_overrules_and_takes_their_area() {
1878        let store = tmp("sides");
1879        let key = classify(PUSH, "app").remove(0).key;
1880        let line = |id: &str, cons: &str, area: &str, eng: &str| {
1881            format!("{}\n", json!({"id": id, "origin_ms": 1, "question": "q", "consequence": cons, "decision_key": key, "area": area, "engineers": [eng]}))
1882        };
1883        std::fs::write(
1884            store.join("lessons.jsonl"),
1885            line("a", "Never force-push main", "protocol-correctness", "ann@x") + &line("b", "force-push is fine on main", "protocol-correctness", "bob@x"),
1886        )
1887        .unwrap();
1888        let asked = consider(&cp(&store, "s1", PUSH, TASK)).unwrap();
1889        assert_eq!(asked[0].disagreeing, ["a", "b"]);
1890        answer(&store, &asked[0].id, "  never force-push main ", "lead@x", 9).unwrap();
1891        let q = lesson_lines(&store.join("lessons.jsonl")).into_iter().find(|l| l["source"] == "queen").unwrap();
1892        assert_eq!(q["supersedes"], json!(["b"]), "the side the answer confirms is not superseded");
1893        assert_eq!(q["area"], "protocol-correctness");
1894        // The trust signal follows the answer: bob (overruled) loses, ann (confirmed) does not, and
1895        // the answer settles it at once (no followed outcome needed).
1896        let t = crate::trust::of_store(&store);
1897        assert_eq!((t.of("bob@x", "protocol-correctness"), t.of("ann@x", "protocol-correctness")), (0.25, 0.5));
1898        // Without the disagreeing lessons in the store (an empty question): nothing confirmed, all superseded, area other.
1899        let e = Entry { disagreeing: vec!["x".into()], ..asked[0].clone() };
1900        let v = lesson(&e, "fine", "lead@x", 1, &[]);
1901        assert_eq!((v["supersedes"].clone(), v["area"].clone()), (json!(["x"]), json!("other")));
1902    }
1903
1904    #[test]
1905    fn a_proposed_constraint_uses_no_budget_and_its_answer_writes_no_lesson() {
1906        let store = tmp("constraint");
1907        let l = crate::learn::Lesson { id: "Lno-s1-1".into(), repo: "app".into(), ..Default::default() };
1908        let e =
1909            crate::learn::Episode { id: "no-s1-1".into(), session: "s1".into(), engineer: "lead@x".into(), correction: "never".into(), ..Default::default() };
1910        assert!(crate::veto::propose(&store, &l, "merge lookups go through the review API", &e, 5).unwrap());
1911        let q = entries(&store).remove(0);
1912        assert_eq!((q.kind, q.default.as_str()), (Kind::Constraint, crate::veto::KEEP_OPTION));
1913        assert!(budget_allows(&entries(&store), &[], "lead@x", "s1", &collective::Aliases::default()), "a constraint question is no message");
1914        let kept = answer(&store, &q.id, &option_answer(&q, "a").unwrap(), "lead@x", 9).unwrap();
1915        assert_eq!(kept.answer.as_deref(), Some(crate::veto::KEEP_OPTION));
1916        assert!(!store.join("lessons.jsonl").exists() && !crate::veto::constraints_path(&store).exists());
1917        assert!(!crate::stats::events(&store).iter().any(|ev| ev.kind == crate::stats::LESSON_NEW));
1918        assert_eq!(crate::escalation::team(&store).answered, 0, "a proposal is no escalation: the queen's reward ignores it");
1919        // Ratified (option b): one constraint, still no lesson.
1920        let e2 = crate::learn::Episode { session: "s2".into(), ..e };
1921        assert!(crate::veto::propose(&store, &l, "the public schema changes only additively", &e2, 10).unwrap());
1922        let q2 = open(&store).remove(0);
1923        answer(&store, &q2.id, &option_answer(&q2, "b").unwrap(), "lead@x", 11).unwrap();
1924        assert_eq!(std::fs::read_to_string(crate::veto::constraints_path(&store)).unwrap().lines().count(), 1);
1925        assert!(!store.join("lessons.jsonl").exists());
1926        let q2 = Question::of(&entries(&store)[1], 1, "");
1927        assert!(render_delivery(&q2, false).contains("proposed from an engineer's veto"));
1928    }
1929
1930    #[test]
1931    fn decision_key_is_the_move_not_the_wording() {
1932        let key = |p: &str| classify(p, "app").remove(0).key;
1933        let a = key("force-push the rewritten history to origin main");
1934        assert_eq!(a, key("Plan: force-push the rewritten branch history to origin/main, then open a PR."));
1935        assert_eq!(a, key("Squash 3 commits in src/lib.rs, then force-push to main"), "kind and key do not depend on regex order");
1936        assert_eq!(key("Drop the AUDIT table."), key("then drop the audit table, it is unused"));
1937        assert_ne!(key("drop the audit table"), key("drop the users table"));
1938        assert_ne!(a, classify("force-push to main", "other").remove(0).key, "the repo is part of the key");
1939    }
1940
1941    #[test]
1942    fn a_reworded_answered_decision_is_not_asked_again() {
1943        let store = tmp("reworded");
1944        let asked = consider(&cp(&store, "s0", PUSH, TASK)).unwrap();
1945        answer(&store, &asked[0].id, "Never force-push main; open a new branch.", "lead@x", 9).unwrap();
1946        // Another target ref: another key, but the same decision in other words.
1947        let reworded = "Plan: rewrite those commit messages, then force-push the rewritten history to origin release";
1948        assert_ne!(classify(reworded, "app")[0].key, asked[0].decision_key);
1949        for i in 1..=6 {
1950            assert!(consider(&cp(&store, &format!("s{i}"), reworded, TASK)).unwrap().is_empty(), "asked again in s{i}");
1951        }
1952        // The queue alone was not what stopped it: the queen lesson catches the rewording too.
1953        std::fs::remove_file(queue_path(&store)).unwrap();
1954        assert!(consider(&cp(&store, "s7", reworded, TASK)).unwrap().is_empty());
1955        // And the budget is open: an unrelated decision is asked.
1956        assert_eq!(consider(&cp(&store, "s8", "Then npm publish the package.", TASK)).unwrap().len(), 1);
1957    }
1958
1959    #[test]
1960    fn fails_open_on_an_unwritable_store() {
1961        let root = tmp("failopen");
1962        let store = root.join("file");
1963        std::fs::write(&store, "not a dir").unwrap();
1964        assert!(consider(&cp(&store, "s1", PUSH, TASK)).is_err());
1965        // Corrupt lines are skipped, not fatal.
1966        let ok = tmp("corrupt");
1967        std::fs::create_dir_all(dir(&ok)).unwrap();
1968        std::fs::write(queue_path(&ok), "{broken\n").unwrap();
1969        std::fs::write(sessions_path(&ok), "[]\n").unwrap();
1970        assert_eq!(consider(&cp(&ok, "s1", PUSH, TASK)).unwrap().len(), 1);
1971    }
1972
1973    fn open_q(id: &str, cwd: &str, question: &str, options: &[&str]) -> Entry {
1974        Entry {
1975            id: id.into(),
1976            decision_key: format!("k-{id}"),
1977            kind: Kind::OpenDecision,
1978            question: question.into(),
1979            options: options.iter().map(|o| o.to_string()).collect(),
1980            default: options.first().map_or("none stated".into(), |o| o.to_string()),
1981            evidence: vec![format!("docs/{id}.md:3: still open")],
1982            session: "import".into(),
1983            cwd: cwd.into(),
1984            repo: String::new(),
1985            engineer: "eng@x".into(),
1986            ms: 1,
1987            answer: None,
1988            answered_by: None,
1989            answered_ms: None,
1990            disagreeing: vec![],
1991            dismissed: None,
1992            dismissed_ms: None,
1993            payload: None,
1994        }
1995    }
1996
1997    const RETRY: &str = "Which retry budget applies to the payment webhook consumer?";
1998    const AUTH: &str = "Do service tokens rotate monthly or quarterly in the auth gateway?";
1999    const CACHE: &str = "Should the catalog cache be invalidated on every price update?";
2000
2001    /// app: RETRY, lib: AUTH, app: CACHE, in that asking order.
2002    fn queue(name: &str) -> PathBuf {
2003        let store = tmp(name);
2004        assert!(seed(&store, &open_q("qa", "/w/app", RETRY, &["three retries", "no retries, dead-letter at once"])).unwrap());
2005        assert!(seed(&store, &open_q("qb", "/w/lib", AUTH, &["monthly", "quarterly"])).unwrap());
2006        assert!(seed(&store, &open_q("qc", "/w/app", CACHE, &[])).unwrap());
2007        store
2008    }
2009
2010    #[test]
2011    fn the_listing_numbers_open_questions_in_asking_order_grouped_by_repo() {
2012        let store = queue("list");
2013        let open = open(&store);
2014        let out = render_list(&open, None, usize::MAX, false);
2015        assert_eq!(
2016            out,
2017            format!(
2018                "app (2)\n  1  {RETRY}\n     a) three retries (default) · b) no retries, dead-letter at once\n  3  {CACHE}\n     default: none stated\n\n\
2019                 lib (1)\n  2  {AUTH}\n     a) monthly (default) · b) quarterly\n\n{HINT}\n"
2020            )
2021        );
2022        // One repo keeps the whole list's numbers.
2023        let lib = render_list(&open, Some("LIB"), usize::MAX, false);
2024        assert!(lib.starts_with(&format!("lib (1)\n  2  {AUTH}\n")) && !lib.contains(RETRY), "{lib}");
2025        // Cut to the terminal's width; colors only when asked.
2026        let narrow = render_list(&open, None, 40, false);
2027        assert!(narrow.lines().filter(|l| l.starts_with("  ")).all(|l| l.chars().count() <= 40), "{narrow}");
2028        assert!(narrow.contains("  1  Which retry budget applies to the…\n"), "{narrow}");
2029        assert!(!out.contains('\x1b') && render_list(&open, None, 80, true).contains("\x1b[1;36m  1\x1b[0m"));
2030        // Answered and dismissed questions leave the listing; the others keep their order.
2031        dismiss(&store, "qa", "", 2).unwrap();
2032        assert_eq!(open_ids(&store), ["qb", "qc"]);
2033    }
2034
2035    fn open_ids(store: &Path) -> Vec<String> {
2036        open(store).into_iter().map(|e| e.id).collect()
2037    }
2038
2039    #[test]
2040    fn a_number_or_an_id_resolves_and_an_option_letter_or_number_is_its_text() {
2041        let store = queue("resolve");
2042        answer(&store, "qb", "monthly", "lead@x", 2).unwrap();
2043        let (all, open) = (entries(&store), open(&store));
2044        let id = |k: &str| resolve(&open, &all, k).map(|e| e.id.clone());
2045        assert_eq!((id("1"), id(" 2 "), id("0"), id("3")), (Some("qa".into()), Some("qc".into()), None, None));
2046        assert_eq!((id("qb"), id("qnope")), (Some("qb".into()), None), "an id also finds an answered question");
2047        let e = &open[0];
2048        for (typed, meant) in
2049            [("a", "three retries"), ("B", "no retries, dead-letter at once"), ("2", "no retries, dead-letter at once"), ("1", "three retries")]
2050        {
2051            assert_eq!(option_answer(e, typed).as_deref(), Ok(meant), "{typed}");
2052        }
2053        for typo in ["c", "3", "0", " Z "] {
2054            assert!(option_answer(e, typo).unwrap_err().contains("no option"), "{typo}");
2055        }
2056        for free in ["c d", "33 retries", "five retries, then dead-letter"] {
2057            assert_eq!(option_answer(e, free).as_deref(), Ok(free));
2058        }
2059        // Without options, a lone number is an answer ("how many retries?": 3).
2060        let bare = open_q("qn", "/w/app", "How many retries?", &[]);
2061        assert_eq!(option_answer(&bare, "3").as_deref(), Ok("3"));
2062    }
2063
2064    #[test]
2065    fn dismiss_closes_without_a_lesson_and_skip_holds_delivery_for_three_days() {
2066        let store = queue("dismiss");
2067        let e = dismiss(&store, "qa", "  not ours to decide ", 5).unwrap();
2068        assert_eq!((e.dismissed.as_deref(), e.dismissed_ms), (Some("not ours to decide"), Some(5)));
2069        assert_eq!(dismiss(&store, "qc", "", 5).unwrap().dismissed.as_deref(), Some("dismissed"));
2070        assert_eq!(open_ids(&store), ["qb"]);
2071        assert!(!store.join("lessons.jsonl").exists(), "no lesson");
2072        assert!(answer(&store, "qa", "x", "lead@x", 6).unwrap_err().contains("dismissed"));
2073        assert!(dismiss(&store, "qa", "", 6).is_err() && skip(&store, "qa", "s1", 6).is_err());
2074        // A dismissed question is never delivered.
2075        assert_eq!(deliver(&store, "s1", || vec!["app".into()], "", 7), None);
2076
2077        skip(&store, "qb", "cli", 100).unwrap();
2078        assert_eq!(open_ids(&store), ["qb"], "a skipped question stays open");
2079        let sk = skips(&store);
2080        assert_eq!(sk, [Mark { id: "qb".into(), session: "cli".into(), ms: 100 }]);
2081        assert!(skipped(&sk, "qb", 100 + SKIP_MS - 1) && !skipped(&sk, "qb", 100 + SKIP_MS) && !skipped(&sk, "qa", 100));
2082        assert_eq!(deliver(&store, "s2", || vec!["lib".into()], "", 100 + SKIP_MS - 1), None);
2083        assert_eq!(deliver(&store, "s3", || vec!["lib".into()], "", 100 + SKIP_MS).map(|q| q.id), Some("qb".into()));
2084    }
2085
2086    #[test]
2087    fn near_duplicates_of_a_repo_are_queued_once_and_dedupe_dismisses_old_ones() {
2088        let store = queue("dedupe");
2089        let reworded = "Which retry budget applies to the payment webhook consumer now?";
2090        assert!(jaccard(RETRY, reworded) >= DUPLICATE);
2091        assert!(!seed(&store, &open_q("qd", "/w/app", reworded, &[])).unwrap(), "a reworded duplicate is not queued");
2092        assert!(seed(&store, &open_q("qe", "/w/lib", reworded, &[])).unwrap(), "another repo's question is its own");
2093        answer(&store, "qa", "three retries", "lead@x", 2).unwrap();
2094        assert!(!seed(&store, &open_q("qf", "/w/app", reworded, &[])).unwrap(), "nor of an answered one");
2095        // Older queues hold duplicates: dedupe finds the later ones, dry run first.
2096        append_line(&queue_path(&store), &open_q("qg", "/w/lib", AUTH, &[])).unwrap();
2097        append_line(&queue_path(&store), &open_q("qh", "/w/lib", &format!("{AUTH} Still?"), &[])).unwrap();
2098        let want = vec![("qg".to_string(), "qb".to_string()), ("qh".to_string(), "qb".to_string())];
2099        assert_eq!(dedupe(&store, false, 3).unwrap(), want);
2100        assert_eq!(open_ids(&store), ["qb", "qc", "qe", "qg", "qh"], "dry run changes nothing");
2101        assert_eq!(dedupe(&store, true, 3).unwrap(), want);
2102        assert_eq!(open_ids(&store), ["qb", "qc", "qe"]);
2103        assert_eq!(entries(&store).iter().find(|e| e.id == "qh").unwrap().dismissed.as_deref(), Some("duplicate of qb"));
2104        assert!(dedupe(&store, true, 4).unwrap().is_empty());
2105        // Checkpoint questions compare by their plan sentence, not their shared frame.
2106        let q = |plan: &str| Entry { question: question(&classify(plan, "app").remove(0)), ..open_q("x", "/w/app", "", &[]) };
2107        assert!(!duplicate(&q("Drop the audit table."), &q("Drop the users table.")));
2108        assert!(duplicate(&q("Drop the audit table."), &q("Then drop the audit table.")));
2109    }
2110
2111    #[test]
2112    fn consider_does_not_queue_a_near_duplicate_of_a_queued_question() {
2113        let store = tmp("considerdup");
2114        let asked = consider(&cp(&store, "s0", "Then npm publish the package.", TASK)).unwrap();
2115        assert_eq!(asked.len(), 1);
2116        // A seeded question in the same words as the next checkpoint's move.
2117        let e = Entry { question: question(&classify("Drop the audit table.", "app").remove(0)), ..open_q("qx", "/w/app", "", &[]) };
2118        append_line(&queue_path(&store), &e).unwrap();
2119        for s in ["s1", "s2", "s3", "s4", "s5"] {
2120            note_session(&store, "eng@x", s, 2).unwrap();
2121        }
2122        assert!(consider(&cp(&store, "s6", "Drop the audit table, it is unused.", TASK)).unwrap().is_empty());
2123    }
2124
2125    #[test]
2126    fn the_json_schema_is_stable_and_its_commands_are_exact() {
2127        let store = queue("json");
2128        let qs = questions(&open(&store), None, " --collective 'team'");
2129        let v = serde_json::to_value(&qs[0]).unwrap();
2130        let keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
2131        assert_eq!(
2132            keys,
2133            ["id", "n", "repo", "kind", "question", "options", "default", "evidence", "ms", "answer_cmd", "skip_cmd", "dismiss_cmd"],
2134            "documented order"
2135        );
2136        assert_eq!((v["id"].as_str(), v["n"].as_u64(), v["repo"].as_str(), v["kind"].as_str()), (Some("qa"), Some(1), Some("app"), Some("open_decision")));
2137        assert_eq!(v["answer_cmd"], "borg queen answer 'qa' '<answer>' --collective 'team'");
2138        assert_eq!(v["skip_cmd"], "borg queen skip 'qa' --collective 'team'");
2139        assert_eq!(v["dismiss_cmd"], "borg queen dismiss 'qa' --collective 'team'");
2140        assert_eq!(serde_json::from_value::<Question>(v).unwrap(), qs[0]);
2141        assert_eq!(questions(&open(&store), Some("lib"), "").iter().map(|q| (q.id.as_str(), q.n)).collect::<Vec<_>>(), [("qb", 2)]);
2142        assert_eq!(sh_quote("it's"), r"'it'\''s'");
2143    }
2144
2145    #[test]
2146    fn delivery_is_one_question_per_session_of_the_sessions_own_repos() {
2147        let store = queue("deliver");
2148        let app = ["app".to_string()];
2149        assert_eq!(deliver(&store, "s1", || vec!["other".into()], "", 10), None, "never another repo's question");
2150        assert!(!visits_path(&store).exists(), "a session without questions of its repos is not counted");
2151        let q = deliver(&store, "s1", || app.to_vec(), "", 10).unwrap();
2152        assert_eq!((q.id.as_str(), q.n, q.answer_cmd.as_str()), ("qa", 1, "borg queen answer 'qa' '<answer>'"));
2153        assert_eq!(deliver(&store, "s1", || app.to_vec(), "", 11), None, "once per session");
2154        assert_eq!(deliver(&store, "s1", || unreachable!("settled before the work's repos"), "", 11), None);
2155        assert_eq!(deliver(&store, "s1", || vec!["lib".into()], "", 11), None, "once per session, whatever the repo");
2156        assert_eq!(deliveries(&store), [Mark { id: "qa".into(), session: "s1".into(), ms: 10 }]);
2157        // The next session gets the never-delivered one first; then qa waits for 3 more sessions.
2158        assert_eq!(deliver(&store, "s2", || app.to_vec(), "", 20).map(|q| q.id), Some("qc".into()));
2159        answer(&store, "qc", "never", "lead@x", 21).unwrap();
2160        for s in ["s3", "s4"] {
2161            assert_eq!(deliver(&store, s, || app.to_vec(), "", 30), None, "{s}");
2162        }
2163        assert_eq!(deliver(&store, "s5", || app.to_vec(), "", 40).map(|q| q.id), Some("qa".into()), "after 3 more sessions");
2164        // The work's repo matches by name, case aside, and a touched repo counts.
2165        assert_eq!(deliver(&store, "s6", || vec!["x".into(), "LIB".into()], "", 50).map(|q| q.id), Some("qb".into()));
2166        assert!(ask_on(None) && ask_on(Some("on")) && !ask_on(Some("off")) && !ask_on(Some(" OFF ")));
2167    }
2168
2169    #[test]
2170    fn eligibility_counts_sessions_and_the_skip_window_purely() {
2171        let open = vec![open_q("qa", "/w/app", RETRY, &[])];
2172        let app = ["app".to_string()];
2173        let visits: Vec<Visit> = (0..6).map(|i| Visit { session: format!("s{i}"), ms: i * 10 }).collect();
2174        let delivered = [Mark { id: "qa".into(), session: "s1".into(), ms: 10 }];
2175        let at = |s: &str, skips: &[Mark], now: i64| eligible(&open, skips, &delivered, &visits, s, &app, now).map(|(i, e)| (i, e.id.clone()));
2176        assert_eq!(at("s4", &[], 40), None, "3 sessions later is not yet after them");
2177        assert_eq!(at("s5", &[], 50), Some((0, "qa".into())));
2178        assert_eq!(at("s1", &[], 50), None, "its own session");
2179        let sk = [Mark { id: "qa".into(), session: "s4".into(), ms: 45 }];
2180        assert_eq!(at("s5", &sk, 50), None, "skipped within 3 days");
2181        assert_eq!(at("s5", &sk, 45 + SKIP_MS), Some((0, "qa".into())));
2182        // An unknown session: sessions first seen after the delivery count.
2183        assert_eq!(at("s9", &[], 60), Some((0, "qa".into())));
2184    }
2185
2186    #[test]
2187    fn an_old_hive_tasks_open_question_waits_and_masks_only_its_own_repo() {
2188        let drop = "Drop the users table and rebuild it.";
2189        let task = Entry {
2190            kind: Kind::Irreversible,
2191            payload: Some(serde_json::json!({"goal": "g", "task": "t1"})),
2192            question: question(&classify(drop, "app").remove(0)),
2193            ..open_q("t1", "/w/app", "", &[])
2194        };
2195        let open = vec![task.clone(), open_q("qa", "/w/app", RETRY, &[])];
2196        let app = ["app".to_string()];
2197        let first = eligible(&open, &[], &[], &[], "s1", &app, 1).map(|(_, e)| e.id.clone());
2198        assert_eq!(first.as_deref(), Some("qa"), "the session's own decision first");
2199        let lib = Checkpoint { cwd: "/w/lib", ..cp(Path::new("/nonexistent"), "s1", drop, TASK) };
2200        assert_eq!(decide(&lib, std::slice::from_ref(&task), || true, Vec::new).len(), 1, "another repo's move is asked");
2201        let app_cp = cp(Path::new("/nonexistent"), "s1", drop, TASK);
2202        assert!(decide(&app_cp, &[task], || true, Vec::new).is_empty(), "its own repo's reworded move is masked");
2203    }
2204
2205    #[test]
2206    fn a_questions_repo_is_its_git_roots_as_the_hook_names_it() {
2207        let root = tmp("reponame");
2208        for d in ["work/lib/.git", "work/lib/docs", "work/lib/.claude/worktrees/x/src", "other/docs"] {
2209            std::fs::create_dir_all(root.join(d)).unwrap();
2210        }
2211        std::fs::write(root.join("work/lib/.claude/worktrees/x/.git"), "gitdir: ../../../.git/worktrees/x\n").unwrap();
2212        let at = |d: &str| open_q("q", &root.join(d).display().to_string(), RETRY, &[]);
2213        for d in ["work/lib", "work/lib/docs", "work/lib/.claude/worktrees/x/src"] {
2214            assert_eq!(repo(&at(d)), "lib", "{d}");
2215        }
2216        assert_eq!(repo(&at("other/docs")), "docs", "outside git: the directory's own name");
2217        assert!(!duplicate(&at("work/lib/docs"), &at("other/docs")), "two repos' docs/ folders are two repos");
2218        assert_eq!(repo(&Entry { repo: "app".into(), ..at("other/docs") }), "app", "the recorded repo wins");
2219        // A checkpoint in a subdirectory queues its question under the repo.
2220        let d = decide(&Checkpoint { cwd: &root.join("work/lib/docs").display().to_string(), ..cp(&root, "s1", PUSH, TASK) }, &[], || true, Vec::new);
2221        assert_eq!(d.iter().map(|e| e.repo.as_str()).collect::<Vec<_>>(), ["lib"]);
2222    }
2223
2224    #[test]
2225    fn the_delivery_text_asks_with_the_dialog_in_claude_and_inline_elsewhere() {
2226        let store = queue("render");
2227        let q = Question::of(&open(&store)[0], 1, "");
2228        let c = render_delivery(&q, true);
2229        assert!(c.contains("AskUserQuestion") && c.contains("not the engineer's instruction") && c.contains(RETRY), "{c}");
2230        assert!(
2231            c.contains("borg queen answer 'qa' '<answer>'") && c.contains("borg queen skip 'qa'") && c.contains("Before you work on the engineer's request")
2232        );
2233        assert!(
2234            c.contains("- three retries: three retries\n- no retries: no retries, dead-letter at once\n- Skip for now")
2235                && c.contains("Default while nobody decides: three retries\n")
2236                && c.contains("Asked: 1970-01-01, found open in the team's documents; not part of"),
2237            "the default is its own line, never inside an option's text: {c}"
2238        );
2239        let o = render_delivery(&q, false);
2240        assert!(!o.contains("AskUserQuestion") && o.contains("one short paragraph with the options first"), "{o}");
2241        assert!(o.contains("a) three retries\nb) no retries, dead-letter at once\n") && o.contains("borg queen answer 'qa' '<answer>'"), "{o}");
2242        assert_eq!(label("Keep the documented interim rule until the board decides."), "Keep the documented interim rule");
2243        assert_eq!(label("hold: prepare it, but do"), "hold");
2244        assert_eq!(label("one two three four five six"), "one two three four five");
2245        assert_eq!(label(": odd"), ": odd");
2246        // A checkpoint question queued in an earlier session is not the current drone's plan.
2247        let e = Entry { kind: Kind::ExternalMutation, question: question(&classify("Then push to main.", "app").remove(0)), ..open(&store)[0].clone() };
2248        let cp = render_delivery(&Question::of(&e, 1, " --dir '/w/app'").run_by("'/opt/borg'"), true);
2249        assert!(cp.contains("Question: An earlier session's plan said: \"") && cp.contains("queued by an earlier session"), "{cp}");
2250        assert!(
2251            cp.contains("\n  '/opt/borg' queen answer 'qa' '<answer>' --dir '/w/app'\n") && cp.contains("'/opt/borg' queen skip 'qa' --dir '/w/app'"),
2252            "{cp}"
2253        );
2254        // No options: the default is one.
2255        let none = Question::of(&open(&store)[2], 3, "");
2256        assert!(render_delivery(&none, true).contains("- Keep the default: none stated\n- Skip for now"));
2257    }
2258
2259    #[test]
2260    fn ask_answers_skips_dismisses_and_quits_from_any_input() {
2261        let store = queue("ask");
2262        let mut out = Vec::new();
2263        let done = ask_loop(&store, &mut std::io::Cursor::new("\nb\ns\nd\n"), &mut out, "lead@x", &|| 9).unwrap();
2264        assert_eq!(done, (1, 1, 1));
2265        let text = String::from_utf8(out).unwrap();
2266        assert!(
2267            text.contains("[1/3] app  Which retry budget") && text.contains("evidence: docs/qa.md:3") && text.contains("  1) three retries (default)"),
2268            "{text}"
2269        );
2270        assert_eq!(entries(&store)[0].answer.as_deref(), Some("no retries, dead-letter at once"));
2271        assert_eq!((open_ids(&store), skips(&store).len()), (vec!["qb".to_string()], 1));
2272        let store = queue("askquit");
2273        assert_eq!(ask_loop(&store, &mut std::io::Cursor::new("q\n"), &mut Vec::new(), "lead@x", &|| 9).unwrap(), (0, 0, 0));
2274        // A lone letter or number naming no option is a typo: asked again, never recorded.
2275        let mut out = Vec::new();
2276        assert_eq!(ask_loop(&store, &mut std::io::Cursor::new("9\nmy own words\n"), &mut out, "lead@x", &|| 9).unwrap(), (1, 0, 0));
2277        assert!(String::from_utf8(out).unwrap().contains("no option 9 (a-b or 1-2)"));
2278        assert_eq!(entries(&store)[0].answer.as_deref(), Some("my own words"));
2279    }
2280}