1use 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
64const CHUNK_LINES: usize = 150;
67
68pub 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#[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#[derive(Debug, Clone, PartialEq)]
126pub struct Chunk {
127 pub part: Part,
128 pub key: String,
130 pub path: String,
131 pub lines: Vec<usize>,
133 pub text: String,
135 pub about: String,
137 pub sha: String,
139}
140
141fn 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
146pub 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(®ex::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
175fn 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
182type 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
190struct Table<'a> {
192 head: (usize, &'a str),
193 cells: Vec<String>,
194 rows: Vec<(usize, &'a str)>,
195}
196
197fn 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
220fn 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
231pub 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 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, ¬_cases)));
267 rows.extend(t.rows.iter().map(|(n, l)| (*n, columns(l, &t.cells, ¬_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 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
298pub 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 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
391fn 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#[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#[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 #[serde(default)]
438 pub dropped: Vec<String>,
439 #[serde(default)]
441 pub rejected: Vec<String>,
442}
443
444fn 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
450fn 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
469fn 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
514fn 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
519fn 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
527pub 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 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 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#[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#[derive(Debug, Clone, Default)]
628pub enum Target {
629 #[default]
631 Source,
632 For(PathBuf),
634 Named(String),
636}
637
638#[derive(Debug, Default)]
639pub struct Report {
640 pub collective: Option<(String, PathBuf)>,
642 pub chunks: usize,
643 pub known: usize,
645 pub changed: Vec<String>,
647 pub calls: usize,
648 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 pub queued: Vec<Entry>,
656 pub routed: String,
658 pub unplanned: Vec<String>,
661 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
683fn 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
689pub 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 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 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 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 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 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 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 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
972pub 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(); }
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 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 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 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 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 std::fs::write(dir.join("skills/team-knowledge/references/scratch.md"), "# mine\nsecret plan\n").unwrap();
1117 dir
1118 }
1119
1120 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 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 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 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 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 assert!(queen::within_budget(&base, &queue[0].engineer, "s1"));
1229 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 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 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 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 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 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 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}