use std::path::Path;
use crate::rules::{Evidence, Examine, Finding, Rule, Stance, Trend};
use crate::shell::Parsed;
pub const RULE: Rule = Rule {
id: "agent-guidance-shape",
default_stance: Stance::Advise,
max_stance: Stance::Advise,
evidence: Evidence {
per_1000: 0.65,
measured: "2026-10-13",
trend: Trend::Rare,
},
examine: Examine::Legacy(examine),
confirm: None,
};
fn examine(_parsed: &Parsed) -> Option<Finding> {
None
}
pub const BUDGET: usize = 8192;
fn has_trigger(value: &str) -> bool {
value.to_lowercase().contains("whenever")
|| value.match_indices("Use ").any(|(at, _)| {
value[at + 4..]
.chars()
.next()
.is_some_and(char::is_lowercase)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Skill,
AlwaysOn,
}
pub fn kind(path: &Path) -> Option<Kind> {
match path.file_name().and_then(|n| n.to_str())? {
"SKILL.md" => Some(Kind::Skill),
"CLAUDE.md" | "CLAUDE.md.tmpl" | "AGENTS.md" | "MEMORY.md" => Some(Kind::AlwaysOn),
_ => None,
}
}
pub fn check(path: &Path, kind: Kind, before: &str, after: &str) -> Option<String> {
let shown = path.to_string_lossy();
let file = path.file_name().and_then(|n| n.to_str()).unwrap_or(&shown);
match kind {
Kind::Skill => {
let problem = skill_problem(after)?;
Some(crate::decision::phrase(
RULE.id,
&format!("`{file}` {problem}, so the model cannot tell when to load it."),
"Give it a one-line `description:` with a `Use when …` clause, or set \
`disable-model-invocation: true` (guidance.pointer-names-when).",
))
}
Kind::AlwaysOn => {
let size = normalised(after).len();
let was = normalised(before).len();
if size <= BUDGET || size <= was {
return None;
}
Some(crate::decision::phrase(
RULE.id,
&format!(
"`{file}` is loaded into every session and is now {size} bytes, over the \
{BUDGET} byte budget; this write adds {} bytes.",
size - was
),
"Move the text behind a pointer: one line that names when to fetch the detail \
(guidance.always-on-is-an-index).",
))
}
}
}
pub fn excerpt(shown: &str, kind: Kind) -> String {
let what = match kind {
Kind::Skill => "skill",
Kind::AlwaysOn => "size",
};
format!("{what} {shown}")
}
fn normalised(text: &str) -> String {
text.replace("\r\n", "\n")
}
fn skill_problem(text: &str) -> Option<&'static str> {
let text = normalised(text);
let mut fences = text
.lines()
.enumerate()
.filter(|(_, l)| l.trim_end() == "---")
.map(|(i, _)| i);
let front: Vec<&str> = match (fences.next(), fences.next()) {
(Some(a), Some(b)) => text.lines().skip(a + 1).take(b - a - 1).collect(),
_ => Vec::new(),
};
let mut description: Option<usize> = None;
for (i, line) in front.iter().enumerate() {
if let Some(value) = line.strip_prefix("disable-model-invocation:") {
if value.trim().trim_matches(['"', '\'']) == "true" {
return None;
}
} else if line.starts_with("description:") && description.is_none() {
description = Some(i);
}
}
let Some(at) = description else {
return Some("has no `description:` in its front matter");
};
let value = front[at]["description:".len()..].trim();
let continues = front
.get(at + 1)
.is_some_and(|l| l.starts_with(' ') || l.starts_with('\t'));
if value.starts_with('>') || value.starts_with('|') {
Some("has a block-scalar `description:`")
} else if continues {
Some("has a `description:` that spans more than one line")
} else if value.is_empty() {
Some("has an empty `description:`")
} else if !has_trigger(value) {
Some("has a `description:` with no `Use when …` trigger clause")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD: &str =
"---\nname: x\ndescription: Does x. Use when the person asks for x.\n---\nbody\n";
fn skill(text: &str) -> Option<String> {
check(Path::new("a/SKILL.md"), Kind::Skill, "", text)
}
#[test]
fn a_one_line_description_with_a_trigger_passes() {
assert_eq!(skill(GOOD), None);
}
#[test]
fn every_trigger_form_counts() {
for d in [
"Does x. Use when y.",
"Does x. Use whenever y.",
"Does x. Use for y.",
"Does x. Use at the start of a task.",
"Does x. Use before a push.",
"Does x. Use after a merge.",
"Does x. Use proactively, without being asked.",
"Run the panel. Use in plan mode every time a plan is presented.",
"Load this FIRST, before any command, whenever a bug's cause is not known yet.",
] {
let text = format!("---\ndescription: {d}\n---\n");
assert_eq!(skill(&text), None, "{d}");
}
}
#[test]
fn use_followed_by_a_name_is_not_a_trigger() {
assert!(skill("---\ndescription: Does x. Use Foo.\n---\n").is_some());
}
#[test]
fn a_description_without_a_trigger_is_flagged() {
let t = skill("---\nname: x\ndescription: Does x.\n---\n").expect("flagged");
assert!(
t.contains("agent-guidance-shape") && t.contains("trigger"),
"{t}"
);
assert!(t.contains("guidance.pointer-names-when"), "{t}");
}
#[test]
fn block_and_multi_line_descriptions_are_flagged() {
assert!(skill("---\ndescription: >\n Use when y.\n---\n").is_some());
assert!(skill("---\ndescription: |\n Use when y.\n---\n").is_some());
assert!(skill("---\ndescription: Does x.\n Use when y.\n---\n").is_some());
assert!(skill("---\ndescription:\n Use when y.\n---\n").is_some());
}
#[test]
fn a_missing_description_or_front_matter_is_flagged() {
assert!(skill("---\nname: x\n---\n").is_some());
assert!(skill("# Title\n").is_some());
}
#[test]
fn disable_model_invocation_exempts_the_skill() {
assert_eq!(skill("---\ndisable-model-invocation: true\n---\n"), None);
assert_eq!(
skill("---\ndescription: >\n x\ndisable-model-invocation: \"true\"\n---\n"),
None
);
assert!(skill("---\ndisable-model-invocation: false\n---\n").is_some());
}
fn always(before: usize, after: usize) -> Option<String> {
check(
Path::new("CLAUDE.md"),
Kind::AlwaysOn,
&"x".repeat(before),
&"x".repeat(after),
)
}
#[test]
fn growing_past_the_budget_is_flagged_with_numbers() {
let t = always(9000, 9100).expect("flagged");
assert!(
t.contains("9100") && t.contains("8192") && t.contains("100 bytes"),
"{t}"
);
assert!(t.contains("guidance.always-on-is-an-index"), "{t}");
}
#[test]
fn crossing_the_budget_is_flagged_and_a_new_file_starts_at_zero() {
assert!(always(7000, 9000).is_some());
assert!(always(0, 9000).is_some());
}
#[test]
fn shrinking_staying_equal_or_staying_under_passes() {
assert_eq!(always(9000, 8900), None);
assert_eq!(always(9000, 9000), None);
assert_eq!(always(100, 8192), None);
}
#[test]
fn crlf_counts_as_one_byte() {
let before = "a\n".repeat(4000);
let after = "a\r\n".repeat(4096);
assert_eq!(
check(Path::new("MEMORY.md"), Kind::AlwaysOn, &before, &after),
None
);
}
#[test]
fn kinds() {
assert_eq!(kind(Path::new("x/SKILL.md")), Some(Kind::Skill));
assert_eq!(kind(Path::new("CLAUDE.md.tmpl")), Some(Kind::AlwaysOn));
assert_eq!(kind(Path::new("feedback.md")), None);
assert_eq!(kind(Path::new(".claude/agents/a.md")), None);
}
}