Skip to main content

lean_ctx/core/skillify/
mod.rs

1//! Skillify (#290): distill a project's recurring session patterns into versioned,
2//! git-committable `.cursor/rules/skillify-*.mdc` rule files.
3//!
4//! Pipeline: [`candidate::mine_candidates`] (read diary + knowledge) →
5//! `gate::judge` (precision-biased KEEP/SKIP) → `rule_file::plan`
6//! (create / merge / unchanged). Runs on demand only; nothing is written unless
7//! the miner is invoked, and re-runs are idempotent.
8//!
9//! A rule file is a derived copy of the facts it was distilled from: it is
10//! bound to their sources when it is written and asked again before lean-ctx
11//! lists or promotes it (`provenance`).
12
13pub mod candidate;
14pub(crate) mod gate;
15mod provenance;
16pub(crate) mod rule_file;
17
18use std::path::{Path, PathBuf};
19
20use gate::Verdict;
21use rule_file::WriteOutcome;
22
23/// Summary of one mining run, for human + machine reporting.
24#[derive(Debug, Default)]
25pub struct MineReport {
26    pub created: Vec<String>,
27    pub merged: Vec<String>,
28    pub unchanged: Vec<String>,
29    /// `(title, reason)` for every rejected candidate.
30    pub skipped: Vec<(String, String)>,
31    pub candidates_seen: usize,
32    pub output_dir: String,
33    /// Rule files the run found and must not touch, sorted by path.
34    pub notices: Vec<RuleNotice>,
35}
36
37/// One generated rule on disk.
38#[derive(Debug, Clone)]
39pub struct RuleSummary {
40    pub slug: String,
41    pub version: u32,
42    pub title: String,
43}
44
45/// A rule file that needs the user's attention. Never a rule: the path, a
46/// reason code and a fixed sentence — nothing of the file's content.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RuleNotice {
49    pub path: String,
50    pub code: &'static str,
51    pub reason: &'static str,
52}
53
54/// What `list` returns: the rules lean-ctx may show now, and — apart from
55/// them — the files it reports instead. Both sorted (#498).
56#[derive(Debug, Default)]
57pub struct RuleListing {
58    pub rules: Vec<RuleSummary>,
59    pub notices: Vec<RuleNotice>,
60}
61
62/// The walk every operation runs first (`provenance::review`), as notices.
63fn reviewed(output_root: &Path, project_root: &str) -> Vec<RuleNotice> {
64    provenance::review(output_root, project_root)
65        .into_iter()
66        .map(|(path, notice)| RuleNotice {
67            path: path.display().to_string(),
68            code: notice.code(),
69            reason: notice.sentence(),
70        })
71        .collect()
72}
73
74fn config() -> crate::core::config::SkillifyConfig {
75    crate::core::config::Config::load().skillify
76}
77
78/// Resolve the output root from the configured scope.
79fn output_root(project_root: &str, scope: &str) -> PathBuf {
80    if scope.eq_ignore_ascii_case("global")
81        && let Some(home) = dirs::home_dir()
82    {
83        return home;
84    }
85    PathBuf::from(project_root)
86}
87
88/// Run the miner end-to-end. Returns an error only when skillify is disabled or
89/// a write fails; an empty project simply yields an empty report.
90pub fn mine(project_root: &str) -> Result<MineReport, String> {
91    let cfg = config();
92    if !cfg.enabled {
93        return Err("skillify is disabled — set `[skillify] enabled = true`".to_string());
94    }
95    let root = output_root(project_root, &cfg.scope);
96    let now = chrono::Utc::now().to_rfc3339();
97    let candidates = candidate::mine_candidates(project_root);
98
99    let mut report = MineReport {
100        candidates_seen: candidates.len(),
101        output_dir: rule_file::rules_dir(&root).display().to_string(),
102        // Rules whose sources were withdrawn leave the host's directory
103        // first; a rule that is derived again below is written anew through
104        // admission.
105        notices: reviewed(&root, project_root),
106        ..Default::default()
107    };
108
109    for c in &candidates {
110        match gate::judge(c, cfg.min_confidence, cfg.min_recurrence) {
111            Verdict::Skip(reason) => report.skipped.push((c.title.clone(), reason)),
112            Verdict::Keep => {
113                let full = rule_file::full_slug(&c.slug);
114                match write_rule(&root, project_root, c, &now) {
115                    Ok(WriteOutcome::Created) => report.created.push(full),
116                    Ok(WriteOutcome::Merged) => report.merged.push(full),
117                    Ok(WriteOutcome::Unchanged) => report.unchanged.push(full),
118                    Err(Refused::Withheld(reason)) => report.skipped.push((full, reason)),
119                    Err(Refused::Failed(error)) => return Err(error),
120                }
121            }
122        }
123    }
124    Ok(report)
125}
126
127/// Why a kept candidate did not become a rule file.
128enum Refused {
129    /// Not permitted now; the reason is typed and content-free.
130    Withheld(String),
131    /// The file system refused the write.
132    Failed(String),
133}
134
135/// Write the rule file of one kept candidate under `output_root`, for a
136/// request working in `project_root`.
137///
138/// A rule file is a persisted, git-committable artefact: only admitted text
139/// is written into it (E3), and it is a derived copy of the facts it was
140/// distilled from (G04) — its binding to their sources is decided before the
141/// write and recorded after it. An unchanged rule is bound again, so a rule
142/// written before bindings existed gets one on the next run.
143fn write_rule(
144    output_root: &Path,
145    project_root: &str,
146    candidate: &candidate::SkillCandidate,
147    now: &str,
148) -> Result<WriteOutcome, Refused> {
149    let admitted = admitted_candidate(candidate).map_err(Refused::Withheld)?;
150    let planned = rule_file::plan(output_root, &admitted, now);
151    let binding = provenance::prepare(&planned.text, &candidate.derivation, project_root)
152        .map_err(|withheld| Refused::Withheld(withheld.message("rule")))?;
153    planned.write().map_err(Refused::Failed)?;
154    if let Some(binding) = &binding {
155        provenance::record(&planned.path, binding).map_err(Refused::Failed)?;
156    }
157    provenance::release(&planned.path);
158    Ok(planned.outcome)
159}
160
161/// List the generated rules currently on disk for the configured scope.
162pub fn list_rules(project_root: &str) -> RuleListing {
163    let cfg = config();
164    let root = output_root(project_root, &cfg.scope);
165    let dir = rule_file::rules_dir(&root);
166    // Under protection: a rule lean-ctx wrote whose sources were withdrawn
167    // has left this directory now; one it must not touch is a notice, apart
168    // from the rules.
169    let mut listing = RuleListing {
170        rules: Vec::new(),
171        notices: reviewed(&root, project_root),
172    };
173    let out = &mut listing.rules;
174    let Ok(entries) = std::fs::read_dir(&dir) else {
175        return listing;
176    };
177    for entry in entries.flatten() {
178        let path = entry.path();
179        if path.extension().and_then(|e| e.to_str()) != Some("mdc") {
180            continue;
181        }
182        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
183            continue;
184        };
185        if !stem.starts_with(rule_file::SLUG_PREFIX) {
186            continue;
187        }
188        // A rule is a derived copy: it is listed only while the sources it is
189        // bound to still admit it (`provenance::served`); a rule without a
190        // binding follows the originless rule.
191        let held = std::fs::read_to_string(&path)
192            .is_ok_and(|raw| provenance::served(&path, &raw, project_root).is_ok());
193        if !held {
194            continue;
195        }
196        // The listed title is content of a file in the output root: it is
197        // read through the common rooted admitted reader; a rule that is
198        // withheld is not listed.
199        if let Ok(content) = crate::core::rooted_read::read_file_for_tool_rooted(
200            &path.to_string_lossy(),
201            &root.to_string_lossy(),
202            "ctx_skillify",
203        ) {
204            let version = rule_file::parse_existing(&content).map_or(1, |r| r.version);
205            let title =
206                rule_file::extract_description(&content).unwrap_or_else(|| stem.to_string());
207            out.push(RuleSummary {
208                slug: stem.to_string(),
209                version,
210                title,
211            });
212        }
213    }
214    out.sort_by(|a, b| a.slug.cmp(&b.slug));
215    listing
216}
217
218/// Copy a project-scoped generated rule into the global `~/.cursor/rules` so it
219/// applies to every project. Returns the destination path on success.
220pub fn promote(project_root: &str, slug: &str) -> Result<String, String> {
221    let home = dirs::home_dir().ok_or("cannot resolve home directory")?;
222    promote_into(project_root, slug, &home)
223}
224
225/// [`promote`] into the rules directory under `home`. Nothing under `home`
226/// is created or written before every check has passed.
227fn promote_into(project_root: &str, slug: &str, home: &Path) -> Result<String, String> {
228    let full = if slug.starts_with(rule_file::SLUG_PREFIX) {
229        slug.to_string()
230    } else {
231        rule_file::full_slug(slug)
232    };
233    // The walk every operation runs first: a rule whose sources were
234    // withdrawn is no longer in the project to be promoted.
235    provenance::review(Path::new(project_root), project_root);
236    let src = rule_file::rule_path(&PathBuf::from(project_root), &full);
237    if !src.exists() {
238        return Err(format!("no generated rule `{full}` in this project"));
239    }
240    // Decided before anything outside the project is touched: the copy may
241    // exist (`promotable`), and the rule is still admitted by the sources it
242    // is bound to (`provenance::served`).
243    let content = promotable(&src, project_root)?;
244    provenance::served(&src, &content, project_root)
245        .map_err(|withheld| withheld.message("rule"))?;
246    let dst = rule_file::rule_path(home, &full);
247    if let Some(parent) = dst.parent() {
248        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
249    }
250    crate::config_io::write_atomic_with_backup(&dst, &content)?;
251    // The copy keeps the binding of its original: it stays as revocable.
252    provenance::carry_over(&src, &dst, &content)?;
253    Ok(dst.display().to_string())
254}
255
256/// The bytes a promotion copies out of the project: the ORIGINAL rule file,
257/// and only when the copy may exist. A promoted rule is a persisted copy
258/// outside the project, so the source must be admitted, complete and storable
259/// NOW (E3), and admission must leave it unchanged — a rule the current
260/// policy would mask is not copied as it is, and never copied masked.
261fn promotable(src: &std::path::Path, project_root: &str) -> Result<String, String> {
262    let mut remaining = crate::core::limits::max_read_bytes();
263    let read = crate::core::rooted_read::read_file_for_tool_rooted_admitted(
264        &src.to_string_lossy(),
265        project_root,
266        "ctx_skillify",
267        &mut remaining,
268    )
269    .map_err(|error| error.to_string())?;
270    let original = std::fs::read_to_string(src).map_err(|e| e.to_string())?;
271    if !read.storable || !read.complete || read.content != original {
272        return Err(
273            "rule withheld by the context gateway: its content may not be copied out of the \
274             project unchanged"
275                .to_string(),
276        );
277    }
278    Ok(original)
279}
280
281/// The candidate as it may be written to a rule file: title and body
282/// admitted for a derived store under the current policy. A candidate is
283/// distilled from knowledge facts and the session diary; a part that is
284/// withheld or restricted refuses the whole rule, with a content-free reason.
285fn admitted_candidate(
286    candidate: &candidate::SkillCandidate,
287) -> Result<candidate::SkillCandidate, String> {
288    use crate::core::context_admission::carrier::admit_for_store;
289    let admit = |text: &str| admit_for_store(text).map_err(|withheld| withheld.message("rule"));
290    Ok(candidate::SkillCandidate {
291        title: admit(&candidate.title)?,
292        body: admit(&candidate.body)?,
293        ..candidate.clone()
294    })
295}
296
297/// Current skillify configuration, for `status`.
298pub fn current_config() -> crate::core::config::SkillifyConfig {
299    config()
300}
301
302#[cfg(test)]
303mod admission_tests {
304    use super::*;
305
306    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
307
308    fn candidate(body: &str) -> candidate::SkillCandidate {
309        candidate::SkillCandidate {
310            slug: "rotate-keys".into(),
311            title: "Rotate keys".into(),
312            body: body.into(),
313            category: "decision".into(),
314            recurrence: 3,
315            confidence: 0.8,
316            sources: vec!["sess1".into()],
317            derivation: candidate::Derivation::default(),
318        }
319    }
320
321    /// G04: a rule distilled from a fact that is bound to a file is bound to
322    /// that file. lean-ctx lists and promotes it while the file is admitted
323    /// and no longer once it is withdrawn; a rule distilled from a fact
324    /// without a source on record is originless and withheld under
325    /// protection. Refusals are typed and carry nothing of the rule.
326    #[test]
327    fn a_rule_is_used_only_while_the_sources_of_its_facts_admit_it() {
328        use crate::core::context_admission::stored::lineage_support::{
329            governed, project, scoped, two_facts, withdraw_source,
330        };
331        let _data = governed();
332        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
333        let (_directory, root) = project();
334        let output = Path::new(&root);
335        two_facts(&root, "decision");
336        // Under protection a rule is written only with a source on record:
337        // the one distilled from the bound fact is, the one from the typed
338        // fact is refused with a typed reason and leaves no file.
339        scoped(&root, || {
340            for mined in candidate::mine_candidates(&root) {
341                match write_rule(output, &root, &mined, "2026-01-01T00:00:00Z") {
342                    Ok(outcome) => {
343                        assert_eq!(mined.slug, "token-check");
344                        assert_eq!(outcome, WriteOutcome::Created);
345                    }
346                    Err(Refused::Withheld(reason)) => {
347                        assert_eq!(mined.slug, "release-duty");
348                        assert!(reason.contains("store.no_source_authority"), "{reason}");
349                    }
350                    Err(Refused::Failed(error)) => panic!("{error}"),
351                }
352            }
353        });
354        assert!(!rule_file::rule_path(output, "skillify-release-duty").exists());
355        let created = rule_file::rule_path(output, "skillify-token-check");
356        let text = std::fs::read_to_string(&created).expect("written rule");
357        // A sibling lean-ctx wrote from the same sources, edited by hand
358        // since, and a file that was there before bindings existed.
359        let sibling = rule_file::rule_path(output, "skillify-edited");
360        std::fs::write(&sibling, &text).expect("sibling");
361        provenance::carry_over(&created, &sibling, &text).expect("binding of the sibling");
362        let edited = format!("{text}\nA line added by hand.\n");
363        std::fs::write(&sibling, &edited).expect("hand edit");
364        let legacy = rule_file::rule_path(output, "skillify-legacy");
365        std::fs::write(&legacy, "Run the linter before a push.\n").expect("legacy rule");
366
367        let listed = || scoped(&root, || list_rules(&root));
368        let slugs = |listing: &RuleListing| -> Vec<String> {
369            listing.rules.iter().map(|rule| rule.slug.clone()).collect()
370        };
371        let notice = |path: &Path, code: &'static str| -> (String, &'static str) {
372            (path.display().to_string(), code)
373        };
374        let codes = |listing: &RuleListing| -> Vec<(String, &'static str)> {
375            listing
376                .notices
377                .iter()
378                .map(|notice| (notice.path.clone(), notice.code))
379                .collect()
380        };
381        // Served while its source is admitted. A notice is not a rule.
382        let before = listed();
383        assert_eq!(slugs(&before), ["skillify-token-check"]);
384        assert_eq!(
385            codes(&before),
386            [notice(&legacy, "rule.no_source_on_record")]
387        );
388
389        // Withdrawn: the rule lean-ctx wrote leaves the host's directory — a
390        // move, the bytes are kept — and the hand-edited one is left alone
391        // and reported, with nothing of either rule in the report. The
392        // quarantine is keyed by the canonical path of the rule while it
393        // exists, as the move computes it: once moved, the path no longer
394        // canonicalizes (on Windows the raw spelling then differs).
395        let (copy, origin) = provenance::quarantine_paths(&created).expect("quarantine");
396        withdraw_source(&root);
397        let after = listed();
398        assert!(slugs(&after).is_empty());
399        assert!(!created.exists());
400        assert_eq!(
401            std::fs::read_to_string(copy).ok().as_deref(),
402            Some(text.as_str())
403        );
404        assert!(origin.is_file());
405        assert_eq!(
406            std::fs::read_to_string(&sibling).ok().as_deref(),
407            Some(edited.as_str())
408        );
409        assert_eq!(
410            codes(&after),
411            [
412                notice(&sibling, "rule.edited_source_withdrawn"),
413                notice(&legacy, "rule.no_source_on_record"),
414            ]
415        );
416        assert!(
417            after
418                .notices
419                .iter()
420                .all(|notice| !notice.reason.contains("RS256"))
421        );
422
423        // After the move there is no rule to promote, and nothing is copied.
424        let home = tempfile::tempdir().expect("home");
425        let refused = scoped(&root, || promote_into(&root, "token-check", home.path()))
426            .expect_err("a moved rule is not promoted");
427        assert!(refused.contains("no generated rule"), "{refused}");
428        assert!(!home.path().join(".cursor").exists());
429    }
430
431    /// E3: a generated rule file was written with whatever the miner
432    /// distilled, and `promote` copied any rule file out of the project raw.
433    #[test]
434    fn rule_files_receive_admitted_text_and_only_unmasked_rules_are_promoted() {
435        use crate::core::context_admission::stored::lineage_support::{
436            bound_fact, project as canonical_project, scoped, withdraw_source,
437        };
438        let _data = crate::core::data_dir::isolated_data_dir();
439        let project = tempfile::tempdir().unwrap();
440        let root = project.path().to_string_lossy().into_owned();
441        let rule = |name: &str, content: &str| {
442            let path = rule_file::rule_path(project.path(), name);
443            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
444            std::fs::write(&path, content).unwrap();
445            path
446        };
447        {
448            let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
449            // Control: a clean candidate and a clean rule keep their bytes.
450            let clean = admitted_candidate(&candidate("Run the linter before a push.")).unwrap();
451            assert_eq!(clean.body, "Run the linter before a push.");
452            let path = rule("skillify-clean", "Run the linter before a push.\n");
453            assert_eq!(
454                promotable(&path, &root).as_deref(),
455                Ok("Run the linter before a push.\n")
456            );
457
458            let keyed = admitted_candidate(&candidate(&format!("Deploy with {AWS_KEY}.")));
459            assert!(keyed.is_ok_and(|rule| !rule.body.contains(AWS_KEY)));
460            let path = rule("skillify-keyed", &format!("Deploy with {AWS_KEY}.\n"));
461            let refused = promotable(&path, &root).expect_err("a masked rule is not copied");
462            assert!(!refused.contains(AWS_KEY), "{refused}");
463
464            // G04, Community developer mode: a rule bound to a source that is
465            // withdrawn is not moved (no protection claim) and is not copied
466            // out of the project — refused with the typed reason, nothing of
467            // the rule in it, nothing written under the home directory.
468            let (_bound, bound_root) = canonical_project();
469            let output = Path::new(&bound_root);
470            bound_fact(&bound_root, "decision");
471            scoped(&bound_root, || {
472                for mined in candidate::mine_candidates(&bound_root) {
473                    let written = write_rule(output, &bound_root, &mined, "2026-01-01T00:00:00Z");
474                    assert!(
475                        matches!(written, Ok(WriteOutcome::Created)),
476                        "{}",
477                        mined.slug
478                    );
479                }
480            });
481            let kept = rule_file::rule_path(output, "skillify-token-check");
482            assert!(kept.is_file());
483            withdraw_source(&bound_root);
484            let home = tempfile::tempdir().unwrap();
485            let refused = scoped(&bound_root, || {
486                promote_into(&bound_root, "token-check", home.path())
487            })
488            .expect_err("a rule whose source is withdrawn is not copied");
489            assert!(refused.contains("store.source_unavailable"), "{refused}");
490            assert!(!refused.contains("RS256"), "{refused}");
491            assert!(kept.is_file());
492            assert!(!home.path().join(".cursor").exists());
493        }
494        let policy = crate::core::policy::load(
495            "name = \"skillify-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
496             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
497        )
498        .unwrap();
499        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
500        let refused = admitted_candidate(&candidate("CONFIDENTIAL\nroadmap-marker"))
501            .expect_err("a blocked candidate is not written");
502        assert!(!refused.contains("roadmap-marker"), "{refused}");
503        let path = rule("skillify-plan", "CONFIDENTIAL\nroadmap-marker\n");
504        let refused = promotable(&path, &root).expect_err("a blocked rule is not copied");
505        assert!(!refused.contains("roadmap-marker"), "{refused}");
506    }
507}