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;
49pub const MAX_QUESTIONS: usize = 2;
52pub(crate) const STATED: f64 = 0.6;
56pub 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 OpenDecision,
72 Reach,
75 Constraint,
79}
80
81impl Kind {
82 pub fn escalates(self) -> bool {
86 matches!(self, Kind::ExternalMutation | Kind::Irreversible | Kind::PublicApi | Kind::CrossRepo)
87 }
88
89 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 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
127static 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 (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
165static 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
171static 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
178static REF: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b(main|master|prod|production|staging)\b").unwrap());
180
181static 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#[derive(Debug, Clone, PartialEq)]
217pub struct Decision {
218 pub kind: Kind,
219 pub sentence: String,
220 pub what: String,
222 pub key: String,
224}
225
226pub(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
234pub(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
242fn 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
247fn 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
252pub fn decision_key(kind: Kind, repo: &str, what: &str) -> String {
255 fnv(&format!("{kind:?}:{repo}:{what}"))
256}
257
258fn 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
272pub 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 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
307pub 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
324fn 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
331fn 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#[derive(Debug, Clone, PartialEq)]
341pub enum Known {
342 Nothing,
344 Resolved,
346 Disagree(Vec<String>),
348}
349
350fn 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#[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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 pub disagreeing: Vec<String>,
406 #[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 #[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 std::fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(line.as_bytes())
442}
443
444pub 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
456pub fn open_decision_key(repo: &str, question: &str) -> String {
458 decision_key(Kind::OpenDecision, repo, &norm(&question.to_lowercase()))
459}
460
461pub fn constraint_key(repo: &str, text: &str) -> String {
463 decision_key(Kind::Constraint, repo, &norm(&text.to_lowercase()))
464}
465
466pub 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
477fn 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
489pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct Seen {
504 pub engineer: String,
505 pub session: String,
506 pub ms: i64,
507}
508
509fn 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
518pub 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
524pub 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; }
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 _ => mine.iter().filter(|s| s.ms > last.ms).count(),
541 };
542 since >= BUDGET_SESSIONS
543}
544
545pub struct Checkpoint<'a> {
547 pub store: &'a Path,
549 pub session: &'a str,
550 pub cwd: &'a str,
551 pub engineer: &'a str,
552 pub plan: &'a str,
554 pub inputs: &'a str,
557 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
566pub 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
573pub 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
579pub 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
599pub 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
621pub 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 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
696pub 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
710pub fn enabled() -> bool {
712 std::env::var("BORG_QUEEN").is_ok_and(|v| v.trim().eq_ignore_ascii_case("on"))
713}
714
715pub 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 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
741fn said(text: &str) -> String {
743 text.trim().to_lowercase()
744}
745
746pub 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 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 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 "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
793pub 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 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
838pub const SKIP_MS: i64 = 3 * crate::clock::DAY_MS;
845pub const REOFFER_SESSIONS: usize = 3;
849pub 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
865pub 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
871pub 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
881pub 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
887pub 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
898pub 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
907pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
915pub struct Mark {
916 pub id: String,
917 pub session: String,
918 pub ms: i64,
919}
920
921#[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
937pub 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
942fn 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
954pub 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
964pub 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
972pub 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
982pub 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
999pub 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
1012pub 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#[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
1044pub fn sh_quote(s: &str) -> String {
1046 format!("'{}'", s.replace('\'', r"'\''"))
1047}
1048
1049impl Question {
1050 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 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
1082pub 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
1087pub fn ask_enabled() -> bool {
1089 ask_on(std::env::var("BORG_QUEEN_ASK").ok().as_deref())
1090}
1091
1092pub fn ask_on(value: Option<&str>) -> bool {
1094 !value.is_some_and(|v| v.trim().eq_ignore_ascii_case("off"))
1095}
1096
1097pub 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 _ => 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
1132pub 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
1165fn 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
1172pub 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
1183pub 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 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
1249fn 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
1261pub 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
1291pub 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
1326pub type Asked = (usize, usize, usize);
1328
1329pub 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
1387pub(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 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
1403fn 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
1433pub 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 for s in ["s0", "s1", "s2", "s3", "s4"] {
1807 assert!(consider(&cp(&store, s, plans[1], TASK)).unwrap().is_empty(), "{s}");
1808 }
1809 let mut other = cp(&store, "t0", plans[2], TASK);
1811 other.engineer = "other@x";
1812 assert_eq!(consider(&other).unwrap().len(), 1);
1813 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 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 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 assert_eq!(learn::lessons(&store.join("lessons.jsonl")).len(), 1);
1867 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 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 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 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 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 std::fs::remove_file(queue_path(&store)).unwrap();
1954 assert!(consider(&cp(&store, "s7", reworded, TASK)).unwrap().is_empty());
1955 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}