1use 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
45pub const BUDGET_SESSIONS: usize = 5;
47pub const MAX_QUESTIONS: usize = 2;
49pub(crate) const STATED: f64 = 0.6;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Kind {
56 ExternalMutation,
57 Irreversible,
58 PublicApi,
59 CrossRepo,
60 OpenDecision,
64 Reach,
67 Constraint,
71}
72
73impl Kind {
74 pub fn escalates(self) -> bool {
78 matches!(self, Kind::ExternalMutation | Kind::Irreversible | Kind::PublicApi | Kind::CrossRepo)
79 }
80
81 pub fn proceeds(self) -> &'static str {
85 match self {
86 Kind::ExternalMutation => self.options().1,
87 k if k.escalates() => k.options().0,
88 _ => "",
89 }
90 }
91
92 pub fn label(self) -> &'static str {
93 match self {
94 Kind::ExternalMutation => "an external mutation",
95 Kind::Irreversible => "an irreversible change",
96 Kind::PublicApi => "a public API or schema change",
97 Kind::CrossRepo => "a cross-repo or cross-team decision",
98 Kind::OpenDecision => "a decision the team's documents leave open",
99 Kind::Reach => "who should hear a lesson",
100 Kind::Constraint => "an invariant proposed as a constraint",
101 }
102 }
103
104 pub(crate) fn options(self) -> (&'static str, &'static str) {
107 match self {
108 Kind::ExternalMutation => ("hold: prepare it, but do not run it until the engineer approves", "run it as planned"),
109 Kind::Irreversible => ("proceed as planned", "make it reversible first (backup, soft delete, a revert path)"),
110 Kind::PublicApi => ("proceed as planned", "keep the old contract working (additive change, versioning)"),
111 Kind::CrossRepo => ("proceed as planned", "leave the other repo's or team's side to its owners"),
112 Kind::OpenDecision => ("keep the documented interim rule", "decide it now"),
113 Kind::Reach => (crate::reach::TEAM_OPTION, crate::reach::PERSONAL_OPTION),
114 Kind::Constraint => (crate::veto::KEEP_OPTION, crate::veto::RATIFY_OPTION),
115 }
116 }
117}
118
119static KINDS: LazyLock<Vec<(Kind, Regex)>> = LazyLock::new(|| {
123 let r = |s: &str| Regex::new(s).unwrap();
124 vec![
125 (
126 Kind::ExternalMutation,
127 r(
128 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",
129 ),
130 ),
131 (Kind::ExternalMutation, r(r"\bgit push ([-\w./=@]+ ){0,3}?((origin|upstream) )?([\w./-]+:)?(main|master|prod(uction)?)([^-/\w]|$)")),
134 (
135 Kind::Irreversible,
136 r(
137 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",
138 ),
139 ),
140 (
141 Kind::PublicApi,
142 r(
143 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",
144 ),
145 ),
146 (
147 Kind::CrossRepo,
148 r(
149 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",
150 ),
151 ),
152 ]
153});
154
155static SENTENCES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[.!?]\s+|\n|;").unwrap());
156
157static NOT_A_MOVE: LazyLock<Regex> = LazyLock::new(|| {
160 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()
161});
162
163static DESCRIBES: LazyLock<Regex> = LazyLock::new(|| {
167 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()
168});
169
170static REF: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b(main|master|prod|production|staging)\b").unwrap());
172
173static TARGET: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\S+(?:\s+\S+){0,6}?\s+to\s+(?:the\s+)?(?:(?:origin|upstream)[/\s]+)?([\w./-]+)").unwrap());
175
176const NEGATIONS: [&str; 18] = [
177 "no",
178 "not",
179 "never",
180 "without",
181 "avoid",
182 "instead",
183 "skip",
184 "nor",
185 "dont",
186 "wont",
187 "didnt",
188 "doesnt",
189 "isnt",
190 "wasnt",
191 "shouldnt",
192 "cant",
193 "cannot",
194 "unchanged",
195];
196
197fn negation(word: &str) -> bool {
198 let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
199 w.ends_with("n't") || NEGATIONS.contains(&w)
200}
201
202const STOP: [&str; 24] = [
203 "the", "and", "will", "then", "this", "that", "with", "for", "from", "into", "our", "now", "also", "i'll", "we'll", "let", "lets", "can", "should",
204 "would", "first", "after", "before", "all",
205];
206
207#[derive(Debug, Clone, PartialEq)]
209pub struct Decision {
210 pub kind: Kind,
211 pub sentence: String,
212 pub what: String,
214 pub key: String,
216}
217
218pub(crate) fn words(text: &str) -> Vec<String> {
220 let mut w: Vec<String> = conscience::tokens(text).into_iter().filter(|t| !STOP.contains(&t.as_str())).collect();
221 w.sort();
222 w.dedup();
223 w
224}
225
226pub(crate) fn overlap(need: &[String], have: &HashSet<String>, min: usize) -> f64 {
228 match need.len() >= min {
229 true => need.iter().filter(|w| have.contains(*w)).count() as f64 / need.len() as f64,
230 false => 0.0,
231 }
232}
233
234fn fnv(s: &str) -> String {
236 format!("{:016x}", s.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3)))
237}
238
239fn norm(text: &str) -> String {
241 text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty() && !["the", "a", "an", "this", "it"].contains(w)).collect::<Vec<_>>().join(" ")
242}
243
244pub fn decision_key(kind: Kind, repo: &str, what: &str) -> String {
247 fnv(&format!("{kind:?}:{repo}:{what}"))
248}
249
250fn sentences(text: &str) -> Vec<(&str, bool)> {
252 let mut out = Vec::new();
253 let mut at = 0;
254 for m in SENTENCES.find_iter(text) {
255 let s = &text[at..m.start()];
256 out.push((s.trim(), m.as_str().starts_with('?') || s.trim_end().ends_with('?')));
257 at = m.end();
258 }
259 let s = &text[at..];
260 out.push((s.trim(), s.trim_end().ends_with('?')));
261 out
262}
263
264pub fn classify(plan: &str, repo: &str) -> Vec<Decision> {
267 let lower = plan.to_lowercase().replace(['\u{2019}', '\u{2018}'], "'");
268 let mut seen = HashSet::new();
269 let mut out = Vec::new();
270 for (sentence, asks) in sentences(&lower) {
271 if sentence.is_empty() || asks || NOT_A_MOVE.is_match(sentence) {
273 continue;
274 }
275 let described = DESCRIBES.find(sentence).map_or(usize::MAX, |m| m.end());
276 let hit = KINDS
277 .iter()
278 .flat_map(|(kind, re)| re.find_iter(sentence).map(move |m| (*kind, m)))
279 .filter(|(_, m)| m.start() < described)
280 .filter(|(_, m)| !sentence[..m.start()].split_whitespace().rev().take(4).any(negation))
281 .min_by_key(|(_, m)| m.start());
282 let Some((kind, m)) = hit else { continue };
283 let mut what = norm(m.as_str());
284 if let Some(t) = TARGET.captures(&sentence[m.start()..]).and_then(|c| c.get(1)) {
285 let t = norm(t.as_str().trim_end_matches(['.', ',']).rsplit('/').next().unwrap_or_default());
286 if !t.is_empty() && !what.split(' ').any(|w| w == t) {
287 what = format!("{what} {t}");
288 }
289 }
290 let key = decision_key(kind, repo, &what);
291 if seen.insert(key.clone()) {
292 let sentence: String = sentence.chars().take(300).collect();
293 out.push(Decision { kind, sentence, what, key });
294 }
295 }
296 out
297}
298
299pub fn stated(d: &Decision, inputs: &str) -> bool {
303 let lower = inputs.to_lowercase().replace(['\u{2019}', '\u{2018}'], "'");
304 let move_words = words(&d.what);
305 let same = |m: &str| {
306 let have: HashSet<String> = words(m).into_iter().collect();
307 move_words.is_empty() || overlap(&move_words, &have, 1) >= STATED
308 };
309 if KINDS.iter().any(|(k, re)| *k == d.kind && re.find_iter(&lower).any(|m| same(m.as_str()))) {
310 return true;
311 }
312 let have: HashSet<String> = words(&lower).into_iter().collect();
313 overlap(&words(&d.sentence), &have, 3) >= STATED
314}
315
316fn near(d: &Decision, earlier: &str) -> bool {
319 let have: HashSet<String> = words(earlier).into_iter().collect();
320 overlap(&words(&d.sentence), &have, 4) >= STATED
321}
322
323fn same_move(a: &Decision, b: &Decision) -> bool {
327 let target = |d: &Decision| REF.find(&d.sentence).map(|m| m.as_str().replace("production", "prod"));
328 a.key == b.key || near(a, &b.sentence) || near(b, &a.sentence) || (a.kind == b.kind && target(a).is_some() && target(a) == target(b))
329}
330
331#[derive(Debug, Clone, PartialEq)]
333pub enum Known {
334 Nothing,
336 Resolved,
338 Disagree(Vec<String>),
340}
341
342fn lesson_lines(path: &Path) -> Vec<Value> {
344 let mut by: HashMap<String, Value> = HashMap::new();
345 let mut order = Vec::new();
346 for v in read_lines::<Value>(path) {
347 let Some(id) = v["id"].as_str().map(str::to_string) else { continue };
348 let ver = |v: &Value| v["version"].as_u64().unwrap_or(0);
349 if by.get(&id).is_none_or(|o| ver(o) <= ver(&v)) {
350 if !by.contains_key(&id) {
351 order.push(id.clone());
352 }
353 by.insert(id, v);
354 }
355 }
356 order.into_iter().filter_map(|id| by.remove(&id)).collect()
357}
358
359pub fn known(lessons: &[Value], key: &str) -> Known {
360 let with: Vec<&Value> = lessons.iter().filter(|l| l["decision_key"].as_str() == Some(key)).collect();
361 let superseded: HashSet<&str> = with.iter().flat_map(|l| l["supersedes"].as_array().into_iter().flatten().filter_map(Value::as_str)).collect();
362 let live: Vec<&&Value> = with.iter().filter(|l| !superseded.contains(l["id"].as_str().unwrap_or_default())).collect();
363 let answers: HashSet<String> = live.iter().map(|l| l["consequence"].as_str().unwrap_or_default().trim().to_lowercase()).collect();
364 match answers.len() {
365 0 => Known::Nothing,
366 1 => Known::Resolved,
367 _ => Known::Disagree(live.iter().filter_map(|l| l["id"].as_str().map(str::to_string)).collect()),
368 }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374pub struct Entry {
375 pub id: String,
376 pub decision_key: String,
377 pub kind: Kind,
378 pub question: String,
379 pub options: Vec<String>,
380 pub default: String,
381 pub evidence: Vec<String>,
382 pub session: String,
383 pub cwd: String,
384 #[serde(default, skip_serializing_if = "String::is_empty")]
386 pub repo: String,
387 pub engineer: String,
388 pub ms: i64,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub answer: Option<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub answered_by: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub answered_ms: Option<i64>,
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub disagreeing: Vec<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub dismissed: Option<String>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub dismissed_ms: Option<i64>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub payload: Option<Value>,
408}
409
410pub fn dir(store: &Path) -> PathBuf {
411 store.join("queen")
412}
413
414fn queue_path(store: &Path) -> PathBuf {
415 dir(store).join("queue.jsonl")
416}
417
418fn sessions_path(store: &Path) -> PathBuf {
419 dir(store).join("sessions.jsonl")
420}
421
422fn read_lines<T: for<'de> Deserialize<'de>>(path: &Path) -> Vec<T> {
423 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
424}
425
426fn append_line<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
427 if let Some(d) = path.parent() {
428 std::fs::create_dir_all(d)?;
429 }
430 let mut line = serde_json::to_string(v).map_err(std::io::Error::other)?;
431 line.push('\n');
432 std::fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(line.as_bytes())
434}
435
436pub fn entries(store: &Path) -> Vec<Entry> {
438 let mut out: Vec<Entry> = Vec::new();
439 for e in read_lines::<Entry>(&queue_path(store)) {
440 match out.iter_mut().find(|o| o.id == e.id) {
441 Some(o) => *o = e,
442 None => out.push(e),
443 }
444 }
445 out
446}
447
448pub fn open_decision_key(repo: &str, question: &str) -> String {
450 decision_key(Kind::OpenDecision, repo, &norm(&question.to_lowercase()))
451}
452
453pub fn constraint_key(repo: &str, text: &str) -> String {
455 decision_key(Kind::Constraint, repo, &norm(&text.to_lowercase()))
456}
457
458pub fn seed(store: &Path, e: &Entry) -> std::io::Result<bool> {
461 if entries(store).iter().any(|x| x.decision_key == e.decision_key || duplicate(x, e)) {
462 return Ok(false);
463 }
464 append_line(&queue_path(store), e)?;
465 counted(store, crate::stats::QUEEN_QUEUED, e, e.ms, |_| {});
466 Ok(true)
467}
468
469fn counted(store: &Path, kind: &str, e: &Entry, ms: i64, fill: impl FnOnce(&mut crate::stats::Event)) {
471 crate::stats::count(store, kind, &e.session, |ev| {
472 ev.ms = ms;
473 ev.engineer = e.engineer.clone();
474 ev.repo = e.repo.clone();
475 ev.lessons = vec![e.id.clone()];
476 ev.text = crate::stats::excerpt(&e.question, 160);
477 fill(ev);
478 });
479}
480
481pub fn open(store: &Path) -> Vec<Entry> {
484 entries(store).into_iter().filter(Entry::is_open).collect()
485}
486
487impl Entry {
488 pub fn is_open(&self) -> bool {
489 self.answer.is_none() && self.dismissed.is_none()
490 }
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct Seen {
496 pub engineer: String,
497 pub session: String,
498 pub ms: i64,
499}
500
501fn note_session(store: &Path, engineer: &str, session: &str, now: i64) -> std::io::Result<()> {
503 let seen: Vec<Seen> = read_lines(&sessions_path(store));
504 if seen.iter().any(|s| s.engineer == engineer && s.session == session) {
505 return Ok(());
506 }
507 append_line(&sessions_path(store), &Seen { engineer: engineer.into(), session: session.into(), ms: now })
508}
509
510pub fn within_budget(store: &Path, engineer: &str, session: &str) -> bool {
513 budget_allows(&entries(store), &read_lines::<Seen>(&sessions_path(store)), engineer, session, collective::aliases())
514}
515
516pub fn budget_allows(asked: &[Entry], seen: &[Seen], engineer: &str, session: &str, aliases: &collective::Aliases) -> bool {
520 let me = aliases.canon(engineer);
521 let sent = asked.iter().filter(|e| e.kind.escalates());
522 let Some(last) = sent.filter(|e| aliases.canon(&e.engineer) == me).max_by_key(|e| e.ms) else { return true };
523 if last.session == session {
524 return false; }
526 let mine: Vec<&Seen> = seen.iter().filter(|s| aliases.canon(&s.engineer) == me).collect();
527 let now = mine.iter().position(|s| s.session == session);
528 let then = mine.iter().position(|s| s.session == last.session);
529 let since = match (then, now) {
530 (Some(t), Some(n)) => n.saturating_sub(t),
531 _ => mine.iter().filter(|s| s.ms > last.ms).count(),
533 };
534 since >= BUDGET_SESSIONS
535}
536
537pub struct Checkpoint<'a> {
539 pub store: &'a Path,
541 pub session: &'a str,
542 pub cwd: &'a str,
543 pub engineer: &'a str,
544 pub plan: &'a str,
546 pub inputs: &'a str,
549 pub selected: &'a [&'a Challenge],
551 pub now: i64,
552}
553
554fn question(d: &Decision) -> String {
555 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())
556}
557
558pub fn covers(c: &Challenge, d: &Decision) -> bool {
561 let have: HashSet<String> = words(&format!("{} {} {}", c.question, c.situations.join(" "), c.consequence)).into_iter().collect();
562 overlap(&words(&d.what), &have, 1) >= STATED
563}
564
565pub fn conflicts(selected: &[&Challenge]) -> Vec<String> {
567 let pairs = conflict_pairs(selected);
568 (0..selected.len()).filter(|i| pairs.iter().any(|&(a, b)| a == *i || b == *i)).map(|i| selected[i].id.clone()).collect()
569}
570
571pub fn conflict_pairs(selected: &[&Challenge]) -> Vec<(usize, usize)> {
574 let set = |v: &[String]| words(&v.join(" ")).into_iter().collect::<HashSet<String>>();
575 let same = |a: &HashSet<String>, b: &HashSet<String>| {
576 let n = a.len().min(b.len());
577 n > 0 && a.intersection(b).count() as f64 / n as f64 >= STATED
578 };
579 let form: Vec<(HashSet<String>, HashSet<String>)> = selected.iter().map(|c| (set(&c.situations), set(std::slice::from_ref(&c.consequence)))).collect();
580 let mut out = Vec::new();
581 for i in 0..selected.len() {
582 for j in i + 1..selected.len() {
583 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) {
584 out.push((i, j));
585 }
586 }
587 }
588 out
589}
590
591pub fn consider(cp: &Checkpoint) -> std::io::Result<Vec<Entry>> {
597 note_session(cp.store, cp.engineer, cp.session, cp.now)?;
598 let asked = entries(cp.store);
599 let mut ask: Vec<Entry> = Vec::new();
600 for e in decide(cp, &asked, || within_budget(cp.store, cp.engineer, cp.session), || lesson_lines(&cp.store.join("lessons.jsonl"))) {
601 if !asked.iter().chain(&ask).any(|x| duplicate(x, &e)) {
602 ask.push(e);
603 }
604 }
605 for e in &ask {
606 append_line(&queue_path(cp.store), e)?;
607 counted(cp.store, crate::stats::QUEEN_QUEUED, e, e.ms, |_| {});
608 }
609 crate::notify::queued(&ask);
610 Ok(ask)
611}
612
613pub fn decide(cp: &Checkpoint, asked: &[Entry], budget: impl FnOnce() -> bool, lessons: impl FnOnce() -> Vec<Value>) -> Vec<Entry> {
618 let repo = repo_name(cp.cwd);
619 let ever: HashSet<&str> = asked.iter().map(|e| e.decision_key.as_str()).collect();
620 let mut candidates: Vec<(Decision, Vec<String>)> = Vec::new();
621 for d in classify(cp.plan, &repo) {
622 let masks = |e: &Entry| !legacy_hive(e) || same_repo(&self::repo(e), &repo);
623 if ever.contains(d.key.as_str()) || stated(&d, cp.inputs) || asked.iter().any(|e| masks(e) && near(&d, &e.question)) {
624 continue;
625 }
626 let about: Vec<&Challenge> = cp.selected.iter().copied().filter(|c| covers(c, &d)).collect();
630 let clashing = conflicts(&about);
631 if !about.is_empty() && clashing.is_empty() {
632 continue;
633 }
634 candidates.push((d, clashing));
635 }
636 if candidates.is_empty() || !budget() {
637 return Vec::new();
638 }
639 let lessons = lessons();
640 let mut ask = Vec::new();
641 let mut picked: Vec<Decision> = Vec::new();
642 for (d, relevant) in candidates {
643 if ask.len() == MAX_QUESTIONS {
644 break;
645 }
646 if picked.iter().any(|p| same_move(&d, p)) {
647 continue;
648 }
649 let answered = lessons.iter().any(|l| l["source"] == "queen" && near(&d, l["decision_question"].as_str().unwrap_or_default()));
650 let disagreeing = match (known(&lessons, &d.key), answered) {
651 (Known::Resolved, _) | (Known::Nothing, true) => continue,
652 (Known::Nothing, false) => relevant,
653 (Known::Disagree(ids), _) => ids.into_iter().chain(relevant).collect::<Vec<_>>(),
654 };
655 let (default, other) = d.kind.options();
656 let mut evidence = vec![format!("plan: \"{}\"", d.sentence), format!("high consequence: {}", d.kind.label())];
657 evidence.push(match disagreeing.is_empty() {
658 true => "no lesson of this collective is about this move".into(),
659 false => format!("lessons {} disagree", disagreeing.join(", ")),
660 });
661 evidence.push("the engineer's prompts this session do not state this decision".into());
662 picked.push(d.clone());
663 ask.push(Entry {
664 id: format!("q{}", &d.key[..8]),
665 decision_key: d.key.clone(),
666 kind: d.kind,
667 question: question(&d),
668 options: vec![default.into(), other.into()],
669 default: default.into(),
670 evidence,
671 session: cp.session.into(),
672 cwd: cp.cwd.into(),
673 repo: repo.clone(),
674 engineer: cp.engineer.into(),
675 ms: cp.now,
676 answer: None,
677 answered_by: None,
678 answered_ms: None,
679 disagreeing,
680 dismissed: None,
681 dismissed_ms: None,
682 payload: None,
683 });
684 }
685 ask
686}
687
688pub fn render(asked: &[Entry]) -> String {
690 let who = asked.first().map(|e| e.engineer.as_str()).filter(|e| !e.is_empty()).unwrap_or("the engineer");
691 let mut s = format!(
692 "Borg asked the engineer ({who}) about a high-consequence decision this collective has no settled lesson for. \
693Do not wait for the answer and do not ask again: proceed with the default below unless an answer arrives. \
694Mention the open question and the default you took in your final message.\n"
695 );
696 for (i, e) in asked.iter().enumerate() {
697 s.push_str(&format!("\n{}. [{}] {}\n proceed with default: {}", i + 1, e.id, e.question, e.default));
698 }
699 s
700}
701
702pub fn enabled() -> bool {
704 std::env::var("BORG_QUEEN").is_ok_and(|v| v.trim().eq_ignore_ascii_case("on"))
705}
706
707pub fn stop(session: &str, input: &Value, plan: &dyn Fn() -> String, prompts: &str, selected: &[&Challenge]) -> Option<Value> {
712 if !learn::learning() || !enabled() {
713 return None;
714 }
715 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
717 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default();
718 let plan = &plan();
719 let store = learn::home();
720 let engineer = learn::engineer(s("cwd"));
721 let cp = Checkpoint { store: &store, session, cwd: s("cwd"), engineer: &engineer, plan, inputs: prompts, selected, now: learn::now_ms() };
722 let asked = consider(&cp).ok()?;
723 let reason = match selected.is_empty() {
724 true => render(&asked),
725 false => format!("{}\n\n{}", conscience::render(selected), render(&asked)),
726 };
727 (!asked.is_empty()).then(|| json!({"decision": "block", "reason": reason}))
728 }))
729 .ok()
730 .flatten()
731}
732
733fn said(text: &str) -> String {
735 text.trim().to_lowercase()
736}
737
738pub fn lesson(e: &Entry, answer: &str, author: &str, now: i64, lessons: &[Value]) -> Value {
743 let repo = Path::new(&e.cwd).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
744 let sides: Vec<&Value> = e.disagreeing.iter().filter_map(|id| lessons.iter().find(|l| l["id"].as_str() == Some(id))).collect();
745 let supersedes: Vec<&String> = e
747 .disagreeing
748 .iter()
749 .filter(|id| !sides.iter().any(|l| l["id"].as_str() == Some(id) && said(l["consequence"].as_str().unwrap_or_default()) == said(answer)))
750 .collect();
751 let mut areas: Vec<(&str, usize)> = Vec::new();
752 for a in sides.iter().map(|l| crate::trust::area(l["area"].as_str().unwrap_or_default())) {
753 match areas.iter_mut().find(|(x, _)| *x == a) {
754 Some((_, n)) => *n += 1,
755 None => areas.push((a, 1)),
756 }
757 }
758 let area = areas.iter().fold(None, |best: Option<(&str, usize)>, &(a, n)| match best {
760 Some((_, m)) if m >= n => best,
761 _ => Some((a, n)),
762 });
763 json!({
764 "id": format!("queen-{}", e.decision_key),
765 "origin_ms": now,
766 "repo": repo,
767 "question": format!("{} The engineer ({author}) decided: \"{answer}\"", e.question),
769 "situations": [format!("{} under a plan that says: {}", e.kind.label(), e.evidence.first().map(String::as_str).unwrap_or_default())],
770 "consequence": answer,
771 "support": 1,
772 "episodes": [],
773 "history": [],
774 "version": 1,
775 "engineers": if author.is_empty() { vec![] } else { vec![author.to_string()] },
776 "author": author,
777 "decision_key": e.decision_key,
778 "decision_question": e.question,
779 "area": area.map_or(crate::trust::OTHER, |(a, _)| a),
780 "supersedes": supersedes,
781 "source": "queen",
782 })
783}
784
785pub fn answer(store: &Path, id: &str, text: &str, author: &str, now: i64) -> Result<Entry, String> {
787 let text = text.trim();
788 if text.is_empty() {
789 return Err("the answer is empty".into());
790 }
791 let Some(mut e) = entries(store).into_iter().find(|e| e.id == id) else {
792 return Err(format!("no queen question {id} in this collective"));
793 };
794 if e.answer.is_some() {
795 return Err(format!("{id} is already answered"));
796 }
797 if e.dismissed.is_some() {
798 return Err(format!("{id} was dismissed"));
799 }
800 if e.kind == Kind::Constraint {
804 crate::veto::ratify(store, &e, text, author, now)?;
805 } else if e.kind == Kind::Reach {
806 crate::reach::answer(store, &e, text)?;
807 } else {
808 let lessons = lesson_lines(&store.join("lessons.jsonl"));
809 append_line(&store.join("lessons.jsonl"), &lesson(&e, text, author, now, &lessons)).map_err(|err| err.to_string())?;
810 }
811 e.answer = Some(text.into());
812 e.answered_by = Some(author.into());
813 e.answered_ms = Some(now);
814 append_line(&queue_path(store), &e).map_err(|err| err.to_string())?;
815 counted(store, crate::stats::QUEEN_ANSWERED, &e, now, |ev| {
816 ev.engineer = author.into();
817 ev.dur_ms = u64::try_from(now - e.ms).unwrap_or(0);
818 ev.text = crate::stats::excerpt(text, 160);
819 });
820 crate::escalation::answered(store, &e, text, author, now);
821 if !matches!(e.kind, Kind::Reach | Kind::Constraint) {
822 counted(store, crate::stats::LESSON_NEW, &e, now, |ev| {
823 ev.engineer = author.into();
824 ev.why = "queen".into();
825 });
826 }
827 Ok(e)
828}
829
830pub const SKIP_MS: i64 = 3 * 24 * 60 * 60 * 1000;
836pub const REOFFER_SESSIONS: usize = 3;
839pub const DUPLICATE: f64 = 0.6;
841
842fn skips_path(store: &Path) -> PathBuf {
843 dir(store).join("skips.jsonl")
844}
845
846fn delivered_path(store: &Path) -> PathBuf {
847 dir(store).join("delivered.jsonl")
848}
849
850fn visits_path(store: &Path) -> PathBuf {
851 dir(store).join("visits.jsonl")
852}
853
854pub fn repo_name(cwd: &str) -> String {
857 Path::new(&borg_trace::repo_key(cwd)).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
858}
859
860pub fn repo(e: &Entry) -> String {
863 if e.repo.is_empty() { repo_name(&e.cwd) } else { e.repo.clone() }
864}
865
866fn same_repo(a: &str, b: &str) -> bool {
867 !a.is_empty() && a.eq_ignore_ascii_case(b)
868}
869
870pub fn gist(e: &Entry) -> &str {
873 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)
874}
875
876pub fn clip(text: &str, max: usize) -> String {
878 let t = text.trim();
879 if t.chars().count() <= max {
880 return t.to_string();
881 }
882 let mut s: String = t.chars().take(max.saturating_sub(1)).collect::<String>().trim_end().to_string();
883 s.push('…');
884 s
885}
886
887pub fn jaccard(a: &str, b: &str) -> f64 {
889 let (a, b): (HashSet<String>, HashSet<String>) = (words(a).into_iter().collect(), words(b).into_iter().collect());
890 match a.union(&b).count() {
891 0 => 0.0,
892 n => a.intersection(&b).count() as f64 / n as f64,
893 }
894}
895
896pub fn duplicate(a: &Entry, b: &Entry) -> bool {
898 repo(a).eq_ignore_ascii_case(&repo(b)) && jaccard(gist(a), gist(b)) >= DUPLICATE
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
904pub struct Mark {
905 pub id: String,
906 pub session: String,
907 pub ms: i64,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
913pub struct Visit {
914 pub session: String,
915 pub ms: i64,
916}
917
918pub fn skips(store: &Path) -> Vec<Mark> {
919 read_lines(&skips_path(store))
920}
921
922pub fn deliveries(store: &Path) -> Vec<Mark> {
923 read_lines(&delivered_path(store))
924}
925
926pub fn skipped(skips: &[Mark], id: &str, now: i64) -> bool {
928 skips.iter().any(|s| s.id == id && now.saturating_sub(s.ms) < SKIP_MS)
929}
930
931fn find_open(store: &Path, id: &str) -> Result<Entry, String> {
933 let Some(e) = entries(store).into_iter().find(|e| e.id == id) else {
934 return Err(format!("no queen question {id} in this collective"));
935 };
936 match (&e.answer, &e.dismissed) {
937 (Some(_), _) => Err(format!("{id} is already answered")),
938 (_, Some(_)) => Err(format!("{id} was dismissed")),
939 _ => Ok(e),
940 }
941}
942
943pub fn dismiss(store: &Path, id: &str, reason: &str, now: i64) -> Result<Entry, String> {
945 let mut e = find_open(store, id)?;
946 e.dismissed = Some(Some(reason.trim()).filter(|r| !r.is_empty()).unwrap_or("dismissed").to_string());
947 e.dismissed_ms = Some(now);
948 append_line(&queue_path(store), &e).map_err(|err| err.to_string())?;
949 counted(store, crate::stats::QUEEN_DISMISSED, &e, now, |ev| ev.why = e.dismissed.clone().unwrap_or_default());
950 Ok(e)
951}
952
953pub fn skip(store: &Path, id: &str, session: &str, now: i64) -> Result<Entry, String> {
955 let e = find_open(store, id)?;
956 append_line(&skips_path(store), &Mark { id: e.id.clone(), session: session.into(), ms: now }).map_err(|err| err.to_string())?;
957 counted(store, crate::stats::QUEEN_SKIPPED, &e, now, |ev| ev.session = session.into());
958 Ok(e)
959}
960
961pub fn resolve<'a>(open: &'a [Entry], all: &'a [Entry], key: &str) -> Option<&'a Entry> {
964 let key = key.trim();
965 match key.parse::<usize>() {
966 Ok(n) => n.checked_sub(1).and_then(|i| open.get(i)),
967 Err(_) => all.iter().find(|e| e.id == key),
968 }
969}
970
971pub fn option_answer(e: &Entry, text: &str) -> Result<String, String> {
975 let t = text.trim();
976 let i = match t.as_bytes() {
977 [c @ b'a'..=b'z'] => Some((c - b'a') as usize),
978 [c @ b'A'..=b'Z'] => Some((c - b'A') as usize),
979 _ => t.parse::<usize>().ok().map(|n| n.wrapping_sub(1)),
980 };
981 match (i, e.options.len()) {
982 (Some(i), n) if i < n => Ok(e.options[i].clone()),
983 (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))),
984 _ => Ok(t.to_string()),
985 }
986}
987
988pub fn duplicates(all: &[Entry]) -> Vec<(String, String)> {
991 let mut out: Vec<(String, String)> = Vec::new();
992 for (j, e) in all.iter().enumerate().filter(|(_, e)| e.is_open()) {
993 let first = all[..j].iter().find(|x| x.dismissed.is_none() && !out.iter().any(|(d, _)| *d == x.id) && duplicate(x, e));
994 if let Some(x) = first {
995 out.push((e.id.clone(), x.id.clone()));
996 }
997 }
998 out
999}
1000
1001pub fn dedupe(store: &Path, apply: bool, now: i64) -> Result<Vec<(String, String)>, String> {
1003 let found = duplicates(&entries(store));
1004 if apply {
1005 for (id, of) in &found {
1006 dismiss(store, id, &format!("duplicate of {of}"), now)?;
1007 }
1008 }
1009 Ok(found)
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1018pub struct Question {
1019 pub id: String,
1020 pub n: usize,
1021 pub repo: String,
1022 pub kind: Kind,
1023 pub question: String,
1024 pub options: Vec<String>,
1025 pub default: String,
1026 pub evidence: Vec<String>,
1027 pub ms: i64,
1028 pub answer_cmd: String,
1029 pub skip_cmd: String,
1030 pub dismiss_cmd: String,
1031}
1032
1033pub fn sh_quote(s: &str) -> String {
1035 format!("'{}'", s.replace('\'', r"'\''"))
1036}
1037
1038impl Question {
1039 pub fn of(e: &Entry, n: usize, scope: &str) -> Question {
1042 let id = sh_quote(&e.id);
1043 Question {
1044 id: e.id.clone(),
1045 n,
1046 repo: repo(e),
1047 kind: e.kind,
1048 question: e.question.clone(),
1049 options: e.options.clone(),
1050 default: e.default.clone(),
1051 evidence: e.evidence.clone(),
1052 ms: e.ms,
1053 answer_cmd: format!("borg queen answer {id} '<answer>'{scope}"),
1054 skip_cmd: format!("borg queen skip {id}{scope}"),
1055 dismiss_cmd: format!("borg queen dismiss {id}{scope}"),
1056 }
1057 }
1058
1059 pub fn run_by(mut self, bin: &str) -> Question {
1062 for c in [&mut self.answer_cmd, &mut self.skip_cmd, &mut self.dismiss_cmd] {
1063 if let Some(rest) = c.strip_prefix("borg ") {
1064 *c = format!("{bin} {rest}");
1065 }
1066 }
1067 self
1068 }
1069}
1070
1071pub fn questions(open: &[Entry], only: Option<&str>, scope: &str) -> Vec<Question> {
1073 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()
1074}
1075
1076pub fn ask_enabled() -> bool {
1078 ask_on(std::env::var("BORG_QUEEN_ASK").ok().as_deref())
1079}
1080
1081pub fn ask_on(value: Option<&str>) -> bool {
1083 !value.is_some_and(|v| v.trim().eq_ignore_ascii_case("off"))
1084}
1085
1086pub fn eligible<'a>(
1093 open: &'a [Entry],
1094 skips: &[Mark],
1095 delivered: &[Mark],
1096 visits: &[Visit],
1097 session: &str,
1098 repos: &[String],
1099 now: i64,
1100) -> Option<(usize, &'a Entry)> {
1101 if delivered.iter().any(|d| d.session == session) {
1102 return None;
1103 }
1104 let at = |s: &str| visits.iter().position(|v| v.session == s);
1105 open.iter()
1106 .enumerate()
1107 .filter(|(_, e)| repos.iter().any(|r| same_repo(r, &repo(e))) && !skipped(skips, &e.id, now))
1108 .filter_map(|(i, e)| {
1109 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)) };
1110 let since = match (at(&last.session), at(session)) {
1111 (Some(t), Some(n)) => n.saturating_sub(t),
1112 _ => visits.iter().filter(|v| v.ms > last.ms).count(),
1114 };
1115 (since > REOFFER_SESSIONS).then_some((legacy_hive(e), true, i, e))
1116 })
1117 .min_by_key(|&(proposal, again, i, _)| (proposal, again, i))
1118 .map(|(_, _, i, e)| (i, e))
1119}
1120
1121pub fn deliver(store: &Path, session: &str, repos: impl FnOnce() -> Vec<String>, scope: &str, now: i64) -> Option<Question> {
1126 if session.is_empty() {
1127 return None;
1128 }
1129 let delivered = deliveries(store);
1130 if delivered.iter().any(|d| d.session == session) {
1131 return None;
1132 }
1133 let open = open(store);
1134 if open.is_empty() {
1135 return None;
1136 }
1137 let repos = repos();
1138 if !open.iter().any(|e| repos.iter().any(|r| same_repo(r, &repo(e)))) {
1139 return None;
1140 }
1141 let mut visits: Vec<Visit> = read_lines(&visits_path(store));
1142 if !visits.iter().any(|v| v.session == session) {
1143 let v = Visit { session: session.into(), ms: now };
1144 append_line(&visits_path(store), &v).ok()?;
1145 visits.push(v);
1146 }
1147 let (_, e) = eligible(&open, &skips(store), &delivered, &visits, session, &repos, now)?;
1148 let n = open.iter().position(|o| o.id == e.id).unwrap_or_default() + 1;
1149 append_line(&delivered_path(store), &Mark { id: e.id.clone(), session: session.into(), ms: now }).ok()?;
1150 counted(store, crate::stats::QUEEN_DELIVERED, e, now, |ev| ev.session = session.into());
1151 Some(Question::of(e, n, scope))
1152}
1153
1154fn legacy_hive(e: &Entry) -> bool {
1158 e.kind.escalates() && e.payload.as_ref().is_some_and(|p| p["goal"].is_string() && p["task"].is_string())
1159}
1160
1161pub fn label(option: &str) -> String {
1163 let head = option.split([':', ',']).next().filter(|h| !h.trim().is_empty()).unwrap_or(option);
1164 let w: Vec<&str> = head.split_whitespace().take(5).collect();
1165 w.join(" ").trim_end_matches([',', '.', ';', ':', '(', '-']).to_string()
1166}
1167
1168fn letter(i: usize) -> char {
1169 (b'a' + (i % 26) as u8) as char
1170}
1171
1172pub fn render_delivery(q: &Question, dialog: bool) -> String {
1176 let repo = if q.repo.is_empty() { "this repo" } else { q.repo.as_str() };
1177 let mut s =
1178 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");
1179 s.push_str(match dialog {
1180 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",
1181 false => "Start your reply with this one question, in one short paragraph with the options first, then continue with the engineer's request.\n",
1182 });
1183 let (question, origin) = match q.question.strip_prefix("The drone's plan says: ") {
1186 Some(rest) if q.kind.escalates() => (format!("An earlier session's plan said: {rest}"), "queued by an earlier session"),
1187 _ if q.kind == Kind::OpenDecision => (q.question.clone(), "found open in the team's documents"),
1188 _ if q.kind == Kind::Reach => (q.question.clone(), "queued by a cleanup of the team's lessons"),
1189 _ if q.kind == Kind::Constraint => (q.question.clone(), "proposed from an engineer's veto"),
1190 _ => (q.question.clone(), "queued by an earlier session"),
1191 };
1192 let day = crate::install::utc(q.ms);
1193 s.push_str(&format!("\nQuestion: {question}\n"));
1194 s.push_str(&format!("Asked: {}-{}-{}, {origin}; not part of the engineer's current request.\n", &day[0..4], &day[4..6], &day[6..8]));
1195 if let Some(ev) = q.evidence.first() {
1196 s.push_str(&format!("Why it is open: {ev}\n"));
1197 }
1198 if dialog {
1199 s.push_str("Options (label: description):\n");
1200 for o in q.options.iter().take(3) {
1201 s.push_str(&format!("- {}: {o}\n", label(o)));
1202 }
1203 if q.options.is_empty() {
1204 s.push_str(&format!("- Keep the default: {}\n", q.default));
1205 }
1206 s.push_str("- Skip for now: ask again in a few days\n");
1207 if q.options.len() > 3 {
1208 s.push_str(&format!("More options the engineer may type: {}\n", q.options[3..].join(" | ")));
1209 }
1210 } else {
1211 s.push_str("Options:\n");
1212 for (i, o) in q.options.iter().enumerate() {
1213 s.push_str(&format!("{}) {o}\n", letter(i)));
1214 }
1215 s.push_str("or skip it for now.\n");
1216 }
1217 if !q.default.is_empty() {
1218 s.push_str(&format!("Default while nobody decides: {}\n", q.default));
1219 }
1220 s.push_str("The engineer can always answer in their own words.\n");
1221 s.push_str(&format!(
1222 "\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",
1223 if dialog { "Then" } else { "When the engineer answers," },
1224 q.answer_cmd
1225 ));
1226 s.push_str(&format!("If the engineer skips it: {}\nIf they say it no longer applies: {} '<reason>'\n", q.skip_cmd, q.dismiss_cmd));
1227 s.push_str("Ask it only once in this session.");
1228 s
1229}
1230
1231fn paint(color: bool, code: &str, s: &str) -> String {
1232 match color {
1233 true => format!("\x1b[{code}m{s}\x1b[0m"),
1234 false => s.to_string(),
1235 }
1236}
1237
1238fn options_line(e: &Entry) -> String {
1240 let mut parts: Vec<String> =
1241 e.options.iter().enumerate().map(|(i, o)| format!("{}) {o}{}", letter(i), if *o == e.default { " (default)" } else { "" })).collect();
1242 if !e.options.contains(&e.default) && !e.default.is_empty() {
1243 parts.push(format!("default: {}", e.default));
1244 }
1245 parts.join(" · ")
1246}
1247
1248const HINT: &str = "answer: borg queen ask · borg queen answer <n> <text|a|b> · borg queen show <n>";
1249
1250pub fn render_list(open: &[Entry], only: Option<&str>, width: usize, color: bool) -> String {
1254 let mut repos: Vec<String> = Vec::new();
1255 for r in open.iter().map(repo).filter(|r| only.is_none_or(|o| same_repo(o, r))) {
1256 if !repos.contains(&r) {
1257 repos.push(r);
1258 }
1259 }
1260 let body = width.saturating_sub(5).max(10);
1261 let mut s = String::new();
1262 for r in &repos {
1263 let mine: Vec<(usize, &Entry)> = open.iter().enumerate().filter(|(_, e)| repo(e) == *r).collect();
1264 let name = if r.is_empty() { "(no repo)" } else { r };
1265 s.push_str(&format!("{}\n", paint(color, "1", &format!("{name} ({})", mine.len()))));
1266 for (i, e) in mine {
1267 s.push_str(&format!("{} {}\n", paint(color, "1;36", &format!("{:>3}", i + 1)), clip(&e.question, body)));
1268 let opts = options_line(e);
1269 if !opts.is_empty() {
1270 s.push_str(&format!(" {}\n", paint(color, "2", &clip(&opts, body))));
1271 }
1272 }
1273 s.push('\n');
1274 }
1275 s.push_str(&paint(color, "2", &clip(HINT, width)));
1276 s.push('\n');
1277 s
1278}
1279
1280pub fn render_show(e: &Entry, n: Option<usize>) -> String {
1282 let who = |s: &str| if s.is_empty() { "?".to_string() } else { s.to_string() };
1283 let mut s = match n {
1284 Some(n) => format!("#{n} {}\n", e.id),
1285 None => format!("{}\n", e.id),
1286 };
1287 s.push_str(&format!("{}\n\n", e.question));
1288 s.push_str(&format!("kind: {}\n", e.kind.label()));
1289 s.push_str(&format!("repo: {} ({})\n", repo(e), e.cwd));
1290 s.push_str(&format!("asked: {} by {} (session {})\n", crate::install::utc(e.ms), who(&e.engineer), e.session));
1291 if !e.options.is_empty() {
1292 s.push_str("options:\n");
1293 for (i, o) in e.options.iter().enumerate() {
1294 s.push_str(&format!(" {}) {o}{}\n", letter(i), if *o == e.default { " (default)" } else { "" }));
1295 }
1296 }
1297 s.push_str(&format!("default: {}\n", e.default));
1298 if !e.evidence.is_empty() {
1299 s.push_str("evidence:\n");
1300 for ev in &e.evidence {
1301 s.push_str(&format!(" - {ev}\n"));
1302 }
1303 }
1304 let status = match (&e.answer, &e.dismissed) {
1305 (Some(a), _) => {
1306 format!("answered by {} at {}: {a}", who(e.answered_by.as_deref().unwrap_or_default()), crate::install::utc(e.answered_ms.unwrap_or(0)))
1307 }
1308 (_, Some(r)) => format!("dismissed at {}: {r}", crate::install::utc(e.dismissed_ms.unwrap_or(0))),
1309 _ => "open".into(),
1310 };
1311 s.push_str(&format!("status: {status}\n"));
1312 s
1313}
1314
1315pub type Asked = (usize, usize, usize);
1317
1318pub 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> {
1323 let open = open(store);
1324 let mut done: Asked = (0, 0, 0);
1325 'questions: for (i, e) in open.iter().enumerate() {
1326 writeln!(out, "\n[{}/{}] {} {}", i + 1, open.len(), repo(e), e.question)?;
1327 if let Some(ev) = e.evidence.first() {
1328 writeln!(out, " evidence: {ev}")?;
1329 }
1330 for (k, o) in e.options.iter().enumerate() {
1331 writeln!(out, " {}) {o}{}", k + 1, if *o == e.default { " (default)" } else { "" })?;
1332 }
1333 if !e.options.contains(&e.default) {
1334 writeln!(out, " default: {}", e.default)?;
1335 }
1336 loop {
1337 write!(out, "answer (option number, or your own words; s skip, d dismiss, q quit): ")?;
1338 out.flush()?;
1339 let mut line = String::new();
1340 if input.read_line(&mut line)? == 0 {
1341 break 'questions;
1342 }
1343 let said = match line.trim() {
1344 "" => continue,
1345 "q" => break 'questions,
1346 "s" => skip(store, &e.id, "cli", now()).map(|_| {
1347 done.1 += 1;
1348 "skipped for 3 days".to_string()
1349 }),
1350 "d" => dismiss(store, &e.id, "dismissed in borg queen ask", now()).map(|_| {
1351 done.2 += 1;
1352 "dismissed".to_string()
1353 }),
1354 t => match option_answer(e, t) {
1355 Err(err) => {
1356 writeln!(out, " {err}")?;
1357 continue;
1358 }
1359 Ok(a) => answer(store, &e.id, &a, author, now()).map(|x| {
1360 done.0 += 1;
1361 format!("answered: {}", x.answer.unwrap_or_default())
1362 }),
1363 },
1364 };
1365 match said {
1366 Ok(m) => writeln!(out, " {m}")?,
1367 Err(err) => writeln!(out, " borg queen: {err}")?,
1368 }
1369 break;
1370 }
1371 }
1372 writeln!(out, "\n{} answered, {} skipped, {} dismissed", done.0, done.1, done.2)?;
1373 Ok(done)
1374}
1375
1376pub(crate) fn width() -> usize {
1378 if let Some(w) = std::env::var("COLUMNS").ok().and_then(|c| c.trim().parse::<usize>().ok()).filter(|w| *w > 20) {
1379 return w;
1380 }
1381 #[cfg(unix)]
1382 {
1383 let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
1385 if unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0 && ws.ws_col > 20 {
1386 return ws.ws_col as usize;
1387 }
1388 }
1389 usize::MAX
1390}
1391
1392fn locate(cwd: &str, named: Option<&str>) -> Result<Option<PathBuf>, (String, i32)> {
1395 let cfg = collective::load().map_err(|e| (format!("invalid collectives config ({e}); everything is off"), 1))?;
1396 let base = learn::home();
1397 let Some(n) = named else {
1398 return Ok(match collective::route(cfg.as_ref(), cwd, &base) {
1399 collective::Route::Collective { home, .. } => Some(home),
1400 collective::Route::Off => None,
1401 });
1402 };
1403 match &cfg {
1404 None if n == "default" => Ok(Some(base)),
1405 None => Err((format!("no collective named {n:?}: there is no collectives config"), 2)),
1406 Some(c) => {
1407 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))
1408 }
1409 }
1410}
1411
1412fn print_json<T: Serialize>(v: &T) -> i32 {
1413 println!("{}", serde_json::to_string_pretty(v).unwrap_or_else(|_| "null".into()));
1414 0
1415}
1416
1417fn fail(msg: impl std::fmt::Display) -> i32 {
1418 eprintln!("borg queen: {msg}");
1419 1
1420}
1421
1422pub fn main(args: &[&str]) -> i32 {
1425 use std::io::IsTerminal;
1426 let mut rest: Vec<&str> = Vec::new();
1427 let (mut dir, mut named, mut only, mut json, mut apply) = (None, None, None, false, false);
1428 let mut it = args.iter();
1429 while let Some(a) = it.next() {
1430 match *a {
1431 "--dir" | "--collective" | "--repo" => {
1432 let Some(v) = it.next().map(|v| v.to_string()) else { return usage() };
1433 match *a {
1434 "--dir" => dir = Some(v),
1435 "--collective" => named = Some(v),
1436 _ => only = Some(v),
1437 }
1438 }
1439 "--json" => json = true,
1440 "--apply" => apply = true,
1441 a => rest.push(a),
1442 }
1443 }
1444 if !matches!(rest.first(), None | Some(&("show" | "ask" | "answer" | "dismiss" | "skip" | "dedupe"))) {
1446 return usage();
1447 }
1448 let cwd = dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_default().display().to_string());
1449 let store = match locate(&cwd, named.as_deref()) {
1450 Ok(Some(s)) => s,
1451 Ok(None) => {
1452 match json {
1453 true => println!("[]"),
1454 false => println!("off: {cwd} belongs to no collective; the queen is never asked here"),
1455 }
1456 return 0;
1457 }
1458 Err((msg, code)) => {
1459 eprintln!("borg queen: {msg}");
1460 return code;
1461 }
1462 };
1463 let scope = match (&named, &dir) {
1464 (Some(n), _) => format!(" --collective {}", sh_quote(n)),
1465 (None, Some(d)) => format!(" --dir {}", sh_quote(d)),
1466 _ => String::new(),
1467 };
1468 let now = learn::now_ms();
1469 let all = entries(&store);
1470 let open: Vec<Entry> = all.iter().filter(|e| e.is_open()).cloned().collect();
1471 let find = |key: &str| resolve(&open, &all, key).cloned().ok_or_else(|| format!("no queen question {key} in this collective (see borg queen)"));
1472 match rest.as_slice() {
1473 [] if json => print_json(&questions(&open, only.as_deref(), &scope)),
1474 [] => {
1475 if !open.iter().any(|e| only.as_deref().is_none_or(|r| same_repo(r, &repo(e)))) {
1476 println!("no open queen questions ({})", store.display());
1477 return 0;
1478 }
1479 print!(
1480 "{}",
1481 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()))
1482 );
1483 0
1484 }
1485 ["show", key] => match find(key) {
1486 Ok(e) => {
1487 let n = open.iter().position(|o| o.id == e.id).map(|i| i + 1);
1488 match json {
1489 true => print_json(&n.map_or_else(|| serde_json::to_value(&e).unwrap_or_default(), |n| json!(Question::of(&e, n, &scope)))),
1490 false => {
1491 print!("{}", render_show(&e, n));
1492 0
1493 }
1494 }
1495 }
1496 Err(err) => fail(err),
1497 },
1498 ["ask"] => {
1499 if !std::io::stdin().is_terminal() {
1500 eprintln!("borg queen ask needs a terminal; otherwise: borg queen answer <n> <text|a|b>");
1501 return 2;
1502 }
1503 let author = learn::engineer(&cwd);
1504 match ask_loop(&store, &mut std::io::stdin().lock(), &mut std::io::stdout(), &author, &learn::now_ms) {
1505 Ok(_) => 0,
1506 Err(err) => fail(err),
1507 }
1508 }
1509 ["answer", key, text @ ..] if !text.is_empty() => {
1510 let author = learn::engineer(&cwd);
1511 match find(key).and_then(|e| answer(&store, &e.id, &option_answer(&e, &text.join(" "))?, &author, now)) {
1512 Ok(e) if e.kind == Kind::Reach => {
1513 println!("{} answered: {}", e.id, e.answer.as_deref().unwrap_or_default());
1514 0
1515 }
1516 Ok(e) if e.kind == Kind::Constraint => {
1517 match e.answer.as_deref().is_some_and(|a| a.trim().to_lowercase().starts_with("ratify")) {
1518 true => println!("{} answered: constraint added to {}", e.id, crate::veto::constraints_path(&store).display()),
1519 false => println!("{} answered: kept as a lesson only", e.id),
1520 }
1521 0
1522 }
1523 Ok(e) => {
1524 println!("{} answered; lesson queen-{} added to {}", e.id, e.decision_key, store.join("lessons.jsonl").display());
1525 0
1526 }
1527 Err(err) => fail(err),
1528 }
1529 }
1530 ["dismiss", key, reason @ ..] => match find(key).and_then(|e| dismiss(&store, &e.id, &reason.join(" "), now)) {
1531 Ok(e) => {
1532 println!("{} dismissed ({}); no lesson written", e.id, e.dismissed.unwrap_or_default());
1533 0
1534 }
1535 Err(err) => fail(err),
1536 },
1537 ["skip", key] => match find(key).and_then(|e| skip(&store, &e.id, "cli", now)) {
1538 Ok(e) => {
1539 println!("{} skipped; not delivered again for 3 days", e.id);
1540 0
1541 }
1542 Err(err) => fail(err),
1543 },
1544 ["dedupe"] => match dedupe(&store, apply, now) {
1545 Ok(found) => {
1546 for (id, of) in &found {
1547 let q = all.iter().find(|e| e.id == *id).map(|e| clip(gist(e), 100)).unwrap_or_default();
1548 println!("{} {id} (duplicate of {of}): {q}", if apply { "dismissed" } else { "would dismiss" });
1549 }
1550 match (found.is_empty(), apply) {
1551 (true, _) => println!("no duplicate open questions"),
1552 (false, false) => println!("{} duplicates; dry run, --apply dismisses them", found.len()),
1553 (false, true) => println!("{} duplicates dismissed", found.len()),
1554 }
1555 0
1556 }
1557 Err(err) => fail(err),
1558 },
1559 _ => usage(),
1560 }
1561}
1562
1563fn usage() -> i32 {
1564 eprintln!(
1565 "usage: borg queen [--json] [--repo NAME] open questions, numbered
1566 borg queen show <n|id> one question in full
1567 borg queen ask answer them one by one (a terminal)
1568 borg queen answer <n|id> <text|a|b|1|2> answer one (an option letter or number picks it)
1569 borg queen dismiss <n|id> [reason] drop one: no lesson, never delivered
1570 borg queen skip <n|id> not delivered again for 3 days
1571 borg queen dedupe [--apply] dismiss later near-duplicates (dry run by default)
1572 each takes --dir DIR (default: the current directory) or --collective NAME"
1573 );
1574 2
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580
1581 fn tmp(name: &str) -> PathBuf {
1582 let d = std::env::temp_dir().join(format!("borg-queen-{name}-{}", std::process::id()));
1583 let _ = std::fs::remove_dir_all(&d);
1584 std::fs::create_dir_all(&d).unwrap();
1585 d
1586 }
1587
1588 const PUSH: &str = "Plan: rewrite the commit messages, then force-push the rewritten history to origin main.";
1589 const TASK: &str = "Clean up the commit messages on this branch.";
1590
1591 fn cp<'a>(store: &'a Path, session: &'a str, plan: &'a str, inputs: &'a str) -> Checkpoint<'a> {
1592 Checkpoint { store, session, cwd: "/w/app", engineer: "eng@x", plan, inputs, selected: &[], now: 1 }
1593 }
1594
1595 fn lesson_ch(id: &str, situation: &str, consequence: &str) -> Challenge {
1596 Challenge {
1597 id: id.into(),
1598 origin_ms: 1,
1599 question: format!("When you {situation}, what happens?"),
1600 situations: vec![situation.into()],
1601 consequence: consequence.into(),
1602 ..Default::default()
1603 }
1604 }
1605
1606 #[test]
1607 fn criterion_1_only_high_consequence_moves_are_asked() {
1608 let store = tmp("c1");
1609 let low = "Plan: rename the local helper and add a unit test for the parser.";
1610 assert!(consider(&cp(&store, "s1", low, TASK)).unwrap().is_empty());
1611 let asked = consider(&cp(&store, "s1", PUSH, TASK)).unwrap();
1612 assert_eq!(asked.len(), 1);
1613 assert_eq!(asked[0].kind, Kind::Irreversible);
1614 assert!(classify("We will not drop the users table; we add a nullable column instead.", "").iter().all(|d| d.kind != Kind::Irreversible));
1616 let kinds = |p: &str| classify(p, "").into_iter().map(|d| d.kind).collect::<Vec<_>>();
1617 assert_eq!(kinds("Then npm publish the package."), [Kind::ExternalMutation]);
1618 assert_eq!(kinds("This is a breaking change to the public API."), [Kind::PublicApi]);
1619 assert_eq!(kinds("Patch the shared library in the other repo."), [Kind::CrossRepo]);
1620 assert_eq!(kinds("Next, deploy to production."), [Kind::ExternalMutation]);
1621 assert_eq!(kinds("I'll write to the prod database to backfill the column."), [Kind::ExternalMutation]);
1622 assert_eq!(kinds("Rename the public API endpoint /v1/users."), [Kind::PublicApi]);
1623 assert_eq!(kinds("Bump the major version and publish the crate."), [Kind::PublicApi]);
1624 assert_eq!(kinds("We keep nothing of it: drop the users table."), [Kind::Irreversible]);
1625 }
1626
1627 #[test]
1628 fn criterion_1_mentions_completed_negated_and_suggested_moves_are_not_moves() {
1629 for p in [
1630 "Done. The public API is unchanged.",
1631 "This change doesn't touch the production database.",
1632 "This isn't a breaking change.",
1633 "I didn't force-push anything.",
1634 "We don\u{2019}t drop the users table.",
1635 "I deployed to staging and verified it.",
1636 "I read rows from the prod database to check counts.",
1637 "Updated the protobuf comments",
1638 "Bumped the major version in the changelog notes",
1639 "Next step would be to git push.",
1640 "You can now force-push to main if you want.",
1641 "No changes to the openapi spec.",
1642 "I force-pushed the branch earlier.",
1643 "Talked to the other team about the shared config.",
1644 ] {
1645 assert_eq!(classify(p, ""), vec![], "{p}");
1646 }
1647 }
1648
1649 #[test]
1651 fn criterion_1_handed_over_asked_described_and_routine_moves_are_not_asked() {
1652 for p in [
1653 "You:** force-push the corrected tip to `main` and tell me.",
1654 "Should I run git push origin main?",
1655 "The force-push to `main` stays with you and the teams.",
1656 "Run it when you're ready: git push origin main",
1657 "That job runs `terraform apply -auto-approve` on 3_github.",
1658 "To push, the repo's convention is `git push origin main`.",
1659 "Then git push origin feature/lumen-120.",
1660 "- the 30-day purge, backups included: crypto-shredding",
1661 "Truncate the product title with an ellipsis.",
1662 "- **a breaking change:** `TerminationReason` gets a new value.",
1663 "Added a changelog entry that flags the breaking change.",
1664 ] {
1665 assert_eq!(classify(p, ""), vec![], "{p}");
1666 }
1667 let kinds = |p: &str| classify(p, "").into_iter().map(|d| d.kind).collect::<Vec<_>>();
1668 assert_eq!(kinds("Then I'll git push origin main."), [Kind::ExternalMutation]);
1669 assert_eq!(kinds("Then git push -u origin main."), [Kind::ExternalMutation]);
1670 assert_eq!(kinds("Truncate the audit table before the import."), [Kind::Irreversible]);
1671 assert_eq!(kinds("Purge the old backups."), [Kind::Irreversible]);
1672 assert_eq!(kinds("Adding variants to the enum is a major-breaking change."), [Kind::PublicApi]);
1673 }
1674
1675 #[test]
1678 fn criterion_1_a_trailing_clause_or_a_convention_mention_does_not_hide_a_move() {
1679 let kinds = |p: &str| classify(p, "").into_iter().map(|d| d.kind).collect::<Vec<_>>();
1680 assert_eq!(kinds("I'm force-pushing main now, which triggers the deploy."), [Kind::Irreversible]);
1681 assert_eq!(kinds("Next I run terraform apply on prod, it runs about 4 minutes."), [Kind::ExternalMutation]);
1682 assert_eq!(kinds("I will drop the users table, per the team convention."), [Kind::Irreversible]);
1683 assert_eq!(kinds("The deploy step runs `kubectl apply -f prod.yaml` nightly."), []);
1684 assert_eq!(kinds("Then git push origin HEAD:main."), [Kind::ExternalMutation]);
1685 assert_eq!(kinds("Then `git push origin main:main`"), [Kind::ExternalMutation]);
1686 assert_eq!(kinds("Then git push origin main-fix."), []);
1687 assert_eq!(kinds("Then git push origin feature/main."), []);
1688 assert_eq!(kinds("Then git push origin production"), [Kind::ExternalMutation]);
1689 }
1690
1691 #[test]
1692 fn one_message_asks_one_move_once() {
1693 let store = tmp("onemove");
1694 let plan = "Next:\n`git push -u origin main`\nSo we push to the remote `origin` and its branch `main`, no force.";
1696 assert_eq!(classify(plan, "app").len(), 2, "{:?}", classify(plan, "app"));
1697 let asked = consider(&cp(&store, "s1", plan, TASK)).unwrap();
1698 assert_eq!(asked.len(), 1, "{asked:?}");
1699 let store = tmp("twomoves");
1701 let asked = consider(&cp(&store, "s1", "Drop the audit table.\nThen git push origin main.", TASK)).unwrap();
1702 assert_eq!(asked.len(), 2, "{asked:?}");
1703 }
1704
1705 #[test]
1706 fn criterion_3_the_inputs_must_state_the_same_move_not_just_its_kind() {
1707 let d = classify("Then force-push the rewritten history to origin main.", "app").remove(0);
1708 assert!(stated(&d, "go ahead and force-push it once the messages read well"));
1710 assert!(!stated(&d, "coordinate a history wipe among the teams"), "another irreversible move");
1711 assert!(!stated(&d, "wipe the build cache first"));
1712 }
1713
1714 #[test]
1715 fn criterion_2_a_resolving_lesson_or_agreeing_conscience_lessons_stop_the_queen() {
1716 let store = tmp("c2");
1717 let agree = [lesson_ch("x", "force-push to main", "never force-push main")];
1718 let sel: Vec<&Challenge> = agree.iter().collect();
1719 let mut c = cp(&store, "s1", PUSH, TASK);
1720 c.selected = &sel;
1721 assert!(consider(&c).unwrap().is_empty(), "the conscience asked one lesson: L1 answered");
1722 let key = classify(PUSH, "app").remove(0).key;
1723 let line = |id: &str, cons: &str| format!("{}\n", json!({"id": id, "origin_ms": 1, "question": "q", "consequence": cons, "decision_key": key}));
1724 std::fs::write(store.join("lessons.jsonl"), line("a", "never force-push main")).unwrap();
1725 assert!(consider(&cp(&store, "s2", PUSH, TASK)).unwrap().is_empty(), "one lesson resolves it");
1726 std::fs::write(store.join("lessons.jsonl"), line("a", "never force-push main") + &line("b", "force-push is fine on main")).unwrap();
1729 let asked = consider(&cp(&store, "s3", PUSH, TASK)).unwrap();
1730 assert_eq!(asked[0].disagreeing, ["a", "b"]);
1731 let lessons =
1733 vec![json!({"id": "a", "consequence": "x", "decision_key": "k"}), json!({"id": "b", "consequence": "y", "decision_key": "k", "supersedes": ["a"]})];
1734 assert_eq!(known(&lessons, "k"), Known::Resolved);
1735 }
1736
1737 #[test]
1738 fn criterion_2_contradicting_lessons_the_conscience_selected_go_to_the_queen() {
1739 let store = tmp("c2conflict");
1740 let ls = [
1741 lesson_ch("a", "force-push the rewritten history to origin main", "the team loses commits; never do it"),
1742 lesson_ch("b", "force-push rewritten history to main", "fine after a squash; CI rebuilds cleanly"),
1743 lesson_ch("c", "rename a parser helper", "callers break"),
1744 ];
1745 let sel: Vec<&Challenge> = ls.iter().collect();
1746 assert_eq!(conflicts(&sel), ["a", "b"]);
1747 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")];
1749 assert!(conflicts(&same.iter().collect::<Vec<_>>()).is_empty());
1750 let mut c = cp(&store, "s1", PUSH, TASK);
1751 c.selected = &sel;
1752 let asked = consider(&c).unwrap();
1753 assert_eq!(asked.len(), 1);
1754 assert_eq!(asked[0].disagreeing, ["a", "b"]);
1755 assert!(asked[0].evidence.iter().any(|e| e.contains("lessons a, b disagree")), "{:?}", asked[0].evidence);
1756 let store = tmp("c2unrelated");
1759 let other = [lesson_ch("p", "cache the parser tables", "stale reads"), lesson_ch("q", "cache parser tables", "faster startup, no issue")];
1760 let sel: Vec<&Challenge> = other.iter().collect();
1761 assert_eq!(conflicts(&sel), ["p", "q"]);
1762 let mut c = cp(&store, "s1", PUSH, TASK);
1763 c.selected = &sel;
1764 let asked = consider(&c).unwrap();
1765 assert_eq!(asked.len(), 1);
1766 assert!(asked[0].disagreeing.is_empty());
1767 assert!(asked[0].evidence.iter().any(|e| e.contains("no lesson of this collective is about this move")), "{:?}", asked[0].evidence);
1768 let store = tmp("c2unrelated1");
1770 let one = [lesson_ch("p", "cache the parser tables", "stale reads")];
1771 let sel: Vec<&Challenge> = one.iter().collect();
1772 let mut c = cp(&store, "s1", PUSH, TASK);
1773 c.selected = &sel;
1774 assert_eq!(consider(&c).unwrap().len(), 1);
1775 }
1776
1777 #[test]
1778 fn criterion_3_a_decision_the_inputs_state_is_not_asked() {
1779 let store = tmp("c3");
1780 let inputs = "Squash the branch and force-push it to origin main, I approve.";
1781 assert!(consider(&cp(&store, "s1", PUSH, inputs)).unwrap().is_empty());
1782 let d =
1784 Decision { kind: Kind::Irreversible, sentence: "drop the legacy orders table".into(), what: "drop legacy orders table".into(), key: "k".into() };
1785 assert!(stated(&d, "please remove the legacy orders table"));
1786 assert!(!stated(&d, "speed up the checkout page"));
1787 }
1788
1789 #[test]
1790 fn criterion_4_budget_is_one_message_per_engineer_per_five_sessions() {
1791 let store = tmp("c4");
1792 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."];
1793 assert_eq!(consider(&cp(&store, "s0", plans[0], TASK)).unwrap().len(), 1);
1794 for s in ["s0", "s1", "s2", "s3", "s4"] {
1796 assert!(consider(&cp(&store, s, plans[1], TASK)).unwrap().is_empty(), "{s}");
1797 }
1798 let mut other = cp(&store, "t0", plans[2], TASK);
1800 other.engineer = "other@x";
1801 assert_eq!(consider(&other).unwrap().len(), 1);
1802 assert!(consider(&cp(&store, "s2", plans[1], TASK)).unwrap().is_empty());
1804 assert_eq!(consider(&cp(&store, "s5", plans[1], TASK)).unwrap().len(), 1);
1805 assert!(consider(&cp(&store, "s6", plans[3], TASK)).unwrap().is_empty());
1806 }
1807
1808 #[test]
1809 fn batched_at_most_two_questions_in_one_message() {
1810 let store = tmp("batch");
1811 let plan = "Drop the audit table.\nThen npm publish the package.\nPatch the shared library in the other repo.";
1812 let asked = consider(&cp(&store, "s1", plan, TASK)).unwrap();
1813 assert_eq!(asked.len(), MAX_QUESTIONS);
1814 assert_eq!(open(&store).len(), 2);
1815 let reason = render(&asked);
1816 assert!(reason.contains("eng@x") && reason.contains("proceed with default: proceed as planned"), "{reason}");
1817 assert!(reason.contains("hold: prepare it"), "external mutations default to holding: {reason}");
1818 }
1819
1820 #[test]
1821 fn the_same_decision_is_never_asked_twice_even_after_its_answer() {
1822 let store = tmp("twice");
1823 let asked = consider(&cp(&store, "s0", PUSH, TASK)).unwrap();
1824 assert_eq!(asked.len(), 1);
1825 for i in 1..=6 {
1826 let s = format!("s{i}");
1827 assert!(consider(&cp(&store, &s, PUSH, TASK)).unwrap().is_empty(), "asked again in {s}");
1828 }
1829 answer(&store, &asked[0].id, "Never force-push main; open a new branch.", "lead@x", 9).unwrap();
1830 let rewarded: Vec<String> = crate::stats::events(&store)
1832 .into_iter()
1833 .filter(|e| e.kind.starts_with("queen_") && e.kind != crate::stats::QUEEN_ANSWERED && e.kind != crate::stats::QUEEN_QUEUED)
1834 .map(|e| e.kind)
1835 .collect();
1836 assert_eq!(rewarded, [crate::stats::QUEEN_NO]);
1837 std::fs::remove_file(store.join("lessons.jsonl")).unwrap();
1839 assert!(consider(&cp(&store, "s9", PUSH, TASK)).unwrap().is_empty());
1840 assert!(answer(&store, &asked[0].id, "again", "lead@x", 10).is_err());
1841 assert!(answer(&store, "qnope", "x", "lead@x", 10).is_err());
1842 }
1843
1844 #[test]
1845 fn an_answer_becomes_a_lesson_the_conscience_recalls() {
1846 let store = tmp("answer");
1847 let asked = consider(&cp(&store, "s0", PUSH, TASK)).unwrap();
1848 let e = answer(&store, &asked[0].id, "Never force-push main; open a new branch.", "lead@x", 9).unwrap();
1849 assert_eq!(e.answer.as_deref(), Some("Never force-push main; open a new branch."));
1850 assert!(open(&store).is_empty());
1851 let v: Value = serde_json::from_str(std::fs::read_to_string(store.join("lessons.jsonl")).unwrap().trim()).unwrap();
1852 assert_eq!((v["author"].as_str(), v["decision_key"].as_str()), (Some("lead@x"), Some(asked[0].decision_key.as_str())));
1853 assert_eq!(v["consequence"], "Never force-push main; open a new branch.");
1854 assert_eq!(learn::lessons(&store.join("lessons.jsonl")).len(), 1);
1856 let cs = conscience::load(&store.join("lessons.jsonl"), None);
1858 let later = format!("{TASK}\n{PUSH}");
1859 let sel = conscience::select(&cs, &later, 5);
1860 assert_eq!(sel.len(), 1);
1861 assert!(conscience::render(&sel).contains("Never force-push main"));
1862 assert_eq!(known(&lesson_lines(&store.join("lessons.jsonl")), &asked[0].decision_key), Known::Resolved);
1863 }
1864
1865 #[test]
1866 fn an_answer_supersedes_only_the_side_it_overrules_and_takes_their_area() {
1867 let store = tmp("sides");
1868 let key = classify(PUSH, "app").remove(0).key;
1869 let line = |id: &str, cons: &str, area: &str, eng: &str| {
1870 format!("{}\n", json!({"id": id, "origin_ms": 1, "question": "q", "consequence": cons, "decision_key": key, "area": area, "engineers": [eng]}))
1871 };
1872 std::fs::write(
1873 store.join("lessons.jsonl"),
1874 line("a", "Never force-push main", "protocol-correctness", "ann@x") + &line("b", "force-push is fine on main", "protocol-correctness", "bob@x"),
1875 )
1876 .unwrap();
1877 let asked = consider(&cp(&store, "s1", PUSH, TASK)).unwrap();
1878 assert_eq!(asked[0].disagreeing, ["a", "b"]);
1879 answer(&store, &asked[0].id, " never force-push main ", "lead@x", 9).unwrap();
1880 let q = lesson_lines(&store.join("lessons.jsonl")).into_iter().find(|l| l["source"] == "queen").unwrap();
1881 assert_eq!(q["supersedes"], json!(["b"]), "the side the answer confirms is not superseded");
1882 assert_eq!(q["area"], "protocol-correctness");
1883 let t = crate::trust::of_store(&store);
1886 assert_eq!((t.of("bob@x", "protocol-correctness"), t.of("ann@x", "protocol-correctness")), (0.25, 0.5));
1887 let e = Entry { disagreeing: vec!["x".into()], ..asked[0].clone() };
1889 let v = lesson(&e, "fine", "lead@x", 1, &[]);
1890 assert_eq!((v["supersedes"].clone(), v["area"].clone()), (json!(["x"]), json!("other")));
1891 }
1892
1893 #[test]
1894 fn a_proposed_constraint_uses_no_budget_and_its_answer_writes_no_lesson() {
1895 let store = tmp("constraint");
1896 let l = crate::learn::Lesson { id: "Lno-s1-1".into(), repo: "app".into(), ..Default::default() };
1897 let e =
1898 crate::learn::Episode { id: "no-s1-1".into(), session: "s1".into(), engineer: "lead@x".into(), correction: "never".into(), ..Default::default() };
1899 assert!(crate::veto::propose(&store, &l, "merge lookups go through the review API", &e, 5).unwrap());
1900 let q = entries(&store).remove(0);
1901 assert_eq!((q.kind, q.default.as_str()), (Kind::Constraint, crate::veto::KEEP_OPTION));
1902 assert!(budget_allows(&entries(&store), &[], "lead@x", "s1", &collective::Aliases::default()), "a constraint question is no message");
1903 let kept = answer(&store, &q.id, &option_answer(&q, "a").unwrap(), "lead@x", 9).unwrap();
1904 assert_eq!(kept.answer.as_deref(), Some(crate::veto::KEEP_OPTION));
1905 assert!(!store.join("lessons.jsonl").exists() && !crate::veto::constraints_path(&store).exists());
1906 assert!(!crate::stats::events(&store).iter().any(|ev| ev.kind == crate::stats::LESSON_NEW));
1907 assert_eq!(crate::escalation::team(&store).answered, 0, "a proposal is no escalation: the queen's reward ignores it");
1908 let e2 = crate::learn::Episode { session: "s2".into(), ..e };
1910 assert!(crate::veto::propose(&store, &l, "the public schema changes only additively", &e2, 10).unwrap());
1911 let q2 = open(&store).remove(0);
1912 answer(&store, &q2.id, &option_answer(&q2, "b").unwrap(), "lead@x", 11).unwrap();
1913 assert_eq!(std::fs::read_to_string(crate::veto::constraints_path(&store)).unwrap().lines().count(), 1);
1914 assert!(!store.join("lessons.jsonl").exists());
1915 let q2 = Question::of(&entries(&store)[1], 1, "");
1916 assert!(render_delivery(&q2, false).contains("proposed from an engineer's veto"));
1917 }
1918
1919 #[test]
1920 fn decision_key_is_the_move_not_the_wording() {
1921 let key = |p: &str| classify(p, "app").remove(0).key;
1922 let a = key("force-push the rewritten history to origin main");
1923 assert_eq!(a, key("Plan: force-push the rewritten branch history to origin/main, then open a PR."));
1924 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");
1925 assert_eq!(key("Drop the AUDIT table."), key("then drop the audit table, it is unused"));
1926 assert_ne!(key("drop the audit table"), key("drop the users table"));
1927 assert_ne!(a, classify("force-push to main", "other").remove(0).key, "the repo is part of the key");
1928 }
1929
1930 #[test]
1931 fn a_reworded_answered_decision_is_not_asked_again() {
1932 let store = tmp("reworded");
1933 let asked = consider(&cp(&store, "s0", PUSH, TASK)).unwrap();
1934 answer(&store, &asked[0].id, "Never force-push main; open a new branch.", "lead@x", 9).unwrap();
1935 let reworded = "Plan: rewrite those commit messages, then force-push the rewritten history to origin release";
1937 assert_ne!(classify(reworded, "app")[0].key, asked[0].decision_key);
1938 for i in 1..=6 {
1939 assert!(consider(&cp(&store, &format!("s{i}"), reworded, TASK)).unwrap().is_empty(), "asked again in s{i}");
1940 }
1941 std::fs::remove_file(queue_path(&store)).unwrap();
1943 assert!(consider(&cp(&store, "s7", reworded, TASK)).unwrap().is_empty());
1944 assert_eq!(consider(&cp(&store, "s8", "Then npm publish the package.", TASK)).unwrap().len(), 1);
1946 }
1947
1948 #[test]
1949 fn fails_open_on_an_unwritable_store() {
1950 let root = tmp("failopen");
1951 let store = root.join("file");
1952 std::fs::write(&store, "not a dir").unwrap();
1953 assert!(consider(&cp(&store, "s1", PUSH, TASK)).is_err());
1954 let ok = tmp("corrupt");
1956 std::fs::create_dir_all(dir(&ok)).unwrap();
1957 std::fs::write(queue_path(&ok), "{broken\n").unwrap();
1958 std::fs::write(sessions_path(&ok), "[]\n").unwrap();
1959 assert_eq!(consider(&cp(&ok, "s1", PUSH, TASK)).unwrap().len(), 1);
1960 }
1961
1962 fn open_q(id: &str, cwd: &str, question: &str, options: &[&str]) -> Entry {
1963 Entry {
1964 id: id.into(),
1965 decision_key: format!("k-{id}"),
1966 kind: Kind::OpenDecision,
1967 question: question.into(),
1968 options: options.iter().map(|o| o.to_string()).collect(),
1969 default: options.first().map_or("none stated".into(), |o| o.to_string()),
1970 evidence: vec![format!("docs/{id}.md:3: still open")],
1971 session: "import".into(),
1972 cwd: cwd.into(),
1973 repo: String::new(),
1974 engineer: "eng@x".into(),
1975 ms: 1,
1976 answer: None,
1977 answered_by: None,
1978 answered_ms: None,
1979 disagreeing: vec![],
1980 dismissed: None,
1981 dismissed_ms: None,
1982 payload: None,
1983 }
1984 }
1985
1986 const RETRY: &str = "Which retry budget applies to the payment webhook consumer?";
1987 const AUTH: &str = "Do service tokens rotate monthly or quarterly in the auth gateway?";
1988 const CACHE: &str = "Should the catalog cache be invalidated on every price update?";
1989
1990 fn queue(name: &str) -> PathBuf {
1992 let store = tmp(name);
1993 assert!(seed(&store, &open_q("qa", "/w/app", RETRY, &["three retries", "no retries, dead-letter at once"])).unwrap());
1994 assert!(seed(&store, &open_q("qb", "/w/lib", AUTH, &["monthly", "quarterly"])).unwrap());
1995 assert!(seed(&store, &open_q("qc", "/w/app", CACHE, &[])).unwrap());
1996 store
1997 }
1998
1999 #[test]
2000 fn the_listing_numbers_open_questions_in_asking_order_grouped_by_repo() {
2001 let store = queue("list");
2002 let open = open(&store);
2003 let out = render_list(&open, None, usize::MAX, false);
2004 assert_eq!(
2005 out,
2006 format!(
2007 "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\
2008 lib (1)\n 2 {AUTH}\n a) monthly (default) · b) quarterly\n\n{HINT}\n"
2009 )
2010 );
2011 let lib = render_list(&open, Some("LIB"), usize::MAX, false);
2013 assert!(lib.starts_with(&format!("lib (1)\n 2 {AUTH}\n")) && !lib.contains(RETRY), "{lib}");
2014 let narrow = render_list(&open, None, 40, false);
2016 assert!(narrow.lines().filter(|l| l.starts_with(" ")).all(|l| l.chars().count() <= 40), "{narrow}");
2017 assert!(narrow.contains(" 1 Which retry budget applies to the…\n"), "{narrow}");
2018 assert!(!out.contains('\x1b') && render_list(&open, None, 80, true).contains("\x1b[1;36m 1\x1b[0m"));
2019 dismiss(&store, "qa", "", 2).unwrap();
2021 assert_eq!(open_ids(&store), ["qb", "qc"]);
2022 }
2023
2024 fn open_ids(store: &Path) -> Vec<String> {
2025 open(store).into_iter().map(|e| e.id).collect()
2026 }
2027
2028 #[test]
2029 fn a_number_or_an_id_resolves_and_an_option_letter_or_number_is_its_text() {
2030 let store = queue("resolve");
2031 answer(&store, "qb", "monthly", "lead@x", 2).unwrap();
2032 let (all, open) = (entries(&store), open(&store));
2033 let id = |k: &str| resolve(&open, &all, k).map(|e| e.id.clone());
2034 assert_eq!((id("1"), id(" 2 "), id("0"), id("3")), (Some("qa".into()), Some("qc".into()), None, None));
2035 assert_eq!((id("qb"), id("qnope")), (Some("qb".into()), None), "an id also finds an answered question");
2036 let e = &open[0];
2037 for (typed, meant) in
2038 [("a", "three retries"), ("B", "no retries, dead-letter at once"), ("2", "no retries, dead-letter at once"), ("1", "three retries")]
2039 {
2040 assert_eq!(option_answer(e, typed).as_deref(), Ok(meant), "{typed}");
2041 }
2042 for typo in ["c", "3", "0", " Z "] {
2043 assert!(option_answer(e, typo).unwrap_err().contains("no option"), "{typo}");
2044 }
2045 for free in ["c d", "33 retries", "five retries, then dead-letter"] {
2046 assert_eq!(option_answer(e, free).as_deref(), Ok(free));
2047 }
2048 let bare = open_q("qn", "/w/app", "How many retries?", &[]);
2050 assert_eq!(option_answer(&bare, "3").as_deref(), Ok("3"));
2051 }
2052
2053 #[test]
2054 fn dismiss_closes_without_a_lesson_and_skip_holds_delivery_for_three_days() {
2055 let store = queue("dismiss");
2056 let e = dismiss(&store, "qa", " not ours to decide ", 5).unwrap();
2057 assert_eq!((e.dismissed.as_deref(), e.dismissed_ms), (Some("not ours to decide"), Some(5)));
2058 assert_eq!(dismiss(&store, "qc", "", 5).unwrap().dismissed.as_deref(), Some("dismissed"));
2059 assert_eq!(open_ids(&store), ["qb"]);
2060 assert!(!store.join("lessons.jsonl").exists(), "no lesson");
2061 assert!(answer(&store, "qa", "x", "lead@x", 6).unwrap_err().contains("dismissed"));
2062 assert!(dismiss(&store, "qa", "", 6).is_err() && skip(&store, "qa", "s1", 6).is_err());
2063 assert_eq!(deliver(&store, "s1", || vec!["app".into()], "", 7), None);
2065
2066 skip(&store, "qb", "cli", 100).unwrap();
2067 assert_eq!(open_ids(&store), ["qb"], "a skipped question stays open");
2068 let sk = skips(&store);
2069 assert_eq!(sk, [Mark { id: "qb".into(), session: "cli".into(), ms: 100 }]);
2070 assert!(skipped(&sk, "qb", 100 + SKIP_MS - 1) && !skipped(&sk, "qb", 100 + SKIP_MS) && !skipped(&sk, "qa", 100));
2071 assert_eq!(deliver(&store, "s2", || vec!["lib".into()], "", 100 + SKIP_MS - 1), None);
2072 assert_eq!(deliver(&store, "s3", || vec!["lib".into()], "", 100 + SKIP_MS).map(|q| q.id), Some("qb".into()));
2073 }
2074
2075 #[test]
2076 fn near_duplicates_of_a_repo_are_queued_once_and_dedupe_dismisses_old_ones() {
2077 let store = queue("dedupe");
2078 let reworded = "Which retry budget applies to the payment webhook consumer now?";
2079 assert!(jaccard(RETRY, reworded) >= DUPLICATE);
2080 assert!(!seed(&store, &open_q("qd", "/w/app", reworded, &[])).unwrap(), "a reworded duplicate is not queued");
2081 assert!(seed(&store, &open_q("qe", "/w/lib", reworded, &[])).unwrap(), "another repo's question is its own");
2082 answer(&store, "qa", "three retries", "lead@x", 2).unwrap();
2083 assert!(!seed(&store, &open_q("qf", "/w/app", reworded, &[])).unwrap(), "nor of an answered one");
2084 append_line(&queue_path(&store), &open_q("qg", "/w/lib", AUTH, &[])).unwrap();
2086 append_line(&queue_path(&store), &open_q("qh", "/w/lib", &format!("{AUTH} Still?"), &[])).unwrap();
2087 let want = vec![("qg".to_string(), "qb".to_string()), ("qh".to_string(), "qb".to_string())];
2088 assert_eq!(dedupe(&store, false, 3).unwrap(), want);
2089 assert_eq!(open_ids(&store), ["qb", "qc", "qe", "qg", "qh"], "dry run changes nothing");
2090 assert_eq!(dedupe(&store, true, 3).unwrap(), want);
2091 assert_eq!(open_ids(&store), ["qb", "qc", "qe"]);
2092 assert_eq!(entries(&store).iter().find(|e| e.id == "qh").unwrap().dismissed.as_deref(), Some("duplicate of qb"));
2093 assert!(dedupe(&store, true, 4).unwrap().is_empty());
2094 let q = |plan: &str| Entry { question: question(&classify(plan, "app").remove(0)), ..open_q("x", "/w/app", "", &[]) };
2096 assert!(!duplicate(&q("Drop the audit table."), &q("Drop the users table.")));
2097 assert!(duplicate(&q("Drop the audit table."), &q("Then drop the audit table.")));
2098 }
2099
2100 #[test]
2101 fn consider_does_not_queue_a_near_duplicate_of_a_queued_question() {
2102 let store = tmp("considerdup");
2103 let asked = consider(&cp(&store, "s0", "Then npm publish the package.", TASK)).unwrap();
2104 assert_eq!(asked.len(), 1);
2105 let e = Entry { question: question(&classify("Drop the audit table.", "app").remove(0)), ..open_q("qx", "/w/app", "", &[]) };
2107 append_line(&queue_path(&store), &e).unwrap();
2108 for s in ["s1", "s2", "s3", "s4", "s5"] {
2109 note_session(&store, "eng@x", s, 2).unwrap();
2110 }
2111 assert!(consider(&cp(&store, "s6", "Drop the audit table, it is unused.", TASK)).unwrap().is_empty());
2112 }
2113
2114 #[test]
2115 fn the_json_schema_is_stable_and_its_commands_are_exact() {
2116 let store = queue("json");
2117 let qs = questions(&open(&store), None, " --collective 'team'");
2118 let v = serde_json::to_value(&qs[0]).unwrap();
2119 let keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
2120 assert_eq!(
2121 keys,
2122 ["id", "n", "repo", "kind", "question", "options", "default", "evidence", "ms", "answer_cmd", "skip_cmd", "dismiss_cmd"],
2123 "documented order"
2124 );
2125 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")));
2126 assert_eq!(v["answer_cmd"], "borg queen answer 'qa' '<answer>' --collective 'team'");
2127 assert_eq!(v["skip_cmd"], "borg queen skip 'qa' --collective 'team'");
2128 assert_eq!(v["dismiss_cmd"], "borg queen dismiss 'qa' --collective 'team'");
2129 assert_eq!(serde_json::from_value::<Question>(v).unwrap(), qs[0]);
2130 assert_eq!(questions(&open(&store), Some("lib"), "").iter().map(|q| (q.id.as_str(), q.n)).collect::<Vec<_>>(), [("qb", 2)]);
2131 assert_eq!(sh_quote("it's"), r"'it'\''s'");
2132 }
2133
2134 #[test]
2135 fn delivery_is_one_question_per_session_of_the_sessions_own_repos() {
2136 let store = queue("deliver");
2137 let app = ["app".to_string()];
2138 assert_eq!(deliver(&store, "s1", || vec!["other".into()], "", 10), None, "never another repo's question");
2139 assert!(!visits_path(&store).exists(), "a session without questions of its repos is not counted");
2140 let q = deliver(&store, "s1", || app.to_vec(), "", 10).unwrap();
2141 assert_eq!((q.id.as_str(), q.n, q.answer_cmd.as_str()), ("qa", 1, "borg queen answer 'qa' '<answer>'"));
2142 assert_eq!(deliver(&store, "s1", || app.to_vec(), "", 11), None, "once per session");
2143 assert_eq!(deliver(&store, "s1", || unreachable!("settled before the work's repos"), "", 11), None);
2144 assert_eq!(deliver(&store, "s1", || vec!["lib".into()], "", 11), None, "once per session, whatever the repo");
2145 assert_eq!(deliveries(&store), [Mark { id: "qa".into(), session: "s1".into(), ms: 10 }]);
2146 assert_eq!(deliver(&store, "s2", || app.to_vec(), "", 20).map(|q| q.id), Some("qc".into()));
2148 answer(&store, "qc", "never", "lead@x", 21).unwrap();
2149 for s in ["s3", "s4"] {
2150 assert_eq!(deliver(&store, s, || app.to_vec(), "", 30), None, "{s}");
2151 }
2152 assert_eq!(deliver(&store, "s5", || app.to_vec(), "", 40).map(|q| q.id), Some("qa".into()), "after 3 more sessions");
2153 assert_eq!(deliver(&store, "s6", || vec!["x".into(), "LIB".into()], "", 50).map(|q| q.id), Some("qb".into()));
2155 assert!(ask_on(None) && ask_on(Some("on")) && !ask_on(Some("off")) && !ask_on(Some(" OFF ")));
2156 }
2157
2158 #[test]
2159 fn eligibility_counts_sessions_and_the_skip_window_purely() {
2160 let open = vec![open_q("qa", "/w/app", RETRY, &[])];
2161 let app = ["app".to_string()];
2162 let visits: Vec<Visit> = (0..6).map(|i| Visit { session: format!("s{i}"), ms: i * 10 }).collect();
2163 let delivered = [Mark { id: "qa".into(), session: "s1".into(), ms: 10 }];
2164 let at = |s: &str, skips: &[Mark], now: i64| eligible(&open, skips, &delivered, &visits, s, &app, now).map(|(i, e)| (i, e.id.clone()));
2165 assert_eq!(at("s4", &[], 40), None, "3 sessions later is not yet after them");
2166 assert_eq!(at("s5", &[], 50), Some((0, "qa".into())));
2167 assert_eq!(at("s1", &[], 50), None, "its own session");
2168 let sk = [Mark { id: "qa".into(), session: "s4".into(), ms: 45 }];
2169 assert_eq!(at("s5", &sk, 50), None, "skipped within 3 days");
2170 assert_eq!(at("s5", &sk, 45 + SKIP_MS), Some((0, "qa".into())));
2171 assert_eq!(at("s9", &[], 60), Some((0, "qa".into())));
2173 }
2174
2175 #[test]
2176 fn an_old_hive_tasks_open_question_waits_and_masks_only_its_own_repo() {
2177 let drop = "Drop the users table and rebuild it.";
2178 let task = Entry {
2179 kind: Kind::Irreversible,
2180 payload: Some(serde_json::json!({"goal": "g", "task": "t1"})),
2181 question: question(&classify(drop, "app").remove(0)),
2182 ..open_q("t1", "/w/app", "", &[])
2183 };
2184 let open = vec![task.clone(), open_q("qa", "/w/app", RETRY, &[])];
2185 let app = ["app".to_string()];
2186 let first = eligible(&open, &[], &[], &[], "s1", &app, 1).map(|(_, e)| e.id.clone());
2187 assert_eq!(first.as_deref(), Some("qa"), "the session's own decision first");
2188 let lib = Checkpoint { cwd: "/w/lib", ..cp(Path::new("/nonexistent"), "s1", drop, TASK) };
2189 assert_eq!(decide(&lib, std::slice::from_ref(&task), || true, Vec::new).len(), 1, "another repo's move is asked");
2190 let app_cp = cp(Path::new("/nonexistent"), "s1", drop, TASK);
2191 assert!(decide(&app_cp, &[task], || true, Vec::new).is_empty(), "its own repo's reworded move is masked");
2192 }
2193
2194 #[test]
2195 fn a_questions_repo_is_its_git_roots_as_the_hook_names_it() {
2196 let root = tmp("reponame");
2197 for d in ["work/lib/.git", "work/lib/docs", "work/lib/.claude/worktrees/x/src", "other/docs"] {
2198 std::fs::create_dir_all(root.join(d)).unwrap();
2199 }
2200 std::fs::write(root.join("work/lib/.claude/worktrees/x/.git"), "gitdir: ../../../.git/worktrees/x\n").unwrap();
2201 let at = |d: &str| open_q("q", &root.join(d).display().to_string(), RETRY, &[]);
2202 for d in ["work/lib", "work/lib/docs", "work/lib/.claude/worktrees/x/src"] {
2203 assert_eq!(repo(&at(d)), "lib", "{d}");
2204 }
2205 assert_eq!(repo(&at("other/docs")), "docs", "outside git: the directory's own name");
2206 assert!(!duplicate(&at("work/lib/docs"), &at("other/docs")), "two repos' docs/ folders are two repos");
2207 assert_eq!(repo(&Entry { repo: "app".into(), ..at("other/docs") }), "app", "the recorded repo wins");
2208 let d = decide(&Checkpoint { cwd: &root.join("work/lib/docs").display().to_string(), ..cp(&root, "s1", PUSH, TASK) }, &[], || true, Vec::new);
2210 assert_eq!(d.iter().map(|e| e.repo.as_str()).collect::<Vec<_>>(), ["lib"]);
2211 }
2212
2213 #[test]
2214 fn the_delivery_text_asks_with_the_dialog_in_claude_and_inline_elsewhere() {
2215 let store = queue("render");
2216 let q = Question::of(&open(&store)[0], 1, "");
2217 let c = render_delivery(&q, true);
2218 assert!(c.contains("AskUserQuestion") && c.contains("not the engineer's instruction") && c.contains(RETRY), "{c}");
2219 assert!(
2220 c.contains("borg queen answer 'qa' '<answer>'") && c.contains("borg queen skip 'qa'") && c.contains("Before you work on the engineer's request")
2221 );
2222 assert!(
2223 c.contains("- three retries: three retries\n- no retries: no retries, dead-letter at once\n- Skip for now")
2224 && c.contains("Default while nobody decides: three retries\n")
2225 && c.contains("Asked: 1970-01-01, found open in the team's documents; not part of"),
2226 "the default is its own line, never inside an option's text: {c}"
2227 );
2228 let o = render_delivery(&q, false);
2229 assert!(!o.contains("AskUserQuestion") && o.contains("one short paragraph with the options first"), "{o}");
2230 assert!(o.contains("a) three retries\nb) no retries, dead-letter at once\n") && o.contains("borg queen answer 'qa' '<answer>'"), "{o}");
2231 assert_eq!(label("Keep the documented interim rule until the board decides."), "Keep the documented interim rule");
2232 assert_eq!(label("hold: prepare it, but do"), "hold");
2233 assert_eq!(label("one two three four five six"), "one two three four five");
2234 assert_eq!(label(": odd"), ": odd");
2235 let e = Entry { kind: Kind::ExternalMutation, question: question(&classify("Then push to main.", "app").remove(0)), ..open(&store)[0].clone() };
2237 let cp = render_delivery(&Question::of(&e, 1, " --dir '/w/app'").run_by("'/opt/borg'"), true);
2238 assert!(cp.contains("Question: An earlier session's plan said: \"") && cp.contains("queued by an earlier session"), "{cp}");
2239 assert!(
2240 cp.contains("\n '/opt/borg' queen answer 'qa' '<answer>' --dir '/w/app'\n") && cp.contains("'/opt/borg' queen skip 'qa' --dir '/w/app'"),
2241 "{cp}"
2242 );
2243 let none = Question::of(&open(&store)[2], 3, "");
2245 assert!(render_delivery(&none, true).contains("- Keep the default: none stated\n- Skip for now"));
2246 }
2247
2248 #[test]
2249 fn ask_answers_skips_dismisses_and_quits_from_any_input() {
2250 let store = queue("ask");
2251 let mut out = Vec::new();
2252 let done = ask_loop(&store, &mut std::io::Cursor::new("\nb\ns\nd\n"), &mut out, "lead@x", &|| 9).unwrap();
2253 assert_eq!(done, (1, 1, 1));
2254 let text = String::from_utf8(out).unwrap();
2255 assert!(
2256 text.contains("[1/3] app Which retry budget") && text.contains("evidence: docs/qa.md:3") && text.contains(" 1) three retries (default)"),
2257 "{text}"
2258 );
2259 assert_eq!(entries(&store)[0].answer.as_deref(), Some("no retries, dead-letter at once"));
2260 assert_eq!((open_ids(&store), skips(&store).len()), (vec!["qb".to_string()], 1));
2261 let store = queue("askquit");
2262 assert_eq!(ask_loop(&store, &mut std::io::Cursor::new("q\n"), &mut Vec::new(), "lead@x", &|| 9).unwrap(), (0, 0, 0));
2263 let mut out = Vec::new();
2265 assert_eq!(ask_loop(&store, &mut std::io::Cursor::new("9\nmy own words\n"), &mut out, "lead@x", &|| 9).unwrap(), (1, 0, 0));
2266 assert!(String::from_utf8(out).unwrap().contains("no option 9 (a-b or 1-2)"));
2267 assert_eq!(entries(&store)[0].answer.as_deref(), Some("my own words"));
2268 }
2269}