1use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42
43use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
44use crate::capability::handler::{CapabilityError, Context};
45use crate::capability::model::{CliExposure, Exposure, McpExposure, McpResource, Stability};
46use crate::capability::module::ModuleDescriptor;
47use crate::capability::CachePolicy;
48use crate::git::GitState;
49use crate::model::Object;
50use crate::session::Ledger;
51use crate::{capability, module};
52
53use super::continuity::{thresholds_of, Freshness, Thresholds};
54use super::{get, mcp, Empty};
55
56pub const KNOWLEDGE_CANDIDATES_URI: &str = "majordomus://knowledge-candidates";
58pub const KNOWLEDGE_STATUS_URI: &str = "majordomus://knowledge-status";
60
61const KIND: &str = "knowledge";
65const CANDIDATES_CLASS: &str = "candidates";
66const STATE_DIR: &str = ".ai/local/state";
70
71#[derive(
85 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
86)]
87#[serde(rename_all = "snake_case")]
88pub enum AgeSource {
89 Derivation,
91 Commit,
93 Date,
95}
96
97impl AgeSource {
98 pub fn as_str(self) -> &'static str {
106 match self {
107 AgeSource::Derivation => "derivation",
108 AgeSource::Commit => "commit",
109 AgeSource::Date => "date",
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
129pub struct KnowledgeCandidate {
130 pub id: String,
132 pub uri: String,
134 pub path: String,
136 pub class: String,
138 pub status: String,
140 pub epistemics: String,
142 pub title: String,
144 pub date: String,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub derived_from: Vec<String>,
149 #[serde(default, skip_serializing_if = "String::is_empty")]
151 pub episode: String,
152 #[serde(default)]
158 pub branch: Option<String>,
159 pub freshness: Freshness,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub age_minutes: Option<i64>,
164 pub freshness_reason: String,
166 pub age_source: AgeSource,
168 #[serde(default, skip_serializing_if = "String::is_empty")]
171 pub about: String,
172 #[serde(default, skip_serializing_if = "String::is_empty")]
174 pub route: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190pub struct KnowledgeGroup {
191 pub about: String,
193 pub route: String,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub candidates: Vec<String>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub rejected: Vec<String>,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub episodes: Vec<String>,
204}
205
206impl crate::order::Ordered for KnowledgeCandidate {
209 fn order_key(&self) -> crate::order::OrderKey<'_> {
210 crate::order::OrderKey::plain(&self.id, &self.id)
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
225pub struct KnowledgeCandidates {
226 pub total: usize,
228 pub on_this_branch: usize,
230 pub unattributed: usize,
232 pub branch: String,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub candidates: Vec<KnowledgeCandidate>,
237 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub groups: Vec<KnowledgeGroup>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub cap: Option<usize>,
244 pub over_cap: bool,
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub findings: Vec<String>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265#[serde(deny_unknown_fields)]
266pub struct KnowledgeRecordInput {
267 pub id: String,
269}
270
271impl BenchmarkCases for KnowledgeRecordInput {
272 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
273 ctx.index
274 .objects
275 .iter()
276 .find(|o| o.kind == KIND)
277 .map(|o| {
278 vec![NamedCase::new(
279 "first-record",
280 KnowledgeRecordInput {
281 id: o.identity.clone(),
282 },
283 )]
284 })
285 .unwrap_or_else(|| {
286 vec![NamedCase::new(
287 "absent",
288 KnowledgeRecordInput {
289 id: "absent".into(),
290 },
291 )]
292 })
293 }
294}
295
296#[derive(
312 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
313)]
314#[serde(rename_all = "snake_case")]
315pub enum ReferenceResolution {
316 Object,
318 External,
320 Historical,
324 Missing,
326}
327
328impl ReferenceResolution {
329 pub fn as_str(self) -> &'static str {
337 match self {
338 ReferenceResolution::Object => "object",
339 ReferenceResolution::External => "external",
340 ReferenceResolution::Historical => "historical",
341 ReferenceResolution::Missing => "missing",
342 }
343 }
344
345 pub fn resolves(self) -> bool {
353 !matches!(self, ReferenceResolution::Missing)
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
368pub struct ResolvedReference {
369 pub reference: String,
371 pub resolution: ReferenceResolution,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub target: Option<String>,
377 #[serde(default, skip_serializing_if = "String::is_empty")]
379 pub reason: String,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
393pub struct ResolvedRelation {
394 pub relation_type: String,
396 pub reference: String,
398 pub resolution: ReferenceResolution,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub target: Option<String>,
403 #[serde(default, skip_serializing_if = "String::is_empty")]
405 pub reason: String,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
427pub struct KnowledgeRecord {
428 pub id: String,
430 pub uri: String,
432 pub path: String,
434 pub source_class: String,
436 pub class: String,
438 pub status: String,
440 pub epistemics: String,
442 #[serde(default, skip_serializing_if = "String::is_empty")]
444 pub origin: String,
445 pub date: String,
447 pub title: String,
449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
451 pub derived_from: Vec<ResolvedReference>,
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub relations: Vec<ResolvedRelation>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub superseded_by: Option<String>,
458 #[serde(default)]
460 pub resolved: bool,
461 pub content: String,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
475pub struct LedgerMark {
476 pub ts: String,
478 #[serde(default, skip_serializing_if = "String::is_empty")]
480 pub episode: String,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
503pub struct KnowledgeStatus {
504 pub present: bool,
506 pub branch: String,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub last_derived: Option<LedgerMark>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub last_closed: Option<LedgerMark>,
514 pub closed_without_derivation: usize,
516 pub candidates: usize,
518 pub on_this_branch: usize,
520 pub freshness: Freshness,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub age_minutes: Option<i64>,
525 pub freshness_reason: String,
527 pub judged: bool,
531 pub stopped_writer: bool,
534 pub knowledge_on_end: bool,
536 pub knowledge_on_compact: bool,
538 #[serde(default, skip_serializing_if = "Vec::is_empty")]
540 pub findings: Vec<String>,
541}
542
543fn field(o: &Object, key: &str) -> String {
547 o.metadata
548 .get(key)
549 .and_then(Value::as_str)
550 .unwrap_or_default()
551 .to_string()
552}
553
554fn derived_from_of(o: &Object) -> Vec<String> {
556 o.metadata
557 .get("provenance")
558 .and_then(|p| p.get("derived_from"))
559 .and_then(Value::as_array)
560 .map(|a| {
561 a.iter()
562 .filter_map(Value::as_str)
563 .map(str::to_string)
564 .collect()
565 })
566 .unwrap_or_default()
567}
568
569fn relations_of(o: &Object) -> Vec<(String, String)> {
571 o.metadata
572 .get("relations")
573 .and_then(Value::as_array)
574 .map(|a| {
575 a.iter()
576 .map(|r| {
577 let s = |k: &str| {
578 r.get(k)
579 .and_then(Value::as_str)
580 .unwrap_or_default()
581 .to_string()
582 };
583 (s("type"), s("target"))
584 })
585 .collect()
586 })
587 .unwrap_or_default()
588}
589
590fn episode_of(refs: &[String]) -> String {
592 refs.iter()
593 .find_map(|r| r.strip_prefix("session:"))
594 .unwrap_or_default()
595 .to_string()
596}
597
598fn branch_of(ctx: &Context) -> String {
600 match &ctx.index.repository.git {
601 GitState::Available(info) => info.branch.clone().unwrap_or_else(|| "DETACHED".into()),
602 GitState::Unavailable { .. } => "DETACHED".into(),
603 }
604}
605
606fn now() -> i64 {
608 std::time::SystemTime::now()
609 .duration_since(std::time::UNIX_EPOCH)
610 .map(|d| d.as_secs() as i64)
611 .unwrap_or(0)
612}
613
614fn policy_of(ctx: &Context) -> Option<crate::policy::Policy> {
617 let root = PathBuf::from(&ctx.index.repository.root);
618 let repo = crate::repository::Repository::open(&root).ok()?;
619 Some(crate::policy::LoadedPolicy::load(&repo).ok()?.policy)
620}
621
622fn episode_branches(ctx: &Context, events: &[Value]) -> BTreeMap<String, String> {
627 let mut out = BTreeMap::new();
628 for e in events {
629 if e["event"].as_str() == Some("session.started") {
630 if let (Some(sid), Some(branch)) = (e["session"].as_str(), e["branch"].as_str()) {
631 out.entry(sid.to_string())
632 .or_insert_with(|| branch.to_string());
633 }
634 }
635 }
636 for o in ctx.index.objects.iter().filter(|o| o.kind == "session") {
637 let sid = field(o, "session_id");
638 let branch = field(o, "branch");
639 if !sid.is_empty() && !branch.is_empty() {
640 out.insert(sid, branch);
641 }
642 }
643 out
644}
645
646fn derivation_times(events: &[Value]) -> BTreeMap<String, String> {
651 let mut out = BTreeMap::new();
652 for e in events {
653 if e["event"].as_str() != Some("knowledge.derived") {
654 continue;
655 }
656 let Some(ts) = e["ts"].as_str() else {
657 continue;
658 };
659 for path in e["paths"].as_str().unwrap_or_default().split_whitespace() {
660 out.entry(path.to_string())
661 .or_insert_with(|| ts.to_string());
662 }
663 }
664 out
665}
666
667fn added_times(root: &Path, paths: &[&str]) -> BTreeMap<String, String> {
672 if paths.is_empty() {
673 return BTreeMap::new();
674 }
675 match crate::git::read_only(root)
676 .args([
677 "log",
678 "--diff-filter=A",
679 "--format=commit:%ct",
680 "--name-only",
681 "--",
682 ])
683 .args(paths)
684 .output()
685 {
686 Ok(run) if run.status.success() => added_from_log(&String::from_utf8_lossy(&run.stdout)),
687 _ => BTreeMap::new(),
688 }
689}
690
691fn added_from_log(text: &str) -> BTreeMap<String, String> {
695 let mut out = BTreeMap::new();
696 let mut current: Option<u64> = None;
697 for line in text.lines() {
698 if let Some(secs) = line.strip_prefix("commit:") {
699 current = secs.trim().parse().ok();
700 continue;
701 }
702 let path = line.trim();
703 if path.is_empty() {
704 continue;
705 }
706 if let Some(secs) = current {
707 let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs);
708 out.insert(path.to_string(), crate::peers::rfc3339(at));
711 }
712 }
713 out
714}
715
716fn candidate_objects(ctx: &Context) -> Vec<&Object> {
720 ctx.index
721 .objects
722 .iter()
723 .filter(|o| {
724 o.kind == KIND
725 && o.provenance.source_class == CANDIDATES_CLASS
726 && field(o, "status") == "candidate"
727 })
728 .collect()
729}
730
731fn groups_of(ctx: &Context) -> Vec<KnowledgeGroup> {
734 let mut by: BTreeMap<String, KnowledgeGroup> = BTreeMap::new();
735 let records: BTreeMap<&str, &Object> = ctx
738 .index
739 .objects
740 .iter()
741 .filter(|o| o.kind == KIND && o.provenance.source_class == CANDIDATES_CLASS)
742 .filter(|o| !field(o, "about").is_empty())
743 .map(|o| (o.identity.as_str(), o))
744 .collect();
745 for o in records.into_values() {
746 let about = field(o, "about");
747 let group = by.entry(about.clone()).or_insert_with(|| KnowledgeGroup {
748 about,
749 route: field(o, "route"),
750 candidates: Vec::new(),
751 rejected: Vec::new(),
752 episodes: Vec::new(),
753 });
754 match field(o, "status").as_str() {
755 "candidate" => group.candidates.push(o.identity.clone()),
756 "superseded" => group.rejected.push(o.identity.clone()),
757 _ => continue,
758 }
759 let episode = episode_of(&derived_from_of(o));
760 if !episode.is_empty() && !group.episodes.contains(&episode) {
761 group.episodes.push(episode);
762 }
763 }
764 by.into_values()
765 .filter(|g| !(g.candidates.is_empty() && g.rejected.is_empty()))
766 .collect()
767}
768
769fn candidates_of(ctx: &Context, events: &[Value], now: i64) -> KnowledgeCandidates {
772 let root = PathBuf::from(&ctx.index.repository.root);
773 let branch = branch_of(ctx);
774 let thresholds = thresholds_of(ctx);
775 let policy = policy_of(ctx);
776 let cap = policy
777 .as_ref()
778 .and_then(|p| p.knowledge.candidates_max_files);
779
780 let objects = candidate_objects(ctx);
781 let branches = episode_branches(ctx, events);
782 let derived = derivation_times(events);
783 let paths: Vec<&str> = objects
784 .iter()
785 .filter(|o| !derived.contains_key(&o.provenance.path))
786 .map(|o| o.provenance.path.as_str())
787 .collect();
788 let added = added_times(&root, &paths);
789
790 let mut findings = Vec::new();
791 let mut candidates: Vec<KnowledgeCandidate> = objects
792 .iter()
793 .map(|o| {
794 let refs = derived_from_of(o);
795 let episode = episode_of(&refs);
796 let path = o.provenance.path.clone();
797 let date = field(o, "date");
798 let (entered, age_source) = match derived.get(&path) {
799 Some(ts) => (ts.clone(), AgeSource::Derivation),
800 None => match added.get(&path) {
801 Some(ts) => (ts.clone(), AgeSource::Commit),
802 None if date.is_empty() => (String::new(), AgeSource::Date),
803 None => (format!("{date}T00:00:00Z"), AgeSource::Date),
804 },
805 };
806 let (freshness, age_minutes, freshness_reason) = thresholds.judge(&entered, now);
807 KnowledgeCandidate {
808 id: o.identity.clone(),
809 uri: o.uri.clone(),
810 path,
811 class: field(o, "class"),
812 status: field(o, "status"),
813 epistemics: field(o, "epistemics"),
814 title: o.title.clone().unwrap_or_default(),
815 date,
816 branch: if episode.is_empty() {
817 None
818 } else {
819 branches.get(&episode).cloned()
820 },
821 episode,
822 derived_from: refs,
823 freshness,
824 age_minutes,
825 freshness_reason,
826 age_source,
827 about: field(o, "about"),
828 route: field(o, "route"),
829 }
830 })
831 .collect();
832 crate::order::canonical(&mut candidates);
833 let groups = groups_of(ctx);
834
835 let total = candidates.len();
836 let on_this_branch = candidates
837 .iter()
838 .filter(|c| c.branch.as_deref() == Some(branch.as_str()))
839 .count();
840 let unattributed = candidates.iter().filter(|c| c.branch.is_none()).count();
841 if unattributed > 0 {
842 findings.push(format!(
843 "{unattributed} candidate(s) name an episode this checkout does not know; their branch is reported as absent, not guessed"
844 ));
845 }
846 let over_cap = cap.is_some_and(|cap| total > cap);
847 if over_cap {
848 findings.push(format!(
849 "{total} candidates await review, over the {} the policy calls the cap (knowledge.candidates_max_files); the queue is measured, never emptied by a machine",
850 cap.unwrap_or_default()
851 ));
852 }
853 if let Some(age) = policy
854 .as_ref()
855 .and_then(|p| p.knowledge.candidate_max_age_minutes)
856 {
857 let old = candidates
858 .iter()
859 .filter(|c| c.age_minutes.is_some_and(|m| m >= age))
860 .count();
861 if old > 0 {
862 findings.push(format!(
863 "{old} candidate(s) have waited longer than the {} the policy calls the age (knowledge.candidate_max_age_minutes); review them or reject them",
864 super::continuity::span(age)
865 ));
866 }
867 }
868
869 KnowledgeCandidates {
870 total,
871 on_this_branch,
872 unattributed,
873 branch,
874 candidates,
875 groups,
876 cap,
877 over_cap,
878 findings,
879 }
880}
881
882fn candidates(ctx: &Context, _: Empty) -> Result<KnowledgeCandidates, CapabilityError> {
883 let root = PathBuf::from(&ctx.index.repository.root);
884 let read = Ledger::of(&root).read();
885 Ok(candidates_of(ctx, &read.events, now()))
886}
887
888fn resolve(
897 ctx: &Context,
898 root: &Path,
899 events: &[Value],
900 reference: &str,
901) -> (ReferenceResolution, Option<String>, String) {
902 use ReferenceResolution::{External, Historical, Missing, Object};
903 let Some((prefix, value)) = reference.split_once(':') else {
904 return (
905 Missing,
906 None,
907 "a reference is `<prefix>:<value>`; this one has no prefix".into(),
908 );
909 };
910 if value.is_empty() {
911 return (Missing, None, format!("`{prefix}:` names nothing"));
912 }
913 let by_kind = |kind: &str| -> Option<String> {
914 ctx.index
915 .objects
916 .iter()
917 .find(|o| {
918 o.kind == kind
919 && (o.identity == value || o.identity.starts_with(&format!("{value}@")))
920 })
921 .map(|o| o.uri.clone())
922 };
923 match prefix {
924 "session" => {
925 let object = ctx
926 .index
927 .objects
928 .iter()
929 .find(|o| o.kind == "session" && field(o, "session_id") == value)
930 .map(|o| o.uri.clone());
931 if let Some(uri) = object {
932 return (Object, Some(uri), String::new());
933 }
934 if events.iter().any(|e| e["session"].as_str() == Some(value)) {
935 return (External, Some("ledger".into()), String::new());
936 }
937 (
938 Missing,
939 None,
940 format!("no session record and no ledger line names the episode {value}"),
941 )
942 }
943 "task" | "decision" => {
944 if value == "none" {
945 return (
946 Missing,
947 None,
948 format!("`{prefix}:none` is not a reference: `none` is the ledger's word for a line recorded outside a task"),
949 );
950 }
951 if events.iter().any(|e| e["task_id"].as_str() == Some(value)) {
952 return (External, Some("ledger".into()), String::new());
953 }
954 let state = root.join(STATE_DIR);
955 let current = super::continuity::read_task(&state.join("current.yaml"))
956 .is_some_and(|t| t.id == value);
957 if current {
958 return (External, Some("task store".into()), String::new());
959 }
960 if state
961 .join("completed")
962 .join(format!("{value}.md"))
963 .is_file()
964 {
965 return (External, Some("task store".into()), String::new());
966 }
967 (
968 Missing,
969 None,
970 format!("no ledger line and no task record names the task {value}"),
971 )
972 }
973 "commit" => {
974 let ok = crate::git::read_only(root)
975 .args([
976 "rev-parse",
977 "--verify",
978 "--quiet",
979 &format!("{value}^{{commit}}"),
980 ])
981 .output()
982 .is_ok_and(|o| o.status.success());
983 if ok {
984 (External, Some("git".into()), String::new())
985 } else {
986 (Missing, None, format!("this history has no commit {value}"))
987 }
988 }
989 "file" | "test" => {
990 if let Some(o) = ctx
991 .index
992 .objects
993 .iter()
994 .find(|o| o.provenance.path == value)
995 {
996 return (Object, Some(o.uri.clone()), String::new());
997 }
998 if crate::policy::is_safe_relative(value) && root.join(value).exists() {
999 return (External, Some(value.to_string()), String::new());
1000 }
1001 if let Some(blob) = file_in_history(root, value) {
1002 return (Historical, Some(blob), String::new());
1003 }
1004 (
1005 Missing,
1006 None,
1007 format!("no such path in the tree or in its history: {value}"),
1008 )
1009 }
1010 "knowledge" | "rule" | "adr" | "issue" => match by_kind(prefix) {
1011 Some(uri) => (Object, Some(uri), String::new()),
1012 None => (
1013 Missing,
1014 None,
1015 format!("the index holds no {prefix} with the identity {value}"),
1016 ),
1017 },
1018 other => (
1019 Missing,
1020 None,
1021 format!("`{other}:` is not a reference prefix the knowledge schema admits"),
1022 ),
1023 }
1024}
1025
1026fn file_in_history(root: &Path, value: &str) -> Option<String> {
1042 let git = |args: &[&str]| {
1043 crate::git::read_only(root)
1044 .args(args)
1045 .output()
1046 .ok()
1047 .filter(|o| o.status.success())
1048 };
1049 if let Some((path, commit)) = value.rsplit_once('@') {
1050 let hex = (7..=40).contains(&commit.len())
1051 && commit
1052 .bytes()
1053 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
1054 if !path.is_empty() && hex {
1055 let blob = format!("{commit}:{path}");
1056 return git(&["cat-file", "-e", &blob]).map(|_| blob);
1057 }
1058 }
1059 if !crate::policy::is_safe_relative(value) {
1060 return None;
1061 }
1062 let out = git(&["--literal-pathspecs", "rev-list", "-1", "HEAD", "--", value])?;
1063 let last = String::from_utf8_lossy(&out.stdout).trim().to_string();
1064 if last.is_empty() {
1065 return None;
1066 }
1067 let held = format!("{last}:{value}");
1068 if git(&["cat-file", "-e", &held]).is_some() {
1069 Some(held)
1070 } else {
1071 Some(format!("{last}^:{value}"))
1072 }
1073}
1074
1075fn record(ctx: &Context, input: KnowledgeRecordInput) -> Result<KnowledgeRecord, CapabilityError> {
1076 let uri = crate::model::uri_for(KIND, &input.id);
1077 let o = ctx.index.get(&uri).ok_or_else(|| {
1078 CapabilityError::NotFound(format!(
1079 "no knowledge record '{}'; `knowledge candidates` lists the ones awaiting review",
1080 input.id
1081 ))
1082 })?;
1083 let root = PathBuf::from(&ctx.index.repository.root);
1084 let events = Ledger::of(&root).read().events;
1085
1086 let derived_from: Vec<ResolvedReference> = derived_from_of(o)
1087 .into_iter()
1088 .map(|reference| {
1089 let (resolution, target, reason) = resolve(ctx, &root, &events, &reference);
1090 ResolvedReference {
1091 reference,
1092 resolution,
1093 target,
1094 reason,
1095 }
1096 })
1097 .collect();
1098 let relations: Vec<ResolvedRelation> = relations_of(o)
1099 .into_iter()
1100 .map(|(relation_type, reference)| {
1101 let (resolution, target, reason) = resolve(ctx, &root, &events, &reference);
1102 ResolvedRelation {
1103 relation_type,
1104 reference,
1105 resolution,
1106 target,
1107 reason,
1108 }
1109 })
1110 .collect();
1111 let resolved = derived_from.iter().all(|r| r.resolution.resolves())
1112 && relations.iter().all(|r| r.resolution.resolves());
1113 let superseded_by = Some(field(o, "superseded_by")).filter(|s| !s.is_empty());
1114
1115 Ok(KnowledgeRecord {
1116 id: o.identity.clone(),
1117 uri: o.uri.clone(),
1118 path: o.provenance.path.clone(),
1119 source_class: o.provenance.source_class.clone(),
1120 class: field(o, "class"),
1121 status: field(o, "status"),
1122 epistemics: field(o, "epistemics"),
1123 origin: o
1124 .metadata
1125 .get("provenance")
1126 .and_then(|p| p.get("origin"))
1127 .and_then(Value::as_str)
1128 .unwrap_or_default()
1129 .to_string(),
1130 date: field(o, "date"),
1131 title: o.title.clone().unwrap_or_default(),
1132 derived_from,
1133 relations,
1134 superseded_by,
1135 resolved,
1136 content: o.content.clone(),
1137 })
1138}
1139
1140fn mark(e: &Value, episode_field: &str) -> LedgerMark {
1147 LedgerMark {
1148 ts: e["ts"].as_str().unwrap_or_default().to_string(),
1149 episode: e[episode_field].as_str().unwrap_or_default().to_string(),
1150 }
1151}
1152
1153struct Judgement {
1157 last_derived: Option<LedgerMark>,
1158 last_closed: Option<LedgerMark>,
1159 closed_without_derivation: usize,
1160 judged: bool,
1161 stopped: bool,
1162 verdict: (Freshness, Option<i64>, String),
1163 findings: Vec<String>,
1164}
1165
1166fn judge(events: &[Value], knowledge_on_end: bool, thresholds: Thresholds, now: i64) -> Judgement {
1168 let closes: Vec<&Value> = events
1169 .iter()
1170 .filter(|e| e["event"].as_str() == Some("session.closed"))
1171 .collect();
1172 let derivations: Vec<&Value> = events
1173 .iter()
1174 .filter(|e| e["event"].as_str() == Some("knowledge.derived"))
1175 .collect();
1176 let derived_episodes: std::collections::BTreeSet<&str> = derivations
1177 .iter()
1178 .filter_map(|e| e["episode"].as_str())
1179 .collect();
1180 let last_closed = closes.last().map(|e| mark(e, "session"));
1181 let last_derived = derivations.last().map(|e| mark(e, "episode"));
1182 let closed_without_derivation = closes
1183 .iter()
1184 .filter(|e| {
1185 e["session"]
1186 .as_str()
1187 .is_some_and(|s| !derived_episodes.contains(s))
1188 })
1189 .count();
1190
1191 let mut findings = Vec::new();
1192 let withheld = |findings: Vec<String>, why: &str| Judgement {
1193 last_derived: last_derived.clone(),
1194 last_closed: last_closed.clone(),
1195 closed_without_derivation,
1196 judged: false,
1197 stopped: false,
1198 verdict: (Freshness::Unknown, None, why.to_string()),
1199 findings,
1200 };
1201
1202 let Some(closed) = last_closed.clone() else {
1203 findings.push("no episode has closed here; nothing to judge".to_string());
1204 return withheld(findings, "no episode has closed here");
1205 };
1206 if !knowledge_on_end {
1207 findings.push(
1208 "session.knowledge_on_end is false; the deriver is switched off here and its silence is not judged".to_string(),
1209 );
1210 return withheld(findings, "the deriver is switched off");
1211 }
1212 if derivations.is_empty() {
1213 findings.push(
1214 "no knowledge derivation has run in this checkout yet, so the newest close is not judged; the first `majordomus knowledge derive` starts the judgement".to_string(),
1215 );
1216 return withheld(findings, "no derivation has run here yet");
1217 }
1218 if closed.episode.is_empty() {
1219 findings.push(
1220 "the newest session.closed line names no episode; nothing to compare a derivation against".to_string(),
1221 );
1222 return withheld(findings, "the newest close names no episode");
1223 }
1224 let verdict = thresholds.judge(&closed.ts, now);
1225 let covered = derived_episodes.contains(closed.episode.as_str());
1226 let stopped = !covered && verdict.0 == Freshness::Stale;
1227 if !covered {
1228 match verdict.0 {
1229 Freshness::Stale => findings.push(format!(
1230 "episodes close and no knowledge.derived followed the newest ({}, {}); the writer has stopped. fix: majordomus knowledge derive --episode {}",
1231 closed.episode, verdict.2, closed.episode
1232 )),
1233 Freshness::Unknown | Freshness::Invalid => findings.push(format!(
1234 "the newest closed episode ({}) has no derivation and cannot be judged: {}",
1235 closed.episode, verdict.2
1236 )),
1237 Freshness::Fresh | Freshness::Aging => findings.push(format!(
1238 "the newest closed episode ({}) awaits its derivation: {}",
1239 closed.episode, verdict.2
1240 )),
1241 }
1242 }
1243 Judgement {
1244 last_derived,
1245 last_closed,
1246 closed_without_derivation,
1247 judged: true,
1248 stopped,
1249 verdict,
1250 findings,
1251 }
1252}
1253
1254fn status(ctx: &Context, _: Empty) -> Result<KnowledgeStatus, CapabilityError> {
1255 let root = PathBuf::from(&ctx.index.repository.root);
1256 let read = Ledger::of(&root).read();
1257 let now = now();
1258 let policy = policy_of(ctx);
1259 let (knowledge_on_end, knowledge_on_compact) = policy
1260 .as_ref()
1261 .map(|p| (p.session.knowledge_on_end, p.session.knowledge_on_compact))
1262 .unwrap_or((true, true));
1263 let thresholds = thresholds_of(ctx);
1264 let queue = candidates_of(ctx, &read.events, now);
1265
1266 let Judgement {
1267 last_derived,
1268 last_closed,
1269 closed_without_derivation,
1270 judged,
1271 stopped,
1272 verdict: (freshness, age_minutes, freshness_reason),
1273 mut findings,
1274 } = judge(&read.events, knowledge_on_end, thresholds, now);
1275 if !read.corrupt.is_empty() {
1276 findings.push(format!(
1277 "{} ledger line(s) did not parse and were not judged; run `majordomus doctor`",
1278 read.corrupt.len()
1279 ));
1280 }
1281 findings.extend(queue.findings.iter().cloned());
1282
1283 Ok(KnowledgeStatus {
1284 present: !read.events.is_empty() || queue.total > 0,
1285 branch: queue.branch.clone(),
1286 last_derived,
1287 last_closed,
1288 closed_without_derivation,
1289 candidates: queue.total,
1290 on_this_branch: queue.on_this_branch,
1291 freshness,
1292 age_minutes,
1293 freshness_reason,
1294 judged,
1295 stopped_writer: stopped,
1296 knowledge_on_end,
1297 knowledge_on_compact,
1298 findings,
1299 })
1300}
1301
1302pub fn module() -> ModuleDescriptor {
1315 module! {
1316 id: "knowledge_base",
1317 title: "Knowledge base",
1318 description: "What the knowledge deriver left for review and whether it is still writing: the candidate records awaiting promotion with the branch of the episode each came from, one record by id with every reference it names resolved against the index, the ledger and git, and the derivation status of this checkout judged against the policy's freshness thresholds. Read from the index and the ledger; written by nothing here — the deriver is the shell tool's, and a person promotes.",
1319 stability: Stability::BehaviorallyVerified,
1320 capabilities: [
1321 capability! {
1322 id: "knowledge_base.candidates",
1323 title: "Candidates awaiting review",
1324 description: "Every knowledge record with status candidate under the candidates source class: its class, its assertion, the episode it came from and that episode's branch, and how long it has waited — measured from the derivation that wrote it, then the commit that added it, then its own date — judged against session.freshness; against the policy's cap. A candidate whose episode this checkout does not know is reported with no branch rather than hidden. Ordered by id.",
1325 input: Empty,
1326 output: KnowledgeCandidates,
1327 stability: Stability::BehaviorallyVerified,
1328 exposure: Exposure {
1329 mcp: Some(McpExposure {
1330 tool: Some("majordomus_knowledge_candidates".into()),
1331 resource: Some(McpResource { uri: KNOWLEDGE_CANDIDATES_URI.into(), name: "knowledge-candidates".into() }),
1332 }),
1333 http: get("/api/v1/knowledge/candidates"),
1334 cli: Some(CliExposure { path: vec!["knowledge".into(), "candidates".into()] }),
1335 },
1336 tags: ["knowledge", "continuity"],
1337 handler: candidates,
1338 },
1339 capability! {
1340 id: "knowledge_base.record",
1341 title: "One knowledge record",
1342 description: "One knowledge record by id — candidate or curated — with every reference it names resolved: a session against the tracked session records and the ledger, a task or decision against the ledger and the task store, a commit against git, a file or test against the index and the tree, a rule, an ADR, an issue or another record against the index. Which references dangle is the question a reviewer asks before promoting, and the one the integrity validator asks of every record. A `task:none` or `decision:none` reference is refused by name.",
1343 input: KnowledgeRecordInput,
1344 output: KnowledgeRecord,
1345 stability: Stability::BehaviorallyVerified,
1346 exposure: Exposure {
1347 mcp: mcp("majordomus_knowledge_record"),
1348 http: get("/api/v1/knowledge/record"),
1349 cli: Some(CliExposure { path: vec!["knowledge".into(), "record".into()] }),
1350 },
1351 tags: ["knowledge"],
1352 handler: record,
1353 },
1354 capability! {
1355 id: "knowledge_base.status",
1356 title: "Whether the deriver is still writing",
1357 description: "The freshness half of the stopped-writer judgement (ADR 0052, applied to the knowledge deriver by ADR 0091): the newest knowledge.derived line, the newest session.closed line, how many closed episodes no derivation names, the candidate counts, and — once a derivation has run in this checkout and the switch is on — whether the newest closed episode went underived past session.freshness.stale_minutes. Episode ids are compared, never line order. Whether the close path still calls the deriver is read from the source by the shell validator, not here.",
1358 input: Empty,
1359 output: KnowledgeStatus,
1360 stability: Stability::BehaviorallyVerified,
1361 exposure: Exposure {
1362 mcp: Some(McpExposure {
1363 tool: Some("majordomus_knowledge_status".into()),
1364 resource: Some(McpResource { uri: KNOWLEDGE_STATUS_URI.into(), name: "knowledge-status".into() }),
1365 }),
1366 http: get("/api/v1/knowledge/status"),
1367 cli: Some(CliExposure { path: vec!["knowledge".into(), "status".into()] }),
1368 },
1369 tags: ["knowledge", "continuity", "session"],
1370 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1373 handler: status,
1374 },
1375 ],
1376 }
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381 use super::*;
1382 use serde_json::json;
1383
1384 #[test]
1389 fn the_declaration_yields_the_projections_it_claims() {
1390 let m = module();
1391 assert_eq!(m.id.as_str(), "knowledge_base");
1392 let expected: &[(&str, &str, &str, &str)] = &[
1393 (
1394 "knowledge_base.candidates",
1395 "majordomus_knowledge_candidates",
1396 "/api/v1/knowledge/candidates",
1397 "knowledge candidates",
1398 ),
1399 (
1400 "knowledge_base.record",
1401 "majordomus_knowledge_record",
1402 "/api/v1/knowledge/record",
1403 "knowledge record",
1404 ),
1405 (
1406 "knowledge_base.status",
1407 "majordomus_knowledge_status",
1408 "/api/v1/knowledge/status",
1409 "knowledge status",
1410 ),
1411 ];
1412 let ids: Vec<&str> = m
1413 .capabilities
1414 .iter()
1415 .map(|e| e.capability.id.as_str())
1416 .collect();
1417 let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
1418 assert_eq!(
1419 ids, want,
1420 "the module declares a different set of capabilities"
1421 );
1422 for (executable, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
1423 let exposure = &executable.capability.exposure;
1424 assert_eq!(
1425 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
1426 Some(*tool),
1427 "{id} lost or renamed its MCP tool"
1428 );
1429 assert_eq!(
1430 exposure.http.as_ref().map(|h| h.path.as_str()),
1431 Some(*path),
1432 "{id} lost or renamed its HTTP route"
1433 );
1434 assert_eq!(
1435 exposure.cli.as_ref().map(|c| c.path.join(" ")),
1436 Some((*cli).to_string()),
1437 "{id} lost or renamed its command line"
1438 );
1439 }
1440 }
1441
1442 fn line(event: &str, ts: &str, extra: Value) -> Value {
1443 let mut v = json!({ "ts": ts, "event": event, "head": "h", "branch": "master", "by": "t" });
1444 if let Some(map) = extra.as_object() {
1445 for (k, val) in map {
1446 v[k] = val.clone();
1447 }
1448 }
1449 v
1450 }
1451
1452 const T: Thresholds = Thresholds {
1453 fresh_minutes: Some(720),
1454 stale_minutes: Some(2880),
1455 };
1456 const NOW: i64 = 1_788_000_000;
1458
1459 #[test]
1462 fn nothing_closed_is_not_judged() {
1463 let j = judge(&[], true, T, NOW);
1464 assert!(j.last_derived.is_none() && j.last_closed.is_none());
1465 assert_eq!(j.closed_without_derivation, 0);
1466 assert!(!j.judged && !j.stopped);
1467 assert_eq!(j.verdict.0, Freshness::Unknown);
1468 assert!(j.findings[0].contains("no episode has closed"));
1469 }
1470
1471 #[test]
1475 fn a_close_before_any_derivation_is_withheld_not_red() {
1476 let events = [line(
1477 "session.closed",
1478 "2026-01-01T00:00:00Z",
1479 json!({ "session": "e1", "outcome": "closed" }),
1480 )];
1481 let j = judge(&events, true, T, NOW);
1482 assert_eq!(j.last_closed.unwrap().episode, "e1");
1483 assert_eq!(
1484 j.closed_without_derivation, 1,
1485 "counted, even while not judged"
1486 );
1487 assert!(!j.judged && !j.stopped);
1488 assert!(j.findings[0].contains("majordomus knowledge derive"));
1489 }
1490
1491 #[test]
1495 fn a_covered_close_is_fine_and_an_uncovered_stale_one_is_a_stopped_writer() {
1496 let covered = [
1497 line(
1498 "knowledge.derived",
1499 "2026-01-01T00:00:00Z",
1500 json!({ "episode": "e2", "written": 1, "unchanged": 0, "skipped": 0, "paths": "" }),
1501 ),
1502 line(
1503 "session.closed",
1504 "2026-01-01T00:00:01Z",
1505 json!({ "session": "e2", "outcome": "closed" }),
1506 ),
1507 ];
1508 let j = judge(&covered, true, T, NOW);
1509 assert_eq!(j.last_derived.unwrap().episode, "e2");
1510 assert_eq!(j.closed_without_derivation, 0);
1511 assert!(j.judged && !j.stopped, "{:?}", j.findings);
1512 assert!(j.findings.is_empty());
1513
1514 let uncovered = [
1515 line(
1516 "knowledge.derived",
1517 "2026-01-01T00:00:00Z",
1518 json!({ "episode": "e1", "written": 0, "unchanged": 0, "skipped": 0, "paths": "" }),
1519 ),
1520 line(
1521 "session.closed",
1522 "2026-01-02T00:00:00Z",
1523 json!({ "session": "e2", "outcome": "closed" }),
1524 ),
1525 ];
1526 let j = judge(&uncovered, true, T, NOW);
1527 assert_eq!(j.last_closed.unwrap().episode, "e2");
1528 assert_eq!(j.closed_without_derivation, 1);
1529 assert!(j.judged && j.stopped);
1530 assert_eq!(j.verdict.0, Freshness::Stale);
1531 assert!(j.findings[0].contains("the writer has stopped"));
1532 assert!(j.findings[0].contains("--episode e2"));
1533
1534 let j = judge(&uncovered, false, T, NOW);
1536 assert!(!j.judged && !j.stopped);
1537 assert!(j.findings[0].contains("knowledge_on_end is false"));
1538
1539 let young = [
1541 line(
1542 "knowledge.derived",
1543 "2026-01-01T00:00:00Z",
1544 json!({ "episode": "e1", "written": 0, "unchanged": 0, "skipped": 0, "paths": "" }),
1545 ),
1546 line(
1547 "session.closed",
1548 "2026-08-29T10:00:00Z",
1549 json!({ "session": "e2", "outcome": "closed" }),
1550 ),
1551 ];
1552 let j = judge(&young, true, T, NOW);
1553 assert!(j.judged && !j.stopped);
1554 assert_eq!(j.verdict.0, Freshness::Fresh);
1555 }
1556
1557 #[test]
1560 fn the_oldest_derivation_naming_a_path_is_when_it_entered_the_queue() {
1561 let events = [
1562 line(
1563 "knowledge.derived",
1564 "2026-01-01T00:00:00Z",
1565 json!({ "episode": "e1", "paths": ".ai/repo/knowledge/candidates/a.md .ai/repo/knowledge/candidates/b.md" }),
1566 ),
1567 line(
1568 "knowledge.derived",
1569 "2026-02-01T00:00:00Z",
1570 json!({ "episode": "e1", "paths": ".ai/repo/knowledge/candidates/a.md" }),
1571 ),
1572 ];
1573 let times = derivation_times(&events);
1574 assert_eq!(
1575 times
1576 .get(".ai/repo/knowledge/candidates/a.md")
1577 .map(String::as_str),
1578 Some("2026-01-01T00:00:00Z")
1579 );
1580 assert_eq!(
1581 times
1582 .get(".ai/repo/knowledge/candidates/b.md")
1583 .map(String::as_str),
1584 Some("2026-01-01T00:00:00Z")
1585 );
1586 assert!(!times.contains_key("elsewhere.md"));
1587 }
1588
1589 #[test]
1592 fn an_episode_is_the_session_reference_and_nothing_else() {
1593 assert_eq!(
1594 episode_of(&["decision:t-1".into(), "session:e1".into()]),
1595 "e1"
1596 );
1597 assert_eq!(episode_of(&["task:t-1".into()]), "");
1598 }
1599
1600 #[test]
1602 fn every_age_source_and_resolution_prints_as_the_word_it_serialises_to() {
1603 for a in [AgeSource::Derivation, AgeSource::Commit, AgeSource::Date] {
1604 assert_eq!(
1605 serde_json::to_string(&a).unwrap(),
1606 format!("\"{}\"", a.as_str())
1607 );
1608 }
1609 for r in [
1610 ReferenceResolution::Object,
1611 ReferenceResolution::External,
1612 ReferenceResolution::Historical,
1613 ReferenceResolution::Missing,
1614 ] {
1615 assert_eq!(
1616 serde_json::to_string(&r).unwrap(),
1617 format!("\"{}\"", r.as_str())
1618 );
1619 }
1620 }
1621
1622 #[test]
1625 fn a_close_that_names_no_episode_or_no_readable_time_is_not_a_stopped_writer() {
1626 let derived = line(
1627 "knowledge.derived",
1628 "2026-01-01T00:00:00Z",
1629 json!({ "episode": "e1", "paths": "" }),
1630 );
1631 let nameless = [
1632 derived.clone(),
1633 line(
1634 "session.closed",
1635 "2026-01-02T00:00:00Z",
1636 json!({ "outcome": "closed" }),
1637 ),
1638 ];
1639 let j = judge(&nameless, true, T, NOW);
1640 assert!(!j.judged && !j.stopped);
1641 assert!(
1642 j.findings[0].contains("names no episode"),
1643 "{:?}",
1644 j.findings
1645 );
1646
1647 let unreadable = [
1648 derived,
1649 line("session.closed", "yesterday", json!({ "session": "e2" })),
1650 ];
1651 let j = judge(&unreadable, true, T, NOW);
1652 assert!(j.judged && !j.stopped);
1653 assert_eq!(j.verdict.0, Freshness::Invalid);
1654 assert!(
1655 j.findings[0].contains("cannot be judged"),
1656 "{:?}",
1657 j.findings
1658 );
1659 }
1660
1661 #[test]
1663 fn a_derivation_without_a_time_dates_nothing() {
1664 let mut undated = line("knowledge.derived", "", json!({ "paths": "a.md" }));
1665 undated.as_object_mut().unwrap().remove("ts");
1666 assert!(derivation_times(&[undated]).is_empty());
1667 }
1668
1669 #[test]
1672 fn a_name_in_the_log_takes_the_time_of_its_commit_line() {
1673 let added = added_from_log(
1674 "orphan.md\ncommit:1788000000\n\na.md\ncommit:never\n\nb.md\ncommit:0\n\na.md\n",
1675 );
1676 assert_eq!(
1677 added,
1678 [("a.md".to_string(), "1970-01-01T00:00:00Z".to_string())]
1679 .into_iter()
1680 .collect(),
1681 "the oldest addition wins and the undated names are left out"
1682 );
1683 assert!(added_from_log("").is_empty());
1684 let nowhere = tempfile::tempdir().unwrap();
1686 assert!(added_times(nowhere.path(), &["a.md"]).is_empty());
1687 }
1688
1689 fn object(kind: &str, identity: &str, path: &str, metadata: Value) -> Object {
1690 Object {
1691 kind: kind.into(),
1692 identity: identity.into(),
1693 uri: crate::model::uri_for(kind, identity),
1694 title: None,
1695 description: None,
1696 metadata,
1697 body: String::new(),
1698 content: String::new(),
1699 media_type: "text/markdown",
1700 provenance: crate::model::Provenance {
1701 path: path.into(),
1702 directory: String::new(),
1703 source_class: kind.into(),
1704 section: None,
1705 bytes: 0,
1706 member: None,
1707 },
1708 }
1709 }
1710
1711 fn context_with(repo: &crate::synthetic::SyntheticRepository, extra: Vec<Object>) -> Context {
1713 let mut index = repo.index().unwrap();
1714 index.objects.extend(extra);
1715 index.objects.sort_by(|a, b| a.uri.cmp(&b.uri));
1716 let registry = crate::capability::CapabilityRegistry::builder()
1717 .with_modules(super::super::modules())
1718 .build()
1719 .unwrap();
1720 Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry))
1721 }
1722
1723 #[test]
1727 fn every_reference_resolves_against_what_it_names_or_says_why_not() {
1728 use ReferenceResolution::{External, Missing, Object as Obj};
1729 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1730 let root = repo.root().to_path_buf();
1731 let state = root.join(STATE_DIR);
1732 std::fs::create_dir_all(state.join("completed")).unwrap();
1733 std::fs::write(state.join("current.yaml"), "id: t-current\n").unwrap();
1734 std::fs::write(state.join("completed/t-done.md"), "# done\n").unwrap();
1735 std::fs::create_dir_all(root.join("notes")).unwrap();
1736 std::fs::write(
1737 root.join("notes/plain.txt"),
1738 "on disk, in no source class\n",
1739 )
1740 .unwrap();
1741 let ctx = context_with(
1742 &repo,
1743 vec![object(
1744 "session",
1745 "s1",
1746 ".ai/repo/sessions/s1.md",
1747 json!({ "session_id": "s1", "branch": "master" }),
1748 )],
1749 );
1750 let rule = ctx
1752 .index
1753 .objects
1754 .iter()
1755 .rev()
1756 .find(|o| o.kind == "rule")
1757 .expect("the synthetic repository holds rules")
1758 .clone();
1759 let document = ctx
1760 .index
1761 .objects
1762 .iter()
1763 .find(|o| o.provenance.path.starts_with("docs/"))
1764 .expect("and documents")
1765 .clone();
1766 let events = [json!({ "session": "s2" }), json!({ "task_id": "t-ledger" })];
1767 let resolve = |reference: &str| resolve(&ctx, &root, &events, reference);
1768
1769 let rule_ref = format!("rule:{}", rule.identity);
1770 let document_ref = format!("file:{}", document.provenance.path);
1771 let cases: Vec<(&str, ReferenceResolution, Option<&str>, &str)> = vec![
1772 ("noprefix", Missing, None, "has no prefix"),
1773 ("task:", Missing, None, "`task:` names nothing"),
1774 ("session:s1", Obj, Some("majordomus://session/s1"), ""),
1775 ("session:s2", External, Some("ledger"), ""),
1776 (
1777 "session:s3",
1778 Missing,
1779 None,
1780 "no session record and no ledger line",
1781 ),
1782 ("task:t-ledger", External, Some("ledger"), ""),
1783 ("decision:t-current", External, Some("task store"), ""),
1784 ("task:t-done", External, Some("task store"), ""),
1785 (
1786 "task:t-nowhere",
1787 Missing,
1788 None,
1789 "no ledger line and no task record",
1790 ),
1791 (
1792 "commit:0123abc",
1793 Missing,
1794 None,
1795 "this history has no commit 0123abc",
1796 ),
1797 (&document_ref, Obj, Some(document.uri.as_str()), ""),
1798 (
1799 "file:notes/plain.txt",
1800 External,
1801 Some("notes/plain.txt"),
1802 "",
1803 ),
1804 (
1805 "test:../outside.sh",
1806 Missing,
1807 None,
1808 "no such path in the tree",
1809 ),
1810 ("file:nowhere.md", Missing, None, "no such path in the tree"),
1811 (&rule_ref, Obj, Some(rule.uri.as_str()), ""),
1812 (
1813 "adr:0001",
1814 Missing,
1815 None,
1816 "the index holds no adr with the identity 0001",
1817 ),
1818 (
1819 "ticket:1",
1820 Missing,
1821 None,
1822 "`ticket:` is not a reference prefix",
1823 ),
1824 ];
1825 for (reference, resolution, target, reason) in cases {
1826 let (got, at, why) = resolve(reference);
1827 assert_eq!(got, resolution, "{reference}: {why}");
1828 assert_eq!(at.as_deref(), target, "{reference}");
1829 assert!(why.contains(reason), "{reference}: {why}");
1830 }
1831 }
1832
1833 #[test]
1838 fn a_deleted_file_resolves_through_history_and_one_that_never_existed_does_not() {
1839 use ReferenceResolution::{Historical, Missing};
1840 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1841 let root = repo.root().to_path_buf();
1842 let git = |args: &[&str]| {
1844 let out = std::process::Command::new("git")
1845 .arg("-C")
1846 .arg(&root)
1847 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
1848 .args([
1849 "-c",
1850 "commit.gpgsign=false",
1851 "-c",
1852 "core.hooksPath=/dev/null",
1853 ])
1854 .args(args)
1855 .output()
1856 .expect("git");
1857 assert!(
1858 out.status.success(),
1859 "git {args:?}: {}",
1860 String::from_utf8_lossy(&out.stderr)
1861 );
1862 String::from_utf8_lossy(&out.stdout).trim().to_string()
1863 };
1864 git(&["init", "-q"]);
1865 std::fs::create_dir_all(root.join("tests")).unwrap();
1866 std::fs::write(root.join("tests/engine.cjs"), "the engine\n").unwrap();
1867 git(&["add", "tests/engine.cjs"]);
1868 git(&["commit", "-q", "-m", "an engine"]);
1869 let added = git(&["rev-parse", "--short=10", "HEAD"]);
1870 git(&["rm", "-q", "tests/engine.cjs"]);
1871 git(&["commit", "-q", "-m", "V2 removes it"]);
1872 let removed = git(&["rev-parse", "HEAD"]);
1873 assert!(!root.join("tests/engine.cjs").exists());
1874
1875 let ctx = context_with(&repo, vec![]);
1876 let resolve = |reference: &str| resolve(&ctx, &root, &[], reference);
1877
1878 let (got, at, why) = resolve("file:tests/engine.cjs");
1879 assert_eq!(got, Historical, "{why}");
1880 assert!(got.resolves(), "a historical reference resolves");
1881 assert_eq!(at, Some(format!("{removed}^:tests/engine.cjs")));
1882 let pinned = format!("file:tests/engine.cjs@{added}");
1883 let (got, at, why) = resolve(&pinned);
1884 assert_eq!(got, Historical, "{pinned}: {why}");
1885 assert_eq!(at, Some(format!("{added}:tests/engine.cjs")));
1886 let short = &removed[..10];
1887 for (reference, reason) in [
1888 ("file:tests/smoke.cjs".to_string(), "never existed"),
1889 (
1890 format!("file:tests/engine.cjs@{short}"),
1891 "pinned where it was already gone",
1892 ),
1893 ("test:tests/*.cjs".to_string(), "a glob is one literal path"),
1894 ("file:../engine.cjs".to_string(), "outside the repository"),
1895 ] {
1896 let (got, at, why) = resolve(&reference);
1897 assert_eq!(got, Missing, "{reference} ({reason})");
1898 assert_eq!(at, None, "{reference}");
1899 assert!(why.contains("no such path"), "{reference}: {why}");
1900 }
1901 }
1902
1903 #[test]
1906 fn a_record_is_resolved_only_when_its_relations_answer_too() {
1907 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1908 let record_of = |target: &str| {
1909 object(
1910 KIND,
1911 "k1",
1912 ".ai/repo/knowledge/curated/k1.md",
1913 json!({
1914 "status": "accepted",
1915 "provenance": { "derived_from": ["file:.ai/manifest.yaml"] },
1916 "relations": [{ "type": "relates_to", "target": target }]
1917 }),
1918 )
1919 };
1920 let ctx = context_with(&repo, vec![record_of("file:docs/DOC_0.md")]);
1921 let r = record(&ctx, KnowledgeRecordInput { id: "k1".into() }).unwrap();
1922 assert!(r.resolved, "{r:?}");
1923 assert_eq!(r.relations[0].resolution, ReferenceResolution::Object);
1924
1925 let ctx = context_with(&repo, vec![record_of("file:gone.md")]);
1926 let r = record(&ctx, KnowledgeRecordInput { id: "k1".into() }).unwrap();
1927 assert!(!r.resolved);
1928 assert_eq!(r.relations[0].resolution, ReferenceResolution::Missing);
1929 }
1930
1931 #[test]
1934 fn a_detached_head_and_an_unreadable_policy_are_named_not_guessed() {
1935 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1936 let mut index = repo.index().unwrap();
1937 index.repository.git = GitState::Available(crate::git::GitInfo {
1938 toplevel: repo.root().to_path_buf(),
1939 head: Some("0123abc".into()),
1940 branch: None,
1941 working_tree: "clean".into(),
1942 });
1943 let registry = crate::capability::CapabilityRegistry::builder()
1944 .with_modules(super::super::modules())
1945 .build()
1946 .unwrap();
1947 let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
1948 assert_eq!(branch_of(&ctx), "DETACHED");
1949 assert!(policy_of(&ctx).is_some(), "the synthetic policy reads");
1950
1951 std::fs::write(
1952 repo.root().join(".ai/repo/policy.yaml"),
1953 "version: [unclosed\n",
1954 )
1955 .unwrap();
1956 assert!(policy_of(&ctx).is_none(), "an unreadable policy is none");
1957 std::fs::remove_file(repo.root().join(".ai/manifest.yaml")).unwrap();
1958 assert!(
1959 policy_of(&ctx).is_none(),
1960 "and so is a root that is no repository"
1961 );
1962 }
1963
1964 #[test]
1967 fn an_episode_branch_needs_both_an_episode_and_a_branch() {
1968 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1969 let ctx = context_with(
1970 &repo,
1971 vec![
1972 object(
1973 "session",
1974 "a",
1975 "s/a.md",
1976 json!({ "session_id": "a", "branch": "recorded" }),
1977 ),
1978 object("session", "b", "s/b.md", json!({ "session_id": "b" })),
1979 object("session", "c", "s/c.md", json!({ "branch": "nobody" })),
1980 ],
1981 );
1982 let events = [
1983 json!({ "event": "session.started", "session": "a", "branch": "started" }),
1984 json!({ "event": "session.started", "session": "d", "branch": "from-ledger" }),
1985 json!({ "event": "session.started", "session": "e" }),
1986 json!({ "event": "session.started", "branch": "orphan" }),
1987 ];
1988 let branches = episode_branches(&ctx, &events);
1989 assert_eq!(
1990 branches,
1991 [
1992 ("a".to_string(), "recorded".to_string()),
1993 ("d".to_string(), "from-ledger".to_string()),
1994 ]
1995 .into_iter()
1996 .collect()
1997 );
1998 }
1999
2000 #[test]
2002 fn a_ledger_line_that_does_not_parse_is_reported() {
2003 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2004 let ledger = Ledger::of(repo.root()).path().to_path_buf();
2005 std::fs::create_dir_all(ledger.parent().unwrap()).unwrap();
2006 std::fs::write(&ledger, "not json\n").unwrap();
2007 let ctx = repo.context().unwrap();
2008 let s = status(&ctx, Empty {}).unwrap();
2009 assert!(
2010 s.findings.iter().any(|f| f
2011 == "1 ledger line(s) did not parse and were not judged; run `majordomus doctor`"),
2012 "{:?}",
2013 s.findings
2014 );
2015 }
2016
2017 #[test]
2022 fn observations_are_grouped_by_what_they_are_about() {
2026 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2027 let dir = ".ai/repo/knowledge/candidates";
2028 let record = |id: &str, status: &str, about: &str, episode: &str| {
2029 let mut meta = json!({ "status": status, "class": "lesson",
2030 "provenance": { "derived_from": [format!("session:{episode}")] } });
2031 if !about.is_empty() {
2032 meta["about"] = json!(about);
2033 meta["route"] = json!("project");
2034 }
2035 let mut o = object(KIND, id, &format!("{dir}/{id}.md"), meta);
2036 o.provenance.source_class = CANDIDATES_CLASS.into();
2037 o
2038 };
2039 let ctx = context_with(
2040 &repo,
2041 vec![
2042 record("e2-b", "candidate", "lib/a.sh", "e2"),
2043 record("e1-a", "superseded", "lib/a.sh", "e1"),
2044 record("e3-c", "candidate", "lib/a.sh", "e2"),
2045 record("e1-d", "candidate", "docs/x.md", "e1"),
2046 record("e1-e", "candidate", "", "e1"),
2047 record("e1-f", "verified", "zzz", "e1"),
2048 ],
2049 );
2050 let q = candidates_of(&ctx, &[], NOW);
2051 let groups: Vec<(&str, usize, usize, usize)> = q
2052 .groups
2053 .iter()
2054 .map(|g| {
2055 (
2056 g.about.as_str(),
2057 g.candidates.len(),
2058 g.rejected.len(),
2059 g.episodes.len(),
2060 )
2061 })
2062 .collect();
2063 assert_eq!(groups, [("docs/x.md", 1, 0, 1), ("lib/a.sh", 2, 1, 2)]);
2064 let a = &q.groups[1];
2065 assert_eq!(a.rejected, ["e1-a"], "the rejection stays in the group");
2066 assert_eq!(a.episodes, ["e1", "e2"], "each episode once, in order");
2067 assert_eq!(a.route, "project");
2068 let carried = q.candidates.iter().find(|c| c.id == "e2-b").unwrap();
2069 assert_eq!(
2070 (carried.about.as_str(), carried.route.as_str()),
2071 ("lib/a.sh", "project")
2072 );
2073 let plain = q.candidates.iter().find(|c| c.id == "e1-e").unwrap();
2074 assert!(
2075 plain.about.is_empty() && plain.route.is_empty(),
2076 "nothing said, nothing carried"
2077 );
2078 }
2079
2080 #[test]
2081 fn a_candidate_is_aged_from_the_best_evidence_there_is_and_the_queue_is_held_to_its_cap() {
2082 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2083 let root = repo.root().to_path_buf();
2084 let git = |args: &[&str]| {
2085 let ok = std::process::Command::new("git")
2086 .current_dir(&root)
2087 .args([
2088 "-c",
2089 "user.email=t@example.com",
2090 "-c",
2091 "user.name=t",
2092 "-c",
2093 "commit.gpgsign=false",
2094 ])
2095 .args(args)
2096 .status()
2097 .unwrap()
2098 .success();
2099 assert!(ok, "git {args:?}");
2100 };
2101 let dir = ".ai/repo/knowledge/candidates";
2102 git(&["init", "-q", "-b", "master"]);
2103 std::fs::create_dir_all(root.join(dir)).unwrap();
2104 std::fs::write(root.join(dir).join("c-committed.md"), "committed\n").unwrap();
2105 git(&["add", "-A"]);
2106 git(&["commit", "-q", "-m", "a hand-copied candidate"]);
2107 let policy = root.join(".ai/repo/policy.yaml");
2108 let text = std::fs::read_to_string(&policy).unwrap();
2109 std::fs::write(
2110 &policy,
2111 format!(
2112 "{text}session:\n freshness:\n fresh_minutes: 720\n stale_minutes: 2880\nknowledge:\n candidates_max_files: 2\n"
2113 ),
2114 )
2115 .unwrap();
2116
2117 let candidate = |id: &str, date: Option<&str>, derived_from: &[&str]| {
2118 let mut meta = json!({ "status": "candidate", "class": "fact",
2119 "provenance": { "derived_from": derived_from } });
2120 if let Some(d) = date {
2121 meta["date"] = json!(d);
2122 }
2123 let mut o = object(KIND, id, &format!("{dir}/{id}.md"), meta);
2124 o.provenance.source_class = CANDIDATES_CLASS.into();
2125 o
2126 };
2127 let ctx = context_with(
2128 &repo,
2129 vec![
2130 candidate("c-undated", None, &[]),
2131 candidate("c-dated", Some("2026-03-01"), &[]),
2132 candidate("c-derived", Some("2026-01-05"), &["session:e9"]),
2133 candidate("c-committed", Some("2026-02-01"), &["session:unknown"]),
2134 ],
2135 );
2136 let events = [
2137 line(
2138 "knowledge.derived",
2139 "2026-01-01T00:00:00Z",
2140 json!({ "episode": "e9", "paths": format!("{dir}/c-derived.md") }),
2141 ),
2142 line(
2143 "session.started",
2144 "2026-01-01T00:00:00Z",
2145 json!({ "session": "e9", "branch": "feature" }),
2146 ),
2147 ];
2148
2149 let q = candidates_of(&ctx, &events, NOW);
2150 let ids: Vec<&str> = q.candidates.iter().map(|c| c.id.as_str()).collect();
2151 assert_eq!(ids, ["c-committed", "c-dated", "c-derived", "c-undated"]);
2152 let by = |id: &str| q.candidates.iter().find(|c| c.id == id).unwrap();
2153
2154 assert_eq!(by("c-derived").age_source, AgeSource::Derivation);
2155 assert_eq!(by("c-derived").branch.as_deref(), Some("feature"));
2156 assert_eq!(by("c-committed").age_source, AgeSource::Commit);
2157 assert_eq!(
2158 by("c-committed").branch,
2159 None,
2160 "an episode nobody knows has no branch"
2161 );
2162 assert_eq!(by("c-dated").age_source, AgeSource::Date);
2163 assert_eq!(
2164 by("c-dated").freshness,
2165 Freshness::Stale,
2166 "{:?}",
2167 by("c-dated")
2168 );
2169 assert_eq!(by("c-undated").age_source, AgeSource::Date);
2170 assert_eq!(by("c-undated").freshness, Freshness::Unknown);
2171 assert_eq!(by("c-undated").branch, None);
2172
2173 assert_eq!(q.total, 4);
2174 assert_eq!(q.unattributed, 3);
2175 assert_eq!(q.cap, Some(2));
2176 assert!(q.over_cap);
2177 assert!(
2178 q.findings.iter().any(
2179 |f| f.starts_with("3 candidate(s) name an episode this checkout does not know")
2180 ),
2181 "{:?}",
2182 q.findings
2183 );
2184 assert!(
2185 q.findings
2186 .iter()
2187 .any(|f| f
2188 .starts_with("4 candidates await review, over the 2 the policy calls the cap")),
2189 "{:?}",
2190 q.findings
2191 );
2192 }
2193}