From bc8ccb1bd23199657bcd38d6f8c481c4304161a9 Mon Sep 17 00:00:00 2001 From: ledger Date: Mon, 26 Oct 2026 22:19:11 +0000 Subject: [PATCH] volt: spark/hermes_profile_health.py grades a named profile (Q81) -- --profile/NAME/HERMES_HOME replaces the module constant, is_dir() replaces exists() on the four state dirs, config.yaml is parsed by yaml.safe_load and graded non-empty/non-mapping, the .hermes_profile branch is deleted (0 of 27 profile homes carry it, 0 occurrences in the Hermes source tree), and rc 2 is reserved for a target that cannot be graded --- wizard-work/spark/hermes_profile_health.py | 172 +++++++++++++++++---- 1 file changed, 138 insertions(+), 34 deletions(-) diff --git a/wizard-work/spark/hermes_profile_health.py b/wizard-work/spark/hermes_profile_health.py index 153279c..45bed1e 100644 --- a/wizard-work/spark/hermes_profile_health.py +++ b/wizard-work/spark/hermes_profile_health.py @@ -1,49 +1,153 @@ #!/usr/bin/env python3 -"""Hermes Profile Health Checker - verifies spark profile structure. -Exit 0 = healthy, 1 = issues found, 2 = critical missing +"""Hermes profile health checker - grades ONE profile home. + +Exit codes (troupe contract): 0 = clean, 1 = ran and named faults in the profile, +2 = could not grade (no profile named, profile home absent or not a directory, or an +input could not be read). A 2 never prints a verdict. + +Profile home resolution, first match wins: + 1. --profile PATH (absolute, contains os.sep, or starts with '~' or '.') + 2. --profile NAME resolved under --profiles-root + 3. $HERMES_HOME Hermes' own variable for the active profile home +--profiles-root defaults to $HERMES_PROFILES_DIR, else ~/.hermes/profiles. + +Graded: config.yaml must exist, be non-empty, and yaml.safe_load to a non-empty +mapping; skills/, plugins/, cron/, memories/ must be directories (a regular file of +that name is a fault, not a pass). + +Not graded, and not claimed: permissions, ownership, symlinked profile homes, whether +individual skills/plugins load, and config.yaml's schema (any non-empty mapping +passes; this tool does not know Hermes' key set). +The `.hermes_profile` marker is neither required nor reported: no profile home on +this host carries one (0 of 27) and nothing in the Hermes source tree writes it. """ +import argparse +import os import sys from pathlib import Path -PROFILE_DIR = Path("/root/.hermes/profiles/spark") -EXPECTED_DIRS = ["skills", "plugins", "cron", "memories"] +try: + import yaml +except ImportError: # graded as ungradable, never as a pass + yaml = None -def check_profile(): - issues = [] - - # Check profile dir exists - if not PROFILE_DIR.exists(): - print("CRITICAL: Profile directory missing") +EXPECTED_DIRS = ("skills", "plugins", "cron", "memories") +DEFAULT_PROFILES_ROOT = "~/.hermes/profiles" + + +class Ungradable(Exception): + """Raised when no verdict can be earned; main() turns it into rc 2.""" + + +def resolve_home(profile, profiles_root): + """Map a --profile value (path or name) or $HERMES_HOME to a profile home path.""" + if profile: + if ( + os.sep in profile + or (os.altsep and os.altsep in profile) + or profile.startswith("~") + or profile.startswith(".") + or Path(profile).is_absolute() + ): + return Path(profile).expanduser() + root = profiles_root or os.environ.get("HERMES_PROFILES_DIR") or DEFAULT_PROFILES_ROOT + return Path(root).expanduser() / profile + env_home = os.environ.get("HERMES_HOME", "").strip() + if env_home: + return Path(env_home).expanduser() + raise Ungradable("no profile named: pass --profile PATH|NAME or set HERMES_HOME") + + +def grade_config(home): + """Return (ok_lines, issues) for /config.yaml.""" + cfg = home / "config.yaml" + if not cfg.exists(): + return [], ["config.yaml missing"] + if not cfg.is_file(): + return [], ["config.yaml exists but is not a regular file"] + try: + raw = cfg.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise Ungradable(f"config.yaml unreadable: {exc}") from exc + if not raw.strip(): + return [], ["config.yaml is empty"] + if yaml is None: + raise Ungradable("PyYAML not importable, cannot grade config.yaml") + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + text = str(exc).strip() + first = text.splitlines()[0] if text else exc.__class__.__name__ + return [], [f"config.yaml is not valid YAML: {first}"] + if data is None: + return [], ["config.yaml parses to no document"] + if not isinstance(data, dict): + return [], [f"config.yaml is a {type(data).__name__}, not a mapping"] + if not data: + return [], ["config.yaml is an empty mapping"] + return [f"{cfg} ({len(data)} key(s), parsed)"], [] + + +def check_profile(home): + """Grade one profile home. Returns the process exit code.""" + home = Path(home) + if not home.exists(): + print(f"ungradable: profile home does not exist: {home}", file=sys.stderr) return 2 - - # Check config.yaml - config = PROFILE_DIR / "config.yaml" - if not config.exists(): - issues.append("config.yaml missing") - else: - print(f"OK: {config}") - - # Check expected directories + if not home.is_dir(): + print(f"ungradable: profile home is not a directory: {home}", file=sys.stderr) + return 2 + + oks, issues = grade_config(home) for dirname in EXPECTED_DIRS: - dirpath = PROFILE_DIR / dirname - if not dirpath.exists(): - issues.append(f"{dirname}/ missing") + dirpath = home / dirname + if dirpath.is_dir(): + oks.append(f"{dirpath}/") + elif dirpath.exists(): + issues.append(f"{dirname} exists but is not a directory") else: - print(f"OK: {dirpath}/") - - # Check magic file - magic = PROFILE_DIR / ".hermes_profile" - if magic.exists(): - print(f"OK: {magic}") - + issues.append(f"{dirname}/ missing") + + for line in oks: + print(f"OK: {line}") if issues: print("\nIssues:") - for i in issues: - print(f" - {i}") + for issue in issues: + print(f" - {issue}") + print(f"\nProfile is unhealthy: {len(issues)} issue(s)") return 1 - - print("\nProfile is healthy") + + print(f"\nProfile is healthy: {home}") return 0 + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Grade one Hermes profile home (0 healthy / 1 faults / 2 ungradable).", + ) + parser.add_argument( + "--profile", + help="profile home PATH (absolute, or containing '/', '~', '.') or a profile NAME " + "resolved under --profiles-root; defaults to $HERMES_HOME", + ) + parser.add_argument( + "--profiles-root", + default=None, + help="root for NAME resolution (default: $HERMES_PROFILES_DIR or " + + DEFAULT_PROFILES_ROOT + ")", + ) + args = parser.parse_args(argv) + try: + home = resolve_home(args.profile, args.profiles_root) + except Ungradable as exc: + print(f"ungradable: {exc}", file=sys.stderr) + return 2 + try: + return check_profile(home) + except Ungradable as exc: + print(f"ungradable: {exc}", file=sys.stderr) + return 2 + + if __name__ == "__main__": - sys.exit(check_profile()) \ No newline at end of file + sys.exit(main())