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