Files
hermes-agent/tools/skill_linter.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

216 lines
11 KiB
Python

"""Structural + convention linter for SKILL.md files.
The hard validator (``skill_manager_tool._validate_frontmatter``) blocks the
non-negotiables; this is the advisory companion encoding the CONTRIBUTING.md
"Skill authoring standards" a human reviewer would otherwise catch. Findings
never block by themselves — ``lint_skill`` returns ``LintFinding`` rows and the
caller decides. Frontmatter parsing is delegated to ``agent.skill_utils`` so
BOM handling and the prompt description budget stay in one place.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
from agent.skill_utils import SKILL_PROMPT_DESC_LIMIT, parse_frontmatter
# Shell utilities already wrapped as native tools; naming them in prose steers
# the model to a raw shell call. banned token -> native tool to name instead.
_SHELL_UTIL_TO_TOOL: Dict[str, str] = {
"grep": "search_files", "rg": "search_files", "cat": "read_file", "head": "read_file",
"tail": "read_file", "sed": "patch", "awk": "patch",
"find": "search_files (target='files')", "ls": "search_files (target='files')"}
_MARKETING_WORDS = (
"powerful", "comprehensive", "seamless", "advanced", "cutting-edge", "state-of-the-art",
"revolutionary", "robust")
# POSIX-only primitives that require ``platforms:`` when a bundled script uses
# them. Detected in scripts/, not in prose.
_POSIX_PRIMITIVES = (
"fcntl",
"termios",
"os.setsid", # windows-footgun: ok (search-pattern string, not a call)
"signal.SIGKILL", # windows-footgun: ok (search-pattern string, not a call)
"osascript",
"/proc/",
"apt-get",
"systemctl")
# Scaffolding files a skill should not ship (noise, not skill content).
_FORBIDDEN_FILES = ("README.md", "CHANGELOG.md", "install.sh", ".env", ".env.example", ".gitignore")
# Presence of the load-bearing section is checked, not exact ordering, so the
# linter is not a change-detector.
_EXPECTED_SECTIONS = ("When to Use", "When to use")
# incident-log-shape: at least this many PR/issue refs AND this density (per 1k chars of prose).
_INCIDENT_REF_MIN = 4
_INCIDENT_REF_PER_KCHAR = 0.5 # the 100k incident-log SKILL.md this targets sat at ~0.7
# references-sprawl: a skill carrying more reference files than this is hoarding per-session notes.
# Calibration: a deliberately curated large workflow skill sits near 50 topical files; the hoarding
# shape this catches was 443 one-per-session files.
_MAX_REFERENCE_FILES = 60
# oversized-body: SKILL.md is loaded whole by skill_view and then rides in context for every later
# call of the session, so body size is paid per turn, not once. The authoring standard is ~200 lines;
# this budget is ~3x that (bundled skills average ~20k chars). The hard cap in skill_manager_tool
# (100k) is a safety stop, not a target — agent-authored skills grew to sit right under it.
_BODY_SOFT_BUDGET_CHARS = 24_000
ERROR = "error"
WARNING = "warning"
@dataclass
class LintFinding:
"""A single lint result. ``severity`` is advisory metadata for the caller."""
severity: str # ERROR | WARNING
rule: str
message: str
def _err(rule: str, message: str) -> LintFinding:
return LintFinding(ERROR, rule, message)
def _warn(rule: str, message: str) -> LintFinding:
return LintFinding(WARNING, rule, message)
def _strip_code_blocks(body: str) -> str:
"""Remove fenced code blocks so prose-only checks don't fire on examples."""
return re.sub(r"```.*?```", "", body, flags=re.S)
def _check_frontmatter(frontmatter: Dict[str, Any], skill_dir: Optional[Path]) -> Iterator[LintFinding]:
name = str(frontmatter.get("name", "")).strip()
if name and not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", name):
yield _err("name-format", f"name '{name}' must be lowercase letters, digits, hyphens, "
f"and underscores only.")
if skill_dir is not None and name and name != skill_dir.name:
yield _err("name-dir-mismatch", f"frontmatter name '{name}' does not match directory "
f"'{skill_dir.name}'; they must be identical.")
# Measure the raw authored value: extract_skill_description() already
# truncates to the prompt budget, so it can never exceed the limit.
desc = str(frontmatter.get("description", "")).strip().strip("'\"")
if len(desc) > SKILL_PROMPT_DESC_LIMIT:
yield _warn("description-length",
f"description is {len(desc)} chars; the skill index truncates past "
f"{SKILL_PROMPT_DESC_LIMIT} chars + '...', losing routing "
f"signal. Keep it to one sentence.")
hits = [w for w in _MARKETING_WORDS if re.search(rf"\b{re.escape(w)}\b", desc.lower())]
if hits:
yield _warn("description-marketing",
f"description contains marketing words {hits}; state the capability, not adjectives.")
for key in ("version", "author", "license"):
if key not in frontmatter:
yield _warn("missing-metadata", f"frontmatter is missing '{key}'; every peer skill has it.")
meta = frontmatter.get("metadata")
hermes_meta = meta.get("hermes") if isinstance(meta, dict) else None
if not isinstance(hermes_meta, dict):
yield _warn("missing-metadata", "frontmatter is missing metadata.hermes.{tags, related_skills}.")
elif "tags" not in hermes_meta:
yield _warn("missing-metadata", "metadata.hermes.tags is missing.")
author = str(frontmatter.get("author", ""))
if author and author.strip().lower() in ("hermes", "agent", "hermes agent") and (
author != "Hermes Agent"):
yield _warn("author-caps", f"author '{author}' should be 'Hermes Agent' (proper caps) "
f"or a real contributor name.")
platforms = frontmatter.get("platforms")
if platforms:
valid = {"linux", "macos", "windows", "darwin"}
items = platforms if isinstance(platforms, list) else [platforms]
bad = [p for p in items if str(p).lower() not in valid]
if bad:
yield _warn("platforms-value", f"platforms contains unrecognized value(s) {bad}; "
f"expected a subset of {sorted(valid)}.")
def _check_body(body: str, skill_dir: Optional[Path]) -> Iterator[LintFinding]:
if len(body) > _BODY_SOFT_BUDGET_CHARS:
yield _warn("oversized-body",
f"SKILL.md body is {len(body):,} chars (~{len(body) // 4:,} tokens); skill_view loads "
f"all of it and it stays in context for every later call of the session. Keep the "
f"always-on rules here (~200 lines) and move topic depth into references/<topic>.md, "
f"linked from the body.")
# Only backtick-wrapped mentions in PROSE (not fenced code): bare words are too noisy.
prose = _strip_code_blocks(body)
for util, tool in _SHELL_UTIL_TO_TOOL.items():
if re.search(rf"`{re.escape(util)}`", prose):
yield _warn("shell-utility-reference",
f"prose references `{util}`; name the native tool `{tool}` instead.")
if not any(re.search(rf"^#+\s+{re.escape(s)}", body, re.M) for s in _EXPECTED_SECTIONS):
yield _warn("missing-section", "no '## When to Use' section found; skills need explicit "
"trigger conditions near the top.")
# Incident-log shape: a skill body dense in PR/issue numbers is narrating history instead of
# stating rules. Threshold is per 1k chars so a long body with one citation is fine.
refs = len(re.findall(r"(?<![\w/])#\d{3,6}\b|\b(?:PR|issue)\s*#?\d{3,6}\b", prose))
if refs >= _INCIDENT_REF_MIN and refs / max(len(prose), 1) * 1000 >= _INCIDENT_REF_PER_KCHAR:
yield _warn("incident-log-shape", f"{refs} PR/issue references in prose; write the generalizable "
"rule + why and drop the incident numbers — the rule must stand without the story.")
if skill_dir is None:
return
# Dangling links. Only references/, templates/, assets/ are reliably skill-owned;
# `scripts/` is excluded because dev skills legitimately cite repo-root scripts.
seen: set[str] = set()
for match in re.finditer(r"(references|templates|assets)/[\w./-]+", body):
rel = match.group(0)
if rel in seen or "*" in rel or rel.endswith("/"): # dupes, placeholders, globs
continue
seen.add(rel)
if not (skill_dir / rel).exists():
yield _warn("dangling-reference", f"body references '{rel}' but that file "
f"does not exist in the skill directory.")
def _check_files(frontmatter: Dict[str, Any], skill_dir: Path) -> Iterator[LintFinding]:
# Bundled scripts using POSIX-only primitives require a platforms: declaration.
scripts_dir = skill_dir / "scripts"
offenders: Dict[str, List[str]] = {}
if not frontmatter.get("platforms") and scripts_dir.is_dir():
for script in scripts_dir.rglob("*"):
if not script.is_file() or script.suffix not in (".py", ".sh", ".bash"):
continue
try:
text = script.read_text(encoding="utf-8-sig", errors="ignore")
except OSError:
continue
hit = [p for p in _POSIX_PRIMITIVES if p in text]
if hit:
offenders[script.name] = hit
if offenders:
detail = "; ".join(f"{k}: {v}" for k, v in offenders.items())
yield _warn("platforms-gating",
f"scripts use POSIX-only primitives ({detail}) but no 'platforms:' frontmatter is "
f"declared. Fix cross-platform or gate with platforms: [linux, macos].")
for fname in _FORBIDDEN_FILES:
if (skill_dir / fname).exists():
yield _warn("forbidden-file",
f"skill ships '{fname}'; skills should not include scaffolding/config files.")
refs_dir = skill_dir / "references"
if refs_dir.is_dir():
n_refs = sum(1 for p in refs_dir.rglob("*.md") if not any(part.startswith("_") for part in p.parts))
if n_refs > _MAX_REFERENCE_FILES:
yield _warn("references-sprawl",
f"{n_refs} files under references/; that is a per-session log, not topical depth. "
"Merge same-topic files into one rule set and drop incident narration.")
def lint_content(content: str, *, skill_dir: Optional[Path] = None) -> List[LintFinding]:
"""Lint raw SKILL.md *content*.
``skill_dir`` enables on-disk checks (name/dir match, dangling links, POSIX
gating, forbidden files); without it only content checks run, which is what
the create path needs before the file exists.
"""
frontmatter, body = parse_frontmatter(content)
findings = list(_check_frontmatter(frontmatter, skill_dir)) + list(_check_body(body, skill_dir))
if skill_dir is not None:
findings += _check_files(frontmatter, skill_dir)
return findings
def lint_skill(skill_md_path: Path) -> List[LintFinding]:
"""Lint a SKILL.md file on disk, with all on-disk checks enabled."""
skill_md_path = Path(skill_md_path)
content = skill_md_path.read_text(encoding="utf-8-sig", errors="ignore")
return lint_content(content, skill_dir=skill_md_path.parent)