Skip to main content

lean_ctx/core/knowledge/
agents_md.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Project knowledge → a section of `AGENTS.md` (K1 export).
3//!
4//! The section lives between two markers, so an export replaces exactly its
5//! own earlier output and nothing the people of the project wrote around it.
6//! It lists the current, public facts of the categories an agent acts on —
7//! conventions, procedures, decisions, architecture, gotchas, dead ends and
8//! experiences — one bullet per fact, under one heading per category. The
9//! headings are the ones `core::import::markdown` maps back to these
10//! categories, so importing an export yields the same `(category, value)`
11//! pairs (round trip, tested).
12//!
13//! Only `Public` facts that are not machine-derived are written: the file is
14//! committed and leaves the machine with the repository.
15
16use super::{ProjectKnowledge, is_machine_derived};
17use crate::core::sensitivity::SensitivityLevel;
18
19/// Opening marker of the generated section.
20pub const BEGIN: &str = "<!-- lean-ctx:knowledge:begin (generated by `lean-ctx knowledge export-agents-md`; edits inside are replaced) -->";
21/// Closing marker of the generated section.
22pub const END: &str = "<!-- lean-ctx:knowledge:end -->";
23
24/// The categories written, their heading, and the category names a fact may
25/// carry for each (singular and plural forms).
26pub const SECTIONS: [(&str, &str, &[&str]); 7] = [
27    (
28        "convention",
29        "Conventions",
30        &["convention", "conventions", "style"],
31    ),
32    (
33        "procedure",
34        "Procedures",
35        &["procedure", "procedures", "workflow", "workflows"],
36    ),
37    ("decision", "Decisions", &["decision", "decisions"]),
38    ("architecture", "Architecture", &["architecture", "arch"]),
39    (
40        "gotcha",
41        "Gotchas",
42        &["gotcha", "gotchas", "known_issues", "known_bugs"],
43    ),
44    (
45        "dead_end",
46        "Dead ends",
47        &["dead_end", "dead_ends", "dead-end", "dead-ends"],
48    ),
49    ("experience", "Experiences", &["experience", "experiences"]),
50];
51
52fn one_line(value: &str) -> String {
53    value.split_whitespace().collect::<Vec<_>>().join(" ")
54}
55
56/// The generated section, markers included; empty sections are left out.
57#[must_use]
58pub fn render(knowledge: &ProjectKnowledge) -> String {
59    render_with_hive(knowledge, false)
60}
61
62/// [`render`], with `include_hive` also a clearly marked section of the
63/// team's knowledge pulled from Hive. AGENTS.md instructs agents, and
64/// other members' text is data (X5) everywhere else; only a person's
65/// explicit export (`--include-hive`, owner decision 22.10.) republishes
66/// it, and only reviewed knowledge: pull loads the delivered head revision
67/// (merged, released from quarantine), a swarm never reaches the store, and
68/// a fact under review (to verify, contradicted) is left out.
69#[must_use]
70pub fn render_with_hive(knowledge: &ProjectKnowledge, include_hive: bool) -> String {
71    let mut out = format!("{BEGIN}\n## Project knowledge (lean-ctx)\n");
72    for (_, heading, categories) in SECTIONS {
73        let mut values: Vec<String> = knowledge
74            .facts
75            .iter()
76            .filter(|fact| {
77                // Pulled knowledge only ever in its own section below.
78                fact.is_current()
79                    && fact.hive_origin().is_none()
80                    && fact.sensitivity == SensitivityLevel::Public
81                    && !is_machine_derived(&fact.category, &fact.key)
82                    && categories.contains(&fact.category.to_lowercase().as_str())
83            })
84            .map(|fact| one_line(&fact.value))
85            .filter(|value| !value.is_empty())
86            .collect();
87        values.sort();
88        values.dedup();
89        if values.is_empty() {
90            continue;
91        }
92        out.push_str(&format!("\n### {heading}\n\n"));
93        for value in values {
94            out.push_str(&format!("- {value}\n"));
95        }
96    }
97    if include_hive {
98        let mut team: Vec<String> = knowledge
99            .facts
100            .iter()
101            .filter(|fact| {
102                fact.is_current()
103                    && fact.review.is_none()
104                    && fact.sensitivity == SensitivityLevel::Public
105            })
106            .filter_map(|fact| {
107                let origin = fact.hive_origin()?;
108                let text = crate::core::untrusted::body_of(&fact.value)
109                    .unwrap_or_else(|| fact.value.clone());
110                let text = one_line(&text);
111                // `hive-repo:@ns/name@r4` → `@ns/name r4`.
112                let source = super::import_tag::import_tag_source(origin)
113                    .and_then(|rest| rest.rsplit_once("@r"))
114                    .map_or_else(
115                        || origin.to_string(),
116                        |(repo, rev)| format!("{repo} r{rev}"),
117                    );
118                (!text.is_empty()).then(|| format!("{text} (Hive {source})"))
119            })
120            .collect();
121        team.sort();
122        team.dedup();
123        if !team.is_empty() {
124            out.push_str("\n### Team knowledge from Hive (reviewed)\n\n");
125            for line in team {
126                out.push_str(&format!("- {line}\n"));
127            }
128        }
129    }
130    out.push('\n');
131    out.push_str(END);
132    out.push('\n');
133    out
134}
135
136/// `existing` with its generated section replaced by `section`, or with
137/// `section` appended (after one blank line) when it has none yet.
138#[must_use]
139pub fn merge_into(existing: Option<&str>, section: &str) -> String {
140    let Some(existing) = existing.filter(|text| !text.trim().is_empty()) else {
141        return section.to_owned();
142    };
143    if let (Some(start), Some(end)) = (existing.find(BEGIN), existing.find(END))
144        && start < end
145    {
146        let tail = &existing[end + END.len()..];
147        let tail = tail.strip_prefix('\n').unwrap_or(tail);
148        return format!("{}{section}{tail}", &existing[..start]);
149    }
150    let mut out = existing.trim_end().to_owned();
151    out.push_str("\n\n");
152    out.push_str(section);
153    out
154}
155
156#[cfg(test)]
157#[path = "agents_md_tests.rs"]
158mod tests;