1use crate::git_ops::GitRepo;
14use chrono::NaiveDate;
15use serde::Serialize;
16use std::path::{Component, Path, PathBuf};
17
18pub const KNOWLEDGE_INJECT_MAX_BYTES: usize = 4096;
20
21const HEADER: &str = "## Knowledge from this repo\n\n";
22
23#[derive(Debug, Clone, Default)]
25pub struct KnowledgeQuery<'a> {
26 pub goal: &'a str,
27 pub ticket_body: Option<&'a str>,
28 pub touch_hints: &'a [String],
29 pub changed_files: &'a [String],
30}
31
32pub fn render_knowledge_for_planning(
35 repo_root: &Path,
36 query: &KnowledgeQuery<'_>,
37) -> Option<String> {
38 let vault = repo_root.join("docs").join("knowledge");
39 if !vault.is_dir() {
40 return None;
41 }
42
43 let mut out = String::with_capacity(KNOWLEDGE_INJECT_MAX_BYTES);
44 out.push_str(HEADER);
45
46 if let Some(map) = render_index_map(&vault) {
49 push_truncated(&mut out, &map);
50 }
51 if out.len() >= KNOWLEDGE_INJECT_MAX_BYTES {
52 return Some(truncate_to_bytes(&out, KNOWLEDGE_INJECT_MAX_BYTES));
53 }
54
55 let mut notes = collect_notes(repo_root, &vault);
56 if notes.is_empty() && out.trim() == HEADER.trim() {
57 return None;
58 }
59
60 let haystack = {
61 let mut s = query.goal.to_ascii_lowercase();
62 if let Some(body) = query.ticket_body {
63 s.push('\n');
64 s.push_str(&body.to_ascii_lowercase());
65 }
66 s
67 };
68 let path_hints: Vec<String> = query
69 .touch_hints
70 .iter()
71 .chain(query.changed_files.iter())
72 .map(|p| p.replace('\\', "/"))
73 .collect();
74
75 let mut tier2 = Vec::new();
78 let mut tier3 = Vec::new();
79 for note in notes.drain(..) {
80 if note_referenced(¬e, &haystack) {
81 tier2.push(note);
82 } else if note_overlaps_paths(¬e, &path_hints) {
83 tier3.push(note);
84 }
85 }
86 tier2.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
88 tier3.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
89
90 for note in tier2.into_iter().chain(tier3) {
91 if out.len() >= KNOWLEDGE_INJECT_MAX_BYTES {
92 break;
93 }
94 let block = format_note_excerpt(¬e);
95 if !push_budgeted(&mut out, &block) {
96 break;
97 }
98 }
99
100 let trimmed = out.trim_end();
101 if trimmed == HEADER.trim() || trimmed.is_empty() {
102 None
103 } else {
104 Some(truncate_to_bytes(trimmed, KNOWLEDGE_INJECT_MAX_BYTES))
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "kebab-case", tag = "verdict")]
111pub enum RefreshVerdict {
112 Ok,
113 AlreadyStale,
114 Unverified,
115 InvalidMetadata {
116 field: String,
117 value: Option<String>,
118 },
119 InvalidCitation {
120 citation: String,
121 },
122 PathMissing {
123 path: String,
124 },
125 PathDrifted {
126 path: String,
127 },
128 CommandSkipped {
129 command: String,
130 },
131 ProbeFailed {
132 target: String,
133 error: String,
134 },
135}
136
137impl RefreshVerdict {
138 pub fn is_check_needed(&self) -> bool {
141 matches!(
142 self,
143 Self::Unverified
144 | Self::InvalidMetadata { .. }
145 | Self::InvalidCitation { .. }
146 | Self::PathMissing { .. }
147 | Self::PathDrifted { .. }
148 | Self::ProbeFailed { .. }
149 )
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct RefreshFinding {
157 pub rel_path: String,
158 pub title: String,
159 pub freshness: String,
160 pub verdicts: Vec<RefreshVerdict>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct RefreshReport {
167 pub findings: Vec<RefreshFinding>,
168}
169
170impl RefreshReport {
171 pub fn check_needed(&self) -> bool {
172 self.findings
173 .iter()
174 .any(|f| f.verdicts.iter().any(RefreshVerdict::is_check_needed))
175 }
176}
177
178pub fn refresh_knowledge(repo_root: &Path) -> RefreshReport {
187 let vault = repo_root.join("docs").join("knowledge");
188 let git = GitRepo::open(repo_root).map_err(|err| err.to_string());
189 let mut findings = Vec::new();
190 if !vault.is_dir() {
191 return RefreshReport { findings };
192 }
193 for source in collect_notes_for_refresh(repo_root, &vault) {
194 match source {
195 RefreshSource::Note(note) => findings.push(refresh_one(¬e, repo_root, &git)),
196 RefreshSource::Finding(finding) => findings.push(finding),
197 }
198 }
199 findings.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
200 RefreshReport { findings }
201}
202
203fn refresh_one(
204 note: &KnowledgeNote,
205 repo_root: &Path,
206 git: &std::result::Result<GitRepo, String>,
207) -> RefreshFinding {
208 let mut verdicts = Vec::new();
209 if note.freshness.eq_ignore_ascii_case("stale") {
210 verdicts.push(RefreshVerdict::AlreadyStale);
211 return RefreshFinding {
212 rel_path: note.rel_path.clone(),
213 title: note.title.clone(),
214 freshness: note.freshness.clone(),
215 verdicts,
216 };
217 }
218 if note.verified_against.is_empty() {
219 verdicts.push(RefreshVerdict::Unverified);
220 return RefreshFinding {
221 rel_path: note.rel_path.clone(),
222 title: note.title.clone(),
223 freshness: note.freshness.clone(),
224 verdicts,
225 };
226 }
227 let Some(since) = validated_last_verified(note, &mut verdicts) else {
228 return RefreshFinding {
229 rel_path: note.rel_path.clone(),
230 title: note.title.clone(),
231 freshness: note.freshness.clone(),
232 verdicts,
233 };
234 };
235 for citation in ¬e.verified_against {
236 verdicts.push(refresh_citation(citation, &since, repo_root, git));
237 }
238 RefreshFinding {
239 rel_path: note.rel_path.clone(),
240 title: note.title.clone(),
241 freshness: note.freshness.clone(),
242 verdicts,
243 }
244}
245
246fn validated_last_verified(
247 note: &KnowledgeNote,
248 verdicts: &mut Vec<RefreshVerdict>,
249) -> Option<String> {
250 let Some(value) = note.last_verified.as_deref() else {
251 verdicts.push(RefreshVerdict::InvalidMetadata {
252 field: "last_verified".into(),
253 value: None,
254 });
255 return None;
256 };
257 let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") else {
258 verdicts.push(RefreshVerdict::InvalidMetadata {
259 field: "last_verified".into(),
260 value: Some(value.to_string()),
261 });
262 return None;
263 };
264 Some(date.format("%Y-%m-%d").to_string())
265}
266
267fn refresh_citation(
268 citation: &str,
269 since: &str,
270 repo_root: &Path,
271 git: &std::result::Result<GitRepo, String>,
272) -> RefreshVerdict {
273 let citation = citation.trim();
274 if let Some(command) = citation.strip_prefix("command:") {
275 let command = command.trim();
276 return if command.is_empty() {
277 RefreshVerdict::InvalidCitation {
278 citation: citation.to_string(),
279 }
280 } else {
281 RefreshVerdict::CommandSkipped {
282 command: command.to_string(),
283 }
284 };
285 }
286 let path = citation
287 .strip_prefix("path:")
288 .map(str::trim)
289 .unwrap_or(citation);
290 if !citation_is_repo_relative(path) {
291 return RefreshVerdict::InvalidCitation {
292 citation: citation.to_string(),
293 };
294 }
295
296 match citation_exists_inside_repo(repo_root, path) {
297 Ok(false) => {
298 return RefreshVerdict::PathMissing {
299 path: path.to_string(),
300 };
301 }
302 Ok(true) => {}
303 Err(verdict) => return verdict,
304 }
305
306 let git = match git {
307 Ok(git) => git,
308 Err(error) => {
309 return RefreshVerdict::ProbeFailed {
310 target: path.to_string(),
311 error: error.clone(),
312 };
313 }
314 };
315 match git.path_changed_since(path, since) {
316 Ok(true) => RefreshVerdict::PathDrifted {
317 path: path.to_string(),
318 },
319 Ok(false) => RefreshVerdict::Ok,
320 Err(error) => RefreshVerdict::ProbeFailed {
321 target: path.to_string(),
322 error: error.to_string(),
323 },
324 }
325}
326
327fn citation_is_repo_relative(citation: &str) -> bool {
328 !citation.is_empty()
329 && Path::new(citation)
330 .components()
331 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
332}
333
334fn citation_exists_inside_repo(
335 repo_root: &Path,
336 citation: &str,
337) -> std::result::Result<bool, RefreshVerdict> {
338 let path = repo_root.join(citation);
339 match std::fs::symlink_metadata(&path) {
340 Ok(_) => {}
341 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
342 Err(err) => {
343 return Err(RefreshVerdict::ProbeFailed {
344 target: citation.to_string(),
345 error: err.to_string(),
346 });
347 }
348 }
349
350 let canonical_root =
351 std::fs::canonicalize(repo_root).map_err(|err| RefreshVerdict::ProbeFailed {
352 target: citation.to_string(),
353 error: err.to_string(),
354 })?;
355 let canonical_path =
356 std::fs::canonicalize(&path).map_err(|err| RefreshVerdict::ProbeFailed {
357 target: citation.to_string(),
358 error: err.to_string(),
359 })?;
360 if !canonical_path.starts_with(canonical_root) {
361 return Err(RefreshVerdict::InvalidCitation {
362 citation: citation.to_string(),
363 });
364 }
365 Ok(true)
366}
367
368#[derive(Debug, Clone)]
369struct KnowledgeNote {
370 rel_path: String,
372 title: String,
373 freshness: String,
374 last_verified: Option<String>,
375 verified_against: Vec<String>,
376 body: String,
377}
378
379fn collect_notes(repo_root: &Path, vault: &Path) -> Vec<KnowledgeNote> {
380 let mut out = Vec::new();
381 let Ok(walker) = walkdir_md(vault) else {
382 return out;
383 };
384 for path in walker {
385 let name = path
386 .file_name()
387 .and_then(|n| n.to_str())
388 .unwrap_or_default();
389 if name.eq_ignore_ascii_case("index.md") || name.eq_ignore_ascii_case("CONVENTIONS.md") {
390 continue;
391 }
392 let Ok(text) = std::fs::read_to_string(&path) else {
393 continue;
394 };
395 let Some(note) = parse_note(&path, repo_root, &text) else {
396 continue;
397 };
398 if note.freshness.eq_ignore_ascii_case("stale") || note.verified_against.is_empty() {
400 continue;
401 }
402 out.push(note);
403 }
404 out
405}
406
407enum RefreshSource {
408 Note(KnowledgeNote),
409 Finding(RefreshFinding),
410}
411
412fn collect_notes_for_refresh(repo_root: &Path, vault: &Path) -> Vec<RefreshSource> {
416 let mut out = Vec::new();
417 let walker = match walkdir_md(vault) {
418 Ok(walker) => walker,
419 Err(err) => {
420 return vec![RefreshSource::Finding(refresh_problem(
421 "docs/knowledge".into(),
422 "Knowledge vault".into(),
423 RefreshVerdict::ProbeFailed {
424 target: "docs/knowledge".into(),
425 error: err.to_string(),
426 },
427 ))];
428 }
429 };
430 for path in walker {
431 let name = path
432 .file_name()
433 .and_then(|n| n.to_str())
434 .unwrap_or_default();
435 if name.eq_ignore_ascii_case("CONVENTIONS.md") {
436 continue;
437 }
438 let rel_path = path
439 .strip_prefix(repo_root)
440 .unwrap_or(&path)
441 .to_string_lossy()
442 .replace('\\', "/");
443 let title = path
444 .file_stem()
445 .and_then(|stem| stem.to_str())
446 .unwrap_or("knowledge note")
447 .to_string();
448 let text = match std::fs::read_to_string(&path) {
449 Ok(text) => text,
450 Err(err) => {
451 out.push(RefreshSource::Finding(refresh_problem(
452 rel_path.clone(),
453 title,
454 RefreshVerdict::ProbeFailed {
455 target: rel_path,
456 error: err.to_string(),
457 },
458 )));
459 continue;
460 }
461 };
462 let Some(note) = parse_note(&path, repo_root, &text) else {
463 out.push(RefreshSource::Finding(refresh_problem(
464 rel_path,
465 title,
466 RefreshVerdict::InvalidMetadata {
467 field: "frontmatter".into(),
468 value: None,
469 },
470 )));
471 continue;
472 };
473 out.push(RefreshSource::Note(note));
474 }
475 out
476}
477
478fn refresh_problem(rel_path: String, title: String, verdict: RefreshVerdict) -> RefreshFinding {
479 RefreshFinding {
480 rel_path,
481 title,
482 freshness: "unknown".into(),
483 verdicts: vec![verdict],
484 }
485}
486
487fn walkdir_md(vault: &Path) -> std::io::Result<Vec<PathBuf>> {
488 let mut files = Vec::new();
489 fn walk(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
490 for entry in std::fs::read_dir(dir)? {
491 let entry = entry?;
492 let path = entry.path();
493 let ft = entry.file_type()?;
494 if ft.is_dir() {
495 walk(&path, files)?;
496 } else if ft.is_file()
497 && path
498 .extension()
499 .and_then(|e| e.to_str())
500 .is_some_and(|e| e.eq_ignore_ascii_case("md"))
501 {
502 files.push(path);
503 }
504 }
505 Ok(())
506 }
507 walk(vault, &mut files)?;
508 files.sort();
509 Ok(files)
510}
511
512fn parse_note(path: &Path, repo_root: &Path, text: &str) -> Option<KnowledgeNote> {
513 let (fm, body) = parse_knowledge_frontmatter(text)?;
514 let title = fm.get("title").cloned().unwrap_or_else(|| {
515 path.file_stem()
516 .and_then(|s| s.to_str())
517 .unwrap_or("note")
518 .to_string()
519 });
520 let freshness = fm
521 .get("freshness")
522 .cloned()
523 .unwrap_or_else(|| "check-on-touch".into());
524 let last_verified = fm.get("last_verified").cloned().filter(|s| !s.is_empty());
525 let verified_against = fm
526 .get("verified_against")
527 .map(|s| {
528 s.split('\n')
529 .map(str::trim)
530 .filter(|s| !s.is_empty())
531 .map(str::to_string)
532 .collect::<Vec<_>>()
533 })
534 .unwrap_or_default();
535
536 let rel_path = path
537 .strip_prefix(repo_root)
538 .unwrap_or(path)
539 .to_string_lossy()
540 .replace('\\', "/");
541
542 Some(KnowledgeNote {
543 rel_path,
544 title,
545 freshness,
546 last_verified,
547 verified_against,
548 body,
549 })
550}
551
552fn parse_knowledge_frontmatter(
555 text: &str,
556) -> Option<(std::collections::BTreeMap<String, String>, String)> {
557 let source = text.trim_start_matches('\u{feff}');
558 let first_end = source.find('\n').map(|i| i + 1).unwrap_or(source.len());
559 if source[..first_end].trim_end() != "---" {
560 return None;
561 }
562 let mut map = std::collections::BTreeMap::new();
563 let mut offset = first_end;
564 let mut list_key: Option<String> = None;
565 let mut list_items: Vec<String> = Vec::new();
566
567 let flush_list = |map: &mut std::collections::BTreeMap<String, String>,
568 list_key: &mut Option<String>,
569 list_items: &mut Vec<String>| {
570 if let Some(k) = list_key.take() {
571 map.insert(k, list_items.join("\n"));
572 list_items.clear();
573 }
574 };
575
576 while offset < source.len() {
577 let rest = &source[offset..];
578 let line_len = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
579 let raw = &rest[..line_len];
580 if raw.trim_end() == "---" {
581 flush_list(&mut map, &mut list_key, &mut list_items);
582 let body = source[offset + line_len..].to_string();
583 return Some((map, body));
584 }
585 let line = raw.trim_end();
586 if let Some(item) = line.strip_prefix(" - ").or_else(|| {
587 if line.starts_with("- ") && list_key.is_some() {
588 Some(&line[2..])
589 } else {
590 None
591 }
592 }) {
593 if list_key.is_some() {
595 let item = item.trim().trim_matches('"').trim_matches('\'').to_string();
596 if !item.is_empty() {
597 list_items.push(item);
598 }
599 }
600 } else if let Some((key, value)) = line.split_once(':') {
601 flush_list(&mut map, &mut list_key, &mut list_items);
602 let key = key.trim().to_ascii_lowercase();
603 let value = value.trim().trim_matches('"').trim_matches('\'');
604 if value.is_empty() {
605 list_key = Some(key);
606 } else if let Some(inner) = value.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
607 let joined = inner
608 .split(',')
609 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
610 .filter(|s| !s.is_empty())
611 .collect::<Vec<_>>()
612 .join("\n");
613 map.insert(key, joined);
614 } else {
615 map.insert(key, value.to_string());
616 }
617 } else if !line.trim().is_empty() {
618 flush_list(&mut map, &mut list_key, &mut list_items);
620 }
621 offset += line_len;
622 }
623 None
624}
625
626fn render_index_map(vault: &Path) -> Option<String> {
627 let text = std::fs::read_to_string(vault.join("index.md")).ok()?;
628 let (_, body) = parse_knowledge_frontmatter(&text).unwrap_or((Default::default(), text));
629 let mut map = String::from("### Vault map\n");
630 let mut in_map = false;
631 for line in body.lines() {
632 let t = line.trim();
633 if t.eq_ignore_ascii_case("## Map") || t.eq_ignore_ascii_case("## map") {
634 in_map = true;
635 continue;
636 }
637 if in_map && t.starts_with("## ") {
638 break;
639 }
640 if in_map && (t.starts_with("### ") || t.starts_with("- ") || t.starts_with("* ")) {
641 map.push_str(line.trim_end());
642 map.push('\n');
643 }
644 }
645 if map.trim() == "### Vault map" {
646 map.clear();
648 map.push_str("### Vault map\n");
649 for line in body.lines().filter(|l| !l.trim().is_empty()).take(30) {
650 map.push_str(line.trim_end());
651 map.push('\n');
652 }
653 }
654 Some(map)
655}
656
657fn note_referenced(note: &KnowledgeNote, haystack_lower: &str) -> bool {
658 let path_l = note.rel_path.to_ascii_lowercase();
659 if !path_l.is_empty() && haystack_lower.contains(&path_l) {
661 return true;
662 }
663 if let Some(under) = path_l.strip_prefix("docs/knowledge/") {
664 if under.len() >= 6 && haystack_lower.contains(under) {
665 return true;
666 }
667 }
668 if let Some(base) = Path::new(¬e.rel_path)
670 .file_stem()
671 .and_then(|s| s.to_str())
672 {
673 let base_l = base.to_ascii_lowercase();
674 if base_l.len() >= 6 && contains_word(haystack_lower, &base_l) {
675 return true;
676 }
677 }
678 let title_l = note.title.to_ascii_lowercase();
679 if title_l.len() >= 8 && contains_word(haystack_lower, &title_l) {
680 return true;
681 }
682 false
683}
684
685fn contains_word(haystack: &str, needle: &str) -> bool {
686 if needle.is_empty() {
687 return false;
688 }
689 for (i, _) in haystack.match_indices(needle) {
690 let before_ok = i == 0
691 || !haystack
692 .as_bytes()
693 .get(i - 1)
694 .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-');
695 let after = i + needle.len();
696 let after_ok = after >= haystack.len()
697 || !haystack
698 .as_bytes()
699 .get(after)
700 .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-');
701 if before_ok && after_ok {
702 return true;
703 }
704 }
705 false
706}
707
708fn note_overlaps_paths(note: &KnowledgeNote, hints: &[String]) -> bool {
709 if hints.is_empty() {
710 return false;
711 }
712 for v in ¬e.verified_against {
713 if v.trim_start().starts_with("command:") {
714 continue;
715 }
716 let v_norm = v
717 .trim()
718 .strip_prefix("path:")
719 .map(str::trim)
720 .unwrap_or(v.trim())
721 .replace('\\', "/");
722 for h in hints {
723 if v_norm == *h || v_norm.ends_with(h) || h.ends_with(&v_norm) || h.contains(&v_norm) {
724 return true;
725 }
726 }
727 }
728 false
729}
730
731fn format_note_excerpt(note: &KnowledgeNote) -> String {
732 let mut body_lines = note
733 .body
734 .lines()
735 .map(str::trim)
736 .filter(|l| !l.is_empty())
737 .take(12);
738 let mut excerpt = String::new();
739 for line in body_lines.by_ref() {
740 if excerpt.len() > 600 {
741 break;
742 }
743 excerpt.push_str(line);
744 excerpt.push('\n');
745 }
746 let verified = note.verified_against.join(", ");
747 format!(
748 "### {} (`{}`)\nfreshness: {} · verified_against: {}\n{}\n",
749 note.title, note.rel_path, note.freshness, verified, excerpt
750 )
751}
752
753fn push_budgeted(out: &mut String, chunk: &str) -> bool {
756 let remaining = KNOWLEDGE_INJECT_MAX_BYTES.saturating_sub(out.len());
757 if remaining == 0 || chunk.len() > remaining {
758 return false;
759 }
760 out.push_str(chunk);
761 true
762}
763
764fn push_truncated(out: &mut String, chunk: &str) {
766 let remaining = KNOWLEDGE_INJECT_MAX_BYTES.saturating_sub(out.len());
767 if remaining == 0 {
768 return;
769 }
770 if chunk.len() <= remaining {
771 out.push_str(chunk);
772 } else {
773 out.push_str(&truncate_to_bytes(chunk, remaining));
774 }
775}
776
777fn truncate_to_bytes(s: &str, limit: usize) -> String {
778 if s.len() <= limit {
779 return s.to_string();
780 }
781 let mut end = limit;
782 while end > 0 && !s.is_char_boundary(end) {
783 end -= 1;
784 }
785 s[..end].to_string()
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use std::fs;
792
793 fn write_note(dir: &Path, rel: &str, freshness: &str, verified: &[&str], body: &str) {
794 let path = dir.join(rel);
795 if let Some(parent) = path.parent() {
796 fs::create_dir_all(parent).unwrap();
797 }
798 let mut va = String::from("verified_against:\n");
799 for v in verified {
800 va.push_str(&format!(" - {v}\n"));
801 }
802 let text = format!(
803 "---\ntitle: {rel}\nowner: agent\nfreshness: {freshness}\nlast_verified: 2099-01-01\n{va}---\n\n{body}\n"
804 );
805 fs::write(path, text).unwrap();
806 }
807
808 fn vault(root: &Path) {
809 let v = root.join("docs/knowledge");
810 fs::create_dir_all(v.join("architecture")).unwrap();
811 fs::write(
812 v.join("index.md"),
813 "---\ntitle: index\nfreshness: live\nlast_verified: 2099-01-01\nverified_against:\n - AGENTS.md\n---\n\n# Vault\n\n## Map\n\n### architecture/\n- [Pipe](architecture/pipe.md)\n",
814 )
815 .unwrap();
816 fs::write(v.join("CONVENTIONS.md"), "# conventions\n").unwrap();
817 }
818
819 #[test]
820 fn missing_vault_returns_none() {
821 let tmp = tempfile::tempdir().unwrap();
822 assert!(render_knowledge_for_planning(tmp.path(), &KnowledgeQuery::default()).is_none());
823 }
824
825 #[test]
826 fn stale_and_unverified_notes_excluded() {
827 let tmp = tempfile::tempdir().unwrap();
828 vault(tmp.path());
829 write_note(
830 &tmp.path().join("docs/knowledge"),
831 "architecture/stale.md",
832 "stale",
833 &["crates/engine/src/foo.rs"],
834 "Should not inject.",
835 );
836 write_note(
837 &tmp.path().join("docs/knowledge"),
838 "architecture/bare.md",
839 "live",
840 &[],
841 "No verified_against.",
842 );
843 write_note(
844 &tmp.path().join("docs/knowledge"),
845 "architecture/pipe.md",
846 "live",
847 &["crates/engine/src/orchestrator.rs"],
848 "Good note about the pipeline.",
849 );
850 let q = KnowledgeQuery {
851 goal: "read architecture/pipe.md please",
852 ..Default::default()
853 };
854 let block = render_knowledge_for_planning(tmp.path(), &q).expect("block");
855 assert!(block.contains("architecture/pipe.md"), "{block}");
856 assert!(!block.contains("Should not inject"), "{block}");
857 assert!(!block.contains("No verified_against"), "{block}");
858 assert!(
859 block.contains("Vault map") || block.contains("architecture/"),
860 "{block}"
861 );
862 }
863
864 #[test]
865 fn respects_byte_budget() {
866 let tmp = tempfile::tempdir().unwrap();
867 vault(tmp.path());
868 let big = "x".repeat(3000);
869 for i in 0..5 {
870 write_note(
871 &tmp.path().join("docs/knowledge"),
872 &format!("architecture/n{i}.md"),
873 "live",
874 &["crates/engine/src/orchestrator.rs"],
875 &format!("note {i} {big}"),
876 );
877 }
878 let q = KnowledgeQuery {
879 goal: "architecture/n0.md architecture/n1.md architecture/n2.md architecture/n3.md architecture/n4.md",
880 ..Default::default()
881 };
882 let block = render_knowledge_for_planning(tmp.path(), &q).expect("block");
883 assert!(
884 block.len() <= KNOWLEDGE_INJECT_MAX_BYTES,
885 "len {} > {}",
886 block.len(),
887 KNOWLEDGE_INJECT_MAX_BYTES
888 );
889 }
890
891 #[test]
892 fn path_overlap_selects_tier3_when_not_named() {
893 let tmp = tempfile::tempdir().unwrap();
894 vault(tmp.path());
895 write_note(
896 &tmp.path().join("docs/knowledge"),
897 "validation/gates.md",
898 "check-on-touch",
899 &["crates/engine/src/merge_gate.rs"],
900 "Gate recipes.",
901 );
902 let hints = vec!["crates/engine/src/merge_gate.rs".into()];
903 let q = KnowledgeQuery {
904 goal: "improve merge flow",
905 touch_hints: &hints,
906 ..Default::default()
907 };
908 let block = render_knowledge_for_planning(tmp.path(), &q).expect("block");
909 assert!(block.contains("validation/gates.md"), "{block}");
910 }
911
912 #[test]
913 fn knowledge_budget_constant_is_separate_from_lessons() {
914 assert_eq!(KNOWLEDGE_INJECT_MAX_BYTES, 4096);
915 assert_ne!(
916 KNOWLEDGE_INJECT_MAX_BYTES,
917 crate::lessons::LESSONS_INJECT_MAX_BYTES
918 );
919 }
920
921 fn git(root: &Path, args: &[&str]) -> std::process::Output {
922 std::process::Command::new("git")
923 .args(args)
924 .current_dir(root)
925 .output()
926 .expect("git")
927 }
928
929 fn git_init(root: &Path) {
930 if !git(root, &["init", "-b", "main"]).status.success() {
931 assert!(git(root, &["init"]).status.success());
932 }
933 assert!(git(root, &["config", "user.name", "kranz-test"])
934 .status
935 .success());
936 assert!(git(root, &["config", "user.email", "test@kranz.local"])
937 .status
938 .success());
939 }
940
941 fn git_commit_all(root: &Path, msg: &str) {
942 assert!(git(root, &["add", "-A"]).status.success());
943 assert!(
944 git(root, &["-c", "commit.gpgsign=false", "commit", "-m", msg])
945 .status
946 .success()
947 );
948 }
949
950 fn git_commit_all_at(root: &Path, msg: &str, timestamp: &str) {
951 assert!(git(root, &["add", "-A"]).status.success());
952 assert!(std::process::Command::new("git")
953 .args(["-c", "commit.gpgsign=false", "commit", "-m", msg])
954 .current_dir(root)
955 .env("GIT_AUTHOR_DATE", timestamp)
956 .env("GIT_COMMITTER_DATE", timestamp)
957 .output()
958 .expect("git commit")
959 .status
960 .success());
961 }
962
963 fn replace_note_date(root: &Path, rel: &str, replacement: Option<&str>) {
964 let path = root.join("docs/knowledge").join(rel);
965 let text = fs::read_to_string(&path).unwrap();
966 let text = match replacement {
967 Some(date) => text.replace(
968 "last_verified: 2099-01-01",
969 &format!("last_verified: {date}"),
970 ),
971 None => text
972 .lines()
973 .filter(|line| !line.starts_with("last_verified:"))
974 .collect::<Vec<_>>()
975 .join("\n"),
976 };
977 fs::write(path, format!("{text}\n")).unwrap();
978 }
979
980 #[test]
981 fn knowledge_refresh_reports_missing_path() {
982 let tmp = tempfile::tempdir().unwrap();
983 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
984 vault(tmp.path());
985 write_note(
986 &tmp.path().join("docs/knowledge"),
987 "architecture/ghost.md",
988 "check-on-touch",
989 &["does-not-exist.rs"],
990 "A missing citation.",
991 );
992 let report = refresh_knowledge(tmp.path());
993 assert!(
994 report.check_needed(),
995 "missing path must fail the refresh: {report:?}"
996 );
997 assert!(
998 report.findings.iter().any(|f| {
999 f.rel_path.contains("ghost.md")
1000 && f.verdicts.iter().any(|v| {
1001 matches!(
1002 v,
1003 RefreshVerdict::PathMissing { path } if path == "does-not-exist.rs"
1004 )
1005 })
1006 }),
1007 "{report:?}"
1008 );
1009 }
1010
1011 #[test]
1012 fn knowledge_refresh_skips_commands_and_does_not_fail() {
1013 let tmp = tempfile::tempdir().unwrap();
1014 git_init(tmp.path());
1015 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1016 vault(tmp.path());
1017 write_note(
1018 &tmp.path().join("docs/knowledge"),
1019 "architecture/cmd.md",
1020 "check-on-touch",
1021 &["AGENTS.md", "command: cargo test -p kranz-engine lessons"],
1022 "Path plus a command.",
1023 );
1024 git_commit_all(tmp.path(), "seed");
1025 let report = refresh_knowledge(tmp.path());
1026 let finding = report
1027 .findings
1028 .iter()
1029 .find(|f| f.rel_path.contains("cmd.md"))
1030 .expect("cmd note");
1031 assert!(
1032 finding.verdicts.iter().any(|v| {
1033 matches!(
1034 v,
1035 RefreshVerdict::CommandSkipped { command }
1036 if command.contains("cargo test")
1037 )
1038 }),
1039 "{finding:?}"
1040 );
1041 assert!(
1042 !finding.verdicts.iter().any(RefreshVerdict::is_check_needed),
1043 "command-skipped must not fail: {finding:?}"
1044 );
1045 }
1046
1047 #[test]
1048 fn knowledge_refresh_already_stale_does_not_fail() {
1049 let tmp = tempfile::tempdir().unwrap();
1050 git_init(tmp.path());
1051 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1052 vault(tmp.path());
1053 write_note(
1054 &tmp.path().join("docs/knowledge"),
1055 "architecture/old.md",
1056 "stale",
1057 &[],
1058 "Old news.",
1059 );
1060 git_commit_all(tmp.path(), "seed");
1061 let report = refresh_knowledge(tmp.path());
1062 let finding = report
1063 .findings
1064 .iter()
1065 .find(|f| f.rel_path.contains("old.md"))
1066 .expect("stale note");
1067 assert!(
1068 finding
1069 .verdicts
1070 .iter()
1071 .any(|v| matches!(v, RefreshVerdict::AlreadyStale)),
1072 "{finding:?}"
1073 );
1074 assert!(
1075 !report.check_needed(),
1076 "already-stale alone must not fail: {report:?}"
1077 );
1078 }
1079
1080 #[test]
1081 fn knowledge_refresh_reports_drifted_path() {
1082 let tmp = tempfile::tempdir().unwrap();
1083 git_init(tmp.path());
1084 fs::write(tmp.path().join("AGENTS.md"), "v1\n").unwrap();
1085 vault(tmp.path());
1086 write_note(
1087 &tmp.path().join("docs/knowledge"),
1088 "architecture/drift.md",
1089 "check-on-touch",
1090 &["AGENTS.md"],
1091 "Will drift.",
1092 );
1093 let note_path = tmp.path().join("docs/knowledge/architecture/drift.md");
1095 let text = fs::read_to_string(¬e_path).unwrap();
1096 fs::write(
1097 ¬e_path,
1098 text.replace("last_verified: 2099-01-01", "last_verified: 2020-01-01"),
1099 )
1100 .unwrap();
1101 git_commit_all(tmp.path(), "seed");
1102 fs::write(tmp.path().join("AGENTS.md"), "v2\n").unwrap();
1103 git_commit_all(tmp.path(), "touch agents");
1104 let report = refresh_knowledge(tmp.path());
1105 assert!(
1106 report.check_needed(),
1107 "edited verified path must drift: {report:?}"
1108 );
1109 assert!(
1110 report.findings.iter().any(|f| {
1111 f.rel_path.contains("drift.md")
1112 && f.verdicts.iter().any(|v| {
1113 matches!(v, RefreshVerdict::PathDrifted { path } if path == "AGENTS.md")
1114 })
1115 }),
1116 "{report:?}"
1117 );
1118 }
1119
1120 #[test]
1121 fn knowledge_refresh_reports_unverified_note() {
1122 let tmp = tempfile::tempdir().unwrap();
1123 vault(tmp.path());
1124 write_note(
1125 &tmp.path().join("docs/knowledge"),
1126 "architecture/unverified.md",
1127 "check-on-touch",
1128 &[],
1129 "No citations.",
1130 );
1131
1132 let report = refresh_knowledge(tmp.path());
1133 let finding = report
1134 .findings
1135 .iter()
1136 .find(|finding| finding.rel_path.ends_with("unverified.md"))
1137 .expect("unverified finding");
1138 assert_eq!(finding.verdicts, [RefreshVerdict::Unverified]);
1139 assert!(report.check_needed());
1140 }
1141
1142 #[test]
1145 fn knowledge_refresh_report_serializes_verdicts_for_json_output() {
1146 let tmp = tempfile::tempdir().unwrap();
1147 vault(tmp.path());
1148 write_note(
1149 &tmp.path().join("docs/knowledge"),
1150 "architecture/unverified.md",
1151 "check-on-touch",
1152 &[],
1153 "No citations.",
1154 );
1155
1156 let json = serde_json::to_value(refresh_knowledge(tmp.path())).unwrap();
1157 let finding = json["findings"]
1158 .as_array()
1159 .expect("findings array")
1160 .iter()
1161 .find(|finding| {
1162 finding["relPath"]
1163 .as_str()
1164 .is_some_and(|rel| rel.ends_with("unverified.md"))
1165 })
1166 .expect("unverified finding");
1167 assert_eq!(finding["freshness"], "check-on-touch");
1168 assert_eq!(
1169 finding["verdicts"],
1170 serde_json::json!([{"verdict": "unverified"}]),
1171 "{json:#}"
1172 );
1173 }
1174
1175 #[test]
1176 fn knowledge_refresh_reports_missing_and_invalid_last_verified() {
1177 let tmp = tempfile::tempdir().unwrap();
1178 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1179 vault(tmp.path());
1180 for (name, replacement, verified) in [
1181 (
1182 "missing-date.md",
1183 None,
1184 "cargo test -p kranz-engine knowledge_refresh",
1185 ),
1186 ("bad-date.md", Some("2026-99-99"), "AGENTS.md"),
1187 ] {
1188 write_note(
1189 &tmp.path().join("docs/knowledge"),
1190 &format!("architecture/{name}"),
1191 "check-on-touch",
1192 &[verified],
1193 "Date metadata matters.",
1194 );
1195 replace_note_date(tmp.path(), &format!("architecture/{name}"), replacement);
1196 }
1197
1198 let report = refresh_knowledge(tmp.path());
1199 for name in ["missing-date.md", "bad-date.md"] {
1200 let finding = report
1201 .findings
1202 .iter()
1203 .find(|finding| finding.rel_path.ends_with(name))
1204 .expect("date finding");
1205 assert!(finding.verdicts.iter().any(|verdict| matches!(
1206 verdict,
1207 RefreshVerdict::InvalidMetadata { field, .. } if field == "last_verified"
1208 )));
1209 }
1210 assert!(report.check_needed());
1211 }
1212
1213 #[test]
1214 fn knowledge_refresh_reports_git_open_failure() {
1215 let tmp = tempfile::tempdir().unwrap();
1216 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1217 vault(tmp.path());
1218 write_note(
1219 &tmp.path().join("docs/knowledge"),
1220 "architecture/no-git.md",
1221 "check-on-touch",
1222 &["AGENTS.md"],
1223 "Requires Git history.",
1224 );
1225
1226 let report = refresh_knowledge(tmp.path());
1227 let finding = report
1228 .findings
1229 .iter()
1230 .find(|finding| finding.rel_path.ends_with("no-git.md"))
1231 .expect("git-open finding");
1232 assert!(finding.verdicts.iter().any(|verdict| matches!(
1233 verdict,
1234 RefreshVerdict::ProbeFailed { target, .. } if target == "AGENTS.md"
1235 )));
1236 assert!(report.check_needed());
1237 }
1238
1239 #[test]
1240 fn knowledge_refresh_reports_git_log_failure() {
1241 let tmp = tempfile::tempdir().unwrap();
1242 git_init(tmp.path());
1243 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1244 vault(tmp.path());
1245 write_note(
1246 &tmp.path().join("docs/knowledge"),
1247 "architecture/unborn.md",
1248 "check-on-touch",
1249 &["AGENTS.md"],
1250 "An unborn repository has no log to inspect.",
1251 );
1252
1253 let report = refresh_knowledge(tmp.path());
1254 let finding = report
1255 .findings
1256 .iter()
1257 .find(|finding| finding.rel_path.ends_with("unborn.md"))
1258 .expect("git-log finding");
1259 assert!(finding.verdicts.iter().any(|verdict| matches!(
1260 verdict,
1261 RefreshVerdict::ProbeFailed { target, .. } if target == "AGENTS.md"
1262 )));
1263 assert!(report.check_needed());
1264 }
1265
1266 #[test]
1267 fn knowledge_refresh_reports_malformed_note() {
1268 let tmp = tempfile::tempdir().unwrap();
1269 vault(tmp.path());
1270 fs::write(
1271 tmp.path().join("docs/knowledge/architecture/broken.md"),
1272 "# Missing frontmatter\n",
1273 )
1274 .unwrap();
1275
1276 let report = refresh_knowledge(tmp.path());
1277 let finding = report
1278 .findings
1279 .iter()
1280 .find(|finding| finding.rel_path.ends_with("broken.md"))
1281 .expect("malformed finding");
1282 assert!(finding.verdicts.iter().any(|verdict| matches!(
1283 verdict,
1284 RefreshVerdict::InvalidMetadata { field, .. } if field == "frontmatter"
1285 )));
1286 assert!(report.check_needed());
1287 }
1288
1289 #[test]
1290 fn knowledge_refresh_all_ok_is_clean() {
1291 let tmp = tempfile::tempdir().unwrap();
1292 git_init(tmp.path());
1293 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1294 vault(tmp.path());
1295 write_note(
1296 &tmp.path().join("docs/knowledge"),
1297 "architecture/clean.md",
1298 "check-on-touch",
1299 &["AGENTS.md"],
1300 "No changes after verification.",
1301 );
1302 git_commit_all(tmp.path(), "seed");
1303
1304 let report = refresh_knowledge(tmp.path());
1305 assert!(!report.check_needed(), "{report:?}");
1306 assert!(report
1307 .findings
1308 .iter()
1309 .flat_map(|finding| &finding.verdicts)
1310 .all(|verdict| matches!(verdict, RefreshVerdict::Ok)));
1311 }
1312
1313 #[test]
1314 fn knowledge_refresh_checks_existing_path_with_spaces() {
1315 let tmp = tempfile::tempdir().unwrap();
1316 git_init(tmp.path());
1317 fs::write(tmp.path().join("path with spaces.md"), "evidence\n").unwrap();
1318 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1319 vault(tmp.path());
1320 write_note(
1321 &tmp.path().join("docs/knowledge"),
1322 "architecture/spaces.md",
1323 "check-on-touch",
1324 &["path with spaces.md"],
1325 "Spaces do not imply a command.",
1326 );
1327 git_commit_all(tmp.path(), "seed");
1328
1329 let report = refresh_knowledge(tmp.path());
1330 let finding = report
1331 .findings
1332 .iter()
1333 .find(|finding| finding.rel_path.ends_with("spaces.md"))
1334 .expect("spaces finding");
1335 assert_eq!(finding.verdicts, [RefreshVerdict::Ok]);
1336 }
1337
1338 #[test]
1339 fn knowledge_refresh_missing_path_with_spaces_fails_closed() {
1340 let tmp = tempfile::tempdir().unwrap();
1341 git_init(tmp.path());
1342 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1343 vault(tmp.path());
1344 write_note(
1345 &tmp.path().join("docs/knowledge"),
1346 "architecture/missing-spaces.md",
1347 "check-on-touch",
1348 &["deleted path with spaces.md"],
1349 "A deleted path stays a path.",
1350 );
1351 git_commit_all(tmp.path(), "seed");
1352
1353 let report = refresh_knowledge(tmp.path());
1354 let finding = report
1355 .findings
1356 .iter()
1357 .find(|finding| finding.rel_path.ends_with("missing-spaces.md"))
1358 .expect("spaces finding");
1359 assert!(report.check_needed(), "{report:?}");
1360 assert_eq!(
1361 finding.verdicts,
1362 [RefreshVerdict::PathMissing {
1363 path: "deleted path with spaces.md".to_string()
1364 }]
1365 );
1366 }
1367
1368 #[test]
1369 fn knowledge_refresh_rejects_outside_repo_citation() {
1370 let tmp = tempfile::tempdir().unwrap();
1371 let root = tmp.path().join("repo");
1372 fs::create_dir(&root).unwrap();
1373 fs::write(tmp.path().join("outside file.md"), "private\n").unwrap();
1374 vault(&root);
1375 write_note(
1376 &root.join("docs/knowledge"),
1377 "architecture/outside.md",
1378 "check-on-touch",
1379 &["../outside file.md"],
1380 "Must stay inside the repository.",
1381 );
1382
1383 let report = refresh_knowledge(&root);
1384 let finding = report
1385 .findings
1386 .iter()
1387 .find(|finding| finding.rel_path.ends_with("outside.md"))
1388 .expect("outside finding");
1389 assert!(finding.verdicts.iter().any(|verdict| matches!(
1390 verdict,
1391 RefreshVerdict::InvalidCitation { citation } if citation == "../outside file.md"
1392 )));
1393 assert!(report.check_needed());
1394 }
1395
1396 #[test]
1397 fn knowledge_refresh_already_stale_skips_broken_citations() {
1398 let tmp = tempfile::tempdir().unwrap();
1399 vault(tmp.path());
1400 write_note(
1401 &tmp.path().join("docs/knowledge"),
1402 "architecture/stale-broken.md",
1403 "stale",
1404 &["missing.rs"],
1405 "Already excluded from injection.",
1406 );
1407
1408 let report = refresh_knowledge(tmp.path());
1409 let finding = report
1410 .findings
1411 .iter()
1412 .find(|finding| finding.rel_path.ends_with("stale-broken.md"))
1413 .expect("stale finding");
1414 assert_eq!(finding.verdicts, [RefreshVerdict::AlreadyStale]);
1415 }
1416
1417 #[test]
1418 fn knowledge_refresh_same_day_commit_is_not_drift() {
1419 let tmp = tempfile::tempdir().unwrap();
1420 git_init(tmp.path());
1421 fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1422 vault(tmp.path());
1423 write_note(
1424 &tmp.path().join("docs/knowledge"),
1425 "architecture/same-day.md",
1426 "check-on-touch",
1427 &["AGENTS.md"],
1428 "Verified after the same-day change.",
1429 );
1430 replace_note_date(tmp.path(), "architecture/same-day.md", Some("2026-07-08"));
1431 git_commit_all_at(tmp.path(), "seed", "2026-07-08T12:00:00Z");
1432
1433 let report = refresh_knowledge(tmp.path());
1434 let finding = report
1435 .findings
1436 .iter()
1437 .find(|finding| finding.rel_path.ends_with("same-day.md"))
1438 .expect("same-day finding");
1439 assert_eq!(finding.verdicts, [RefreshVerdict::Ok]);
1440 }
1441}