Skip to main content

borg/
assimilate_dir.rs

1//! `borg assimilate --from-dir <dir>`: a team's written knowledge becomes lessons of its collective.
2//!
3//! A team that already keeps hand-curated knowledge (a knowledge skill and its references,
4//! retrospectives with "Prevention for future" rows, an audit of recurring failure families, documents
5//! that leave decisions open) should not wait for live corrections to teach the same facts again.
6//! This reads that knowledge from a git checkout and turns it into lessons in the standard schema
7//! (question + situations -> consequence, area, scope, overrides, origin `path:line`, engineers from
8//! `git log` of the file), so drones get it the way they get learned lessons: recalled on touch at the
9//! checkpoint, scoped, never broadcast. Open decisions become queen queue entries (queen.rs
10//! `Kind::OpenDecision`), never lessons: a question nobody answered is not a rule.
11//!
12//! It is part of `borg` rather than the eval harness because it is how a team joins: it writes the
13//! routed collective's store exactly as `borg learn` does (the E3 eval only points `BORG_HOME` at a
14//! scratch store).
15//!
16//! **Sources** (tracked files only; globs relative to the directory; defaults fit a handbook layout):
17//! - knowledge `skills/*-knowledge/SKILL.md`, `skills/*-knowledge/references/*.md`: one chunk per file
18//!   (split at `## ` headings past `CHUNK_LINES`);
19//! - retros `docs/*retrospectives/*.md`: the rows of every table whose header mentions prevention;
20//! - families `docs/**/*coverage-audit*.md`: the rows of every table headed "Failure family", minus
21//!   columns naming eval cases (the importer must not carry an eval's own case list into lessons);
22//! - decisions `docs/**/*.md`: the blocks around "Your decision", "Open conflict" and similar markers.
23//!
24//! **Generalisation** uses the same model call as learn.rs (`learn::ask_model`, `BORG_LEARN_MODEL`,
25//! default sonnet): one call per chunk, `BORG_ASSIMILATE_JOBS` (default 4) at a time; every call's cost
26//! is printed and, with `--apply`, recorded in `<store>/assimilated.jsonl`. Scope guards are part of
27//! the contract: a source that limits a rule ("Orion nets only") must give a scoped lesson, and a rule
28//! that replaces outside advice names it in `overrides` (in an earlier eval the drain rule only won once it
29//! named the upstream reset idiom it replaces).
30//!
31//! **Dry run by default**: prints the lessons and decisions, writes nothing (`--plan FILE` saves them
32//! for review; `--apply --plan FILE` then applies exactly those and never asks the model: a plan that
33//! cannot be read stops the run, and a chunk the plan does not answer, or whose source changed since,
34//! is listed and skipped with a non-zero exit).
35//!
36//! **Target**: lessons go to the collective the source directory routes to, unless `--collective NAME`
37//! or `--for PATH` (routed like a drone working there) says otherwise: a team's knowledge repo is
38//! rarely where its drones work. The output names the receiving collective and the repos routed to it.
39//!
40//! **Scope guards** (deterministic, after the model): the repo the knowledge is kept in and ticket
41//! prefixes are never scopes; a one-word common English word is never a scope; a scope name must occur
42//! in an identifier or path of the source (`guard`). A lesson left without a scope name is dropped,
43//! never widened to unscoped. The conscience matches a scope name as a phrase (conscience.rs `in_scope`). Re-running `--apply` costs nothing: a chunk already imported with the same content hash is
44//! skipped. A chunk whose source changed since its import is reported, not re-imported (retiring the
45//! lessons of the old wording is not built). Anything mentioning a word of `BORG_ASSIMILATE_SKIP` is dropped
46//! (assimilate.rs `skip_words`).
47
48use std::collections::{BTreeMap, HashMap, HashSet};
49use std::io::Write;
50use std::path::{Path, PathBuf};
51use std::process::Command;
52use std::sync::Mutex;
53use std::sync::atomic::{AtomicUsize, Ordering};
54
55use regex::Regex;
56use serde::{Deserialize, Serialize};
57use serde_json::{Value, json};
58
59use crate::collective::{self, Config, Route};
60use crate::learn::{self, Lesson};
61use crate::queen::{self, Entry, Kind};
62use crate::trust;
63
64/// A knowledge file longer than this is split at `## ` headings.
65const CHUNK_LINES: usize = 150;
66
67/// The model call: (prompt, JSON schema) -> (answer, cost in USD). `learn::ask_model` in production.
68pub type Ask<'a> = &'a (dyn Fn(&str, &Value) -> Option<(Value, Option<f64>)> + Sync);
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Part {
73    Knowledge,
74    Retro,
75    Family,
76    Decision,
77}
78
79impl Part {
80    fn guidance(self) -> &'static str {
81        match self {
82            Part::Knowledge => "The chunk is a team-knowledge reference: facts and decided house rules the code does not show.",
83            Part::Retro => {
84                "The chunk lists problems from a plan or refinement retrospective, with whether each was preventable and \
85how to prevent it in future. Turn each preventable item into at most one lesson that generalises its prevention: instructions \
86generalise, incidents do not (no ticket ids, dates, product ids or one-off names in the question). Merge items that express \
87the same prevention. Skip items that were not preventable or are purely one-off."
88            }
89            Part::Family => {
90                "The chunk is a table of failure families that recur across the team's retrospectives and sessions. Write \
91one lesson per family: the question that would have prevented it."
92            }
93            Part::Decision => {
94                "The chunk shows places where the team's documents leave a decision open. Return each genuinely open \
95decision about the team's product, code or process rules as an open decision. Do not return questions about whether to keep, \
96drop or change an evaluation case or a test fixture, and nothing that the chunk shows as already decided. Return no lessons."
97            }
98        }
99    }
100}
101
102/// Source globs per part, relative to the directory.
103#[derive(Debug, Clone)]
104pub struct Spec {
105    pub knowledge: Vec<String>,
106    pub retros: Vec<String>,
107    pub families: Vec<String>,
108    pub decisions: Vec<String>,
109}
110
111impl Default for Spec {
112    fn default() -> Self {
113        let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
114        Spec {
115            knowledge: v(&["skills/*-knowledge/SKILL.md", "skills/*-knowledge/references/*.md"]),
116            retros: v(&["docs/*retrospectives/*.md"]),
117            families: v(&["docs/**/*coverage-audit*.md"]),
118            decisions: v(&["docs/**/*.md"]),
119        }
120    }
121}
122
123/// One unit sent to the model: a part of one file, its lines numbered.
124#[derive(Debug, Clone, PartialEq)]
125pub struct Chunk {
126    pub part: Part,
127    /// `path` for a whole file, `path#n` for its n-th piece, `path#families` / `path#decisions`.
128    pub key: String,
129    pub path: String,
130    /// The source lines it holds (1-based), in order.
131    pub lines: Vec<usize>,
132    /// The text as the model sees it: `NNN| line`.
133    pub text: String,
134    /// For a knowledge file: the scope its knowledge base states for itself (the skill's description).
135    pub about: String,
136    /// Hash of `text` and `about`: an import is redone only when one of them changed.
137    pub sha: String,
138}
139
140/// FNV-1a 64 as hex: stable ids and content hashes without a dependency.
141fn fnv(s: &str) -> String {
142    format!("{:016x}", s.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3)))
143}
144
145/// A glob (`*`, `?`, `**`) as an anchored regex over `/`-separated relative paths.
146pub fn glob(pattern: &str) -> Regex {
147    let mut re = String::from("^");
148    let mut it = pattern.chars().peekable();
149    while let Some(c) = it.next() {
150        match c {
151            '*' if it.peek() == Some(&'*') => {
152                it.next();
153                if it.peek() == Some(&'/') {
154                    it.next();
155                    re.push_str("(?:.*/)?");
156                } else {
157                    re.push_str(".*");
158                }
159            }
160            '*' => re.push_str("[^/]*"),
161            '?' => re.push_str("[^/]"),
162            c => re.push_str(&regex::escape(&c.to_string())),
163        }
164    }
165    re.push('$');
166    Regex::new(&re).unwrap_or_else(|_| Regex::new("^$").unwrap())
167}
168
169fn git(dir: &Path, args: &[&str]) -> Option<String> {
170    let out = Command::new("git").arg("-C").arg(dir).args(args).stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null()).output().ok()?;
171    out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
172}
173
174/// The tracked files of `dir` (untracked notes are someone's private scratch, not team knowledge).
175fn tracked(dir: &Path) -> Vec<String> {
176    let mut v: Vec<String> = git(dir, &["ls-files"]).unwrap_or_default().lines().map(str::to_string).collect();
177    v.sort();
178    v
179}
180
181/// A source line: its 1-based number and its text as the model sees it.
182type Line = (usize, String);
183
184fn chunk(part: Part, key: String, path: &str, lines: &[Line]) -> Chunk {
185    let text: String = lines.iter().map(|(n, l)| format!("{n:>4}| {l}\n")).collect();
186    Chunk { part, key, path: path.into(), lines: lines.iter().map(|(n, _)| *n).collect(), sha: fnv(&text), text, about: String::new() }
187}
188
189/// A markdown table: its header line, header cells and body rows (separator dropped).
190struct Table<'a> {
191    head: (usize, &'a str),
192    cells: Vec<String>,
193    rows: Vec<(usize, &'a str)>,
194}
195
196/// Every run of consecutive `|` lines with a header and at least one more line.
197fn tables<'a>(all: &[(usize, &'a str)]) -> Vec<Table<'a>> {
198    let cells = |l: &str| l.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect::<Vec<_>>();
199    let mut out = Vec::new();
200    let mut i = 0;
201    while i < all.len() {
202        if !all[i].1.trim_start().starts_with('|') {
203            i += 1;
204            continue;
205        }
206        let start = i;
207        while i < all.len() && all[i].1.trim_start().starts_with('|') {
208            i += 1;
209        }
210        let block = &all[start..i];
211        if block.len() >= 2 {
212            let rows = block[1..].iter().filter(|(_, l)| !l.chars().all(|c| "|-: ".contains(c))).copied().collect();
213            out.push(Table { head: block[0], cells: cells(block[0].1), rows });
214        }
215    }
216    out
217}
218
219/// Keep only the columns of a table row whose header passes `keep` (the header itself included).
220fn columns(line: &str, header: &[String], keep: &dyn Fn(&str) -> bool) -> String {
221    let cells: Vec<&str> = line.trim().trim_matches('|').split('|').map(str::trim).collect();
222    let kept: Vec<&str> = cells.iter().enumerate().filter(|(i, _)| header.get(*i).is_none_or(|h| keep(h))).map(|(_, c)| *c).collect();
223    format!("| {} |", kept.join(" | "))
224}
225
226static MARKER: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
227    Regex::new(r"(?i)your decision|open conflict|open decision|skill conflicts?\b|no rule covers|until this is decided|\bundecided\b").unwrap()
228});
229
230/// The chunks of one file for one part.
231pub fn chunks_of(part: Part, path: &str, text: &str) -> Vec<Chunk> {
232    let all: Vec<(usize, &str)> = text.lines().enumerate().map(|(i, l)| (i + 1, l)).collect();
233    let owned = |ls: &[(usize, &str)]| ls.iter().map(|(n, l)| (*n, l.to_string())).collect::<Vec<Line>>();
234    let title: Vec<Line> = owned(&all.iter().find(|(_, l)| l.starts_with("# ")).copied().into_iter().collect::<Vec<_>>());
235    match part {
236        Part::Knowledge => {
237            if all.len() <= CHUNK_LINES {
238                return vec![chunk(part, path.into(), path, &owned(&all))];
239            }
240            // Split at `## ` headings, packing sections up to CHUNK_LINES (a longer section stays whole).
241            let mut pieces: Vec<Vec<Line>> = vec![Vec::new()];
242            for &(n, l) in &all {
243                if l.starts_with("## ") && pieces.last().is_some_and(|p| p.len() >= CHUNK_LINES / 2) {
244                    pieces.push(Vec::new());
245                }
246                pieces.last_mut().unwrap().push((n, l.to_string()));
247            }
248            pieces.iter().enumerate().map(|(i, p)| chunk(part, format!("{path}#{i}"), path, p)).collect()
249        }
250        Part::Retro => {
251            let mut rows: Vec<Line> = Vec::new();
252            for t in tables(&all).into_iter().filter(|t| t.cells.iter().any(|c| c.to_lowercase().contains("prevent"))) {
253                rows.push((t.head.0, t.head.1.to_string()));
254                rows.extend(owned(&t.rows));
255            }
256            if rows.is_empty() {
257                return Vec::new();
258            }
259            vec![chunk(part, format!("{path}#prevention"), path, &[title, rows].concat())]
260        }
261        Part::Family => {
262            let not_cases = |h: &str| !h.to_lowercase().contains("case");
263            let mut rows: Vec<Line> = Vec::new();
264            for t in tables(&all).into_iter().filter(|t| t.cells.first().is_some_and(|c| c.to_lowercase().contains("failure family"))) {
265                rows.push((t.head.0, columns(t.head.1, &t.cells, &not_cases)));
266                rows.extend(t.rows.iter().map(|(n, l)| (*n, columns(l, &t.cells, &not_cases))));
267            }
268            if rows.is_empty() {
269                return Vec::new();
270            }
271            vec![chunk(part, format!("{path}#families"), path, &[title, rows].concat())]
272        }
273        Part::Decision => {
274            // Each marker with its context: back to the nearest heading (at most 25 lines), forward to
275            // the next heading or blank line (at most 8 lines).
276            let mut keep: Vec<bool> = vec![false; all.len()];
277            for (i, (_, l)) in all.iter().enumerate() {
278                if !MARKER.is_match(l) {
279                    continue;
280                }
281                let from = (i.saturating_sub(25)..=i).rev().find(|&j| all[j].1.starts_with('#')).unwrap_or(i.saturating_sub(25));
282                let mut to = i;
283                while to + 1 < all.len() && to < i + 8 && !all[to + 1].1.starts_with('#') && !all[to + 1].1.trim().is_empty() {
284                    to += 1;
285                }
286                keep[from..=to].iter_mut().for_each(|k| *k = true);
287            }
288            let lines: Vec<Line> = owned(&all.iter().zip(&keep).filter(|(_, k)| **k).map(|(l, _)| *l).collect::<Vec<_>>());
289            if lines.is_empty() {
290                return Vec::new();
291            }
292            vec![chunk(part, format!("{path}#decisions"), path, &[title.into_iter().filter(|t| !lines.contains(t)).collect(), lines].concat())]
293        }
294    }
295}
296
297/// Every chunk of `dir` under `spec`, in a stable order (a file matched by two parts gives both).
298pub fn chunks(dir: &Path, spec: &Spec) -> Vec<Chunk> {
299    let files = tracked(dir);
300    let mut out = Vec::new();
301    let skip = crate::assimilate::skip_words();
302    // What the team's knowledge base says it covers frames every other source too: a retro or a failure
303    // family learned on that system's work belongs to it (its own wording often sounds general).
304    let knowledge: Vec<Regex> = spec.knowledge.iter().map(|g| glob(g)).collect();
305    let mut team: Vec<String> = Vec::new();
306    for f in files.iter().filter(|f| knowledge.iter().any(|r| r.is_match(f))) {
307        let d = skill_description(dir, f);
308        if !d.is_empty() && !team.contains(&d) {
309            team.push(d);
310        }
311    }
312    let team = team.join(" ");
313    for (part, globs) in [(Part::Knowledge, &spec.knowledge), (Part::Retro, &spec.retros), (Part::Family, &spec.families), (Part::Decision, &spec.decisions)] {
314        let res: Vec<Regex> = globs.iter().map(|g| glob(g)).collect();
315        for f in files.iter().filter(|f| f.ends_with(".md") && res.iter().any(|r| r.is_match(f))) {
316            let Ok(text) = std::fs::read_to_string(dir.join(f)) else { continue };
317            if crate::assimilate::mentions(&text, &skip) {
318                continue;
319            }
320            let about = match part {
321                Part::Knowledge => skill_description(dir, f),
322                _ => team.clone(),
323            };
324            out.extend(chunks_of(part, f, &text).into_iter().map(|mut c| {
325                if !about.is_empty() {
326                    c.sha = fnv(&format!("{}\n{about}", c.text));
327                    c.about = about.clone();
328                }
329                c
330            }));
331        }
332    }
333    out
334}
335
336const PROMPT: &str = "You turn a software team's written knowledge into lessons for the team's coding agents. An agent \
337is asked a lesson's question at a checkpoint (before it finishes a plan or a change) only when its work matches; it sees \
338only the question, plus the outside rule the lesson replaces if any. So:
339- question: ONE question the agent should ask itself. For a team fact the code does not show, the question must carry the \
340fact itself (e.g. \"Do the replayed services all run on their live hosts, since develop lacks the products?\"), so an agent \
341that reads only the question can apply it. Keep the names an agent working there would see (systems, services, classes, \
342places, commands); drop ticket ids, dates and incident details;
343- situations: 1-3 lines, each `<action> on/in <kind of state or component> [under <condition>]`;
344- consequence: one line, what goes wrong or what the team requires instead;
345- area: the one kind of concern: concurrency, spec-conformance, reuse-existing, architecture-boundary, verification, \
346minimality, protocol-correctness, data-source, security, dependency-version, scope, or other;
347- scope: when the source limits the rule to a system, list every name by which an agent would recognise that it works \
348inside that system: the system itself and the repos, modules and services the source ties to it, as they appear in paths, \
349code or prompts (e.g. a rule for Orion's search experiments: [\"Orion\", \"harbor_orion\", \"Falcon\", \"Beacon\", \
350\"Heron\", \"Pyxis\"]; a rule for \"Orion's text and voice nets only\": [\"Orion\", \"harbor_orion\"]). An agent \
351is asked the lesson only where one of these names occurs, so never list a generic word (search, net, test, plan, \
352experiment), never a library, framework or tool the system merely uses (flowlib, Gradle, Prometheus: other systems use \
353them too), never the team or company name, never a ticket key or ticket prefix, and never the repository the knowledge \
354is written in. A rule learned on one system's work (its retrospectives, its incidents) is scoped to that system even when \
355its wording sounds general, unless the source says it holds everywhere. When a file's title or scope line ties all of it to a system (\"Experiments on Orion search\"), every \
356lesson from that file gets the same full list of that system's names, not only the names its own line mentions. Never \
357widen a scope the source states: a rule for one system must not carry the names of systems it does not apply to. Empty \
358when the rule holds for any of the team's work; a prevention learned on one ticket that holds for any work is unscoped;
359- overrides: when the source says this rule replaces, changes or takes precedence over an upstream, vendored or general \
360rule, idiom or skill advice, name that rule, where it comes from and why it is replaced, in one sentence; otherwise empty;
361- line: the number of the source line the lesson rests on.
362Write only lessons that would change what an agent does. Skip navigation, tables of contents, plain definitions and \
363anything already obvious to a capable engineer.
364If the chunk states a decision the team has left open (undecided, a conflict waiting for a ruling), add it to \
365open_decisions: question, the options, the interim default the documents prescribe (or \"none stated\"), a short evidence \
366quote, and its line. A decided rule is a lesson, not an open decision.";
367
368fn schema() -> Value {
369    json!({"type": "object", "additionalProperties": false, "required": ["lessons", "open_decisions"], "properties": {
370        "lessons": {"type": "array", "items": {"type": "object", "additionalProperties": false,
371            "required": ["question", "situations", "consequence", "area", "scope", "overrides", "line"], "properties": {
372            "question": {"type": "string"}, "situations": {"type": "array", "items": {"type": "string"}},
373            "consequence": {"type": "string"}, "area": {"type": "string", "enum": trust::AREAS},
374            "scope": {"type": "array", "items": {"type": "string"}}, "overrides": {"type": "string"},
375            "line": {"type": "integer"}}}},
376        "open_decisions": {"type": "array", "items": {"type": "object", "additionalProperties": false,
377            "required": ["question", "options", "default", "evidence", "line"], "properties": {
378            "question": {"type": "string"}, "options": {"type": "array", "items": {"type": "string"}},
379            "default": {"type": "string"}, "evidence": {"type": "string"}, "line": {"type": "integer"}}}}}})
380}
381
382pub fn prompt(repo: &str, c: &Chunk) -> String {
383    let about = match c.about.is_empty() {
384        true => String::new(),
385        false => format!("\n\nThe team's knowledge base states its own scope as: {}", c.about),
386    };
387    format!("{PROMPT}\n\n{}{about}\n\n## Source: {repo}/{} (line numbers before `|`)\n{}", c.part.guidance(), c.path, c.text)
388}
389
390/// The `description:` of the skill a knowledge file belongs to (`skills/<name>/SKILL.md` frontmatter).
391fn skill_description(dir: &Path, path: &str) -> String {
392    let mut parts = path.split('/');
393    let (Some(root), Some(name)) = (parts.next(), parts.next()) else { return String::new() };
394    let text = std::fs::read_to_string(dir.join(root).join(name).join("SKILL.md")).unwrap_or_default();
395    let mut lines = text.lines();
396    if lines.next() != Some("---") {
397        return String::new();
398    }
399    lines.take_while(|l| *l != "---").find_map(|l| l.strip_prefix("description:")).map(|d| d.trim().to_string()).unwrap_or_default()
400}
401
402/// Provenance of one source file: its engineers (git log author emails, first contributor first) and
403/// the time of its last commit (epoch ms; 0 if unknown).
404#[derive(Debug, Clone, Default)]
405pub struct Provenance {
406    pub engineers: Vec<String>,
407    pub ms: i64,
408}
409
410fn provenance(dir: &Path, path: &str) -> Provenance {
411    let log = git(dir, &["log", "--format=%ae %ct", "--", path]).unwrap_or_default();
412    let mut engineers: Vec<String> = Vec::new();
413    let mut ms = 0;
414    for (i, l) in log.lines().enumerate() {
415        let (who, t) = l.rsplit_once(' ').unwrap_or((l, "0"));
416        if i == 0 {
417            ms = t.parse::<i64>().unwrap_or(0) * 1000;
418        }
419        engineers.push(who.to_string());
420    }
421    engineers.reverse();
422    let mut seen = HashSet::new();
423    engineers.retain(|e| !e.is_empty() && seen.insert(e.clone()));
424    Provenance { engineers, ms }
425}
426
427/// What one chunk became.
428#[derive(Debug, Clone, Default, Serialize, Deserialize)]
429pub struct Done {
430    pub key: String,
431    pub sha: String,
432    pub lessons: Vec<Lesson>,
433    pub decisions: Vec<Entry>,
434    pub cost_usd: Option<f64>,
435    /// Questions of lessons dropped because none of their scope names survived `guard`.
436    #[serde(default)]
437    pub dropped: Vec<String>,
438    /// Scope names `guard` rejected.
439    #[serde(default)]
440    pub rejected: Vec<String>,
441}
442
443/// The common English words (data/common-words.txt): a one-word scope name among them is rejected.
444fn common_word(w: &str) -> bool {
445    static WORDS: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
446    WORDS.get_or_init(|| include_str!("../data/common-words.txt").lines().filter(|l| !l.starts_with('#')).collect()).contains(w)
447}
448
449/// The identifiers and paths a text shows: code spans, and words shaped like code (`_`, `/`, `.` or
450/// `-` inside, letters with digits, or a camelCase hump), never plain prose words.
451fn identifiers(text: &str) -> Vec<&str> {
452    static SPAN: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
453    static WORD: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
454    let span = SPAN.get_or_init(|| Regex::new(r"`([^`\n]+)`").unwrap());
455    let word = WORD.get_or_init(|| Regex::new(r"[A-Za-z0-9_./-]+").unwrap());
456    let mut out: Vec<&str> = span.captures_iter(text).filter_map(|c| c.get(1)).map(|m| m.as_str()).collect();
457    for w in word.find_iter(text).map(|m| m.as_str().trim_matches(['.', '-', '/'])) {
458        let cs: Vec<char> = w.chars().collect();
459        let hump = cs.windows(2).any(|p| p[0].is_lowercase() && p[1].is_uppercase());
460        let mixed = cs.iter().any(char::is_ascii_digit) && cs.iter().any(|c| c.is_alphabetic());
461        if hump || mixed || w.contains(['_', '/', '.', '-']) {
462            out.push(w);
463        }
464    }
465    out
466}
467
468/// Keep only scope names a drone's context cannot hit by accident. A name is rejected when:
469/// - it is one common word the source also uses as a word: written in lowercase in its prose
470///   ("guidance", "conversation"; a host `heron.live…` is not prose), or in capitals (`SCRIPTED`, an enum constant). A common word the
471///   team only ever writes as a name ("Beacon", its search step) stays a name;
472/// - no identifier or path of the source shows it (a prose-only name: "Answer Guard", a dependency
473///   named in passing).
474///
475/// `known` holds the repo's tracked paths and the identifiers of the chunk and of the knowledge base's
476/// scope statement; `prose` is the chunk's text and that statement. A lesson whose every name is
477/// rejected is dropped: dropping a scope must never widen a team rule to unscoped.
478fn guard(d: &mut Done, known: &crate::conscience::Names, prose: &str) {
479    let used_as_word = |n: &str| {
480        let lower = n.to_lowercase();
481        n.chars().all(|c| !c.is_lowercase())
482            || Regex::new(&format!(r#"(?m)(?:^|[\s(\["'*]){}(?:[\s,;:!?)\]"'*]|\.(?:\s|$)|$)"#, regex::escape(&lower))).is_ok_and(|r| r.is_match(prose))
483    };
484    let mut kept = Vec::new();
485    for mut l in std::mem::take(&mut d.lessons) {
486        if l.scope.is_empty() {
487            kept.push(l);
488            continue;
489        }
490        let (ok, bad): (Vec<String>, Vec<String>) = l.scope.drain(..).partition(|n| {
491            let word = match crate::conscience::segments(n).as_slice() {
492                [] => return false,
493                [one] => common_word(one) && used_as_word(n),
494                _ => false,
495            };
496            !word && known.contains(n)
497        });
498        for b in bad {
499            if !d.rejected.contains(&b) {
500                d.rejected.push(b);
501            }
502        }
503        if ok.is_empty() {
504            d.dropped.push(l.question);
505        } else {
506            l.scope = ok;
507            kept.push(l);
508        }
509    }
510    d.lessons = kept;
511}
512
513/// The names `guard` checks scopes against: tracked paths, and the identifiers of the chunk and its frame.
514fn known_names(paths: &[String], c: &Chunk) -> crate::conscience::Names {
515    crate::conscience::Names::of_all(paths.iter().map(String::as_str).chain(identifiers(&c.text)).chain(identifiers(&c.about)))
516}
517
518/// Whether `term`, spelled as given, occurs in `text` only as a ticket key prefix (`LUMEN-213`).
519fn ticket_prefix(term: &str, text: &str) -> bool {
520    let t = regex::escape(term);
521    let as_key = Regex::new(&format!(r"\b{t}-\d")).is_ok_and(|r| r.is_match(text));
522    let alone = Regex::new(&format!(r"\b{t}\b(?:[^-]|-\D|$)")).is_ok_and(|r| r.is_match(text));
523    as_key && !alone
524}
525
526/// Turn the model's answer for `c` into lessons and queue entries. `None` for a malformed answer.
527pub fn build(repo: &str, cwd: &str, c: &Chunk, v: &Value, prov: &Provenance, now: i64) -> Option<(Vec<Lesson>, Vec<Entry>)> {
528    let line = |x: &Value| x["line"].as_u64().map(|n| n as usize).filter(|n| c.lines.contains(n)).unwrap_or(c.lines.first().copied().unwrap_or(1));
529    let s = |x: &Value, k: &str| x[k].as_str().unwrap_or_default().trim().to_string();
530    let strs = |x: &Value, k: &str| {
531        x[k].as_array().into_iter().flatten().filter_map(|y| y.as_str()).map(|y| y.trim().to_string()).filter(|y| !y.is_empty()).collect::<Vec<_>>()
532    };
533    let mut lessons = Vec::new();
534    // A decision chunk yields no lessons even if the model wrote some.
535    for x in v["lessons"].as_array()?.iter().filter(|_| c.part != Part::Decision) {
536        let question = s(x, "question");
537        if question.is_empty() {
538            continue;
539        }
540        let origin = format!("{}:{}", c.path, line(x));
541        lessons.push(Lesson {
542            id: format!("I{}", &fnv(&format!("{repo}\n{origin}\n{question}"))[..12]),
543            origin_ms: if prov.ms > 0 { prov.ms } else { now },
544            repo: repo.into(),
545            question,
546            situations: strs(x, "situations"),
547            consequence: s(x, "consequence"),
548            support: 1,
549            episodes: Vec::new(),
550            history: Vec::new(),
551            version: 1,
552            engineers: prov.engineers.clone(),
553            joined: Default::default(),
554            area: trust::area(&s(x, "area")).into(),
555            supersedes: Vec::new(),
556            source: "import".into(),
557            reach: String::new(),
558            // The repository the knowledge is written in is where it was kept, not where it applies; a
559            // ticket prefix (the source says `LUMEN-213`) names a tracker project, not a system.
560            scope: strs(x, "scope").into_iter().filter(|t| !t.eq_ignore_ascii_case(repo) && !ticket_prefix(t, &c.text)).collect(),
561            overrides: s(x, "overrides"),
562            origin,
563            facets: Vec::new(),
564        });
565    }
566    let mut decisions = Vec::new();
567    for x in v["open_decisions"].as_array()? {
568        let question = s(x, "question");
569        if question.is_empty() {
570            continue;
571        }
572        let key = queen::open_decision_key(repo, &question);
573        let default = Some(s(x, "default")).filter(|d| !d.is_empty()).unwrap_or_else(|| "none stated".into());
574        decisions.push(Entry {
575            id: format!("q{}", &key[..8]),
576            decision_key: key,
577            kind: Kind::OpenDecision,
578            question,
579            options: strs(x, "options"),
580            default,
581            evidence: vec![format!("{}:{}: {}", c.path, line(x), s(x, "evidence"))],
582            session: "import".into(),
583            cwd: cwd.into(),
584            repo: repo.into(),
585            engineer: prov.engineers.last().cloned().unwrap_or_default(),
586            ms: now,
587            answer: None,
588            answered_by: None,
589            answered_ms: None,
590            disagreeing: Vec::new(),
591            dismissed: None,
592            dismissed_ms: None,
593            payload: None,
594        });
595    }
596    let skip = crate::assimilate::skip_words();
597    let obsolete = |t: &str| crate::assimilate::mentions(t, &skip);
598    lessons.retain(|l| ![&l.question, &l.consequence, &l.overrides].iter().any(|t| obsolete(t)) && !l.situations.iter().any(|t| obsolete(t)));
599    decisions.retain(|d| !obsolete(&d.question) && !d.evidence.iter().any(|t| obsolete(t)));
600    Some((lessons, decisions))
601}
602
603/// One line of `<store>/assimilated.jsonl`: a chunk imported, what it gave and what it cost.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct Imported {
606    pub source: String,
607    pub key: String,
608    pub sha: String,
609    pub lessons: Vec<String>,
610    pub decisions: Vec<String>,
611    pub cost_usd: Option<f64>,
612    pub ms: i64,
613}
614
615pub struct Options {
616    pub dir: PathBuf,
617    pub spec: Spec,
618    pub apply: bool,
619    pub plan: Option<PathBuf>,
620    pub jobs: usize,
621    pub target: Target,
622}
623
624/// Which collective receives the lessons. A team's knowledge repo is rarely where its drones work, so
625/// routing by the source directory is only the default.
626#[derive(Debug, Clone, Default)]
627pub enum Target {
628    /// The collective the source directory routes to.
629    #[default]
630    Source,
631    /// The collective a drone working in this path routes to (`--for PATH`).
632    For(PathBuf),
633    /// A collective by name (`--collective NAME`).
634    Named(String),
635}
636
637#[derive(Debug, Default)]
638pub struct Report {
639    /// None: the directory routes off and nothing was read.
640    pub collective: Option<(String, PathBuf)>,
641    pub chunks: usize,
642    /// Imported earlier with the same content: skipped, no call.
643    pub known: usize,
644    /// Imported earlier, source changed since: reported, not re-imported.
645    pub changed: Vec<String>,
646    pub calls: usize,
647    /// Chunks the model gave no usable answer for (retried by the next run).
648    pub failed: Vec<String>,
649    pub cost_usd: f64,
650    pub done: Vec<Done>,
651    pub new_lessons: usize,
652    pub new_decisions: usize,
653    /// The queue entries this run added (one desktop notification for all of them, notify.rs).
654    pub queued: Vec<Entry>,
655    /// Which repos route to the receiving collective, in words.
656    pub routed: String,
657    /// `--apply --plan`: chunks the plan does not answer (absent, or their source changed since the dry
658    /// run). Skipped, never sent to the model.
659    pub unplanned: Vec<String>,
660    /// Why nothing was done (an unreadable plan, a target that routes off or does not exist).
661    pub error: Option<String>,
662}
663
664fn read_jsonl<T: for<'de> Deserialize<'de>>(path: &Path) -> Vec<T> {
665    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
666}
667
668fn append_jsonl<T: Serialize>(path: &Path, rows: &[T]) -> std::io::Result<()> {
669    if rows.is_empty() {
670        return Ok(());
671    }
672    if let Some(d) = path.parent() {
673        std::fs::create_dir_all(d)?;
674    }
675    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
676    for r in rows {
677        writeln!(f, "{}", serde_json::to_string(r).map_err(std::io::Error::other)?)?;
678    }
679    Ok(())
680}
681
682/// The repo name lessons carry: the git top-level's directory name.
683fn repo_name(dir: &Path) -> String {
684    let top = git(dir, &["rev-parse", "--show-toplevel"]).map(|t| PathBuf::from(t.trim())).unwrap_or_else(|| dir.to_path_buf());
685    top.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
686}
687
688/// Read, generalise and (with `apply`) store. `cfg`/`base` route the directory like a drone's cwd.
689pub fn run(opts: &Options, cfg: Option<&Config>, base: &Path, ask: Ask, now: i64) -> Report {
690    let mut report = Report::default();
691    let dir = opts.dir.canonicalize().unwrap_or_else(|_| opts.dir.clone());
692    let cwd = dir.display().to_string();
693    // An off directory is never read, whichever collective would receive it.
694    let source = collective::route(cfg, &cwd, base);
695    if source == Route::Off {
696        return report;
697    }
698    let target = match &opts.target {
699        Target::Source => source,
700        Target::For(p) => collective::route(cfg, &p.canonicalize().unwrap_or_else(|_| p.clone()).display().to_string(), base),
701        Target::Named(n) => match cfg {
702            None if n == "default" => Route::Collective { name: n.clone(), home: base.to_path_buf(), share: collective::Share::Team },
703            None => Route::Off,
704            Some(c) => c.collectives.iter().find(|e| &e.name == n).map_or(Route::Off, |e| Route::Collective {
705                name: e.name.clone(),
706                home: collective::store(base, &e.name),
707                share: e.share,
708            }),
709        },
710    };
711    let Route::Collective { name, home, .. } = target else {
712        report.error = Some(match &opts.target {
713            Target::Named(n) => format!("no collective named {n:?} in the collectives config: nothing read or written"),
714            _ => "the target routes off: no collective receives these lessons; nothing read or written".into(),
715        });
716        return report;
717    };
718    report.routed = match cfg {
719        None => "every repo (no collectives config)".into(),
720        Some(c) => {
721            let mut paths: Vec<String> = c.collectives.iter().filter(|e| e.name == name).flat_map(|e| e.paths.clone()).collect();
722            if c.default == name {
723                paths.push("every repo no rule matches".into());
724            }
725            if paths.is_empty() { "no repo".into() } else { paths.join(", ") }
726        }
727    };
728    report.collective = Some((name, home.clone()));
729    // `--apply --plan` applies exactly the reviewed plan: a plan that cannot be read stops the run.
730    let planned: Option<HashMap<(String, String), Done>> = match opts.plan.as_ref().filter(|_| opts.apply) {
731        None => None,
732        Some(p) => {
733            let read = std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|t| {
734                let v: Value = serde_json::from_str(&t).map_err(|e| e.to_string())?;
735                serde_json::from_value::<Vec<Done>>(v["chunks"].clone()).map_err(|e| e.to_string())
736            });
737            match read {
738                Ok(ds) => Some(ds.into_iter().map(|d| ((d.key.clone(), d.sha.clone()), d)).collect()),
739                Err(e) => {
740                    report.error = Some(format!("cannot read the plan {} ({e}): nothing written", p.display()));
741                    return report;
742                }
743            }
744        }
745    };
746    let repo = repo_name(&dir);
747    let all = chunks(&dir, &opts.spec);
748    report.chunks = all.len();
749    let paths = tracked(&dir);
750
751    let before: HashMap<String, String> =
752        read_jsonl::<Imported>(&home.join("assimilated.jsonl")).into_iter().filter(|i| i.source == repo).map(|i| (i.key, i.sha)).collect();
753    let mut todo: Vec<&Chunk> = Vec::new();
754    for c in &all {
755        match before.get(&c.key) {
756            Some(sha) if *sha == c.sha => report.known += 1,
757            Some(_) => report.changed.push(c.key.clone()),
758            None => todo.push(c),
759        }
760    }
761
762    let mut prov: HashMap<String, Provenance> = HashMap::new();
763    for c in &todo {
764        prov.entry(c.path.clone()).or_insert_with(|| provenance(&dir, &c.path));
765    }
766    // With a reviewed plan, its answers are taken as they are (scope guards re-applied) and nothing goes to
767    // the model: a chunk the plan does not answer is listed and skipped. Without one, everything new is asked.
768    let (from_plan, ask_for): (Vec<&Chunk>, Vec<&Chunk>) = match &planned {
769        Some(p) => {
770            let (yes, no): (Vec<&Chunk>, Vec<&Chunk>) = todo.iter().partition(|c| p.contains_key(&(c.key.clone(), c.sha.clone())));
771            report.unplanned = no.iter().map(|c| c.key.clone()).collect();
772            (yes, Vec::new())
773        }
774        None => (Vec::new(), todo.clone()),
775    };
776    let planned = planned.unwrap_or_default();
777    let mut done: Vec<Done> = from_plan
778        .iter()
779        .map(|c| {
780            let mut d = planned[&(c.key.clone(), c.sha.clone())].clone();
781            guard(&mut d, &known_names(&paths, c), &format!("{}\n{}", c.text, c.about));
782            d
783        })
784        .collect();
785    report.calls = ask_for.len();
786    let next = AtomicUsize::new(0);
787    let results: Mutex<Vec<(usize, Result<Done, ()>)>> = Mutex::new(Vec::new());
788    let (failures, successes) = (AtomicUsize::new(0), AtomicUsize::new(0));
789    std::thread::scope(|s| {
790        for _ in 0..opts.jobs.max(1).min(ask_for.len().max(1)) {
791            s.spawn(|| {
792                loop {
793                    let i = next.fetch_add(1, Ordering::SeqCst);
794                    let Some(c) = ask_for.get(i) else { break };
795                    // The model is unavailable (quota, login): stop asking instead of burning the queue.
796                    if failures.load(Ordering::SeqCst) >= 3 && successes.load(Ordering::SeqCst) == 0 {
797                        results.lock().unwrap().push((i, Err(())));
798                        continue;
799                    }
800                    let got = ask(&prompt(&repo, c), &schema()).and_then(|(v, cost)| build(&repo, &cwd, c, &v, &prov[&c.path], now).map(|(l, d)| (l, d, cost)));
801                    let r = match got {
802                        Some((lessons, decisions, cost_usd)) => {
803                            successes.fetch_add(1, Ordering::SeqCst);
804                            let mut d = Done { key: c.key.clone(), sha: c.sha.clone(), lessons, decisions, cost_usd, ..Default::default() };
805                            guard(&mut d, &known_names(&paths, c), &format!("{}\n{}", c.text, c.about));
806                            eprintln!("  {} -> {} lessons, {} open decisions, ${:.4}", c.key, d.lessons.len(), d.decisions.len(), cost_usd.unwrap_or(0.0));
807                            Ok(d)
808                        }
809                        None => {
810                            failures.fetch_add(1, Ordering::SeqCst);
811                            eprintln!("  {} -> no usable answer from the model", c.key);
812                            Err(())
813                        }
814                    };
815                    results.lock().unwrap().push((i, r));
816                }
817            });
818        }
819    });
820    let mut results = results.into_inner().unwrap();
821    results.sort_by_key(|(i, _)| *i);
822    for (i, r) in results {
823        match r {
824            Ok(d) => done.push(d),
825            Err(()) => report.failed.push(ask_for[i].key.clone()),
826        }
827    }
828    // `+ 0.0`: an empty f64 sum is -0.0, printed as "$-0.0000".
829    report.cost_usd = done.iter().filter(|d| !planned.contains_key(&(d.key.clone(), d.sha.clone()))).filter_map(|d| d.cost_usd).sum::<f64>() + 0.0;
830
831    if !opts.apply {
832        if let Some(p) = &opts.plan {
833            let plan = json!({"source": repo, "dir": cwd, "chunks": done});
834            if std::fs::write(p, serde_json::to_string_pretty(&plan).unwrap_or_default()).is_err() {
835                eprintln!("borg assimilate: cannot write the plan to {}", p.display());
836            }
837        }
838        report.done = done;
839        return report;
840    }
841    let known_lessons: HashSet<String> = learn::lessons(&home.join("lessons.jsonl")).into_iter().map(|l| l.id).collect();
842    for d in &done {
843        let fresh: Vec<&Lesson> = d.lessons.iter().filter(|l| !known_lessons.contains(&l.id)).collect();
844        if append_jsonl(&home.join("lessons.jsonl"), &fresh).is_err() {
845            eprintln!("borg assimilate: cannot write {}", home.join("lessons.jsonl").display());
846            continue;
847        }
848        report.new_lessons += fresh.len();
849        for l in &fresh {
850            crate::stats::count(&home, crate::stats::LESSON_NEW, "", |ev| {
851                ev.lessons = vec![l.id.clone()];
852                ev.repo = repo.clone();
853                ev.why = "import".into();
854            });
855        }
856        for e in &d.decisions {
857            if queen::seed(&home, e).unwrap_or(false) {
858                report.new_decisions += 1;
859                report.queued.push(e.clone());
860            }
861        }
862        // Recorded last: a chunk whose lessons could not be written is retried by the next run.
863        let rec = Imported {
864            source: repo.clone(),
865            key: d.key.clone(),
866            sha: d.sha.clone(),
867            lessons: d.lessons.iter().map(|l| l.id.clone()).collect(),
868            decisions: d.decisions.iter().map(|e| e.id.clone()).collect(),
869            cost_usd: d.cost_usd,
870            ms: now,
871        };
872        let _ = append_jsonl(&home.join("assimilated.jsonl"), &[rec]);
873        // Its model calls are counted as they happen (learn.rs `ask_model`); this counts the chunk.
874        crate::stats::count(&home, crate::stats::ASSIMILATED, "", |ev| {
875            ev.repo = repo.clone();
876            ev.n = fresh.len() as u64;
877        });
878    }
879    report.done = done;
880    report
881}
882
883pub fn render(r: &Report, apply: bool) -> String {
884    if let Some(e) = &r.error {
885        return format!("borg assimilate: {e}\n");
886    }
887    let Some((name, home)) = &r.collective else {
888        return "off: this directory belongs to no collective; nothing read or written\n".into();
889    };
890    let mut s = String::new();
891    let mut by_part: BTreeMap<String, usize> = BTreeMap::new();
892    for d in &r.done {
893        for l in &d.lessons {
894            *by_part
895                .entry(d.key.split('#').nth(1).map_or("knowledge", |p| if p.parse::<usize>().is_ok() { "knowledge" } else { p }).to_string())
896                .or_default() += 1;
897            s.push_str(&format!("[{}] {}\n", l.area, l.question));
898            if !l.situations.is_empty() {
899                s.push_str(&format!("    situations: {}\n", l.situations.join(" | ")));
900            }
901            s.push_str(&format!("    -> {}\n", l.consequence));
902            if !l.scope.is_empty() {
903                s.push_str(&format!("    scope: {}\n", l.scope.join(", ")));
904            }
905            if !l.overrides.is_empty() {
906                s.push_str(&format!("    overrides: {}\n", l.overrides));
907            }
908            s.push_str(&format!("    origin: {} ({})\n", l.origin, l.engineers.join(", ")));
909        }
910        for e in &d.decisions {
911            s.push_str(&format!(
912                "[open decision {}] {}\n    options: {}\n    default: {}\n    evidence: {}\n",
913                e.id,
914                e.question,
915                e.options.join(" / "),
916                e.default,
917                e.evidence.join("; ")
918            ));
919        }
920    }
921    let lessons: usize = r.done.iter().map(|d| d.lessons.len()).sum();
922    let decisions: usize = r.done.iter().map(|d| d.decisions.len()).sum();
923    s.push_str(&format!(
924        "\n{name} {}: {} chunks; {} imported before; {} model calls (${:.4}); {} lessons, {} open decisions{}\n",
925        home.display(),
926        r.chunks,
927        r.known,
928        r.calls,
929        r.cost_usd,
930        lessons,
931        decisions,
932        if apply {
933            format!("; written: {} new lessons, {} new queue entries", r.new_lessons, r.new_decisions)
934        } else {
935            " (dry run: nothing written; --apply to assimilate)".into()
936        }
937    ));
938    if !by_part.is_empty() {
939        s.push_str(&format!("by source: {}\n", by_part.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ")));
940    }
941    s.push_str(&format!("collective {name} receives these lessons; repos routed to it: {}\n", r.routed));
942    let rejected: BTreeMap<&str, ()> = r.done.iter().flat_map(|d| &d.rejected).map(|n| (n.as_str(), ())).collect();
943    if !rejected.is_empty() {
944        s.push_str(&format!(
945            "scope names rejected (a common word, or in no identifier or path of the source): {}\n",
946            rejected.into_keys().collect::<Vec<_>>().join(", ")
947        ));
948    }
949    let dropped: Vec<&String> = r.done.iter().flat_map(|d| &d.dropped).collect();
950    if !dropped.is_empty() {
951        s.push_str(&format!("dropped {} lessons whose every scope name was rejected:\n", dropped.len()));
952        for q in dropped {
953            s.push_str(&format!("    {q}\n"));
954        }
955    }
956    if !r.unplanned.is_empty() {
957        s.push_str(&format!("not in the plan or changed since it (skipped, nothing asked; re-run the dry run): {}\n", r.unplanned.join(", ")));
958    }
959    if !r.changed.is_empty() {
960        s.push_str(&format!("changed since their import (not re-imported): {}\n", r.changed.join(", ")));
961    }
962    if !r.failed.is_empty() {
963        s.push_str(&format!("no answer from the model for {} chunks (re-run to retry): {}\n", r.failed.len(), r.failed.join(", ")));
964    }
965    s
966}
967
968pub const USAGE: &str = "usage: borg assimilate --from-dir DIR [--apply] [--plan FILE] [--collective NAME | --for PATH] \
969[--knowledge GLOB]... [--retros GLOB]... [--families GLOB]... [--decisions GLOB]... [--jobs N]";
970
971/// `borg assimilate --from-dir DIR ...`; the exit code.
972pub fn main(args: &[&str], ask: Ask) -> i32 {
973    let mut spec = Spec::default();
974    let mut given: HashSet<&str> = HashSet::new();
975    let (mut dir, mut plan, mut jobs) = (None, None, std::env::var("BORG_ASSIMILATE_JOBS").ok().and_then(|j| j.parse().ok()).unwrap_or(4));
976    let mut target = Target::Source;
977    let mut it = args.iter();
978    while let Some(a) = it.next() {
979        let mut val = || it.next().map(|v| v.to_string());
980        match *a {
981            "--from-dir" => dir = val(),
982            "--plan" => plan = val().map(PathBuf::from),
983            "--collective" | "--for" if !matches!(target, Target::Source) => {
984                eprintln!("borg assimilate: give one of --collective and --for\n{USAGE}");
985                return 2;
986            }
987            "--collective" | "--for" => {
988                let Some(v) = val() else {
989                    eprintln!("{USAGE}");
990                    return 2;
991                };
992                target = if *a == "--for" { Target::For(PathBuf::from(v)) } else { Target::Named(v) };
993            }
994            "--jobs" => match val().and_then(|v| v.parse().ok()) {
995                Some(j) => jobs = j,
996                None => {
997                    eprintln!("borg assimilate: --jobs wants a number\n{USAGE}");
998                    return 2;
999                }
1000            },
1001            flag @ ("--knowledge" | "--retros" | "--families" | "--decisions") => {
1002                let Some(g) = val() else {
1003                    eprintln!("{USAGE}");
1004                    return 2;
1005                };
1006                let list = match flag {
1007                    "--knowledge" => &mut spec.knowledge,
1008                    "--retros" => &mut spec.retros,
1009                    "--families" => &mut spec.families,
1010                    _ => &mut spec.decisions,
1011                };
1012                if given.insert(flag) {
1013                    list.clear(); // a given glob replaces that part's defaults
1014                }
1015                list.push(g);
1016            }
1017            "--apply" => {}
1018            other => {
1019                eprintln!("borg assimilate: unknown argument {other}\n{USAGE}");
1020                return 2;
1021            }
1022        }
1023    }
1024    let Some(dir) = dir.map(PathBuf::from).filter(|d| d.is_dir()) else {
1025        eprintln!("borg assimilate: --from-dir wants a directory\n{USAGE}");
1026        return 2;
1027    };
1028    let cfg = match collective::load() {
1029        Ok(c) => c,
1030        Err(e) => {
1031            eprintln!("borg assimilate: invalid collectives config ({e}): every repo is off, nothing to assimilate");
1032            return 1;
1033        }
1034    };
1035    let opts = Options { dir, spec, apply: args.contains(&"--apply"), plan, jobs, target };
1036    let report = run(&opts, cfg.as_ref(), &learn::home(), ask, learn::now_ms());
1037    print!("{}", render(&report, opts.apply));
1038    crate::notify::queued(&report.queued);
1039    // The imported lessons get their search facets (facets.rs), one more call each.
1040    if let Some((_, home)) = report.collective.as_ref().filter(|_| opts.apply && report.new_lessons > 0 && crate::facets::enabled()) {
1041        let ids: HashSet<String> = report.done.iter().flat_map(|d| d.lessons.iter().map(|l| l.id.clone())).collect();
1042        let f = crate::facets::fill(&home.join("lessons.jsonl"), Some(&ids), true, ask, opts.jobs, learn::now_ms());
1043        println!(
1044            "facets: written for {} of {} lessons, ${:.4}{}",
1045            f.written,
1046            f.missing,
1047            f.cost_usd,
1048            if f.failed.is_empty() { String::new() } else { format!("; {} failed, `borg facets --apply` retries", f.failed.len()) }
1049        );
1050    }
1051    // ... and their vectors (vectors.rs), without a model call.
1052    if let Some((_, home)) = report.collective.as_ref().filter(|_| opts.apply && report.new_lessons > 0) {
1053        crate::vectors::refresh(&home.join("lessons.jsonl"));
1054    }
1055    if report.error.is_some() {
1056        return 2;
1057    }
1058    i32::from(!report.failed.is_empty() || !report.unplanned.is_empty())
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    fn temp(name: &str) -> PathBuf {
1066        let d = std::env::temp_dir().join(format!("borg-assimilate-dir-{name}-{}", std::process::id()));
1067        let _ = std::fs::remove_dir_all(&d);
1068        std::fs::create_dir_all(&d).unwrap();
1069        // Resolved: collective paths match as written, and macOS's temp dir sits behind /var -> /private/var.
1070        std::fs::canonicalize(&d).unwrap()
1071    }
1072
1073    fn sh(dir: &Path, args: &[&str]) {
1074        let ok = Command::new("git").arg("-C").arg(dir).args(args).env("GIT_CONFIG_GLOBAL", "/dev/null").output().unwrap().status.success();
1075        assert!(ok, "git {args:?}");
1076    }
1077
1078    const KNOWLEDGE: &str = "# Orion Petri nets\n\n**Scope of this whole file:** Orion's text and voice nets. Do not apply it to other flowlib projects.\n\n\
1079| Orion rule | Upstream rule it changes | Why |\n|---|---|---|\n| Per-turn state is drained, not reset. | SKILL seeds a budget with a reset on the starting transition. | Resets hide where a token dies. |\n\n\
1080**Open conflict.** `SplitInput` still resets `RETRY_BUDGET`. Until this is decided, treat these resets as legacy.\n";
1081
1082    const RETRO: &str = "# Plan Retrospective: X-1\n\n| Metric | Score |\n|---|---|\n| Scope | 67% |\n\n\
1083| Problem | Impact | Preventable? |\n|---|---|---|\n| Premise not checked against develop | HIGH | Yes - one curl would have shown it |\n| Vendor outage | LOW | No |\n";
1084
1085    const AUDIT: &str = "# Audit\n\n## 3. Real failures\n\n| Failure family | Evidence | Frequency | Case before this audit | New case |\n|---|---|---|---|---|\n\
1086| Premise not checked | PR-1 | 9 of 17 retros | ws9 | ws15 |\n";
1087
1088    const REVIEW: &str =
1089        "# Review\n\n### ws13: epic readiness\n- Opinion: a house rule.\n- **Your decision:** is EPIC_READINESS, as written, your rule?\n\n## Other\ntext\n";
1090
1091    /// A git repo shaped like the handbook, two engineers on the knowledge file.
1092    fn fixture(root: &Path) -> PathBuf {
1093        let dir = root.join("team_handbook");
1094        std::fs::create_dir_all(dir.join("skills/team-knowledge/references")).unwrap();
1095        std::fs::create_dir_all(dir.join("docs/plan-retrospectives")).unwrap();
1096        std::fs::create_dir_all(dir.join("docs/skill-evaluations")).unwrap();
1097        sh(&dir, &["init", "-q"]);
1098        let commit = |who: &str, msg: &str| {
1099            sh(&dir, &["add", "-A"]);
1100            sh(&dir, &["-c", &format!("user.email={who}"), "-c", "user.name=x", "commit", "-qm", msg]);
1101        };
1102        std::fs::write(dir.join("skills/team-knowledge/references/orion-petri.md"), "# Orion Petri nets\n").unwrap();
1103        std::fs::write(
1104            dir.join("skills/team-knowledge/SKILL.md"),
1105            "---\nname: team-knowledge\ndescription: Team facts for Orion. Not for other flowlib work.\n---\n# Team knowledge\n",
1106        )
1107        .unwrap();
1108        commit("ann@x", "start");
1109        std::fs::write(dir.join("skills/team-knowledge/references/orion-petri.md"), KNOWLEDGE).unwrap();
1110        std::fs::write(dir.join("docs/plan-retrospectives/X-1-plan-retrospective.md"), RETRO).unwrap();
1111        std::fs::write(dir.join("docs/skill-evaluations/eval-coverage-audit-1.md"), AUDIT).unwrap();
1112        std::fs::write(dir.join("docs/review.md"), REVIEW).unwrap();
1113        commit("bob@x", "knowledge");
1114        // Untracked notes are not team knowledge.
1115        std::fs::write(dir.join("skills/team-knowledge/references/scratch.md"), "# mine\nsecret plan\n").unwrap();
1116        dir
1117    }
1118
1119    /// The model: one lesson per chunk, scoped and overriding for the knowledge file, and the open
1120    /// conflict as a decision; a decision chunk's stray lesson must be dropped.
1121    fn model(calls: &AtomicUsize) -> impl Fn(&str, &Value) -> Option<(Value, Option<f64>)> + Sync + '_ {
1122        move |p: &str, _s: &Value| {
1123            calls.fetch_add(1, Ordering::SeqCst);
1124            assert!(!p.contains("ws15") && !p.contains("secret plan"), "eval case ids and untracked notes never reach the model");
1125            let src = p.split("## Source: ").nth(1)?.lines().next()?.to_string();
1126            // The team knowledge base's own scope frames every source, retros and families included.
1127            assert!(p.contains("states its own scope as: Team facts for Orion. Not for other flowlib work."), "{src}");
1128            let v = if src.contains("orion-petri") && !p.contains("open decision about the team") {
1129                json!({"lessons": [{"question": "Is each per-turn place drained by an explicit consumer, not cleared by a new reset arc?",
1130                    "situations": ["adding a per-turn fact to a net"], "consequence": "resets hide where a token dies", "area": "protocol-correctness",
1131                    "scope": ["Orion"], "overrides": "upstream petri-net-design seeds a budget with a reset on the starting transition", "line": 7}],
1132                    "open_decisions": [{"question": "Should SplitInput keep resetting RETRY_BUDGET?", "options": ["reset", "drain"],
1133                    "default": "treat as legacy", "evidence": "Open conflict", "line": 9}]})
1134            } else if p.contains("open decision about the team") {
1135                json!({"lessons": [{"question": "stray", "situations": [], "consequence": "", "area": "other", "scope": [], "overrides": "", "line": 1}],
1136                    "open_decisions": [{"question": "Should SplitInput keep resetting RETRY_BUDGET?", "options": [], "default": "", "evidence": "x", "line": 999}]})
1137            } else {
1138                json!({"lessons": [{"question": format!("Was the premise checked against real data? ({src})"), "situations": [],
1139                    "consequence": "a milestone is built on a wrong premise", "area": "Data Source", "scope": ["team_handbook"], "overrides": "", "line": 999}],
1140                    "open_decisions": []})
1141            };
1142            Some((v, Some(0.01)))
1143        }
1144    }
1145
1146    #[test]
1147    fn chunks_keep_prevention_rows_and_drop_case_columns() {
1148        let retro = chunks_of(Part::Retro, "r.md", RETRO);
1149        assert_eq!(retro.len(), 1);
1150        assert!(retro[0].text.contains("Premise not checked against develop") && !retro[0].text.contains("67%"), "{}", retro[0].text);
1151        assert!(retro[0].lines.contains(&9) && retro[0].text.contains("   9| | Premise"), "{}", retro[0].text);
1152        let fam = chunks_of(Part::Family, "a.md", AUDIT);
1153        assert!(fam[0].text.contains("| Premise not checked | PR-1 | 9 of 17 retros |") && !fam[0].text.contains("ws15"), "{}", fam[0].text);
1154        let dec = chunks_of(Part::Decision, "k.md", KNOWLEDGE);
1155        assert!(dec[0].text.contains("RETRY_BUDGET") && dec[0].text.contains("# Orion Petri nets"), "{}", dec[0].text);
1156        let dec = chunks_of(Part::Decision, "r.md", REVIEW);
1157        assert_eq!(dec[0].lines, [1, 3, 4, 5], "the title, then the block from its heading to the marker");
1158        assert!(chunks_of(Part::Decision, "k.md", RETRO).is_empty(), "no marker, no chunk");
1159        assert!(chunks_of(Part::Retro, "k.md", KNOWLEDGE).is_empty());
1160        assert!(ticket_prefix("LUMEN", "# Retro: LUMEN-213\n| a LUMEN-12 row |"));
1161        assert!(!ticket_prefix("Lumen", "# Retro: LUMEN-213\nthe Lumen environment"), "a proper noun stays");
1162        assert!(!ticket_prefix("SR3", "SR3 summaries; SR3-1 is a ticket"), "named on its own too");
1163        // Globs: `**/` spans directories, `*` stays within one.
1164        assert!(glob("docs/**/*coverage-audit*.md").is_match("docs/skill-evaluations/eval-coverage-audit-1.md"));
1165        assert!(glob("docs/**/*.md").is_match("docs/a.md"));
1166        assert!(!glob("skills/*-knowledge/SKILL.md").is_match("skills/a/b-knowledge/SKILL.md"));
1167    }
1168
1169    #[test]
1170    fn dry_run_prints_scoped_lessons_and_writes_nothing() {
1171        let root = temp("dry");
1172        let dir = fixture(&root);
1173        let base = root.join("store");
1174        let calls = AtomicUsize::new(0);
1175        let plan = root.join("plan.json");
1176        let opts = Options { dir: dir.clone(), spec: Spec::default(), apply: false, plan: Some(plan.clone()), jobs: 2, target: Target::Source };
1177        let r = run(&opts, None, &base, &model(&calls), 5);
1178        assert!(!base.exists(), "a dry run writes nothing to the store");
1179        // SKILL.md and a reference, retro, families, and the decision block of docs/review.md.
1180        assert_eq!((r.chunks, r.calls, calls.load(Ordering::SeqCst)), (5, 5, 5));
1181        let lessons: Vec<&Lesson> = r.done.iter().flat_map(|d| &d.lessons).collect();
1182        let drain = lessons.iter().find(|l| l.question.contains("drained")).unwrap();
1183        assert_eq!(drain.scope, ["Orion"]);
1184        assert!(drain.overrides.contains("reset on the starting transition"));
1185        assert_eq!(drain.origin, "skills/team-knowledge/references/orion-petri.md:7");
1186        assert_eq!(
1187            (drain.engineers.clone(), drain.source.as_str(), drain.area.as_str()),
1188            (vec!["ann@x".to_string(), "bob@x".to_string()], "import", "protocol-correctness")
1189        );
1190        assert!(drain.origin_ms > 0, "the file's last commit time");
1191        let retro = lessons.iter().find(|l| l.origin.starts_with("docs/plan-retrospectives")).unwrap();
1192        assert_eq!((retro.area.as_str(), retro.origin.split(':').nth(1)), ("data-source", Some("1")), "an unknown line falls back to the chunk's first");
1193        assert!(retro.scope.is_empty(), "the repo the knowledge is kept in is not a scope");
1194        assert!(!lessons.iter().any(|l| l.question == "stray"), "a decision chunk gives no lessons");
1195        let decisions: Vec<&Entry> = r.done.iter().flat_map(|d| &d.decisions).collect();
1196        assert_eq!(decisions.len(), 2, "found twice; the queue dedups by key");
1197        assert_eq!((decisions[0].kind, decisions[0].decision_key == decisions[1].decision_key), (Kind::OpenDecision, true));
1198        let text = render(&r, false);
1199        assert!(text.contains("scope: Orion") && text.contains("overrides: upstream") && text.contains("dry run") && text.contains("$0.0500"), "{text}");
1200        assert!(plan.exists());
1201        let _ = std::fs::remove_dir_all(root);
1202    }
1203
1204    #[test]
1205    fn apply_writes_the_routed_store_once_and_reuses_a_reviewed_plan() {
1206        let root = temp("apply");
1207        let dir = fixture(&root);
1208        let base = root.join("store");
1209        let calls = AtomicUsize::new(0);
1210        let plan = root.join("plan.json");
1211        run(
1212            &Options { dir: dir.clone(), spec: Spec::default(), apply: false, plan: Some(plan.clone()), jobs: 1, target: Target::Source },
1213            None,
1214            &base,
1215            &model(&calls),
1216            5,
1217        );
1218        // --apply --plan: the reviewed answers are applied without asking again.
1219        let opts = Options { dir: dir.clone(), spec: Spec::default(), apply: true, plan: Some(plan), jobs: 1, target: Target::Source };
1220        let r = run(&opts, None, &base, &|_, _| panic!("the plan answers every chunk"), 6);
1221        assert_eq!((r.new_lessons, r.new_decisions, r.calls), (4, 1, 0));
1222        let stored = learn::lessons(&base.join("lessons.jsonl"));
1223        assert_eq!(stored.len(), 4);
1224        let queue = queen::open(&base);
1225        assert_eq!((queue.len(), queue[0].kind, queue[0].session.as_str()), (1, Kind::OpenDecision, "import"));
1226        // A seeded decision is not a message: the engineer's queen budget is untouched.
1227        assert!(queen::within_budget(&base, &queue[0].engineer, "s1"));
1228        // The conscience asks the scoped lesson only where Orion is named.
1229        let cs = crate::conscience::load(&base.join("lessons.jsonl"), None);
1230        let asked = |ctx: &str| crate::conscience::select(&cs, ctx, 5).iter().any(|c| c.question.contains("drained"));
1231        assert!(asked("Orion text net: add a per-turn hint place, drained or reset arc?"));
1232        assert!(!asked("PressNet: per-turn reprint budget place, drained or reset arc?"));
1233        // Re-running costs nothing and writes nothing.
1234        let before = std::fs::read_to_string(base.join("lessons.jsonl")).unwrap();
1235        let again = run(&Options { plan: None, ..opts }, None, &base, &|_, _| panic!("nothing new"), 7);
1236        assert_eq!((again.known, again.calls, again.new_lessons), (5, 0, 0));
1237        assert_eq!(std::fs::read_to_string(base.join("lessons.jsonl")).unwrap(), before);
1238        // A changed source is reported, not silently re-imported.
1239        std::fs::write(dir.join("docs/plan-retrospectives/X-1-plan-retrospective.md"), RETRO.replace("HIGH", "MEDIUM")).unwrap();
1240        sh(&dir, &["-c", "user.email=cy@x", "-c", "user.name=x", "commit", "-qam", "edit"]);
1241        let changed = run(
1242            &Options { dir: dir.clone(), spec: Spec::default(), apply: true, plan: None, jobs: 1, target: Target::Source },
1243            None,
1244            &base,
1245            &|_, _| panic!("changed chunks are not re-asked"),
1246            8,
1247        );
1248        assert_eq!(changed.changed, ["docs/plan-retrospectives/X-1-plan-retrospective.md#prevention"]);
1249        let _ = std::fs::remove_dir_all(root);
1250    }
1251
1252    #[test]
1253    fn an_off_directory_is_never_read_and_failures_retry() {
1254        let root = temp("off");
1255        let dir = fixture(&root);
1256        let base = root.join("store");
1257        let off: Config = serde_json::from_str(&format!(r#"{{"default": "off", "off": ["{}"]}}"#, root.display())).unwrap();
1258        let r = run(
1259            &Options { dir: dir.clone(), spec: Spec::default(), apply: true, plan: None, jobs: 1, target: Target::Source },
1260            Some(&off),
1261            &base,
1262            &|_, _| panic!("off"),
1263            1,
1264        );
1265        assert!(r.collective.is_none() && !base.exists());
1266        assert!(render(&r, true).starts_with("off:"));
1267        // The model is down: nothing recorded, the run stops asking after three failures, a re-run retries.
1268        let calls = AtomicUsize::new(0);
1269        let down = |_: &str, _: &Value| {
1270            calls.fetch_add(1, Ordering::SeqCst);
1271            None
1272        };
1273        let r = run(&Options { dir: dir.clone(), spec: Spec::default(), apply: true, plan: None, jobs: 1, target: Target::Source }, None, &base, &down, 2);
1274        assert_eq!((r.failed.len(), calls.load(Ordering::SeqCst)), (5, 3));
1275        assert!(!base.join("assimilated.jsonl").exists());
1276        let ok = AtomicUsize::new(0);
1277        let r = run(&Options { dir, spec: Spec::default(), apply: true, plan: None, jobs: 3, target: Target::Source }, None, &base, &model(&ok), 3);
1278        assert_eq!((r.calls, r.failed.len(), r.new_lessons), (5, 0, 4));
1279        let _ = std::fs::remove_dir_all(root);
1280    }
1281
1282    #[test]
1283    fn scope_guard_rejects_common_words_and_prose_only_names_and_never_widens() {
1284        let c = Chunk {
1285            text: "  3| Orion's `OutputFilter` and harbor_orion; the Answer Guard gives guidance; Draco, Heron and Beacon are neighbours\n  4| `SCRIPTED` mode; heron.live.example/settings; an beacon of maps\n".into(),
1286            ..chunk(Part::Knowledge, "k".into(), "k.md", &[])
1287        };
1288        let paths = vec!["skills/team-knowledge/references/orion-petri.md".to_string(), "docs/beacon-runs/x.md".into(), "docs/guidance/y.md".into()];
1289        let lesson = |q: &str, scope: &[&str]| Lesson { question: q.into(), scope: scope.iter().map(|s| s.to_string()).collect(), ..Default::default() };
1290        let mut d = Done {
1291            lessons: vec![
1292                lesson("drain?", &["Orion", "harbor_orion", "Answer Guard", "Guidance", "Draco", "OutputFilter", "Heron"]),
1293                lesson("beacon?", &["Beacon", "SCRIPTED"]),
1294                lesson("any?", &[]),
1295            ],
1296            ..Default::default()
1297        };
1298        guard(&mut d, &known_names(&paths, &c), &c.text);
1299        let qs: Vec<(&str, Vec<String>)> = d.lessons.iter().map(|l| (l.question.as_str(), l.scope.clone())).collect();
1300        // "Heron": a common word the source only writes as a name, shown in a host: kept.
1301        assert_eq!(qs, [("drain?", vec!["Orion".to_string(), "harbor_orion".into(), "OutputFilter".into(), "Heron".into()]), ("any?", vec![])]);
1302        assert_eq!(d.dropped, ["beacon?"], "a lesson left without a scope is dropped, never unscoped");
1303        for n in ["Answer Guard", "Guidance", "Draco", "Beacon", "SCRIPTED"] {
1304            assert!(d.rejected.contains(&n.to_string()), "{n}: {:?}", d.rejected);
1305        }
1306    }
1307
1308    #[test]
1309    fn apply_with_a_plan_never_asks_the_model() {
1310        let root = temp("strict");
1311        let dir = fixture(&root);
1312        let base = root.join("store");
1313        let opts =
1314            |plan: &Path| Options { dir: dir.clone(), spec: Spec::default(), apply: true, plan: Some(plan.to_path_buf()), jobs: 1, target: Target::Source };
1315        // A typo in the plan path stops the run: nothing asked, nothing written.
1316        let r = run(&opts(&root.join("no-such-plan.json")), None, &base, &|_, _| panic!("never asked"), 1);
1317        assert!(r.error.as_deref().is_some_and(|e| e.contains("cannot read the plan")) && !base.exists(), "{:?}", r.error);
1318        assert!(render(&r, true).contains("cannot read the plan"));
1319        std::fs::write(root.join("bad.json"), "{not json").unwrap();
1320        assert!(run(&opts(&root.join("bad.json")), None, &base, &|_, _| panic!("never asked"), 1).error.is_some());
1321        // A plan that misses a chunk (or whose chunk changed since): that chunk is listed and skipped.
1322        let calls = AtomicUsize::new(0);
1323        let plan = root.join("plan.json");
1324        run(&Options { apply: false, ..opts(&plan) }, None, &base, &model(&calls), 2);
1325        let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&plan).unwrap()).unwrap();
1326        v["chunks"].as_array_mut().unwrap().retain(|d| !d["key"].as_str().unwrap().contains("retrospective"));
1327        std::fs::write(&plan, v.to_string()).unwrap();
1328        let r = run(&opts(&plan), None, &base, &|_, _| panic!("never asked"), 3);
1329        assert_eq!((r.calls, r.unplanned.clone()), (0, vec!["docs/plan-retrospectives/X-1-plan-retrospective.md#prevention".to_string()]));
1330        assert_eq!(r.new_lessons, 3);
1331        assert!(render(&r, true).contains("not in the plan or changed since it"));
1332        let _ = std::fs::remove_dir_all(root);
1333    }
1334
1335    #[test]
1336    fn lessons_go_to_the_collective_the_drones_work_in() {
1337        let root = temp("target");
1338        let dir = fixture(&root);
1339        let base = root.join("store");
1340        let code = root.join("code");
1341        std::fs::create_dir_all(code.join("harbor_orion")).unwrap();
1342        let cfg: Config = serde_json::from_str(&format!(
1343            r#"{{"default": "off", "collectives": [{{"name": "docs", "paths": ["{}"]}}, {{"name": "team", "paths": ["{}"]}}]}}"#,
1344            dir.display(),
1345            code.display()
1346        ))
1347        .unwrap();
1348        let calls = AtomicUsize::new(0);
1349        let opts = |target: Target| Options { dir: dir.clone(), spec: Spec::default(), apply: false, plan: None, jobs: 1, target };
1350        let by_source = run(&opts(Target::Source), Some(&cfg), &base, &model(&calls), 1);
1351        assert_eq!(by_source.collective.as_ref().unwrap().0, "docs");
1352        let r = run(&opts(Target::For(code.join("harbor_orion"))), Some(&cfg), &base, &model(&calls), 1);
1353        assert_eq!(r.collective.clone().unwrap(), ("team".to_string(), collective::store(&base, "team")));
1354        assert!(
1355            render(&r, false).contains(&format!("collective team receives these lessons; repos routed to it: {}", code.display())),
1356            "{}",
1357            render(&r, false)
1358        );
1359        assert_eq!(run(&opts(Target::Named("team".into())), Some(&cfg), &base, &model(&calls), 1).collective.unwrap().0, "team");
1360        let none = run(&opts(Target::Named("nope".into())), Some(&cfg), &base, &|_, _| panic!("no target, no call"), 1);
1361        assert!(none.error.as_deref().is_some_and(|e| e.contains("no collective named")));
1362        let off = run(&opts(Target::For(root.join("elsewhere"))), Some(&cfg), &base, &|_, _| panic!("no target, no call"), 1);
1363        assert!(off.error.as_deref().is_some_and(|e| e.contains("routes off")));
1364        assert!(!base.exists());
1365        let _ = std::fs::remove_dir_all(root);
1366    }
1367}