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