use crate::publish_cmd::{self, Kind};
use crate::rules::{Confirmed, Context, Evidence, Examine, Finding, Rule, Stance, Trend};
use crate::shell::Parsed;
use crate::skill_window::{self, Window};
pub const RULE: Rule = Rule {
id: "publish-without-skill",
default_stance: Stance::Advise,
max_stance: Stance::Deny,
evidence: Evidence {
per_1000: 28.3,
measured: "2026-10-07",
trend: Trend::Improving,
},
examine: Examine::Legacy(examine),
confirm: Some(confirm),
};
fn examine(parsed: &Parsed) -> Option<Finding> {
let p = publish_cmd::classify(parsed)?;
Some(Finding {
reason: missing(p.kind, &p.what),
remedy: remedy(p.kind.skill()),
span: p.span,
})
}
fn missing(kind: Kind, what: &str) -> String {
let skill = kind.skill();
let when = "no call of it is in this turn or the one before";
match kind {
Kind::TagPush => format!(
"Call the {skill} skill first: `{what}` publishes a release, and {when}. \
The skill holds the steps memory skips: HEAD advanced before the tag \
was cut, then the published artefact checked, not the workflow \
(ADR-0008, release.trigger)."
),
Kind::PrMerge => format!(
"Call the {skill} skill first: `{what}` merges a pull request, and {when}. \
The skill holds the steps memory skips: the checks polled to \
completion, their conclusion read as its own step, then the merge \
(ci.gates-the-merge)."
),
}
}
fn remedy(skill: &str) -> String {
format!(
"Run the Skill tool with `{skill}`, follow it, then re-run this command. If \
the skill does not apply here, ask the person; they can run `git config \
--global amont.agent.publish-without-skill.stance observe`."
)
}
fn confirm(ctx: &Context, _finding: &Finding) -> Confirmed {
let Some(p) = publish_cmd::classify(ctx.parsed) else {
return Confirmed::No("not a publish");
};
let skill = p.kind.skill();
let unknown = |why: &str, tag: &str| Confirmed::YesSaying {
remedy: None,
floor: None,
ceiling: Some(Stance::Advise),
reason: format!(
"Whether the {skill} skill ran cannot be told ({why}), and `{}` publishes: \
make sure its steps were followed.",
p.what
),
excerpt: format!("skill={skill} window=unknown:{tag} {}", p.what),
};
match installed(skill, &ctx.cwd_at(p.span.start)) {
Found::Yes => {}
Found::No => return unknown("it is not installed here", "not-installed"),
Found::Unreadable => {
return unknown("the skill folders could not be read", "skills-unreadable");
}
}
let mut best: Option<Window> = None;
let mut failed: Option<&'static str> = None;
for path in [ctx.transcript, ctx.agent_transcript].into_iter().flatten() {
match std::fs::File::open(path)
.map_err(|_| "the transcript could not be opened")
.and_then(|f| skill_window::skill_window(f, skill))
{
Ok(w) => best = Some(best.map_or(w, |b| b.nearer(w))),
Err(why) => failed = Some(why),
}
}
let window = match (best, failed) {
(Some(w), _) if w.covers() => w,
(_, Some(why)) => return unknown(why, "transcript"),
(Some(w), None) => w,
(None, None) => return unknown("the session named no transcript", "no-transcript"),
};
match window {
Window::Current => Confirmed::No("skill called in this turn"),
Window::Previous => Confirmed::No("skill called in the previous turn"),
Window::Outside => Confirmed::YesSaying {
remedy: None,
floor: None,
ceiling: None,
reason: missing(p.kind, &p.what),
excerpt: format!("skill={skill} window=outside {}", p.what),
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Found {
Yes,
No,
Unreadable,
}
fn installed(skill: &str, cwd: &std::path::Path) -> Found {
let config = crate::settings::config_dir();
let leaf = std::path::Path::new("skills").join(skill).join("SKILL.md");
if let Some(config) = &config {
if config.join(&leaf).is_file() {
return Found::Yes;
}
}
if cwd
.ancestors()
.any(|d| d.join(".claude").join(&leaf).is_file())
{
return Found::Yes;
}
match config {
Some(c) => plugin_has(&c.join("plugins"), &leaf, 6),
None => Found::No,
}
}
const PLUGIN_FANOUT: usize = 200;
fn plugin_has(dir: &std::path::Path, leaf: &std::path::Path, depth: u8) -> Found {
if dir.join(leaf).is_file() {
return Found::Yes;
}
if depth == 0 {
return Found::No;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Found::No,
Err(_) => return Found::Unreadable,
};
let mut found = Found::No;
let mut followed = 0;
for entry in entries {
let Ok(entry) = entry else {
found = Found::Unreadable;
continue;
};
let name = entry.file_name();
if !entry.file_type().is_ok_and(|t| t.is_dir()) || name == "node_modules" || name == ".git"
{
continue;
}
if followed == PLUGIN_FANOUT {
break;
}
followed += 1;
match plugin_has(&entry.path(), leaf, depth - 1) {
Found::Yes => return Found::Yes,
Found::Unreadable => found = Found::Unreadable,
Found::No => {}
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plugin_skill_is_found_past_many_files() {
let root = std::env::temp_dir().join(format!("pws-plugins-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let market = root.join("cache/market");
std::fs::create_dir_all(&market).unwrap();
for i in 0..(PLUGIN_FANOUT + 5) {
std::fs::write(market.join(format!("f{i}")), "").unwrap();
}
let skill = market.join("tools/1.0.0/skills/tag-release");
std::fs::create_dir_all(&skill).unwrap();
std::fs::write(skill.join("SKILL.md"), "").unwrap();
let leaf = std::path::Path::new("skills/tag-release/SKILL.md");
assert_eq!(plugin_has(&root, leaf, 6), Found::Yes);
let other = std::path::Path::new("skills/merge-when-green/SKILL.md");
assert_eq!(plugin_has(&root, other, 6), Found::No);
assert_eq!(plugin_has(&root.join("absent"), leaf, 6), Found::No);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_refusal_leads_with_the_skill() {
let head = format!(
"amont-agent/{}: {}",
RULE.id,
missing(Kind::PrMerge, "gh pr merge")
);
let first: String = head.chars().take(80).collect();
assert!(
first.contains("Call the merge-when-green skill first"),
"{first}"
);
}
}