From 7095420694a49cb2deb1e53ee2ddd17812ab25db Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:23:13 +0530 Subject: [PATCH 01/11] Add skill-doctor: diagnose silent skill discovery failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs 6 diagnostic checks per skill (YAML parse, required fields, path conventions, cron format, stateful coherence, schema validity). Exits 1 when FAILs are present — suitable as a post-install gate in install.sh. Co-Authored-By: Claude Sonnet 4.6 --- skills/openclaw-native/skill-doctor/SKILL.md | 97 +++++ .../skill-doctor/STATE_SCHEMA.yaml | 33 ++ skills/openclaw-native/skill-doctor/doctor.py | 374 ++++++++++++++++++ .../skill-doctor/example-state.yaml | 57 +++ 4 files changed, 561 insertions(+) create mode 100644 skills/openclaw-native/skill-doctor/SKILL.md create mode 100644 skills/openclaw-native/skill-doctor/STATE_SCHEMA.yaml create mode 100755 skills/openclaw-native/skill-doctor/doctor.py create mode 100644 skills/openclaw-native/skill-doctor/example-state.yaml diff --git a/skills/openclaw-native/skill-doctor/SKILL.md b/skills/openclaw-native/skill-doctor/SKILL.md new file mode 100644 index 0000000..c269529 --- /dev/null +++ b/skills/openclaw-native/skill-doctor/SKILL.md @@ -0,0 +1,97 @@ +--- +name: skill-doctor +version: "1.0" +category: openclaw-native +description: Diagnoses silent skill discovery failures — YAML parse errors, path violations, schema mismatches — so broken skills don't disappear without a trace. +stateful: true +--- + +# Skill Doctor + +## What it does + +OpenClaw loads skills at startup. When a skill fails to load — corrupt frontmatter, bad cron expression, mismatched STATE_SCHEMA — it silently disappears from the registry. There is no error surfaced to the agent. + +Skill Doctor runs a full diagnostic pass over all installed skills and reports every failure that would cause silent non-loading, so you can fix problems before they become invisible gaps. + +## When to invoke + +- After installing new skills or upgrading openclaw-superpowers +- When a skill you expect to find is missing from the registry +- As a post-install gate inside `install.sh` +- Manually, any time something feels off with skill behaviour + +## Diagnostic checks + +Skill Doctor runs 6 checks per skill: + +| Check | Failure condition | +|---|---| +| YAML parse | Frontmatter cannot be parsed by a YAML parser | +| Required fields | `name` or `description` absent from frontmatter | +| Path conventions | Skill directory name does not match `name:` field | +| Cron format | `cron:` present but not a valid 5-field cron expression | +| Stateful coherence | `stateful: true` but `STATE_SCHEMA.yaml` missing | +| Schema validity | `STATE_SCHEMA.yaml` present but missing `version:` or `fields:` | + +## Output levels + +- **PASS** — skill will load correctly +- **WARN** — skill loads but has a non-critical issue (e.g. schema present but `stateful:` missing) +- **FAIL** — skill will not load; must fix before use + +## How to use + +``` +python3 doctor.py --scan # Full diagnostic pass +python3 doctor.py --scan --only-failures # Show FAILs only +python3 doctor.py --scan --skill cron-hygiene # Single skill +python3 doctor.py --fix-hint cron-hygiene # Print actionable fix suggestion +python3 doctor.py --status # Summary of last scan +python3 doctor.py --format json # Machine-readable output +``` + +## Procedure + +**Step 1 — Run the scan** + +``` +python3 doctor.py --scan +``` + +Review the output. Each skill gets a one-line verdict: PASS / WARN / FAIL. + +**Step 2 — Triage FAILs first** + +For each FAIL, run `--fix-hint ` to get an actionable repair suggestion. Skill Doctor never modifies skill files itself — it tells you exactly what to change. + +**Step 3 — Review WARNs** + +WARNs do not block loading but indicate drift from conventions. Common WARN: `STATE_SCHEMA.yaml` exists without `stateful: true` in frontmatter. Fix by adding the frontmatter field. + +**Step 4 — Re-scan to confirm** + +After applying fixes, re-run `--scan` and verify no FAILs remain. + +**Step 5 — Write scan result to state** + +After a clean pass, the scan summary is automatically written to state. Use `--status` in future sessions to surface the last known health without re-scanning. + +## State + +Skill Doctor persists scan results in `~/.openclaw/skill-state/skill-doctor/state.yaml`. + +Fields: `last_scan_at`, `skills_scanned`, `fail_count`, `warn_count`, `violations` list. + +After a clean install, `fail_count` and `warn_count` should both be 0. + +## Integration + +Add to the end of `install.sh`: + +```bash +echo "Running Skill Doctor post-install check..." +python3 ~/.openclaw/extensions/superpowers/skills/openclaw-native/skill-doctor/doctor.py --scan --only-failures +``` + +This surfaces any broken skills immediately after install rather than letting them silently disappear. diff --git a/skills/openclaw-native/skill-doctor/STATE_SCHEMA.yaml b/skills/openclaw-native/skill-doctor/STATE_SCHEMA.yaml new file mode 100644 index 0000000..fb3701a --- /dev/null +++ b/skills/openclaw-native/skill-doctor/STATE_SCHEMA.yaml @@ -0,0 +1,33 @@ +version: "1.0" +description: Diagnostic scan results and per-skill health ledger. +fields: + last_scan_at: + type: datetime + skills_scanned: + type: integer + default: 0 + fail_count: + type: integer + default: 0 + warn_count: + type: integer + default: 0 + violations: + type: list + description: All FAILs and WARNs from the most recent scan + items: + skill_name: { type: string } + level: { type: enum, values: [FAIL, WARN] } + check: { type: string } + message: { type: string } + fix_hint: { type: string } + detected_at: { type: datetime } + resolved: { type: boolean } + scan_history: + type: list + description: Rolling log of past scans (last 10) + items: + scanned_at: { type: datetime } + skills_scanned: { type: integer } + fail_count: { type: integer } + warn_count: { type: integer } diff --git a/skills/openclaw-native/skill-doctor/doctor.py b/skills/openclaw-native/skill-doctor/doctor.py new file mode 100755 index 0000000..ce430f7 --- /dev/null +++ b/skills/openclaw-native/skill-doctor/doctor.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +Skill Doctor for openclaw-superpowers. + +Diagnoses silent skill discovery failures: YAML parse errors, path +violations, schema mismatches, cron format problems. Reports every +issue that would cause a skill to silently disappear from the registry. + +Usage: + python3 doctor.py --scan # Full diagnostic pass + python3 doctor.py --scan --only-failures # FAILs only + python3 doctor.py --scan --skill cron-hygiene # Single skill + python3 doctor.py --fix-hint cron-hygiene # Actionable fix hint + python3 doctor.py --status # Summary of last scan + python3 doctor.py --format json # Machine-readable output +""" + +import argparse +import json +import os +import re +import sys +from datetime import datetime +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +OPENCLAW_DIR = Path(os.environ.get("OPENCLAW_HOME", Path.home() / ".openclaw")) +STATE_FILE = OPENCLAW_DIR / "skill-state" / "skill-doctor" / "state.yaml" +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] +CRON_RE = re.compile( + r'^(\*|[0-9,\-\/]+)\s+' + r'(\*|[0-9,\-\/]+)\s+' + r'(\*|[0-9,\-\/]+)\s+' + r'(\*|[0-9,\-\/]+)\s+' + r'(\*|[0-9,\-\/]+)$' +) +MAX_HISTORY = 10 + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {"violations": [], "scan_history": [], "skills_scanned": 0, + "fail_count": 0, "warn_count": 0} + try: + text = STATE_FILE.read_text() + return (yaml.safe_load(text) or {}) if HAS_YAML else {} + except Exception: + return {} + + +def save_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + if HAS_YAML: + with open(STATE_FILE, "w") as f: + yaml.dump(state, f, default_flow_style=False, allow_unicode=True) + + +# ── Frontmatter parser ──────────────────────────────────────────────────────── + +def parse_frontmatter(skill_md: Path) -> tuple[dict, str]: + """ + Returns (fields_dict, error_message). + error_message is empty string on success. + """ + try: + text = skill_md.read_text() + except Exception as e: + return {}, f"Cannot read file: {e}" + + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {}, "No frontmatter block found (file must start with ---)" + + end = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end = i + break + + if end is None: + return {}, "Frontmatter block not closed (missing closing ---)" + + fm_text = "\n".join(lines[1:end]) + if not HAS_YAML: + # Minimal key:value parser + fields = {} + for line in fm_text.splitlines(): + if ":" in line: + k, _, v = line.partition(":") + fields[k.strip()] = v.strip().strip('"').strip("'") + return fields, "" + + try: + fields = yaml.safe_load(fm_text) or {} + return fields, "" + except Exception as e: + return {}, f"YAML parse error: {e}" + + +# ── Per-skill diagnostic ────────────────────────────────────────────────────── + +def diagnose_skill(skill_dir: Path) -> list[dict]: + """Returns list of violation dicts (empty = all clear).""" + violations = [] + skill_name = skill_dir.name + skill_md = skill_dir / "SKILL.md" + now = datetime.now().isoformat() + + def violation(level, check, message, fix_hint): + return { + "skill_name": skill_name, + "level": level, + "check": check, + "message": message, + "fix_hint": fix_hint, + "detected_at": now, + "resolved": False, + } + + if not skill_md.exists(): + violations.append(violation( + "FAIL", "SKILL_MD_MISSING", + f"No SKILL.md in {skill_dir}", + "Create SKILL.md with ---frontmatter--- block." + )) + return violations + + fm, parse_err = parse_frontmatter(skill_md) + + # Check 1: YAML parse + if parse_err: + violations.append(violation( + "FAIL", "YAML_PARSE", + f"Frontmatter unparseable: {parse_err}", + "Fix YAML syntax in the --- block at the top of SKILL.md." + )) + return violations # Can't continue without parseable frontmatter + + # Check 2: Required fields + for field in ("name", "description"): + if not fm.get(field): + violations.append(violation( + "FAIL", "REQUIRED_FIELD", + f"Missing required frontmatter field: `{field}`", + f"Add `{field}: ` to the frontmatter block." + )) + + # Check 3: Path convention + fm_name = fm.get("name", "") + if fm_name and fm_name != skill_name: + violations.append(violation( + "FAIL", "PATH_MISMATCH", + f"Directory name `{skill_name}` does not match `name: {fm_name}`", + f"Rename directory to `{fm_name}` or update `name:` in frontmatter." + )) + + # Check 4: Cron format + cron_val = fm.get("cron", "") + if cron_val: + cron_str = str(cron_val).strip() + if not CRON_RE.match(cron_str): + violations.append(violation( + "FAIL", "CRON_FORMAT", + f"Invalid cron expression: `{cron_str}`", + "Use a valid 5-field cron: `minute hour day month weekday` (e.g. `0 9 * * 1-5`)." + )) + + # Check 5: Stateful coherence + schema_file = skill_dir / "STATE_SCHEMA.yaml" + is_stateful = str(fm.get("stateful", "")).lower() == "true" + + if is_stateful and not schema_file.exists(): + violations.append(violation( + "FAIL", "STATEFUL_NO_SCHEMA", + "`stateful: true` in frontmatter but STATE_SCHEMA.yaml is missing", + "Create STATE_SCHEMA.yaml with `version:` and `fields:` keys." + )) + elif schema_file.exists() and not is_stateful: + violations.append(violation( + "WARN", "SCHEMA_NO_STATEFUL", + "STATE_SCHEMA.yaml exists but `stateful: true` is absent from frontmatter", + "Add `stateful: true` to the frontmatter block." + )) + + # Check 6: Schema validity + if schema_file.exists() and HAS_YAML: + try: + schema = yaml.safe_load(schema_file.read_text()) or {} + if "version" not in schema: + violations.append(violation( + "WARN", "SCHEMA_NO_VERSION", + "STATE_SCHEMA.yaml missing `version:` key", + "Add `version: \"1.0\"` to STATE_SCHEMA.yaml." + )) + if "fields" not in schema: + violations.append(violation( + "WARN", "SCHEMA_NO_FIELDS", + "STATE_SCHEMA.yaml missing `fields:` key", + "Add a `fields:` block defining your state shape." + )) + except Exception as e: + violations.append(violation( + "FAIL", "SCHEMA_PARSE", + f"STATE_SCHEMA.yaml unparseable: {e}", + "Fix YAML syntax in STATE_SCHEMA.yaml." + )) + + return violations + + +# ── Scan ────────────────────────────────────────────────────────────────────── + +def scan(only_failures=False, single_skill=None) -> tuple[list, int, int, int]: + """Returns (violations, skills_scanned, fail_count, warn_count).""" + all_violations = [] + skills_scanned = 0 + + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + if single_skill and skill_dir.name != single_skill: + continue + viols = diagnose_skill(skill_dir) + skills_scanned += 1 + if only_failures: + viols = [v for v in viols if v["level"] == "FAIL"] + all_violations.extend(viols) + + fail_count = sum(1 for v in all_violations if v["level"] == "FAIL") + warn_count = sum(1 for v in all_violations if v["level"] == "WARN") + return all_violations, skills_scanned, fail_count, warn_count + + +# ── Fix hints ───────────────────────────────────────────────────────────────── + +def print_fix_hints(skill_name: str, state: dict) -> None: + violations = [v for v in (state.get("violations") or []) + if v.get("skill_name") == skill_name] + if not violations: + print(f"No recorded violations for '{skill_name}'. Run --scan first.") + return + print(f"\nFix hints for: {skill_name}") + print("─" * 40) + for v in violations: + print(f" [{v['level']}] {v['check']}") + print(f" Problem : {v['message']}") + print(f" Fix : {v['fix_hint']}") + print() + + +# ── Output formatting ───────────────────────────────────────────────────────── + +def print_report(violations: list, skills_scanned: int, + fail_count: int, warn_count: int, fmt: str = "text") -> None: + if fmt == "json": + print(json.dumps({ + "skills_scanned": skills_scanned, + "fail_count": fail_count, + "warn_count": warn_count, + "violations": violations, + }, indent=2)) + return + + print(f"\nSkill Doctor Report — {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("─" * 48) + print(f" Skills scanned : {skills_scanned}") + print(f" FAILs : {fail_count}") + print(f" WARNs : {warn_count}") + print() + + if not violations: + print(" ✓ All skills healthy — no issues detected.") + else: + # Group by skill + by_skill: dict = {} + for v in violations: + by_skill.setdefault(v["skill_name"], []).append(v) + for skill_name, viols in sorted(by_skill.items()): + for v in viols: + icon = "✗" if v["level"] == "FAIL" else "⚠" + print(f" {icon} [{v['level']:4s}] {skill_name}: {v['check']}") + print(f" {v['message']}") + print() + + +# ── Status ──────────────────────────────────────────────────────────────────── + +def print_status(state: dict) -> None: + last = state.get("last_scan_at", "never") + scanned = state.get("skills_scanned", 0) + fails = state.get("fail_count", 0) + warns = state.get("warn_count", 0) + print(f"\nSkill Doctor — Last scan: {last}") + print(f" {scanned} skills | {fails} FAILs | {warns} WARNs") + active = [v for v in (state.get("violations") or []) if not v.get("resolved")] + if active: + print(f"\n Active issues ({len(active)}):") + for v in active: + print(f" [{v['level']}] {v['skill_name']}: {v['check']}") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Doctor — diagnose skill loading failures") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--scan", action="store_true", help="Run full diagnostic scan") + group.add_argument("--fix-hint", metavar="SKILL", help="Print fix hint for a skill") + group.add_argument("--status", action="store_true", help="Show last scan summary") + parser.add_argument("--only-failures", action="store_true", + help="With --scan, show FAILs only") + parser.add_argument("--skill", metavar="SKILL", + help="With --scan, scan a single skill only") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + state = load_state() + + if args.status: + print_status(state) + return + + if args.fix_hint: + print_fix_hints(args.fix_hint, state) + return + + if args.scan: + violations, scanned, fails, warns = scan( + only_failures=args.only_failures, + single_skill=args.skill, + ) + print_report(violations, scanned, fails, warns, fmt=args.format) + + # Persist + now = datetime.now().isoformat() + history = state.get("scan_history") or [] + history.insert(0, { + "scanned_at": now, + "skills_scanned": scanned, + "fail_count": fails, + "warn_count": warns, + }) + state["scan_history"] = history[:MAX_HISTORY] + state["last_scan_at"] = now + state["skills_scanned"] = scanned + state["fail_count"] = fails + state["warn_count"] = warns + state["violations"] = violations + save_state(state) + + sys.exit(1 if fails > 0 else 0) + + +if __name__ == "__main__": + main() diff --git a/skills/openclaw-native/skill-doctor/example-state.yaml b/skills/openclaw-native/skill-doctor/example-state.yaml new file mode 100644 index 0000000..19bbe59 --- /dev/null +++ b/skills/openclaw-native/skill-doctor/example-state.yaml @@ -0,0 +1,57 @@ +# Example runtime state for skill-doctor +last_scan_at: "2026-03-15T09:12:44.003000" +skills_scanned: 32 +fail_count: 2 +warn_count: 1 +violations: + - skill_name: my-custom-skill + level: FAIL + check: YAML_PARSE + message: "Frontmatter unparseable: mapping values are not allowed here" + fix_hint: "Fix YAML syntax in the --- block at the top of SKILL.md." + detected_at: "2026-03-15T09:12:44.000000" + resolved: false + - skill_name: expense-tracker + level: FAIL + check: STATEFUL_NO_SCHEMA + message: "`stateful: true` in frontmatter but STATE_SCHEMA.yaml is missing" + fix_hint: "Create STATE_SCHEMA.yaml with `version:` and `fields:` keys." + detected_at: "2026-03-15T09:12:44.000000" + resolved: false + - skill_name: obsidian-sync + level: WARN + check: SCHEMA_NO_STATEFUL + message: "STATE_SCHEMA.yaml exists but `stateful: true` is absent from frontmatter" + fix_hint: "Add `stateful: true` to the frontmatter block." + detected_at: "2026-03-15T09:12:44.000000" + resolved: false +scan_history: + - scanned_at: "2026-03-15T09:12:44.000000" + skills_scanned: 32 + fail_count: 2 + warn_count: 1 + - scanned_at: "2026-03-14T08:00:11.000000" + skills_scanned: 31 + fail_count: 0 + warn_count: 0 +# ── Walkthrough ────────────────────────────────────────────────────────────── +# Run: python3 doctor.py --scan +# Skill Doctor Report — 2026-03-15 09:12 +# ──────────────────────────────────────────────────────────────── +# Skills scanned : 32 +# FAILs : 2 +# WARNs : 1 +# +# ✗ [FAIL] my-custom-skill: YAML_PARSE +# Frontmatter unparseable: mapping values are not allowed here +# ✗ [FAIL] expense-tracker: STATEFUL_NO_SCHEMA +# `stateful: true` in frontmatter but STATE_SCHEMA.yaml is missing +# ⚠ [WARN] obsidian-sync: SCHEMA_NO_STATEFUL +# STATE_SCHEMA.yaml exists but `stateful: true` is absent +# +# Run: python3 doctor.py --fix-hint expense-tracker +# Fix hints for: expense-tracker +# ──────────────────────────────────────────────────────────────── +# [FAIL] STATEFUL_NO_SCHEMA +# Problem : `stateful: true` in frontmatter but STATE_SCHEMA.yaml is missing +# Fix : Create STATE_SCHEMA.yaml with `version:` and `fields:` keys. From e9b3cc58b56f447bf8bec4d244302d327e225a63 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:30:49 +0530 Subject: [PATCH 02/11] Add installed-skill-auditor: weekly post-install security audit Detects INJECTION, CREDENTIAL, EXFILTRATION, DRIFT, and ORPHAN issues in all installed skills. Maintains content baselines for drift detection. Cron: Mondays 9am. Exits 1 on CRITICAL findings. Co-Authored-By: Claude Sonnet 4.6 --- .../installed-skill-auditor/SKILL.md | 90 +++++ .../installed-skill-auditor/STATE_SCHEMA.yaml | 28 ++ .../installed-skill-auditor/audit.py | 374 ++++++++++++++++++ .../example-state.yaml | 56 +++ 4 files changed, 548 insertions(+) create mode 100644 skills/openclaw-native/installed-skill-auditor/SKILL.md create mode 100644 skills/openclaw-native/installed-skill-auditor/STATE_SCHEMA.yaml create mode 100755 skills/openclaw-native/installed-skill-auditor/audit.py create mode 100644 skills/openclaw-native/installed-skill-auditor/example-state.yaml diff --git a/skills/openclaw-native/installed-skill-auditor/SKILL.md b/skills/openclaw-native/installed-skill-auditor/SKILL.md new file mode 100644 index 0000000..b2a4f54 --- /dev/null +++ b/skills/openclaw-native/installed-skill-auditor/SKILL.md @@ -0,0 +1,90 @@ +--- +name: installed-skill-auditor +version: "1.0" +category: openclaw-native +description: Weekly audit of all installed third-party and community skills for malicious patterns, stale credentials, and drift from last-known-good state. +stateful: true +cron: "0 9 * * 1" +--- + +# Installed Skill Auditor + +## What it does + +`skill-vetting` scans before install. `installed-skill-auditor` scans after — continuously. + +Skills can be modified after installation. A community skill that was safe on Monday can be compromised by Tuesday if the source repo is pushed to and your agent auto-pulls. This skill runs weekly to catch post-install drift: injected payloads, hardcoded credentials, and pattern changes that weren't there at install time. + +It maintains a content hash of every skill file at the time it was first audited. On each weekly run it re-hashes and flags anything that changed unexpectedly. + +## When to invoke + +- Automatically, every Monday at 9am (cron) +- Manually after any `git pull` that touches skill directories +- After any agent action that writes to the skills tree + +## Audit checks + +| Check | What it detects | +|---|---| +| INJECTION | Instruction-override patterns in SKILL.md prose | +| CREDENTIAL | Hardcoded tokens, API keys, or secrets in any file | +| EXFILTRATION | URLs + data-sending patterns suggesting exfil | +| DRIFT | File content changed since last known-good baseline | +| ORPHAN | Skill directory present but not in install manifest | + +Severity: CRITICAL (INJECTION, EXFILTRATION) · HIGH (CREDENTIAL) · MEDIUM (DRIFT, ORPHAN) + +## Output + +``` +Installed Skill Audit — 2026-03-16 +──────────────────────────────────────────── +32 skills audited | 0 CRITICAL | 1 HIGH | 2 MEDIUM + +HIGH community/my-custom-skill — CREDENTIAL + Hardcoded token pattern detected in run.py (line 14) + +MEDIUM community/expense-tracker — DRIFT + SKILL.md hash changed since 2026-03-10 baseline + Run: python3 audit.py --diff expense-tracker +``` + +## How to use + +``` +python3 audit.py --scan # Full audit pass +python3 audit.py --scan --critical-only # CRITICAL findings only +python3 audit.py --baseline # Record current state as trusted +python3 audit.py --diff # Show changed lines since baseline +python3 audit.py --resolve # Mark finding resolved after review +python3 audit.py --status # Summary of last run +python3 audit.py --format json # Machine-readable output +``` + +## Procedure + +**Step 1 — Review the report** + +The cron run generates a report automatically. Open it via `--status` or check state. Any CRITICAL finding requires immediate action. + +**Step 2 — Triage by severity** + +- **CRITICAL**: Do not run the skill. Inspect the file, remove or quarantine the skill. +- **HIGH**: Rotate the exposed credential immediately; investigate how it got there. +- **MEDIUM (DRIFT)**: Use `--diff` to see what changed. If the change is expected (you updated the skill), run `--baseline` to accept it. If unexpected, treat as CRITICAL. +- **MEDIUM (ORPHAN)**: A skill directory exists with no install record. Either re-install through the vetting process or remove the directory. + +**Step 3 — Resolve or escalate** + +Run `--resolve ` after reviewing a finding. This marks it acknowledged in state. Unresolved CRITICAL findings are surfaced again on next cron run. + +**Step 4 — Update baseline after intentional changes** + +When you intentionally update a skill (e.g., upgrading to a new version), run `--baseline` so future drift detection has an accurate reference point. + +## State + +Results and content hashes stored in `~/.openclaw/skill-state/installed-skill-auditor/state.yaml`. + +Fields: `last_audit_at`, `baselines` (hash map), `findings`, `audit_history`. diff --git a/skills/openclaw-native/installed-skill-auditor/STATE_SCHEMA.yaml b/skills/openclaw-native/installed-skill-auditor/STATE_SCHEMA.yaml new file mode 100644 index 0000000..d7f3d41 --- /dev/null +++ b/skills/openclaw-native/installed-skill-auditor/STATE_SCHEMA.yaml @@ -0,0 +1,28 @@ +version: "1.0" +description: Content baselines, weekly findings, and audit history for installed skill audits. +fields: + last_audit_at: + type: datetime + baselines: + type: object + description: Map of skill_name -> {file_path -> sha256_hex, recorded_at} + findings: + type: list + description: Active findings awaiting resolution + items: + skill_name: { type: string } + check: { type: enum, values: [INJECTION, CREDENTIAL, EXFILTRATION, DRIFT, ORPHAN] } + severity: { type: enum, values: [CRITICAL, HIGH, MEDIUM] } + file_path: { type: string } + detail: { type: string } + detected_at: { type: datetime } + resolved: { type: boolean } + audit_history: + type: list + description: Rolling summary of weekly audits (last 12) + items: + audited_at: { type: datetime } + skills_audited: { type: integer } + critical_count: { type: integer } + high_count: { type: integer } + medium_count: { type: integer } diff --git a/skills/openclaw-native/installed-skill-auditor/audit.py b/skills/openclaw-native/installed-skill-auditor/audit.py new file mode 100755 index 0000000..bcdbda3 --- /dev/null +++ b/skills/openclaw-native/installed-skill-auditor/audit.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +Installed Skill Auditor for openclaw-superpowers. + +Weekly audit of all installed skills for malicious patterns, +credential leaks, and post-install content drift. + +Usage: + python3 audit.py --scan # Full audit + python3 audit.py --scan --critical-only # CRITICAL only + python3 audit.py --baseline # Record current state + python3 audit.py --diff # Show changes since baseline + python3 audit.py --resolve # Mark finding resolved + python3 audit.py --status # Last audit summary + python3 audit.py --format json # Machine-readable +""" + +import argparse +import hashlib +import json +import os +import re +import sys +from datetime import datetime +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +OPENCLAW_DIR = Path(os.environ.get("OPENCLAW_HOME", Path.home() / ".openclaw")) +STATE_FILE = OPENCLAW_DIR / "skill-state" / "installed-skill-auditor" / "state.yaml" +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] +MAX_HISTORY = 12 + +# ── Detection patterns ──────────────────────────────────────────────────────── + +INJECTION_PATTERNS = [ + re.compile(r'ignore (?:all )?(?:previous|prior|above) instructions', re.I), + re.compile(r'you are now (?:a|an|in)', re.I), + re.compile(r'act as (?:a|an)', re.I), + re.compile(r'disregard (?:your|all) (?:rules|instructions|constraints)', re.I), + re.compile(r'new (?:system|developer|admin) (?:instructions?|prompt)', re.I), + re.compile(r'jailbreak', re.I), +] + +CREDENTIAL_PATTERNS = [ + re.compile(r'(?:api[_\-]?key|apikey)\s*[=:]\s*["\']?[A-Za-z0-9_\-]{16,}', re.I), + re.compile(r'(?:secret|token|password|passwd|pwd)\s*[=:]\s*["\']?[A-Za-z0-9_\-]{8,}', re.I), + re.compile(r'sk-[A-Za-z0-9]{20,}'), + re.compile(r'(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}'), + re.compile(r'AKIA[0-9A-Z]{16}'), # AWS access key + re.compile(r'Bearer [A-Za-z0-9\-_\.]{20,}'), +] + +EXFILTRATION_PATTERNS = [ + re.compile(r'(?:requests?|urllib|curl|wget).*(?:post|put|send).*http', re.I | re.S), + re.compile(r'webhook\.site', re.I), + re.compile(r'requestbin', re.I), + re.compile(r'ngrok\.io', re.I), + re.compile(r'pastebin\.com', re.I), +] + +SEVERITY = { + "INJECTION": "CRITICAL", + "EXFILTRATION": "CRITICAL", + "CREDENTIAL": "HIGH", + "DRIFT": "MEDIUM", + "ORPHAN": "MEDIUM", +} + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {"baselines": {}, "findings": [], "audit_history": []} + try: + text = STATE_FILE.read_text() + return (yaml.safe_load(text) or {}) if HAS_YAML else {} + except Exception: + return {} + + +def save_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + if HAS_YAML: + with open(STATE_FILE, "w") as f: + yaml.dump(state, f, default_flow_style=False, allow_unicode=True) + + +# ── Hashing ─────────────────────────────────────────────────────────────────── + +def file_sha256(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def skill_hashes(skill_dir: Path) -> dict: + hashes = {} + for f in sorted(skill_dir.rglob("*")): + if f.is_file(): + rel = str(f.relative_to(skill_dir)) + try: + hashes[rel] = file_sha256(f) + except Exception: + pass + return hashes + + +# ── Pattern scanning ────────────────────────────────────────────────────────── + +def scan_file_patterns(path: Path) -> list[dict]: + findings = [] + try: + text = path.read_text(errors="replace") + except Exception: + return findings + + for pattern in INJECTION_PATTERNS: + if pattern.search(text): + findings.append({"check": "INJECTION", "file": str(path), "detail": + f"Injection pattern matched: {pattern.pattern[:60]}"}) + + for pattern in CREDENTIAL_PATTERNS: + if pattern.search(text): + findings.append({"check": "CREDENTIAL", "file": str(path), "detail": + f"Credential pattern matched: {pattern.pattern[:60]}"}) + + for pattern in EXFILTRATION_PATTERNS: + if pattern.search(text): + findings.append({"check": "EXFILTRATION", "file": str(path), "detail": + f"Exfiltration pattern matched: {pattern.pattern[:60]}"}) + + return findings + + +# ── Audit core ──────────────────────────────────────────────────────────────── + +def audit_skill(skill_dir: Path, baselines: dict) -> list[dict]: + skill_name = skill_dir.name + now = datetime.now().isoformat() + findings = [] + + def finding(check, file_path, detail): + return { + "skill_name": skill_name, + "check": check, + "severity": SEVERITY[check], + "file_path": str(file_path), + "detail": detail, + "detected_at": now, + "resolved": False, + } + + # Pattern scans on all files + for f in sorted(skill_dir.rglob("*")): + if f.is_file() and f.suffix in (".md", ".py", ".sh", ".yaml", ".yml", ".txt"): + for hit in scan_file_patterns(f): + findings.append(finding(hit["check"], hit["file"], hit["detail"])) + + # Drift detection + bl = baselines.get(skill_name, {}).get("hashes", {}) + if bl: + current = skill_hashes(skill_dir) + for rel_path, old_hash in bl.items(): + new_hash = current.get(rel_path) + if new_hash is None: + findings.append(finding("DRIFT", skill_dir / rel_path, + f"File deleted since baseline: {rel_path}")) + elif new_hash != old_hash: + findings.append(finding("DRIFT", skill_dir / rel_path, + f"Content changed since baseline ({rel_path})")) + for rel_path in current: + if rel_path not in bl: + findings.append(finding("DRIFT", skill_dir / rel_path, + f"New file added since baseline: {rel_path}")) + + return findings + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_scan(state: dict, critical_only: bool, fmt: str) -> None: + baselines = state.get("baselines") or {} + all_findings = [] + skills_audited = 0 + + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + findings = audit_skill(skill_dir, baselines) + all_findings.extend(findings) + skills_audited += 1 + + if critical_only: + all_findings = [f for f in all_findings if f["severity"] == "CRITICAL"] + + critical = sum(1 for f in all_findings if f["severity"] == "CRITICAL") + high = sum(1 for f in all_findings if f["severity"] == "HIGH") + medium = sum(1 for f in all_findings if f["severity"] == "MEDIUM") + now = datetime.now().isoformat() + + if fmt == "json": + print(json.dumps({ + "audited_at": now, + "skills_audited": skills_audited, + "critical_count": critical, + "high_count": high, + "medium_count": medium, + "findings": all_findings, + }, indent=2)) + else: + print(f"\nInstalled Skill Audit — {datetime.now().strftime('%Y-%m-%d')}") + print("─" * 48) + print(f" {skills_audited} skills audited | " + f"{critical} CRITICAL | {high} HIGH | {medium} MEDIUM") + print() + if not all_findings: + print(" ✓ No issues detected.") + else: + by_skill: dict = {} + for f in all_findings: + by_skill.setdefault(f["skill_name"], []).append(f) + for sname, flist in sorted(by_skill.items()): + for f in flist: + print(f" {f['severity']:8s} {sname} — {f['check']}") + print(f" {f['detail']}") + print() + + history = state.get("audit_history") or [] + history.insert(0, { + "audited_at": now, + "skills_audited": skills_audited, + "critical_count": critical, + "high_count": high, + "medium_count": medium, + }) + state["audit_history"] = history[:MAX_HISTORY] + state["last_audit_at"] = now + state["findings"] = all_findings + save_state(state) + + sys.exit(1 if critical > 0 else 0) + + +def cmd_baseline(state: dict) -> None: + baselines = state.get("baselines") or {} + now = datetime.now().isoformat() + count = 0 + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + baselines[skill_dir.name] = { + "hashes": skill_hashes(skill_dir), + "recorded_at": now, + } + count += 1 + state["baselines"] = baselines + save_state(state) + print(f"✓ Baseline recorded for {count} skills at {now[:19]}") + + +def cmd_diff(state: dict, skill_name: str) -> None: + baselines = state.get("baselines") or {} + bl = baselines.get(skill_name, {}).get("hashes", {}) + if not bl: + print(f"No baseline recorded for '{skill_name}'. Run --baseline first.") + return + + skill_dir = None + for skills_root in SKILLS_DIRS: + candidate = skills_root / skill_name + if candidate.exists(): + skill_dir = candidate + break + + if skill_dir is None: + print(f"Skill '{skill_name}' not found in skills directories.") + return + + current = skill_hashes(skill_dir) + changed = [(p, "CHANGED") for p, h in bl.items() if current.get(p) != h] + added = [(p, "ADDED") for p in current if p not in bl] + deleted = [(p, "DELETED") for p in bl if p not in current] + all_diffs = changed + added + deleted + + if not all_diffs: + print(f"✓ {skill_name}: no drift detected from baseline.") + return + + print(f"\nDrift for: {skill_name}") + print("─" * 40) + for path, status in sorted(all_diffs): + print(f" {status:8s} {path}") + print() + + +def cmd_resolve(state: dict, skill_name: str) -> None: + findings = state.get("findings") or [] + count = 0 + for f in findings: + if f.get("skill_name") == skill_name and not f.get("resolved"): + f["resolved"] = True + count += 1 + save_state(state) + print(f"✓ Resolved {count} finding(s) for '{skill_name}'.") + + +def cmd_status(state: dict) -> None: + last = state.get("last_audit_at", "never") + print(f"\nInstalled Skill Auditor — Last run: {last}") + history = state.get("audit_history") or [] + if history: + latest = history[0] + print(f" {latest.get('skills_audited',0)} skills | " + f"{latest.get('critical_count',0)} CRITICAL | " + f"{latest.get('high_count',0)} HIGH | " + f"{latest.get('medium_count',0)} MEDIUM") + active = [f for f in (state.get("findings") or []) if not f.get("resolved")] + if active: + print(f"\n Unresolved findings ({len(active)}):") + for f in active[:5]: + print(f" [{f['severity']}] {f['skill_name']}: {f['check']}") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Installed Skill Auditor") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--scan", action="store_true") + group.add_argument("--baseline", action="store_true") + group.add_argument("--diff", metavar="SKILL") + group.add_argument("--resolve", metavar="SKILL") + group.add_argument("--status", action="store_true") + parser.add_argument("--critical-only", action="store_true") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + state = load_state() + + if args.status: + cmd_status(state) + elif args.baseline: + cmd_baseline(state) + elif args.diff: + cmd_diff(state, args.diff) + elif args.resolve: + cmd_resolve(state, args.resolve) + elif args.scan: + cmd_scan(state, critical_only=args.critical_only, fmt=args.format) + + +if __name__ == "__main__": + main() diff --git a/skills/openclaw-native/installed-skill-auditor/example-state.yaml b/skills/openclaw-native/installed-skill-auditor/example-state.yaml new file mode 100644 index 0000000..aeb3d8d --- /dev/null +++ b/skills/openclaw-native/installed-skill-auditor/example-state.yaml @@ -0,0 +1,56 @@ +# Example runtime state for installed-skill-auditor +last_audit_at: "2026-03-16T09:00:14.227000" +baselines: + obsidian-sync: + recorded_at: "2026-03-10T08:00:00.000000" + hashes: + SKILL.md: "a3f2c1e8d4b5f901..." + sync.py: "7b8e2d3a1f0c4e59..." + STATE_SCHEMA.yaml: "c9d0e1f2a3b4c5d6..." + my-custom-skill: + recorded_at: "2026-03-10T08:00:00.000000" + hashes: + SKILL.md: "1a2b3c4d5e6f7890..." + run.py: "9f8e7d6c5b4a3210..." +findings: + - skill_name: my-custom-skill + check: CREDENTIAL + severity: HIGH + file_path: "skills/community/my-custom-skill/run.py" + detail: "Credential pattern matched: (?:secret|token|password)" + detected_at: "2026-03-16T09:00:14.000000" + resolved: false + - skill_name: obsidian-sync + check: DRIFT + severity: MEDIUM + file_path: "skills/community/obsidian-sync/sync.py" + detail: "Content changed since baseline (sync.py)" + detected_at: "2026-03-16T09:00:14.000000" + resolved: false +audit_history: + - audited_at: "2026-03-16T09:00:14.000000" + skills_audited: 32 + critical_count: 0 + high_count: 1 + medium_count: 1 + - audited_at: "2026-03-09T09:00:00.000000" + skills_audited: 31 + critical_count: 0 + high_count: 0 + medium_count: 0 +# ── Walkthrough ────────────────────────────────────────────────────────────── +# Weekly cron (Monday 09:00) runs: +# python3 audit.py --scan +# +# Installed Skill Audit — 2026-03-16 +# ──────────────────────────────────────────────────────────────── +# 32 skills audited | 0 CRITICAL | 1 HIGH | 1 MEDIUM +# +# HIGH my-custom-skill — CREDENTIAL +# Credential pattern matched in run.py +# MEDIUM obsidian-sync — DRIFT +# Content changed since baseline (sync.py) +# +# Fix CREDENTIAL: Inspect run.py line 14, rotate the exposed token. +# Accept DRIFT: python3 audit.py --diff obsidian-sync +# (review changes, then) python3 audit.py --resolve obsidian-sync From fa28ed004f392267e43b2611a888341e02f11ca0 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:32:33 +0530 Subject: [PATCH 03/11] Add skill-trigger-tester: validate description trigger quality before publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scores a skill's description against should-fire/should-not-fire prompt sets. Computes precision, recall, F1, and assigns a grade A–F. Exits 1 on grade C or lower, suitable as a pre-publish gate. Co-Authored-By: Claude Sonnet 4.6 --- skills/core/skill-trigger-tester/SKILL.md | 126 +++++++++++ skills/core/skill-trigger-tester/test.py | 250 ++++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 skills/core/skill-trigger-tester/SKILL.md create mode 100755 skills/core/skill-trigger-tester/test.py diff --git a/skills/core/skill-trigger-tester/SKILL.md b/skills/core/skill-trigger-tester/SKILL.md new file mode 100644 index 0000000..c8f8698 --- /dev/null +++ b/skills/core/skill-trigger-tester/SKILL.md @@ -0,0 +1,126 @@ +--- +name: skill-trigger-tester +version: "1.0" +category: core +description: Scores a skill's description field against sample user prompts to predict whether OpenClaw will correctly trigger it — before you publish or install. +--- + +# Skill Trigger Tester + +## What it does + +OpenClaw maps user intent to skills by matching the user's message against each skill's `description:` field. A weak description means your skill silently never fires. A description that's too broad means it fires when it shouldn't. + +Skill Trigger Tester helps you validate the trigger quality of a skill's description before publishing. You give it: +- The description string you're testing +- A set of "should fire" prompts (true positives) +- A set of "should not fire" prompts (true negatives) + +It scores precision, recall, and gives an overall trigger quality grade (A–F) plus actionable suggestions. + +## When to invoke + +- Before publishing any new skill to ClawHub +- When a skill you expect to trigger isn't firing +- When a skill keeps firing on irrelevant prompts +- Inside `create-skill` workflow (Step 5: validation) + +## Scoring model + +The tool uses a keyword + semantic overlap heuristic against the description field: + +| Metric | Meaning | +|---|---| +| **Recall** | % of "should fire" prompts that would match | +| **Precision** | % of matches that are actually "should fire" | +| **F1** | Harmonic mean of recall and precision | + +Grade thresholds: + +| Grade | F1 | +|---|---| +| A | ≥ 0.85 | +| B | ≥ 0.70 | +| C | ≥ 0.55 | +| D | ≥ 0.40 | +| F | < 0.40 | + +## How to use + +```bash +python3 test.py --description "Diagnoses skill discovery failures" \ + --should-fire "why isn't my skill loading" \ + "my skill disappeared from the registry" \ + "check if my skills are healthy" \ + --should-not-fire "write a skill" \ + "install superpowers" \ + "review my code" + +python3 test.py --file skill-spec.yaml # Load test cases from YAML file +python3 test.py --format json # Machine-readable output +``` + +## Test spec file format + +```yaml +description: "Diagnoses skill discovery failures — YAML parse errors, path violations" +should_fire: + - "why isn't my skill loading" + - "my skill disappeared" + - "check skill health" +should_not_fire: + - "write a new skill" + - "install openclaw" +``` + +## Procedure + +**Step 1 — Write your test cases** + +For each skill you're testing, list 3–5 prompts that should trigger it and 3–5 that should not. Be honest about edge cases. + +**Step 2 — Run the scorer** + +```bash +python3 test.py --description "" \ + --should-fire "..." --should-not-fire "..." +``` + +**Step 3 — Interpret results** + +- Grade A/B: description is well-calibrated. Publish. +- Grade C: borderline — add more specific keywords to the description, or narrow the wording. +- Grade D/F: description is too vague or uses jargon the user won't say. Rewrite and retest. + +**Step 4 — Iterate** + +Try alternative descriptions and compare scores side-by-side using `--compare`. + +**Step 5 — Add test file to the skill directory** + +Commit the `trigger-tests.yaml` spec alongside the skill. Future contributors can run it to verify trigger quality hasn't regressed. + +## Common mistakes + +- **Too generic**: `"Helps with skills"` — will either never fire or fire on everything +- **Technical jargon**: `"Validates SKILL.md frontmatter schema coherence"` — users don't say this +- **Action + object only**: `"Creates skills"` — add when/why context +- **Missing synonyms**: If users might say "check" or "verify" or "test", the description needs to capture the semantic range + +## Output example + +``` +Skill Trigger Quality — skill-doctor +───────────────────────────────────────────── +Description: "Diagnoses silent skill discovery failures..." + +Should fire (5 prompts): 4 / 5 matched recall = 0.80 +Should not fire (5 prompts): 1 / 5 matched precision = 0.80 +F1 score: 0.80 Grade: B + +⚠ 1 false negative: "check if skills are healthy" + Suggestion: Add "healthy", "health", or "check" to the description. + +⚠ 1 false positive: "check my code" + Suggestion: Narrow description to avoid generic "check" overlap. +``` diff --git a/skills/core/skill-trigger-tester/test.py b/skills/core/skill-trigger-tester/test.py new file mode 100755 index 0000000..d37c4f7 --- /dev/null +++ b/skills/core/skill-trigger-tester/test.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Skill Trigger Tester for openclaw-superpowers. + +Scores a skill description's trigger quality against sample prompts. +Predicts whether OpenClaw will correctly fire the skill — before publish. + +Usage: + python3 test.py --description "Diagnoses skill failures" \\ + --should-fire "why isn't my skill loading" "skill disappeared" \\ + --should-not-fire "write a skill" "install openclaw" + + python3 test.py --file trigger-tests.yaml + python3 test.py --file spec.yaml --compare "Alternative description here" + python3 test.py --format json --file spec.yaml +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +# ── Tokeniser ───────────────────────────────────────────────────────────────── + +_STOPWORDS = { + "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", + "of", "with", "by", "from", "up", "is", "are", "was", "were", "be", + "been", "being", "have", "has", "had", "do", "does", "did", "will", + "would", "could", "should", "may", "might", "shall", "can", "it", + "its", "this", "that", "these", "those", "my", "your", "our", "their", + "i", "you", "we", "they", "he", "she", "not", "no", "so", "if", +} + + +def tokenise(text: str) -> set[str]: + tokens = re.findall(r"[a-z0-9]+(?:'[a-z]+)?", text.lower()) + return {t for t in tokens if t not in _STOPWORDS and len(t) > 1} + + +# ── Synonyms ────────────────────────────────────────────────────────────────── + +_SYNONYMS: list[set[str]] = [ + {"check", "verify", "validate", "test", "inspect", "audit", "scan"}, + {"fix", "repair", "resolve", "remediate", "correct"}, + {"find", "detect", "discover", "locate", "identify"}, + {"create", "write", "build", "make", "generate", "add"}, + {"run", "execute", "launch", "start", "trigger", "invoke", "fire"}, + {"skill", "skills", "extension", "extensions", "plugin", "plugins"}, + {"install", "installed", "setup", "configure"}, + {"error", "errors", "failure", "failures", "broken", "issue", "issues", "problem"}, + {"load", "loading", "loads"}, + {"missing", "gone", "disappeared", "absent"}, + {"memory", "remember", "recall", "stored"}, + {"schedule", "scheduled", "cron", "recurring", "automatic"}, + {"cost", "spend", "budget", "expensive", "usage"}, +] + + +def expand_synonyms(tokens: set[str]) -> set[str]: + expanded = set(tokens) + for group in _SYNONYMS: + if tokens & group: + expanded |= group + return expanded + + +# ── Match scoring ───────────────────────────────────────────────────────────── + +def description_tokens(description: str) -> set[str]: + return expand_synonyms(tokenise(description)) + + +def prompt_matches(prompt: str, desc_tokens: set[str]) -> bool: + ptokens = expand_synonyms(tokenise(prompt)) + overlap = ptokens & desc_tokens + if not ptokens: + return False + score = len(overlap) / len(ptokens) + return score >= 0.25 # 25% token overlap threshold + + +# ── Grading ─────────────────────────────────────────────────────────────────── + +def grade(f1: float) -> str: + if f1 >= 0.85: + return "A" + if f1 >= 0.70: + return "B" + if f1 >= 0.55: + return "C" + if f1 >= 0.40: + return "D" + return "F" + + +# ── Analysis ────────────────────────────────────────────────────────────────── + +def analyse(description: str, should_fire: list[str], + should_not_fire: list[str]) -> dict: + desc_tokens = description_tokens(description) + + tp_matches = [p for p in should_fire if prompt_matches(p, desc_tokens)] + fp_matches = [p for p in should_not_fire if prompt_matches(p, desc_tokens)] + fn_misses = [p for p in should_fire if not prompt_matches(p, desc_tokens)] + tn_correct = [p for p in should_not_fire if not prompt_matches(p, desc_tokens)] + + recall = len(tp_matches) / len(should_fire) if should_fire else 1.0 + precision = (len(tp_matches) / (len(tp_matches) + len(fp_matches)) + if (tp_matches or fp_matches) else 1.0) + f1 = (2 * precision * recall / (precision + recall) + if (precision + recall) > 0 else 0.0) + + suggestions = [] + for miss in fn_misses: + miss_tokens = tokenise(miss) - _STOPWORDS + missing = miss_tokens - desc_tokens + if missing: + suggestions.append( + f"False negative: \"{miss}\" — consider adding: " + + ", ".join(f'"{t}"' for t in sorted(missing)[:3]) + ) + else: + suggestions.append(f"False negative: \"{miss}\" — check synonym coverage") + + for fp in fp_matches: + suggestions.append( + f"False positive: \"{fp}\" — narrow description to reduce overlap" + ) + + return { + "description": description, + "recall": round(recall, 3), + "precision": round(precision, 3), + "f1": round(f1, 3), + "grade": grade(f1), + "should_fire_total": len(should_fire), + "should_fire_matched": len(tp_matches), + "should_not_fire_total": len(should_not_fire), + "should_not_fire_mismatched": len(fp_matches), + "false_negatives": fn_misses, + "false_positives": fp_matches, + "suggestions": suggestions, + } + + +# ── Output ──────────────────────────────────────────────────────────────────── + +def print_result(result: dict, label: str = "") -> None: + header = f"Skill Trigger Quality{' — ' + label if label else ''}" + print(f"\n{header}") + print("─" * 48) + desc = result["description"] + print(f"Description: \"{desc[:80]}{'...' if len(desc) > 80 else ''}\"") + print() + print(f" Should fire ({result['should_fire_total']:2d} prompts): " + f"{result['should_fire_matched']:2d} matched " + f"recall = {result['recall']:.2f}") + print(f" Should not fire({result['should_not_fire_total']:2d} prompts): " + f"{result['should_not_fire_mismatched']:2d} matched " + f"precision = {result['precision']:.2f}") + print() + print(f" F1 score: {result['f1']:.2f} Grade: {result['grade']}") + + if result["suggestions"]: + print() + for s in result["suggestions"]: + print(f" ⚠ {s}") + print() + + +def print_comparison(r1: dict, r2: dict) -> None: + print("\nDescription Comparison") + print("─" * 48) + for label, r in [("Option A (original)", r1), ("Option B (alternative)", r2)]: + print(f" {label}: F1={r['f1']:.2f} Grade={r['grade']} " + f"(recall={r['recall']:.2f}, precision={r['precision']:.2f})") + winner = "A" if r1["f1"] >= r2["f1"] else "B" + print(f"\n → Option {winner} scores higher.") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def load_spec(path: str) -> dict: + if not HAS_YAML: + print("ERROR: pyyaml required for --file. Install with: pip install pyyaml") + sys.exit(1) + text = Path(path).read_text() + return yaml.safe_load(text) or {} + + +def main(): + parser = argparse.ArgumentParser(description="Skill Trigger Tester") + src = parser.add_mutually_exclusive_group(required=True) + src.add_argument("--description", metavar="TEXT", + help="Description string to test") + src.add_argument("--file", metavar="YAML", + help="Load test spec from YAML file") + parser.add_argument("--should-fire", nargs="+", metavar="PROMPT", default=[], + help="Prompts that should trigger the skill") + parser.add_argument("--should-not-fire", nargs="+", metavar="PROMPT", default=[], + help="Prompts that should NOT trigger the skill") + parser.add_argument("--compare", metavar="ALT_DESC", + help="Alternative description to compare against") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + if args.file: + spec = load_spec(args.file) + description = spec.get("description", "") + should_fire = spec.get("should_fire", []) + should_not_fire = spec.get("should_not_fire", []) + else: + description = args.description + should_fire = args.should_fire + should_not_fire = args.should_not_fire + + if not should_fire and not should_not_fire: + print("ERROR: Provide at least one --should-fire or --should-not-fire prompt.") + sys.exit(1) + + result = analyse(description, should_fire, should_not_fire) + + alt_result = None + if args.compare: + alt_result = analyse(args.compare, should_fire, should_not_fire) + + if args.format == "json": + output = {"primary": result} + if alt_result: + output["alternative"] = alt_result + print(json.dumps(output, indent=2)) + else: + print_result(result, label=description[:40]) + if alt_result: + print_result(alt_result, label="Alternative") + print_comparison(result, alt_result) + + sys.exit(0 if result["grade"] in ("A", "B") else 1) + + +if __name__ == "__main__": + main() From 4cd5c511e72f87880f56bdcba722f2979232fe57 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:39:26 +0530 Subject: [PATCH 04/11] Add skill-loadout-manager: named skill profiles to manage context bloat Defines and switches between curated skill subsets (loadouts). Ships 4 presets (minimal, coding, research, ops) and estimates token savings per loadout vs. all-skills-active mode. Co-Authored-By: Claude Sonnet 4.6 --- .../skill-loadout-manager/SKILL.md | 106 +++++ .../skill-loadout-manager/STATE_SCHEMA.yaml | 29 ++ .../skill-loadout-manager/example-state.yaml | 71 ++++ .../skill-loadout-manager/loadout.py | 377 ++++++++++++++++++ 4 files changed, 583 insertions(+) create mode 100644 skills/openclaw-native/skill-loadout-manager/SKILL.md create mode 100644 skills/openclaw-native/skill-loadout-manager/STATE_SCHEMA.yaml create mode 100644 skills/openclaw-native/skill-loadout-manager/example-state.yaml create mode 100755 skills/openclaw-native/skill-loadout-manager/loadout.py diff --git a/skills/openclaw-native/skill-loadout-manager/SKILL.md b/skills/openclaw-native/skill-loadout-manager/SKILL.md new file mode 100644 index 0000000..1d00455 --- /dev/null +++ b/skills/openclaw-native/skill-loadout-manager/SKILL.md @@ -0,0 +1,106 @@ +--- +name: skill-loadout-manager +version: "1.0" +category: openclaw-native +description: Manages named skill profiles (loadouts) so you can switch between focused skill sets and prevent system prompt bloat from too many active skills. +stateful: true +--- + +# Skill Loadout Manager + +## What it does + +Installing more skills increases OpenClaw's system prompt size. Every installed skill contributes its description to the context window on every session start — even skills you haven't used in months. + +Skill Loadout Manager lets you define named loadouts: curated subsets of skills for specific contexts. You switch to a loadout and only those skills are active. Everything else is installed but dormant. + +Examples: +- `coding` — tools for writing, testing, reviewing code +- `research` — browsing, fact-checking, note synthesis +- `ops` — monitoring, cron hygiene, spend tracking +- `minimal` — just the essentials: memory, handoff, recovery + +## When to invoke + +- When you notice system prompt bloat slowing context initialisation +- When switching between focused work modes (deep coding vs. research) +- When you want to test a single skill in isolation +- After adding many new skills that aren't always relevant + +## Loadout structure + +A loadout is a named list of skill names stored in state. Activating a loadout signals to OpenClaw's skill loader which skills to surface in the system prompt. Skills not in the active loadout remain installed but excluded from description injection. + +```yaml +# Example loadout definition +name: coding +skills: + - systematic-debugging + - test-driven-development + - verification-before-completion + - skill-doctor + - dangerous-action-guard +``` + +## How to use + +```bash +python3 loadout.py --list # Show all loadouts and active one +python3 loadout.py --create coding # Create new loadout (interactive) +python3 loadout.py --add coding skill-doctor # Add skill to loadout +python3 loadout.py --remove coding skill-doctor # Remove skill +python3 loadout.py --activate coding # Switch to loadout +python3 loadout.py --activate --all # Activate all skills +python3 loadout.py --show coding # List skills in a loadout +python3 loadout.py --status # Current active loadout +python3 loadout.py --estimate coding # Estimate token savings +``` + +## Procedure + +**Step 1 — Assess current footprint** + +```bash +python3 loadout.py --estimate --all +``` + +This shows the estimated description token count for all installed skills and highlights candidates for loadout pruning. + +**Step 2 — Define your loadouts** + +Think in contexts: What skills do you actually need when writing code? When doing research? During maintenance windows? Create one loadout per context, aiming for 5–10 skills each. + +```bash +python3 loadout.py --create coding +python3 loadout.py --add coding systematic-debugging test-driven-development +python3 loadout.py --add coding verification-before-completion dangerous-action-guard +``` + +**Step 3 — Activate a loadout** + +```bash +python3 loadout.py --activate coding +``` + +OpenClaw reads the active loadout from state on next session start and only injects those skill descriptions. + +**Step 4 — Switch as needed** + +Switching is instant and takes effect on the next session. No restart required. + +**Step 5 — Return to full mode** + +```bash +python3 loadout.py --activate --all +``` + +## State + +Active loadout name and all loadout definitions stored in `~/.openclaw/skill-state/skill-loadout-manager/state.yaml`. + +Fields: `active_loadout`, `loadouts` map, `switch_history`. + +## Notes + +- Always-on skills (e.g., `dangerous-action-guard`, `prompt-injection-guard`) can be marked `pinned: true` so they're included in every loadout automatically. +- The `minimal` loadout is pre-seeded at install time with only safety and recovery skills. diff --git a/skills/openclaw-native/skill-loadout-manager/STATE_SCHEMA.yaml b/skills/openclaw-native/skill-loadout-manager/STATE_SCHEMA.yaml new file mode 100644 index 0000000..300d34f --- /dev/null +++ b/skills/openclaw-native/skill-loadout-manager/STATE_SCHEMA.yaml @@ -0,0 +1,29 @@ +version: "1.0" +description: Named skill loadouts, active loadout pointer, and switch history. +fields: + active_loadout: + type: string + default: "all" + description: Name of the currently active loadout ("all" means all skills active) + pinned_skills: + type: list + description: Skills always included regardless of active loadout + items: + type: string + loadouts: + type: object + description: Map of loadout_name -> loadout definition + items: + name: { type: string } + skills: { type: list, items: { type: string } } + description: { type: string } + created_at: { type: datetime } + last_used: { type: datetime } + switch_history: + type: list + description: Log of loadout switches (last 20) + items: + switched_at: { type: datetime } + from_loadout: { type: string } + to_loadout: { type: string } + skill_count: { type: integer } diff --git a/skills/openclaw-native/skill-loadout-manager/example-state.yaml b/skills/openclaw-native/skill-loadout-manager/example-state.yaml new file mode 100644 index 0000000..63e794a --- /dev/null +++ b/skills/openclaw-native/skill-loadout-manager/example-state.yaml @@ -0,0 +1,71 @@ +# Example runtime state for skill-loadout-manager +active_loadout: coding +pinned_skills: + - dangerous-action-guard + - prompt-injection-guard + - agent-self-recovery +loadouts: + minimal: + name: minimal + description: Safety and recovery essentials only + skills: + - agent-self-recovery + - context-window-management + - dangerous-action-guard + - prompt-injection-guard + - task-handoff + created_at: "2026-03-10T08:00:00.000000" + last_used: null + coding: + name: coding + description: Software development workflow + skills: + - dangerous-action-guard + - skill-doctor + - subagent-driven-development + - systematic-debugging + - test-driven-development + - verification-before-completion + created_at: "2026-03-10T08:00:00.000000" + last_used: "2026-03-15T09:00:00.000000" + ops: + name: ops + description: Operations, monitoring, and cost management + skills: + - cron-hygiene + - installed-skill-auditor + - loop-circuit-breaker + - secrets-hygiene + - spend-circuit-breaker + - workspace-integrity-guardian + created_at: "2026-03-12T10:00:00.000000" + last_used: "2026-03-13T14:00:00.000000" +switch_history: + - switched_at: "2026-03-15T09:00:00.000000" + from_loadout: research + to_loadout: coding + skill_count: 6 + - switched_at: "2026-03-14T14:30:00.000000" + from_loadout: all + to_loadout: research + skill_count: 5 +# ── Walkthrough ────────────────────────────────────────────────────────────── +# python3 loadout.py --list +# Skill Loadouts (active: coding) +# ──────────────────────────────────────── +# all (all installed skills) +# minimal 5 skills Safety and recovery essentials only +# ▶ coding 6 skills Software development workflow +# ops 6 skills Operations, monitoring, and cost management +# +# python3 loadout.py --estimate coding +# Token estimate for loadout 'coding' +# ──────────────────────────────────────── +# Skills in loadout : 6 +# Est. tokens : ~320 +# All skills total : ~1,800 +# Token savings : ~1,480 (82% reduction) +# +# python3 loadout.py --activate ops +# ✓ Activated loadout 'ops' (6 skills). +# Takes effect on next OpenClaw session start. diff --git a/skills/openclaw-native/skill-loadout-manager/loadout.py b/skills/openclaw-native/skill-loadout-manager/loadout.py new file mode 100755 index 0000000..e049e4e --- /dev/null +++ b/skills/openclaw-native/skill-loadout-manager/loadout.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +Skill Loadout Manager for openclaw-superpowers. + +Manages named skill profiles to control which skills are active, +preventing system prompt bloat from too many installed skills. + +Usage: + python3 loadout.py --list + python3 loadout.py --create + python3 loadout.py --add [ ...] + python3 loadout.py --remove + python3 loadout.py --activate + python3 loadout.py --activate --all + python3 loadout.py --show + python3 loadout.py --status + python3 loadout.py --pin + python3 loadout.py --estimate [|--all] +""" + +import argparse +import os +import sys +from datetime import datetime +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +OPENCLAW_DIR = Path(os.environ.get("OPENCLAW_HOME", Path.home() / ".openclaw")) +STATE_FILE = OPENCLAW_DIR / "skill-state" / "skill-loadout-manager" / "state.yaml" +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] +MAX_HISTORY = 20 + +# Skills that should always be active (security + recovery) +DEFAULT_PINNED = [ + "dangerous-action-guard", + "prompt-injection-guard", + "agent-self-recovery", + "task-handoff", +] + +# Pre-built loadout seeds +PRESET_LOADOUTS = { + "minimal": { + "description": "Safety and recovery essentials only", + "skills": [ + "dangerous-action-guard", + "prompt-injection-guard", + "agent-self-recovery", + "task-handoff", + "context-window-management", + ], + }, + "coding": { + "description": "Software development workflow", + "skills": [ + "systematic-debugging", + "test-driven-development", + "verification-before-completion", + "subagent-driven-development", + "skill-doctor", + "dangerous-action-guard", + ], + }, + "research": { + "description": "Research and knowledge synthesis", + "skills": [ + "fact-check-before-trust", + "brainstorming", + "writing-plans", + "channel-context-bridge", + "persistent-memory-hygiene", + ], + }, + "ops": { + "description": "Operations, monitoring, and cost management", + "skills": [ + "cron-hygiene", + "spend-circuit-breaker", + "loop-circuit-breaker", + "workspace-integrity-guardian", + "secrets-hygiene", + "installed-skill-auditor", + ], + }, +} + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load_state() -> dict: + if not STATE_FILE.exists(): + return { + "active_loadout": "all", + "pinned_skills": list(DEFAULT_PINNED), + "loadouts": {}, + "switch_history": [], + } + try: + text = STATE_FILE.read_text() + return (yaml.safe_load(text) or {}) if HAS_YAML else {} + except Exception: + return {} + + +def save_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + if HAS_YAML: + with open(STATE_FILE, "w") as f: + yaml.dump(state, f, default_flow_style=False, allow_unicode=True) + + +# ── Skill discovery ─────────────────────────────────────────────────────────── + +def all_installed_skills() -> list[str]: + names = [] + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for d in sorted(skills_root.iterdir()): + if d.is_dir() and (d / "SKILL.md").exists(): + names.append(d.name) + return names + + +def skill_description_tokens(skill_name: str) -> int: + """Rough estimate of tokens contributed by this skill's description.""" + for skills_root in SKILLS_DIRS: + skill_md = skills_root / skill_name / "SKILL.md" + if skill_md.exists(): + try: + text = skill_md.read_text() + # Extract description from frontmatter + lines = text.splitlines() + for line in lines[1:20]: + if line.strip() == "---": + break + if line.startswith("description:"): + desc = line.split(":", 1)[1].strip().strip('"').strip("'") + return max(1, len(desc.split()) * 4 // 3) + except Exception: + pass + return 20 # default estimate + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_list(state: dict) -> None: + active = state.get("active_loadout", "all") + loadouts = state.get("loadouts") or {} + print(f"\nSkill Loadouts (active: {active})") + print("─" * 40) + print(f" {'all':20s} (all installed skills)") + for name, ldef in sorted(loadouts.items()): + marker = "▶" if name == active else " " + skill_count = len(ldef.get("skills") or []) + desc = ldef.get("description", "")[:40] + print(f" {marker} {name:20s} {skill_count:2d} skills {desc}") + print() + + +def cmd_create(state: dict, name: str, description: str = "") -> None: + loadouts = state.get("loadouts") or {} + if name in loadouts: + print(f"Loadout '{name}' already exists. Use --add to add skills.") + return + preset = PRESET_LOADOUTS.get(name, {}) + loadouts[name] = { + "name": name, + "skills": list(preset.get("skills", [])), + "description": description or preset.get("description", ""), + "created_at": datetime.now().isoformat(), + "last_used": None, + } + state["loadouts"] = loadouts + save_state(state) + seed_count = len(loadouts[name]["skills"]) + seed_msg = f" (seeded with {seed_count} preset skills)" if seed_count else "" + print(f"✓ Created loadout '{name}'{seed_msg}.") + + +def cmd_add(state: dict, loadout_name: str, skills: list[str]) -> None: + loadouts = state.get("loadouts") or {} + if loadout_name not in loadouts: + print(f"Loadout '{loadout_name}' not found. Run --create {loadout_name} first.") + return + existing = set(loadouts[loadout_name].get("skills") or []) + added = [] + for skill in skills: + if skill not in existing: + existing.add(skill) + added.append(skill) + loadouts[loadout_name]["skills"] = sorted(existing) + state["loadouts"] = loadouts + save_state(state) + if added: + print(f"✓ Added to '{loadout_name}': {', '.join(added)}") + else: + print(f"All skills already in '{loadout_name}'.") + + +def cmd_remove(state: dict, loadout_name: str, skill: str) -> None: + loadouts = state.get("loadouts") or {} + if loadout_name not in loadouts: + print(f"Loadout '{loadout_name}' not found.") + return + skills = loadouts[loadout_name].get("skills") or [] + if skill in skills: + skills.remove(skill) + loadouts[loadout_name]["skills"] = skills + state["loadouts"] = loadouts + save_state(state) + print(f"✓ Removed '{skill}' from '{loadout_name}'.") + else: + print(f"'{skill}' not in loadout '{loadout_name}'.") + + +def cmd_activate(state: dict, name: str) -> None: + loadouts = state.get("loadouts") or {} + if name != "all" and name not in loadouts: + print(f"Loadout '{name}' not found. Create it first with --create {name}.") + return + + previous = state.get("active_loadout", "all") + state["active_loadout"] = name + + if name != "all": + loadouts[name]["last_used"] = datetime.now().isoformat() + state["loadouts"] = loadouts + + history = state.get("switch_history") or [] + skill_count = (len(loadouts.get(name, {}).get("skills") or []) + if name != "all" else len(all_installed_skills())) + history.insert(0, { + "switched_at": datetime.now().isoformat(), + "from_loadout": previous, + "to_loadout": name, + "skill_count": skill_count, + }) + state["switch_history"] = history[:MAX_HISTORY] + save_state(state) + print(f"✓ Activated loadout '{name}' ({skill_count} skills).") + print(f" Takes effect on next OpenClaw session start.") + + +def cmd_show(state: dict, name: str) -> None: + loadouts = state.get("loadouts") or {} + pinned = set(state.get("pinned_skills") or []) + + if name == "all": + skills = all_installed_skills() + print(f"\nLoadout: all ({len(skills)} skills)") + elif name in loadouts: + ldef = loadouts[name] + skills = sorted(ldef.get("skills") or []) + print(f"\nLoadout: {name} ({len(skills)} skills)") + if ldef.get("description"): + print(f" {ldef['description']}") + else: + print(f"Loadout '{name}' not found.") + return + + print("─" * 40) + for skill in skills: + pin_marker = " [pinned]" if skill in pinned else "" + print(f" - {skill}{pin_marker}") + print() + + +def cmd_pin(state: dict, skill: str) -> None: + pinned = set(state.get("pinned_skills") or []) + pinned.add(skill) + state["pinned_skills"] = sorted(pinned) + save_state(state) + print(f"✓ Pinned '{skill}' — will be included in all loadouts.") + + +def cmd_estimate(state: dict, name: str) -> None: + if name == "all": + skills = all_installed_skills() + else: + loadouts = state.get("loadouts") or {} + if name not in loadouts: + print(f"Loadout '{name}' not found.") + return + skills = loadouts[name].get("skills") or [] + + total_tokens = sum(skill_description_tokens(s) for s in skills) + all_skills = all_installed_skills() + all_tokens = sum(skill_description_tokens(s) for s in all_skills) + savings = all_tokens - total_tokens + + print(f"\nToken estimate for loadout '{name}'") + print("─" * 40) + print(f" Skills in loadout : {len(skills)}") + print(f" Est. tokens : ~{total_tokens}") + if name != "all": + print(f" All skills total : ~{all_tokens}") + pct = int(100 * savings / all_tokens) if all_tokens else 0 + print(f" Token savings : ~{savings} ({pct}% reduction)") + print() + + +def cmd_status(state: dict) -> None: + active = state.get("active_loadout", "all") + pinned = state.get("pinned_skills") or [] + history = state.get("switch_history") or [] + print(f"\nActive loadout: {active}") + if pinned: + print(f"Pinned skills : {', '.join(pinned)}") + if history: + last = history[0] + print(f"Last switch : {last.get('switched_at','')[:16]} " + f"({last.get('from_loadout','')} → {last.get('to_loadout','')})") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Loadout Manager") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--list", action="store_true") + group.add_argument("--create", metavar="NAME") + group.add_argument("--add", nargs="+", metavar="ARG", + help=" [ ...]") + group.add_argument("--remove", nargs=2, metavar=("LOADOUT", "SKILL")) + group.add_argument("--activate", metavar="NAME") + group.add_argument("--show", metavar="NAME") + group.add_argument("--pin", metavar="SKILL") + group.add_argument("--estimate", metavar="NAME") + group.add_argument("--status", action="store_true") + parser.add_argument("--all", action="store_true", + help="With --activate or --estimate: use all skills") + parser.add_argument("--description", metavar="TEXT", default="", + help="With --create: loadout description") + args = parser.parse_args() + + state = load_state() + + if args.list: + cmd_list(state) + elif args.create: + cmd_create(state, args.create, args.description) + elif args.add: + if len(args.add) < 2: + print("Usage: --add [ ...]") + sys.exit(1) + cmd_add(state, args.add[0], args.add[1:]) + elif args.remove: + cmd_remove(state, args.remove[0], args.remove[1]) + elif args.activate: + cmd_activate(state, "all" if args.all else args.activate) + elif args.show: + cmd_show(state, "all" if args.all else args.show) + elif args.pin: + cmd_pin(state, args.pin) + elif args.estimate: + cmd_estimate(state, "all" if args.all else args.estimate) + elif args.status: + cmd_status(state) + + +if __name__ == "__main__": + main() From 9a2e00c07670d47a587fe0cbaf826c26d27169ff Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:45:23 +0530 Subject: [PATCH 05/11] Add skill-compatibility-checker: detect version/feature incompatibilities Reads requires_openclaw + requires_features frontmatter fields and compares against detected (or overridden) OpenClaw version. Ships feature registry with 5 runtime capabilities and their introduction versions. Co-Authored-By: Claude Sonnet 4.6 --- .../skill-compatibility-checker/SKILL.md | 94 +++++ .../STATE_SCHEMA.yaml | 27 ++ .../skill-compatibility-checker/check.py | 363 ++++++++++++++++++ .../example-state.yaml | 47 +++ 4 files changed, 531 insertions(+) create mode 100644 skills/openclaw-native/skill-compatibility-checker/SKILL.md create mode 100644 skills/openclaw-native/skill-compatibility-checker/STATE_SCHEMA.yaml create mode 100755 skills/openclaw-native/skill-compatibility-checker/check.py create mode 100644 skills/openclaw-native/skill-compatibility-checker/example-state.yaml diff --git a/skills/openclaw-native/skill-compatibility-checker/SKILL.md b/skills/openclaw-native/skill-compatibility-checker/SKILL.md new file mode 100644 index 0000000..affe4de --- /dev/null +++ b/skills/openclaw-native/skill-compatibility-checker/SKILL.md @@ -0,0 +1,94 @@ +--- +name: skill-compatibility-checker +version: "1.0" +category: openclaw-native +description: Checks whether installed skills are compatible with the current OpenClaw version and flags skills that require runtime features not present in your installation. +stateful: true +--- + +# Skill Compatibility Checker + +## What it does + +Skills can declare minimum OpenClaw version requirements and depend on specific runtime features (cron engine, session isolation, state storage, context compaction). When you upgrade or downgrade OpenClaw, or move a skill to a different environment, compatibility silently breaks. + +Skill Compatibility Checker reads skill frontmatter for version constraints and feature requirements, then compares them against the currently running OpenClaw version. It reports incompatibilities before they cause confusing silent failures. + +## When to invoke + +- After upgrading OpenClaw +- Before deploying a skill to a new environment +- When a skill that previously worked stops working after an update +- As a post-upgrade gate in automated deployment pipelines + +## Frontmatter fields checked + +```yaml +--- +name: my-skill +requires_openclaw: ">=1.4.0" # optional semver constraint +requires_features: # optional list of runtime features + - cron + - session_isolation + - state_storage + - context_compaction + - sessions_send +--- +``` + +If these fields are absent the skill is treated as version-agnostic. + +## Feature registry + +| Feature | Introduced | Description | +|---|---|---| +| `cron` | 1.0.0 | Cron-scheduled skill wakeups | +| `state_storage` | 1.0.0 | Persistent skill state at `~/.openclaw/skill-state/` | +| `session_isolation` | 1.2.0 | Skills run in isolated sessions (not main session) | +| `context_compaction` | 1.3.0 | Native context compaction API | +| `sessions_send` | 1.4.0 | Cross-session message passing | + +## Output + +``` +Skill Compatibility Check — OpenClaw 1.3.2 +──────────────────────────────────────────────── +32 skills checked | 0 incompatible | 2 warnings + +WARN channel-context-bridge: requires sessions_send (≥1.4.0, have 1.3.2) +WARN multi-agent-coordinator: requires sessions_send (≥1.4.0, have 1.3.2) +``` + +## How to use + +```bash +python3 check.py --check # Full compatibility scan +python3 check.py --check --skill my-skill # Single skill +python3 check.py --openclaw-version 1.5.0 # Override detected version +python3 check.py --features # List all known features + versions +python3 check.py --status # Last check summary +python3 check.py --format json +``` + +## Procedure + +**Step 1 — Run the check** + +```bash +python3 check.py --check +``` + +**Step 2 — Triage incompatibilities** + +- **FAIL**: The skill cannot run on this version. Either upgrade OpenClaw or disable the skill. +- **WARN**: The skill declares a feature that may be available but wasn't present in the stated minimum. Functionality may be degraded. + +**Step 3 — Document your constraints** + +If you're writing a new skill that uses `sessions_send` or `session_isolation`, add the appropriate `requires_openclaw:` and `requires_features:` frontmatter so future users know immediately if their version supports it. + +## State + +Last check results stored in `~/.openclaw/skill-state/skill-compatibility-checker/state.yaml`. + +Fields: `last_check_at`, `openclaw_version`, `incompatibilities`, `check_history`. diff --git a/skills/openclaw-native/skill-compatibility-checker/STATE_SCHEMA.yaml b/skills/openclaw-native/skill-compatibility-checker/STATE_SCHEMA.yaml new file mode 100644 index 0000000..9270bfe --- /dev/null +++ b/skills/openclaw-native/skill-compatibility-checker/STATE_SCHEMA.yaml @@ -0,0 +1,27 @@ +version: "1.0" +description: Compatibility check results against the current OpenClaw version. +fields: + last_check_at: + type: datetime + openclaw_version: + type: string + description: OpenClaw version at time of last check + incompatibilities: + type: list + description: Skills that failed or warned during the last check + items: + skill_name: { type: string } + level: { type: enum, values: [FAIL, WARN] } + constraint: { type: string } + feature: { type: string } + detail: { type: string } + detected_at: { type: datetime } + check_history: + type: list + description: Rolling log of past checks (last 10) + items: + checked_at: { type: datetime } + openclaw_version: { type: string } + skills_checked: { type: integer } + fail_count: { type: integer } + warn_count: { type: integer } diff --git a/skills/openclaw-native/skill-compatibility-checker/check.py b/skills/openclaw-native/skill-compatibility-checker/check.py new file mode 100755 index 0000000..023c95e --- /dev/null +++ b/skills/openclaw-native/skill-compatibility-checker/check.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +""" +Skill Compatibility Checker for openclaw-superpowers. + +Checks installed skills against the current OpenClaw version for +version constraints and feature availability. + +Usage: + python3 check.py --check + python3 check.py --check --skill channel-context-bridge + python3 check.py --openclaw-version 1.5.0 # override detected version + python3 check.py --features # list feature registry + python3 check.py --status # last check summary + python3 check.py --format json +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +OPENCLAW_DIR = Path(os.environ.get("OPENCLAW_HOME", Path.home() / ".openclaw")) +STATE_FILE = OPENCLAW_DIR / "skill-state" / "skill-compatibility-checker" / "state.yaml" +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] +MAX_HISTORY = 10 + +# Feature availability: feature_name -> introduced_in (as tuple) +FEATURE_REGISTRY = { + "cron": (1, 0, 0), + "state_storage": (1, 0, 0), + "session_isolation": (1, 2, 0), + "context_compaction": (1, 3, 0), + "sessions_send": (1, 4, 0), +} + + +# ── Version helpers ─────────────────────────────────────────────────────────── + +def parse_version(v: str) -> tuple[int, ...]: + """Parse '1.2.3' → (1, 2, 3). Returns (0,) on failure.""" + try: + return tuple(int(x) for x in re.findall(r'\d+', str(v))) + except Exception: + return (0,) + + +def version_str(t: tuple) -> str: + return ".".join(str(x) for x in t) + + +def satisfies_constraint(version: tuple, constraint: str) -> tuple[bool, str]: + """ + Check semver constraint like '>=1.2.0', '==1.3.0', '>1.0'. + Returns (ok, explanation). + """ + constraint = constraint.strip() + m = re.match(r'^(>=|<=|==|!=|>|<)\s*(.+)$', constraint) + if not m: + return True, "" # can't parse → assume compatible + + op, ver_str = m.group(1), m.group(2).strip() + required = parse_version(ver_str) + + ops = { + ">=": lambda a, b: a >= b, + "<=": lambda a, b: a <= b, + "==": lambda a, b: a == b, + "!=": lambda a, b: a != b, + ">": lambda a, b: a > b, + "<": lambda a, b: a < b, + } + ok = ops[op](version, required) + explanation = f"{op}{ver_str}" + return ok, explanation + + +def detect_openclaw_version() -> tuple[int, ...]: + """Try to detect OpenClaw version from CLI or version file.""" + # Try CLI + try: + result = subprocess.run( + ["openclaw", "--version"], + capture_output=True, text=True, timeout=5 + ) + output = result.stdout.strip() or result.stderr.strip() + m = re.search(r'(\d+\.\d+(?:\.\d+)?)', output) + if m: + return parse_version(m.group(1)) + except Exception: + pass + + # Try version file + for candidate in [ + OPENCLAW_DIR / "VERSION", + OPENCLAW_DIR / "version.txt", + Path("/usr/local/lib/openclaw/VERSION"), + ]: + if candidate.exists(): + try: + return parse_version(candidate.read_text().strip()) + except Exception: + pass + + return (1, 0, 0) # safe fallback + + +# ── Frontmatter parser ──────────────────────────────────────────────────────── + +def parse_frontmatter(skill_md: Path) -> dict: + try: + text = skill_md.read_text() + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end = i + break + if end is None: + return {} + fm_text = "\n".join(lines[1:end]) + if HAS_YAML: + return yaml.safe_load(fm_text) or {} + # Minimal fallback + fields = {} + for line in fm_text.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + fields[k.strip()] = v.strip().strip('"').strip("'") + return fields + except Exception: + return {} + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {"incompatibilities": [], "check_history": []} + try: + text = STATE_FILE.read_text() + return (yaml.safe_load(text) or {}) if HAS_YAML else {} + except Exception: + return {} + + +def save_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + if HAS_YAML: + with open(STATE_FILE, "w") as f: + yaml.dump(state, f, default_flow_style=False, allow_unicode=True) + + +# ── Check logic ─────────────────────────────────────────────────────────────── + +def check_skill(skill_dir: Path, oc_version: tuple) -> list[dict]: + skill_name = skill_dir.name + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + return [] + + fm = parse_frontmatter(skill_md) + now = datetime.now().isoformat() + issues = [] + + def issue(level, constraint, feature, detail): + return { + "skill_name": skill_name, + "level": level, + "constraint": constraint, + "feature": feature, + "detail": detail, + "detected_at": now, + } + + # Check requires_openclaw version constraint + req_ver = fm.get("requires_openclaw", "") + if req_ver: + ok, explanation = satisfies_constraint(oc_version, str(req_ver)) + if not ok: + issues.append(issue( + "FAIL", + str(req_ver), + "openclaw_version", + f"Requires OpenClaw {explanation}, have {version_str(oc_version)}" + )) + + # Check requires_features list + req_features = fm.get("requires_features") or [] + if isinstance(req_features, str): + req_features = [req_features] + + for feature in req_features: + feature = str(feature).strip() + if feature not in FEATURE_REGISTRY: + issues.append(issue( + "WARN", + "", + feature, + f"Unknown feature '{feature}' — not in feature registry" + )) + continue + introduced = FEATURE_REGISTRY[feature] + if oc_version < introduced: + level = "FAIL" if oc_version < introduced else "WARN" + issues.append(issue( + level, + f">={version_str(introduced)}", + feature, + f"Requires feature '{feature}' (≥{version_str(introduced)}), " + f"have {version_str(oc_version)}" + )) + + return issues + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_check(state: dict, oc_version: tuple, single_skill: str, + fmt: str) -> None: + all_issues = [] + skills_checked = 0 + + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + if single_skill and skill_dir.name != single_skill: + continue + issues = check_skill(skill_dir, oc_version) + all_issues.extend(issues) + skills_checked += 1 + + fails = sum(1 for i in all_issues if i["level"] == "FAIL") + warns = sum(1 for i in all_issues if i["level"] == "WARN") + now = datetime.now().isoformat() + + if fmt == "json": + print(json.dumps({ + "checked_at": now, + "openclaw_version": version_str(oc_version), + "skills_checked": skills_checked, + "fail_count": fails, + "warn_count": warns, + "incompatibilities": all_issues, + }, indent=2)) + else: + print(f"\nSkill Compatibility Check — OpenClaw {version_str(oc_version)}") + print("─" * 50) + print(f" {skills_checked} skills checked | " + f"{fails} incompatible | {warns} warnings") + print() + if not all_issues: + print(" ✓ All skills compatible.") + else: + for issue in all_issues: + icon = "✗" if issue["level"] == "FAIL" else "⚠" + print(f" {icon} {issue['level']:4s} {issue['skill_name']}: " + f"{issue['detail']}") + print() + + # Persist + history = state.get("check_history") or [] + history.insert(0, { + "checked_at": now, + "openclaw_version": version_str(oc_version), + "skills_checked": skills_checked, + "fail_count": fails, + "warn_count": warns, + }) + state["check_history"] = history[:MAX_HISTORY] + state["last_check_at"] = now + state["openclaw_version"] = version_str(oc_version) + state["incompatibilities"] = all_issues + save_state(state) + + sys.exit(1 if fails > 0 else 0) + + +def cmd_features(fmt: str) -> None: + if fmt == "json": + print(json.dumps({k: version_str(v) for k, v in FEATURE_REGISTRY.items()}, + indent=2)) + return + print("\nOpenClaw Feature Registry") + print("─" * 40) + for feature, introduced in sorted(FEATURE_REGISTRY.items(), + key=lambda x: x[1]): + print(f" {feature:25s} introduced in {version_str(introduced)}") + print() + + +def cmd_status(state: dict) -> None: + last = state.get("last_check_at", "never") + ver = state.get("openclaw_version", "unknown") + history = state.get("check_history") or [] + print(f"\nSkill Compatibility Checker — Last run: {last} (OpenClaw {ver})") + if history: + h = history[0] + print(f" {h['skills_checked']} checked | " + f"{h['fail_count']} incompatible | {h['warn_count']} warnings") + active = state.get("incompatibilities") or [] + if active: + print(f"\n Active incompatibilities ({len(active)}):") + for i in active[:5]: + print(f" [{i['level']}] {i['skill_name']}: {i['detail'][:60]}") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Compatibility Checker") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--check", action="store_true") + group.add_argument("--features", action="store_true") + group.add_argument("--status", action="store_true") + parser.add_argument("--skill", metavar="NAME") + parser.add_argument("--openclaw-version", metavar="VER", + help="Override detected OpenClaw version") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + state = load_state() + + if args.features: + cmd_features(args.format) + return + + if args.status: + cmd_status(state) + return + + oc_version = (parse_version(args.openclaw_version) + if args.openclaw_version + else detect_openclaw_version()) + + cmd_check(state, oc_version, single_skill=args.skill, fmt=args.format) + + +if __name__ == "__main__": + main() diff --git a/skills/openclaw-native/skill-compatibility-checker/example-state.yaml b/skills/openclaw-native/skill-compatibility-checker/example-state.yaml new file mode 100644 index 0000000..bd7b64a --- /dev/null +++ b/skills/openclaw-native/skill-compatibility-checker/example-state.yaml @@ -0,0 +1,47 @@ +# Example runtime state for skill-compatibility-checker +last_check_at: "2026-03-15T09:30:00.000000" +openclaw_version: "1.3.2" +incompatibilities: + - skill_name: channel-context-bridge + level: WARN + constraint: ">=1.4.0" + feature: sessions_send + detail: "Requires feature 'sessions_send' (≥1.4.0), have 1.3.2" + detected_at: "2026-03-15T09:30:00.000000" + - skill_name: multi-agent-coordinator + level: WARN + constraint: ">=1.4.0" + feature: sessions_send + detail: "Requires feature 'sessions_send' (≥1.4.0), have 1.3.2" + detected_at: "2026-03-15T09:30:00.000000" +check_history: + - checked_at: "2026-03-15T09:30:00.000000" + openclaw_version: "1.3.2" + skills_checked: 32 + fail_count: 0 + warn_count: 2 + - checked_at: "2026-03-01T10:00:00.000000" + openclaw_version: "1.3.0" + skills_checked: 31 + fail_count: 0 + warn_count: 2 +# ── Walkthrough ────────────────────────────────────────────────────────────── +# python3 check.py --check +# +# Skill Compatibility Check — OpenClaw 1.3.2 +# ────────────────────────────────────────────────────────────── +# 32 skills checked | 0 incompatible | 2 warnings +# +# ⚠ WARN channel-context-bridge: Requires feature 'sessions_send' (≥1.4.0), have 1.3.2 +# ⚠ WARN multi-agent-coordinator: Requires feature 'sessions_send' (≥1.4.0), have 1.3.2 +# +# Upgrade OpenClaw to 1.4.0+ to fully enable these skills. +# +# python3 check.py --features +# OpenClaw Feature Registry +# ──────────────────────────────────────── +# cron introduced in 1.0.0 +# state_storage introduced in 1.0.0 +# session_isolation introduced in 1.2.0 +# context_compaction introduced in 1.3.0 +# sessions_send introduced in 1.4.0 From cc01630866bee004b310fb001c9530ba43b4de42 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:56:54 +0530 Subject: [PATCH 06/11] Add skill-conflict-detector: detect name shadowing and description overlap (#21) Detects NAME_SHADOW (CRITICAL), EXACT_DUPLICATE (CRITICAL), HIGH_OVERLAP (HIGH), and MEDIUM_OVERLAP (MEDIUM) conflicts between installed skills using Jaccard similarity on description tokens. Co-authored-by: Claude Sonnet 4.6 --- skills/core/skill-conflict-detector/SKILL.md | 85 +++++ skills/core/skill-conflict-detector/detect.py | 293 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 skills/core/skill-conflict-detector/SKILL.md create mode 100755 skills/core/skill-conflict-detector/detect.py diff --git a/skills/core/skill-conflict-detector/SKILL.md b/skills/core/skill-conflict-detector/SKILL.md new file mode 100644 index 0000000..4dfb400 --- /dev/null +++ b/skills/core/skill-conflict-detector/SKILL.md @@ -0,0 +1,85 @@ +--- +name: skill-conflict-detector +version: "1.0" +category: core +description: Detects skill name shadowing and description-overlap conflicts that cause OpenClaw to trigger the wrong skill or silently ignore one when two skills compete for the same intent. +--- + +# Skill Conflict Detector + +## What it does + +Two types of conflict cause skills to misbehave silently: + +**1. Name shadowing** — Two installed skills have the same `name:` field. OpenClaw loads the last one lexicographically; the other silently disappears. No warning. + +**2. Description overlap** — Two skills' descriptions are so semantically similar that OpenClaw can't reliably distinguish them. The wrong skill fires. You think one skill is broken; actually the other is intercepting it. + +Skill Conflict Detector scans all installed skills for both types and reports them with overlap scores and resolution suggestions. + +## When to invoke + +- After installing a new skill from ClawHub +- When a skill fires inconsistently or triggers on unexpected prompts +- Before publishing a new skill (ensure it doesn't shadow an existing one) +- As part of `install.sh` post-install validation + +## Conflict types + +| Type | Severity | Effect | +|---|---|---| +| NAME_SHADOW | CRITICAL | One skill completely hidden | +| EXACT_DUPLICATE | CRITICAL | Identical description — both fire or neither does | +| HIGH_OVERLAP | HIGH | >75% semantic similarity — unreliable trigger routing | +| MEDIUM_OVERLAP | MEDIUM | 50–75% similarity — possible confusion | + +## Output + +``` +Skill Conflict Report — 32 skills +──────────────────────────────────────────────── +0 CRITICAL | 1 HIGH | 0 MEDIUM + +HIGH skill-vetting ↔ installed-skill-auditor overlap: 0.81 + Both describe "scanning skills for security issues" + Suggestion: Differentiate — skill-vetting is pre-install, + installed-skill-auditor is post-install ongoing audit. +``` + +## How to use + +```bash +python3 detect.py --scan # Full conflict scan +python3 detect.py --scan --skill my-skill # Check one skill vs all others +python3 detect.py --scan --threshold 0.6 # Custom similarity threshold +python3 detect.py --names # Check name shadowing only +python3 detect.py --format json +``` + +## Procedure + +**Step 1 — Run the scan** + +```bash +python3 detect.py --scan +``` + +**Step 2 — Resolve CRITICAL conflicts first** + +NAME_SHADOW: Rename one skill's `name:` field and its directory. Run `bash scripts/validate-skills.sh` to confirm. + +EXACT_DUPLICATE: One skill is redundant. Remove or differentiate it. + +**Step 3 — Assess HIGH_OVERLAP pairs** + +Read both descriptions. Ask: could a user's natural-language request unambiguously route to one and not the other? If no, differentiate. Common fix: add the scope or timing to the description (e.g., "before install" vs. "after install"). + +**Step 4 — Accept or suppress MEDIUM_OVERLAP** + +Medium overlaps are informational. If the two skills serve genuinely different contexts and users would naturally phrase requests differently, they can coexist. Document why in the skill's SKILL.md if it's non-obvious. + +## Similarity model + +Token-overlap Jaccard similarity between description strings after stop-word removal. Fast and deterministic — no external dependencies. + +Threshold defaults: HIGH ≥ 0.75, MEDIUM ≥ 0.50. diff --git a/skills/core/skill-conflict-detector/detect.py b/skills/core/skill-conflict-detector/detect.py new file mode 100755 index 0000000..7f6d484 --- /dev/null +++ b/skills/core/skill-conflict-detector/detect.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Skill Conflict Detector for openclaw-superpowers. + +Detects name shadowing and description-overlap conflicts between +installed skills that cause silent trigger routing failures. + +Usage: + python3 detect.py --scan + python3 detect.py --scan --skill my-skill + python3 detect.py --scan --threshold 0.6 + python3 detect.py --names # Name shadowing only + python3 detect.py --format json +""" + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] + +DEFAULT_HIGH_THRESHOLD = 0.75 +DEFAULT_MEDIUM_THRESHOLD = 0.50 + +_STOPWORDS = { + "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", + "of", "with", "by", "from", "is", "are", "was", "were", "be", "been", + "it", "its", "this", "that", "so", "not", "no", "all", "any", "each", + "more", "most", "has", "have", "had", "do", "does", "did", "will", + "would", "could", "should", "may", "can", "which", "when", "where", + "how", "what", "who", "i", "you", "we", "they", "he", "she", +} + + +# ── Frontmatter parser ──────────────────────────────────────────────────────── + +def parse_frontmatter(skill_md: Path) -> dict: + try: + text = skill_md.read_text() + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end = i + break + if end is None: + return {} + fm_text = "\n".join(lines[1:end]) + if HAS_YAML: + return yaml.safe_load(fm_text) or {} + fields = {} + for line in fm_text.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + fields[k.strip()] = v.strip().strip('"').strip("'") + return fields + except Exception: + return {} + + +# ── Tokeniser + similarity ──────────────────────────────────────────────────── + +def tokenise(text: str) -> set[str]: + tokens = re.findall(r"[a-z0-9]+", text.lower()) + return {t for t in tokens if t not in _STOPWORDS and len(t) > 2} + + +def jaccard(a: set, b: set) -> float: + if not a and not b: + return 1.0 + inter = len(a & b) + union = len(a | b) + return inter / union if union > 0 else 0.0 + + +# ── Skill loader ────────────────────────────────────────────────────────────── + +def load_all_skills() -> list[dict]: + skills = [] + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + fm = parse_frontmatter(skill_md) + skills.append({ + "dir_name": skill_dir.name, + "name": fm.get("name", skill_dir.name), + "description": fm.get("description", ""), + "path": str(skill_md), + }) + return skills + + +# ── Conflict detection ──────────────────────────────────────────────────────── + +def detect_conflicts(skills: list[dict], high_threshold: float, + medium_threshold: float, + single_skill: str = None) -> list[dict]: + conflicts = [] + + # Name shadowing: same name field, different directories + by_name: dict = {} + for s in skills: + by_name.setdefault(s["name"], []).append(s) + + for name, group in by_name.items(): + if len(group) > 1: + for i in range(len(group)): + for j in range(i + 1, len(group)): + a, b = group[i], group[j] + if single_skill and single_skill not in (a["dir_name"], b["dir_name"]): + continue + conflicts.append({ + "type": "NAME_SHADOW", + "severity": "CRITICAL", + "skill_a": a["dir_name"], + "skill_b": b["dir_name"], + "overlap_score": 1.0, + "detail": f"Both have `name: {name}` — one will be hidden", + "suggestion": f"Rename one skill's `name:` field and its directory.", + }) + + # Description overlap + for i in range(len(skills)): + for j in range(i + 1, len(skills)): + a, b = skills[i], skills[j] + if single_skill and single_skill not in (a["dir_name"], b["dir_name"]): + continue + + ta = tokenise(a["description"]) + tb = tokenise(b["description"]) + + if not ta or not tb: + continue + + score = jaccard(ta, tb) + + if score >= high_threshold: + # Check for exact duplicate + severity = "CRITICAL" if a["description"] == b["description"] else "HIGH" + ctype = "EXACT_DUPLICATE" if severity == "CRITICAL" else "HIGH_OVERLAP" + common = ta & tb + conflicts.append({ + "type": ctype, + "severity": severity, + "skill_a": a["dir_name"], + "skill_b": b["dir_name"], + "overlap_score": round(score, 3), + "detail": ( + f"Descriptions share key terms: " + + ", ".join(f'"{t}"' for t in sorted(common)[:5]) + ), + "suggestion": ( + "Differentiate descriptions — add scope, timing, or " + "context that distinguishes when each skill fires." + ), + }) + elif score >= medium_threshold: + common = ta & tb + conflicts.append({ + "type": "MEDIUM_OVERLAP", + "severity": "MEDIUM", + "skill_a": a["dir_name"], + "skill_b": b["dir_name"], + "overlap_score": round(score, 3), + "detail": ( + "Moderate description overlap — " + + ", ".join(f'"{t}"' for t in sorted(common)[:4]) + ), + "suggestion": ( + "Acceptable if use-cases are clearly distinct. " + "Consider adding differentiating context to each description." + ), + }) + + return conflicts + + +# ── Output ──────────────────────────────────────────────────────────────────── + +def print_report(conflicts: list, skills_count: int, fmt: str) -> None: + criticals = [c for c in conflicts if c["severity"] == "CRITICAL"] + highs = [c for c in conflicts if c["severity"] == "HIGH"] + mediums = [c for c in conflicts if c["severity"] == "MEDIUM"] + + if fmt == "json": + print(json.dumps({ + "skills_scanned": skills_count, + "critical_count": len(criticals), + "high_count": len(highs), + "medium_count": len(mediums), + "conflicts": conflicts, + }, indent=2)) + return + + print(f"\nSkill Conflict Report — {skills_count} skills") + print("─" * 50) + print(f" {len(criticals)} CRITICAL | {len(highs)} HIGH | {len(mediums)} MEDIUM") + print() + + if not conflicts: + print(" ✓ No conflicts detected.") + else: + for c in conflicts: + icon = "✗" if c["severity"] in ("CRITICAL",) else ( + "!" if c["severity"] == "HIGH" else "⚠" + ) + score_str = f" overlap: {c['overlap_score']:.2f}" if c["type"] != "NAME_SHADOW" else "" + print(f" {icon} {c['severity']:8s} {c['skill_a']} ↔ {c['skill_b']}" + f"{score_str}") + print(f" {c['detail']}") + print(f" → {c['suggestion']}") + print() + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_scan(high_threshold: float, medium_threshold: float, + single_skill: str, fmt: str) -> None: + skills = load_all_skills() + conflicts = detect_conflicts(skills, high_threshold, medium_threshold, single_skill) + print_report(conflicts, len(skills), fmt) + critical_count = sum(1 for c in conflicts if c["severity"] == "CRITICAL") + sys.exit(1 if critical_count > 0 else 0) + + +def cmd_names(fmt: str) -> None: + skills = load_all_skills() + conflicts = detect_conflicts(skills, high_threshold=2.0, medium_threshold=2.0) + name_conflicts = [c for c in conflicts if c["type"] == "NAME_SHADOW"] + if fmt == "json": + print(json.dumps(name_conflicts, indent=2)) + else: + if not name_conflicts: + print("✓ No name shadowing detected.") + else: + for c in name_conflicts: + print(f"✗ SHADOW: {c['skill_a']} ↔ {c['skill_b']} {c['detail']}") + sys.exit(1 if name_conflicts else 0) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Conflict Detector") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--scan", action="store_true") + group.add_argument("--names", action="store_true", + help="Check name shadowing only") + parser.add_argument("--skill", metavar="NAME", + help="Check one skill against all others") + parser.add_argument("--threshold", type=float, default=DEFAULT_HIGH_THRESHOLD, + help=f"HIGH similarity threshold (default: {DEFAULT_HIGH_THRESHOLD})") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + if args.names: + cmd_names(args.format) + elif args.scan: + cmd_scan( + high_threshold=args.threshold, + medium_threshold=DEFAULT_MEDIUM_THRESHOLD, + single_skill=args.skill, + fmt=args.format, + ) + + +if __name__ == "__main__": + main() From 292f707e0f91776cd6db76ad60e955a2ff6125d5 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:56:56 +0530 Subject: [PATCH 07/11] Add heartbeat-governor: per-skill execution budgets for cron skills (#22) Tracks 30-day rolling spend and wall-clock time per scheduled skill. Auto-pauses skills that exceed monthly/per-run budgets. Cron: every hour. Supports manual pause/resume and per-skill budget overrides. Co-authored-by: Claude Sonnet 4.6 --- .../heartbeat-governor/SKILL.md | 105 ++++++ .../heartbeat-governor/STATE_SCHEMA.yaml | 41 +++ .../heartbeat-governor/example-state.yaml | 64 ++++ .../heartbeat-governor/governor.py | 333 ++++++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 skills/openclaw-native/heartbeat-governor/SKILL.md create mode 100644 skills/openclaw-native/heartbeat-governor/STATE_SCHEMA.yaml create mode 100644 skills/openclaw-native/heartbeat-governor/example-state.yaml create mode 100755 skills/openclaw-native/heartbeat-governor/governor.py diff --git a/skills/openclaw-native/heartbeat-governor/SKILL.md b/skills/openclaw-native/heartbeat-governor/SKILL.md new file mode 100644 index 0000000..5d5dc52 --- /dev/null +++ b/skills/openclaw-native/heartbeat-governor/SKILL.md @@ -0,0 +1,105 @@ +--- +name: heartbeat-governor +version: "1.0" +category: openclaw-native +description: Enforces per-skill execution budgets for scheduled cron skills — pauses runaway skills that exceed their token or wall-clock budget before they drain your monthly API allowance. +stateful: true +cron: "0 * * * *" +--- + +# Heartbeat Governor + +## What it does + +Cron skills run autonomously. A skill with a bug — an infinite retry, an unexpectedly large context, a model call inside a loop — can silently consume hundreds of dollars before you notice. + +Heartbeat Governor tracks cumulative execution cost and wall-clock time per scheduled skill on a rolling 30-day basis. When a skill exceeds its budget, the governor pauses it and sends an alert. The skill won't fire again until you explicitly review and resume it. + +It runs every hour to catch budget overruns within one cron cycle. + +## When to invoke + +- Automatically, every hour (cron) +- Manually after noticing an unexpected API bill spike +- When a cron skill has been running unusually long + +## Budget types + +| Budget type | Default | Configurable | +|---|---|---| +| `max_usd_monthly` | $5.00 | Yes, per skill | +| `max_usd_per_run` | $0.50 | Yes, per skill | +| `max_wall_minutes` | 30 | Yes, per skill | +| `max_runs_daily` | 48 | Yes, per skill | + +## Actions on budget breach + +| Breach type | Action | +|---|---| +| `monthly_usd` exceeded | Pause skill, log breach, alert | +| `per_run_usd` exceeded | Abort current run, log breach | +| `wall_clock` exceeded | Abort current run, log breach | +| `daily_runs` exceeded | Skip remaining runs today, log | + +## How to use + +```bash +python3 governor.py --status # Show all skills and budget utilisation +python3 governor.py --record --usd 0.12 --minutes 4 # Record a run +python3 governor.py --pause # Manually pause a skill +python3 governor.py --resume # Resume a paused skill after review +python3 governor.py --set-budget --monthly 10.00 # Override budget +python3 governor.py --check # Run hourly check (called by cron) +python3 governor.py --report # Full monthly spend report +python3 governor.py --format json +``` + +## Cron wakeup behaviour + +Every hour the governor runs `--check`: + +1. Load all skill ledgers from state +2. For each skill with `paused: false`: + - If 30-day rolling spend exceeds `max_usd_monthly` → `paused: true`, log + - If runs today exceed `max_runs_daily` → skip, log +3. Print summary of paused skills and budget utilisation +4. Save updated state + +## Procedure + +**Step 1 — Set sensible budgets** + +After installing any new cron skill, set its monthly budget: + +```bash +python3 governor.py --set-budget daily-review --monthly 2.00 +python3 governor.py --set-budget morning-briefing --monthly 3.00 +``` + +Defaults are conservative ($5/month) but explicit is better. + +**Step 2 — Monitor utilisation** + +```bash +python3 governor.py --status +``` + +Review the utilisation column. Any skill above 80% monthly budget warrants investigation. + +**Step 3 — Respond to pause alerts** + +When the governor pauses a skill, investigate why it's over budget: +- Was there a one-time expensive run (large context)? +- Is there a bug causing repeated expensive calls? +- Does the budget simply need to be raised? + +Resume after investigating: +```bash +python3 governor.py --resume +``` + +## State + +Per-skill ledgers and pause flags stored in `~/.openclaw/skill-state/heartbeat-governor/state.yaml`. + +Fields: `skill_ledgers` map, `paused_skills` list, `breach_log`, `monthly_summary`. diff --git a/skills/openclaw-native/heartbeat-governor/STATE_SCHEMA.yaml b/skills/openclaw-native/heartbeat-governor/STATE_SCHEMA.yaml new file mode 100644 index 0000000..535e4cf --- /dev/null +++ b/skills/openclaw-native/heartbeat-governor/STATE_SCHEMA.yaml @@ -0,0 +1,41 @@ +version: "1.0" +description: Per-skill execution budgets, spend ledgers, pause flags, and breach log. +fields: + skill_ledgers: + type: object + description: Map of skill_name -> budget + rolling spend ledger + items: + budget: + type: object + properties: + max_usd_monthly: { type: float, default: 5.0 } + max_usd_per_run: { type: float, default: 0.5 } + max_wall_minutes: { type: integer, default: 30 } + max_runs_daily: { type: integer, default: 48 } + paused: { type: boolean, default: false } + pause_reason: { type: string } + paused_at: { type: datetime } + runs: + type: list + description: Rolling 30-day run log + items: + ran_at: { type: datetime } + usd_spent: { type: float } + wall_minutes: { type: float } + breach_log: + type: list + description: All budget breach events + items: + skill_name: { type: string } + breach_type: { type: enum, values: [monthly_usd, per_run_usd, wall_clock, daily_runs] } + value: { type: float } + limit: { type: float } + breached_at: { type: datetime } + resolved: { type: boolean } + monthly_summary: + type: object + description: Aggregated spend by skill for current calendar month + items: + skill_name: { type: string } + total_usd: { type: float } + total_runs: { type: integer } diff --git a/skills/openclaw-native/heartbeat-governor/example-state.yaml b/skills/openclaw-native/heartbeat-governor/example-state.yaml new file mode 100644 index 0000000..6a93eb7 --- /dev/null +++ b/skills/openclaw-native/heartbeat-governor/example-state.yaml @@ -0,0 +1,64 @@ +# Example runtime state for heartbeat-governor +skill_ledgers: + morning-briefing: + budget: + max_usd_monthly: 4.00 + max_usd_per_run: 0.30 + max_wall_minutes: 15 + max_runs_daily: 1 + paused: false + pause_reason: null + paused_at: null + runs: + - ran_at: "2026-03-15T07:00:05.000000" + usd_spent: 0.18 + wall_minutes: 6.2 + - ran_at: "2026-03-14T07:00:03.000000" + usd_spent: 0.21 + wall_minutes: 7.1 + long-running-task-management: + budget: + max_usd_monthly: 5.00 + max_usd_per_run: 0.50 + max_wall_minutes: 30 + max_runs_daily: 96 + paused: true + pause_reason: "30-day spend $5.12 reached monthly limit $5.00" + paused_at: "2026-03-15T08:00:00.000000" + runs: [] + cron-hygiene: + budget: + max_usd_monthly: 1.00 + max_usd_per_run: 0.10 + max_wall_minutes: 10 + max_runs_daily: 2 + paused: false + pause_reason: null + paused_at: null + runs: + - ran_at: "2026-03-10T09:00:07.000000" + usd_spent: 0.07 + wall_minutes: 2.1 +breach_log: + - skill_name: long-running-task-management + breach_type: monthly_usd + value: 5.12 + limit: 5.00 + breached_at: "2026-03-15T08:00:00.000000" + resolved: false +monthly_summary: {} +# ── Walkthrough ────────────────────────────────────────────────────────────── +# Hourly cron runs: python3 governor.py --check +# +# Heartbeat Governor — 2026-03-15 08:00 +# ────────────────────────────────────────────────────────────── +# ⏸ Paused: long-running-task-management +# +# python3 governor.py --status +# Skill Spend Budget % Status +# cron-hygiene $0.07 $1.00 7% ✓ +# long-running-task-management $5.12 $5.00 102% ⏸ PAUSED +# morning-briefing $0.39 $4.00 10% ✓ +# +# python3 governor.py --resume long-running-task-management +# ✓ Resumed 'long-running-task-management'. Will fire on next scheduled run. diff --git a/skills/openclaw-native/heartbeat-governor/governor.py b/skills/openclaw-native/heartbeat-governor/governor.py new file mode 100755 index 0000000..39664a6 --- /dev/null +++ b/skills/openclaw-native/heartbeat-governor/governor.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +Heartbeat Governor for openclaw-superpowers. + +Enforces per-skill execution budgets for cron skills. +Pauses runaway skills before they drain your monthly API allowance. + +Usage: + python3 governor.py --check # Hourly cron check + python3 governor.py --status # Current utilisation + python3 governor.py --record --usd 0.12 --minutes 4 + python3 governor.py --pause # Manual pause + python3 governor.py --resume # Resume after review + python3 governor.py --set-budget --monthly 10.00 [--per-run 1.00] + python3 governor.py --report # Monthly spend report + python3 governor.py --format json +""" + +import argparse +import json +import os +from datetime import datetime, timedelta +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +OPENCLAW_DIR = Path(os.environ.get("OPENCLAW_HOME", Path.home() / ".openclaw")) +STATE_FILE = OPENCLAW_DIR / "skill-state" / "heartbeat-governor" / "state.yaml" + +DEFAULT_BUDGET = { + "max_usd_monthly": 5.0, + "max_usd_per_run": 0.50, + "max_wall_minutes": 30, + "max_runs_daily": 48, +} +ROLLING_DAYS = 30 +MAX_BREACH_LOG = 200 + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {"skill_ledgers": {}, "breach_log": [], "monthly_summary": {}} + try: + text = STATE_FILE.read_text() + return (yaml.safe_load(text) or {}) if HAS_YAML else {} + except Exception: + return {} + + +def save_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + if HAS_YAML: + with open(STATE_FILE, "w") as f: + yaml.dump(state, f, default_flow_style=False, allow_unicode=True) + + +# ── Ledger helpers ──────────────────────────────────────────────────────────── + +def get_ledger(state: dict, skill_name: str) -> dict: + ledgers = state.setdefault("skill_ledgers", {}) + if skill_name not in ledgers: + ledgers[skill_name] = { + "budget": dict(DEFAULT_BUDGET), + "paused": False, + "pause_reason": None, + "paused_at": None, + "runs": [], + } + return ledgers[skill_name] + + +def prune_old_runs(runs: list) -> list: + cutoff = datetime.now() - timedelta(days=ROLLING_DAYS) + return [r for r in runs if _parse_dt(r.get("ran_at", "")) >= cutoff] + + +def _parse_dt(s: str) -> datetime: + try: + return datetime.fromisoformat(s) + except Exception: + return datetime.min + + +def rolling_usd(runs: list) -> float: + return sum(r.get("usd_spent", 0) for r in runs) + + +def runs_today(runs: list) -> int: + today = datetime.now().date() + return sum(1 for r in runs if _parse_dt(r.get("ran_at", "")).date() == today) + + +def add_breach(state: dict, skill_name: str, breach_type: str, + value: float, limit: float) -> None: + breach_log = state.setdefault("breach_log", []) + breach_log.append({ + "skill_name": skill_name, + "breach_type": breach_type, + "value": round(value, 4), + "limit": round(limit, 4), + "breached_at": datetime.now().isoformat(), + "resolved": False, + }) + state["breach_log"] = breach_log[-MAX_BREACH_LOG:] + + +def pause_skill(state: dict, skill_name: str, reason: str) -> None: + ledger = get_ledger(state, skill_name) + ledger["paused"] = True + ledger["pause_reason"] = reason + ledger["paused_at"] = datetime.now().isoformat() + print(f" ⏸ PAUSED: {skill_name} — {reason}") + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_record(state: dict, skill_name: str, usd: float, minutes: float) -> None: + ledger = get_ledger(state, skill_name) + ledger["runs"] = prune_old_runs(ledger.get("runs") or []) + + now = datetime.now().isoformat() + run = {"ran_at": now, "usd_spent": usd, "wall_minutes": minutes} + + # Per-run checks + budget = ledger.get("budget") or DEFAULT_BUDGET + per_run_limit = budget.get("max_usd_per_run", DEFAULT_BUDGET["max_usd_per_run"]) + wall_limit = budget.get("max_wall_minutes", DEFAULT_BUDGET["max_wall_minutes"]) + + if usd > per_run_limit: + add_breach(state, skill_name, "per_run_usd", usd, per_run_limit) + print(f"⚠ {skill_name}: per-run spend ${usd:.2f} exceeds limit ${per_run_limit:.2f}") + + if minutes > wall_limit: + add_breach(state, skill_name, "wall_clock", minutes, wall_limit) + print(f"⚠ {skill_name}: wall-clock {minutes:.1f}m exceeds limit {wall_limit}m") + + ledger["runs"].append(run) + save_state(state) + print(f"✓ Recorded run for '{skill_name}': ${usd:.4f} in {minutes:.1f}m") + + +def cmd_check(state: dict, fmt: str) -> None: + """Hourly cron check — evaluate all skill budgets.""" + ledgers = state.get("skill_ledgers") or {} + paused_now = [] + alerts = [] + + for skill_name, ledger in ledgers.items(): + if ledger.get("paused"): + continue + + budget = ledger.get("budget") or DEFAULT_BUDGET + ledger["runs"] = prune_old_runs(ledger.get("runs") or []) + + # Monthly budget check + monthly_limit = budget.get("max_usd_monthly", DEFAULT_BUDGET["max_usd_monthly"]) + total = rolling_usd(ledger["runs"]) + if total >= monthly_limit: + reason = f"30-day spend ${total:.2f} reached monthly limit ${monthly_limit:.2f}" + pause_skill(state, skill_name, reason) + add_breach(state, skill_name, "monthly_usd", total, monthly_limit) + paused_now.append(skill_name) + alerts.append({"skill": skill_name, "breach": "monthly_usd", + "value": total, "limit": monthly_limit}) + continue + + # Daily runs check + daily_limit = budget.get("max_runs_daily", DEFAULT_BUDGET["max_runs_daily"]) + today_runs = runs_today(ledger["runs"]) + if today_runs >= daily_limit: + alerts.append({"skill": skill_name, "breach": "daily_runs", + "value": today_runs, "limit": daily_limit}) + + now = datetime.now().isoformat() + if fmt == "json": + print(json.dumps({ + "checked_at": now, + "paused_this_run": paused_now, + "alerts": alerts, + }, indent=2)) + else: + print(f"\nHeartbeat Governor — {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("─" * 48) + if paused_now: + for name in paused_now: + print(f" ⏸ Paused: {name}") + if not paused_now and not alerts: + print(" ✓ All skills within budget.") + for a in alerts: + if a["breach"] == "daily_runs": + print(f" ⚠ {a['skill']}: {int(a['value'])} runs today " + f"(limit {int(a['limit'])})") + print() + + save_state(state) + + +def cmd_status(state: dict, fmt: str) -> None: + ledgers = state.get("skill_ledgers") or {} + rows = [] + for skill_name, ledger in sorted(ledgers.items()): + budget = ledger.get("budget") or DEFAULT_BUDGET + ledger["runs"] = prune_old_runs(ledger.get("runs") or []) + total = rolling_usd(ledger["runs"]) + monthly_limit = budget.get("max_usd_monthly", DEFAULT_BUDGET["max_usd_monthly"]) + pct = int(100 * total / monthly_limit) if monthly_limit else 0 + rows.append({ + "skill": skill_name, + "paused": ledger.get("paused", False), + "monthly_usd": round(total, 4), + "monthly_limit": monthly_limit, + "pct": pct, + }) + + if fmt == "json": + print(json.dumps(rows, indent=2)) + return + + print(f"\nHeartbeat Governor — Skill Budget Status") + print("─" * 55) + print(f" {'Skill':30s} {'Spend':>7s} {'Budget':>7s} {'%':>4s} Status") + for r in rows: + status = "⏸ PAUSED" if r["paused"] else ("⚠" if r["pct"] >= 80 else "✓") + print(f" {r['skill']:30s} ${r['monthly_usd']:>6.2f} " + f"${r['monthly_limit']:>6.2f} {r['pct']:>3d}% {status}") + print() + + +def cmd_pause(state: dict, skill_name: str) -> None: + pause_skill(state, skill_name, "Manual pause") + save_state(state) + + +def cmd_resume(state: dict, skill_name: str) -> None: + ledger = get_ledger(state, skill_name) + ledger["paused"] = False + ledger["pause_reason"] = None + ledger["paused_at"] = None + save_state(state) + print(f"✓ Resumed '{skill_name}'. Will fire on next scheduled run.") + + +def cmd_set_budget(state: dict, skill_name: str, monthly: float, + per_run: float, wall_minutes: int, daily_runs: int) -> None: + ledger = get_ledger(state, skill_name) + budget = ledger.setdefault("budget", dict(DEFAULT_BUDGET)) + if monthly is not None: + budget["max_usd_monthly"] = monthly + if per_run is not None: + budget["max_usd_per_run"] = per_run + if wall_minutes is not None: + budget["max_wall_minutes"] = wall_minutes + if daily_runs is not None: + budget["max_runs_daily"] = daily_runs + save_state(state) + print(f"✓ Budget updated for '{skill_name}': {budget}") + + +def cmd_report(state: dict, fmt: str) -> None: + ledgers = state.get("skill_ledgers") or {} + month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0) + rows = [] + for skill_name, ledger in sorted(ledgers.items()): + runs = [r for r in (ledger.get("runs") or []) + if _parse_dt(r.get("ran_at", "")) >= month_start] + total = sum(r.get("usd_spent", 0) for r in runs) + rows.append({"skill": skill_name, "runs": len(runs), + "total_usd": round(total, 4)}) + + grand_total = sum(r["total_usd"] for r in rows) + + if fmt == "json": + print(json.dumps({"rows": rows, "grand_total_usd": round(grand_total, 4)}, + indent=2)) + return + + print(f"\nMonthly Spend Report — {datetime.now().strftime('%B %Y')}") + print("─" * 48) + for r in rows: + print(f" {r['skill']:35s} {r['runs']:3d} runs ${r['total_usd']:.4f}") + print(f" {'TOTAL':35s} ${grand_total:.4f}") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Heartbeat Governor") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--check", action="store_true", + help="Hourly budget check (cron entry point)") + group.add_argument("--status", action="store_true") + group.add_argument("--record", metavar="SKILL") + group.add_argument("--pause", metavar="SKILL") + group.add_argument("--resume", metavar="SKILL") + group.add_argument("--set-budget", metavar="SKILL") + group.add_argument("--report", action="store_true") + parser.add_argument("--usd", type=float, default=0.0) + parser.add_argument("--minutes", type=float, default=0.0) + parser.add_argument("--monthly", type=float) + parser.add_argument("--per-run", type=float) + parser.add_argument("--wall-minutes", type=int) + parser.add_argument("--daily-runs", type=int) + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + state = load_state() + + if args.check: + cmd_check(state, args.format) + elif args.status: + cmd_status(state, args.format) + elif args.record: + cmd_record(state, args.record, args.usd, args.minutes) + elif args.pause: + cmd_pause(state, args.pause) + elif args.resume: + cmd_resume(state, args.resume) + elif args.set_budget: + cmd_set_budget(state, args.set_budget, args.monthly, args.per_run, + args.wall_minutes, args.daily_runs) + elif args.report: + cmd_report(state, args.format) + + +if __name__ == "__main__": + main() From 206fcbf062b69761cea6712efb0ddc430ffdf6fe Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:57:09 +0530 Subject: [PATCH 08/11] Add skill-portability-checker: validate OS/binary dependencies in scripts (#23) Detects OS_SPECIFIC_CALL, MISSING_BINARY, BREW_ONLY, PYTHON_IMPORT, and HARDCODED_PATH issues in companion scripts. Cross-checks against os_filter: frontmatter field. No external dependencies. Co-authored-by: Claude Sonnet 4.6 --- .../core/skill-portability-checker/SKILL.md | 92 +++++ .../core/skill-portability-checker/check.py | 369 ++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 skills/core/skill-portability-checker/SKILL.md create mode 100755 skills/core/skill-portability-checker/check.py diff --git a/skills/core/skill-portability-checker/SKILL.md b/skills/core/skill-portability-checker/SKILL.md new file mode 100644 index 0000000..41037da --- /dev/null +++ b/skills/core/skill-portability-checker/SKILL.md @@ -0,0 +1,92 @@ +--- +name: skill-portability-checker +version: "1.0" +category: core +description: Validates that a skill's companion scripts declare their OS and binary dependencies correctly, and checks whether those dependencies are actually present on the current machine. +--- + +# Skill Portability Checker + +## What it does + +Skills with companion scripts (`.py`, `.sh`) can silently fail on machines where their dependencies aren't installed. A skill written on macOS may call `brew`, `pbcopy`, or use `/usr/local/bin` paths that don't exist on Linux. A Python script may `import pandas` on a system without it. + +Skill Portability Checker: +1. Scans companion scripts for OS-specific patterns and external binary calls +2. Checks whether those binaries are present on the current system (`PATH` lookup + `which`) +3. Cross-checks against the skill's declared `os_filter:` frontmatter field (if any) +4. Reports portability issues before the skill fails at runtime + +## Frontmatter field checked + +```yaml +--- +name: my-skill +os_filter: [macos] # optional: ["macos", "linux", "windows"] +--- +``` + +If `os_filter:` is absent the skill is treated as cross-platform. The checker then warns if OS-specific calls are detected without a corresponding `os_filter:`. + +## Checks performed + +| Check | Description | +|---|---| +| OS_SPECIFIC_CALL | Script calls macOS/Linux/Windows-only binary without `os_filter:` | +| MISSING_BINARY | Required binary not found on current system PATH | +| BREW_ONLY | Script uses `brew` (macOS-only) but `os_filter:` includes non-macOS | +| PYTHON_IMPORT | Script imports a non-stdlib module; checks if importable | +| HARDCODED_PATH | Absolute path that doesn't exist on this machine (`/usr/local`, `C:\`) | + +## How to use + +```bash +python3 check.py --check # Full portability scan +python3 check.py --check --skill my-skill # Single skill +python3 check.py --fix-hints my-skill # Print fix suggestions +python3 check.py --format json +``` + +## Procedure + +**Step 1 — Run the scan** + +```bash +python3 check.py --check +``` + +**Step 2 — Triage FAILs first** + +- **MISSING_BINARY**: The script calls a binary that isn't installed. Either install it or add a graceful fallback in the script. +- **OS_SPECIFIC_CALL without os_filter**: Add `os_filter: [macos]` (or whichever OS applies) to the frontmatter so users on other platforms know the skill won't work. + +**Step 3 — Review WARNs** + +- **PYTHON_IMPORT**: Install the missing module or add a `try/except ImportError` with a graceful degradation path (like `HAS_MODULE = False`). +- **HARDCODED_PATH**: Replace with `Path.home()` or environment-variable-based paths. + +**Step 4 — Add os_filter when needed** + +If a skill genuinely only works on one OS, declare it: + +```yaml +os_filter: [macos] +``` + +This prevents the skill from being shown as broken on other platforms — it simply won't be loaded there. + +## Output example + +``` +Skill Portability Report — linux (Python 3.11) +──────────────────────────────────────────────── +32 skills checked | 1 FAIL | 2 WARN + +FAIL obsidian-sync: MISSING_BINARY + sync.py calls `osascript` — not found on this system + Fix: add os_filter: [macos] to frontmatter (osascript is macOS-only) + +WARN morning-briefing: PYTHON_IMPORT + run.py imports `pync` — not importable on this system + Fix: wrap in try/except ImportError and degrade gracefully +``` diff --git a/skills/core/skill-portability-checker/check.py b/skills/core/skill-portability-checker/check.py new file mode 100755 index 0000000..673940b --- /dev/null +++ b/skills/core/skill-portability-checker/check.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Skill Portability Checker for openclaw-superpowers. + +Validates companion script OS/binary dependencies and checks whether +they are present on the current machine. + +Usage: + python3 check.py --check + python3 check.py --check --skill obsidian-sync + python3 check.py --fix-hints + python3 check.py --format json +""" + +import argparse +import importlib +import json +import os +import platform +import re +import shutil +import sys +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] + +# Known OS-specific binaries +MACOS_ONLY_BINARIES = { + "osascript", "pbcopy", "pbpaste", "open", "launchctl", "caffeinate", + "defaults", "plutil", "say", "afplay", "mdfind", "mdls", +} +LINUX_ONLY_BINARIES = { + "systemctl", "journalctl", "apt", "apt-get", "dpkg", "yum", "dnf", + "pacman", "snap", "xclip", "xdotool", "notify-send", "xdg-open", +} +BREW_BINARY = "brew" + +# Stdlib modules (not exhaustive, covers common ones) +STDLIB_MODULES = { + "os", "sys", "re", "json", "yaml", "pathlib", "datetime", "time", + "collections", "itertools", "functools", "typing", "io", "math", + "random", "hashlib", "hmac", "base64", "struct", "copy", "enum", + "abc", "dataclasses", "contextlib", "threading", "subprocess", + "shutil", "tempfile", "glob", "fnmatch", "stat", "socket", "http", + "urllib", "email", "csv", "sqlite3", "logging", "unittest", "argparse", + "configparser", "getpass", "platform", "importlib", "inspect", + "traceback", "warnings", "weakref", "gc", "signal", "textwrap", + "string", "difflib", "html", "xml", "pprint", "decimal", "fractions", +} + +# Patterns for detecting binary calls in scripts +BINARY_CALL_RE = re.compile( + r'(?:subprocess\.(?:run|Popen|call|check_output|check_call)\s*\(\s*[\[\(]?\s*["\'])([a-z_\-]+)', + re.I +) +SHELL_CALL_RE = re.compile(r'(?:os\.system|os\.popen)\s*\(\s*["\']([a-z_\-]+)', re.I) +SHUTIL_WHICH_RE = re.compile(r'shutil\.which\s*\(\s*["\']([a-z_\-]+)', re.I) +IMPORT_RE = re.compile(r'^(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)', re.M) +HARDCODED_PATH_RE = re.compile( + r'["\'](?:/usr/local/|/opt/homebrew/|/home/[a-z]+/|C:\\\\)', re.I +) + + +def current_os() -> str: + s = platform.system().lower() + if s == "darwin": + return "macos" + if s == "windows": + return "windows" + return "linux" + + +# ── Frontmatter ─────────────────────────────────────────────────────────────── + +def parse_frontmatter(skill_md: Path) -> dict: + try: + text = skill_md.read_text() + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end = i + break + if end is None: + return {} + fm_text = "\n".join(lines[1:end]) + if HAS_YAML: + return yaml.safe_load(fm_text) or {} + fields = {} + for line in fm_text.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + fields[k.strip()] = v.strip().strip('"').strip("'") + return fields + except Exception: + return {} + + +# ── Script analysis ─────────────────────────────────────────────────────────── + +def extract_binary_calls(text: str) -> set[str]: + binaries = set() + for pattern in (BINARY_CALL_RE, SHELL_CALL_RE, SHUTIL_WHICH_RE): + for m in pattern.finditer(text): + binaries.add(m.group(1).lower()) + return binaries + + +def extract_imports(text: str) -> set[str]: + imports = set() + for m in IMPORT_RE.finditer(text): + mod = m.group(1).split(".")[0] + if mod not in STDLIB_MODULES: + imports.add(mod) + return imports + + +def is_importable(module: str) -> bool: + try: + importlib.import_module(module) + return True + except ImportError: + return False + + +def binary_present(name: str) -> bool: + return shutil.which(name) is not None + + +# ── Skill checker ───────────────────────────────────────────────────────────── + +def check_skill(skill_dir: Path, os_name: str) -> list[dict]: + skill_name = skill_dir.name + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + return [] + + fm = parse_frontmatter(skill_md) + os_filter = fm.get("os_filter") or [] + if isinstance(os_filter, str): + os_filter = [os_filter] + os_filter = [str(o).lower() for o in os_filter] + + issues = [] + + def issue(level, check, file_path, detail, fix_hint): + return { + "skill_name": skill_name, + "level": level, + "check": check, + "file": str(file_path), + "detail": detail, + "fix_hint": fix_hint, + } + + # Scan companion scripts + for script in skill_dir.iterdir(): + if not script.is_file(): + continue + if script.suffix not in (".py", ".sh"): + continue + + try: + text = script.read_text(errors="replace") + except Exception: + continue + + # Binary call analysis + binaries = extract_binary_calls(text) + + for binary in binaries: + # macOS-only binary + if binary in MACOS_ONLY_BINARIES: + if os_filter and "macos" not in os_filter: + issues.append(issue( + "FAIL", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (macOS-only) but os_filter excludes macOS", + f"Remove macOS calls or set `os_filter: [macos]` in frontmatter." + )) + elif not os_filter: + issues.append(issue( + "WARN", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (macOS-only) but no os_filter declared", + f"Add `os_filter: [macos]` to frontmatter." + )) + if os_name != "macos" and binary not in os_filter: + if not binary_present(binary): + issues.append(issue( + "FAIL", "MISSING_BINARY", script, + f"`{binary}` not found on this system", + f"Install `{binary}` or add `os_filter: [macos]` to frontmatter." + )) + + # Linux-only binary + elif binary in LINUX_ONLY_BINARIES: + if os_filter and "linux" not in os_filter: + issues.append(issue( + "FAIL", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (Linux-only) but os_filter excludes Linux", + "Remove Linux-specific calls or add `linux` to os_filter." + )) + elif not os_filter: + issues.append(issue( + "WARN", "OS_SPECIFIC_CALL", script, + f"Calls `{binary}` (Linux-only) but no os_filter declared", + "Add `os_filter: [linux]` to frontmatter." + )) + + # brew special case + elif binary == BREW_BINARY: + issues.append(issue( + "WARN", "BREW_ONLY", script, + "Script calls `brew` (Homebrew/macOS-only)", + "Add `os_filter: [macos]` or use a cross-platform alternative." + )) + + # General binary — check if present + else: + if not binary_present(binary) and binary not in ( + "python3", "python", "bash", "sh", "openclaw" + ): + issues.append(issue( + "WARN", "MISSING_BINARY", script, + f"`{binary}` not found on PATH", + f"Install `{binary}` or add a fallback when it's missing." + )) + + # Hardcoded paths + if HARDCODED_PATH_RE.search(text): + issues.append(issue( + "WARN", "HARDCODED_PATH", script, + "Script contains hardcoded absolute paths that may not exist on all systems", + "Replace with `Path.home()` or environment-variable-based paths." + )) + + # Python imports (only for .py files) + if script.suffix == ".py": + imports = extract_imports(text) + for mod in imports: + if not is_importable(mod): + issues.append(issue( + "WARN", "PYTHON_IMPORT", script, + f"imports `{mod}` which is not installed on this system", + f"Install with `pip install {mod}` or add try/except ImportError." + )) + + # os_filter correctness: if os_filter present, check it's valid values + valid_os_values = {"macos", "linux", "windows"} + for os_val in os_filter: + if os_val not in valid_os_values: + issues.append(issue( + "WARN", "INVALID_OS_FILTER", skill_md, + f"os_filter contains unknown value: `{os_val}`", + f"Valid values: {sorted(valid_os_values)}" + )) + + return issues + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_check(single_skill: str, fmt: str) -> None: + os_name = current_os() + all_issues = [] + skills_checked = 0 + + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + if single_skill and skill_dir.name != single_skill: + continue + issues = check_skill(skill_dir, os_name) + all_issues.extend(issues) + skills_checked += 1 + + fails = sum(1 for i in all_issues if i["level"] == "FAIL") + warns = sum(1 for i in all_issues if i["level"] == "WARN") + py_ver = f"Python {sys.version_info.major}.{sys.version_info.minor}" + + if fmt == "json": + print(json.dumps({ + "os": os_name, + "python_version": py_ver, + "skills_checked": skills_checked, + "fail_count": fails, + "warn_count": warns, + "issues": all_issues, + }, indent=2)) + else: + print(f"\nSkill Portability Report — {os_name} ({py_ver})") + print("─" * 50) + print(f" {skills_checked} skills checked | {fails} FAIL | {warns} WARN") + print() + if not all_issues: + print(" ✓ All skills portable on this system.") + else: + by_skill: dict = {} + for iss in all_issues: + by_skill.setdefault(iss["skill_name"], []).append(iss) + for sname, issues in sorted(by_skill.items()): + for iss in issues: + icon = "✗" if iss["level"] == "FAIL" else "⚠" + print(f" {icon} {sname}: {iss['check']}") + print(f" {iss['detail']}") + print(f" Fix: {iss['fix_hint']}") + print() + print() + + sys.exit(1 if fails > 0 else 0) + + +def cmd_fix_hints(skill_name: str) -> None: + os_name = current_os() + for skills_root in SKILLS_DIRS: + skill_dir = skills_root / skill_name + if skill_dir.exists(): + issues = check_skill(skill_dir, os_name) + if not issues: + print(f"✓ No portability issues found for '{skill_name}'.") + return + print(f"\nFix hints for: {skill_name}") + print("─" * 40) + for iss in issues: + print(f" [{iss['level']}] {iss['check']}") + print(f" {iss['detail']}") + print(f" → {iss['fix_hint']}") + print() + return + print(f"Skill '{skill_name}' not found.") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Portability Checker") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--check", action="store_true") + group.add_argument("--fix-hints", metavar="SKILL") + parser.add_argument("--skill", metavar="NAME", help="Check single skill only") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + if args.fix_hints: + cmd_fix_hints(args.fix_hints) + elif args.check: + cmd_check(single_skill=args.skill, fmt=args.format) + + +if __name__ == "__main__": + main() From 1d2ed21b81c2619aaf801b1935b747c120c3a0ce Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Mon, 16 Mar 2026 00:01:51 +0530 Subject: [PATCH 09/11] Update README: document all 39 skills across 3 categories Adds 8 new integration-focused skills to the tables: - Core: skill-trigger-tester, skill-conflict-detector, skill-portability-checker - OpenClaw-native: skill-doctor, installed-skill-auditor, skill-loadout-manager, skill-compatibility-checker, heartbeat-governor Expands security section from 3 to 5 skills (adds installed-skill-auditor, skill-doctor). Updates companion script list and total skill counts. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 157ff02..cfb7349 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ That's it. Your agent now has superpowers. ## Skills Included -### Core (12 skills) +### Core (15 skills) Methodology skills that work in any runtime. Adapted from [obra/superpowers](https://github.com/obra/superpowers) plus OpenClaw-specific additions. @@ -60,8 +60,11 @@ Methodology skills that work in any runtime. Adapted from [obra/superpowers](htt | `skill-vetting` | Security scanner for ClawHub skills before installing | `vet.sh` | | `project-onboarding` | Crawls a new codebase to generate a `PROJECT.md` context file | `onboard.py` | | `fact-check-before-trust` | Secondary verification pass for factual claims before acting on them | — | +| `skill-trigger-tester` | Scores a skill's description against sample prompts to predict trigger reliability | `test.py` | +| `skill-conflict-detector` | Detects name shadowing and description-overlap conflicts between installed skills | `detect.py` | +| `skill-portability-checker` | Validates OS/binary dependencies in companion scripts; catches non-portable calls | `check.py` | -### OpenClaw-Native (18 skills) +### OpenClaw-Native (23 skills) Skills that require OpenClaw's persistent runtime — cron scheduling, session state, or long-running execution. Not useful in session-based tools. @@ -85,6 +88,11 @@ Skills that require OpenClaw's persistent runtime — cron scheduling, session s | `multi-agent-coordinator` | Manages parallel agent fleets: health checks, consistency, handoffs | — | ✓ | `run.py` | | `cron-hygiene` | Audits cron skills for session mode waste and token efficiency | Mondays 9am | ✓ | `audit.py` | | `channel-context-bridge` | Writes a resumé card at session end for seamless channel switching | — | ✓ | `bridge.py` | +| `skill-doctor` | Diagnoses silent skill discovery failures — YAML errors, path violations, schema mismatches | — | ✓ | `doctor.py` | +| `installed-skill-auditor` | Weekly post-install audit of all skills for injection, credentials, and drift | Mondays 9am | ✓ | `audit.py` | +| `skill-loadout-manager` | Named skill profiles to manage active skill sets and prevent system prompt bloat | — | ✓ | `loadout.py` | +| `skill-compatibility-checker` | Checks installed skills against the current OpenClaw version for feature compatibility | — | ✓ | `check.py` | +| `heartbeat-governor` | Enforces per-skill execution budgets for cron skills; auto-pauses runaway skills | every hour | ✓ | `governor.py` | ### Community (1 skill) @@ -104,7 +112,7 @@ Stateful skills commit a `STATE_SCHEMA.yaml` defining the shape of their runtime Skills marked with a script in the table above ship a small executable alongside their `SKILL.md`: -- **Python scripts** (`run.py`, `audit.py`, `check.py`, `guard.py`, `bridge.py`, `onboard.py`, `sync.py`) — run directly to manipulate state, generate reports, or trigger actions. No extra dependencies required; `pyyaml` is optional but recommended. +- **Python scripts** (`run.py`, `audit.py`, `check.py`, `guard.py`, `bridge.py`, `onboard.py`, `sync.py`, `doctor.py`, `loadout.py`, `governor.py`, `detect.py`, `test.py`) — run directly to manipulate state, generate reports, or trigger actions. No extra dependencies required; `pyyaml` is optional but recommended. - **`vet.sh`** — Pure bash scanner; runs on any system with grep. - Each script supports `--help` and prints a human-readable summary. JSON output available where useful (`--format json`). Dry-run mode available on scripts that make changes. - See the `example-state.yaml` in each skill directory for sample state and a commented walkthrough of the skill's cron behaviour. @@ -113,13 +121,15 @@ Skills marked with a script in the table above ship a small executable alongside ## Security skills at a glance -Three skills address the documented top security risks for OpenClaw agents: +Five skills address the documented top security risks for OpenClaw agents: | Threat | Skill | How | |---|---|---| | Malicious skill install (36% of ClawHub skills contain injection payloads) | `skill-vetting` | Scans before install — 6 security flags, SAFE / CAUTION / DO NOT INSTALL | | Runtime injection from emails, web pages, scraped data | `prompt-injection-guard` | Detects 6 signal types at runtime; blocks on 2+ signals | | Agent takes destructive action without confirmation | `dangerous-action-guard` | Pre-execution gate with 5-min expiry window and full audit trail | +| Post-install skill tampering or credential injection | `installed-skill-auditor` | Weekly content-hash drift detection; INJECTION / CREDENTIAL / EXFILTRATION checks | +| Silent skill loading failures hiding broken skills | `skill-doctor` | 6 diagnostic checks per skill; surfaces every load-time failure before it disappears | --- @@ -129,6 +139,7 @@ obra/superpowers was built for session-based tools (Claude Code, Cursor, Codex). - Runs **24/7**, not just per-session - Handles tasks that take **hours, not minutes** +- Has **native cron scheduling** — skills wake up automatically on a schedule - Needs skills around **handoff, memory persistence, and self-recovery** that session tools don't require The OpenClaw-native skills in this repo exist because of that difference. From 93664bf549fd6f76cc4e191474ccf5574ce1cdc6 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Mon, 16 Mar 2026 00:18:42 +0530 Subject: [PATCH 10/11] Add community-skill-radar: scan Reddit for skill ideas every 3 days (#26) Searches 5 subreddits (openclaw, LocalLLaMA, ClaudeAI, MachineLearning, AIAgents) via Reddit's public JSON API. Scores candidates by upvotes, comments, recurrence, and keyword density. Writes prioritized PROPOSALS.md for review. Cron: every 3 days at 9am. Co-authored-by: Claude Sonnet 4.6 --- .../community-skill-radar/SKILL.md | 138 ++++ .../community-skill-radar/STATE_SCHEMA.yaml | 46 ++ .../community-skill-radar/example-state.yaml | 84 +++ .../community-skill-radar/radar.py | 602 ++++++++++++++++++ 4 files changed, 870 insertions(+) create mode 100644 skills/openclaw-native/community-skill-radar/SKILL.md create mode 100644 skills/openclaw-native/community-skill-radar/STATE_SCHEMA.yaml create mode 100644 skills/openclaw-native/community-skill-radar/example-state.yaml create mode 100755 skills/openclaw-native/community-skill-radar/radar.py diff --git a/skills/openclaw-native/community-skill-radar/SKILL.md b/skills/openclaw-native/community-skill-radar/SKILL.md new file mode 100644 index 0000000..8a798e7 --- /dev/null +++ b/skills/openclaw-native/community-skill-radar/SKILL.md @@ -0,0 +1,138 @@ +--- +name: community-skill-radar +version: "1.0" +category: openclaw-native +description: Searches Reddit communities for OpenClaw pain points and feature requests, scores them by signal strength, and writes a prioritized PROPOSALS.md for you to review and act on. +stateful: true +cron: "0 9 */3 * *" +--- + +# Community Skill Radar + +## What it does + +Your best skill ideas don't come from guessing — they come from what the community is actually struggling with. Community Skill Radar scans Reddit every 3 days for posts and comments mentioning OpenClaw pain points, feature requests, and skill gaps. It scores them by signal strength (upvotes, comment depth, recurrence) and writes a prioritized `PROPOSALS.md` in the repo root. + +You review the proposals. You decide what to build. The radar just makes sure you never miss a signal. + +## When to invoke + +- Automatically, every 3 days (cron) +- Manually when you want a fresh pulse-check on community needs +- Before planning a new batch of skills + +## Subreddits searched + +| Subreddit | Why | +|---|---| +| `openclaw` | Primary OpenClaw community | +| `LocalLLaMA` | Local AI users — many run OpenClaw | +| `ClaudeAI` | Claude ecosystem — overlaps with OpenClaw users | +| `MachineLearning` | Broader AI practitioners | +| `AIAgents` | Agent-specific discussions | + +Custom subreddits can be configured via `--subreddits`. + +## Signal scoring + +Each candidate is scored on 5 dimensions: + +| Signal | Weight | Source | +|---|---|---| +| Upvotes | 2x | Post/comment score | +| Comment depth | 1.5x | Number of replies — more discussion = stronger signal | +| Recurrence | 3x | Same pain point appearing across multiple posts | +| Keyword density | 1x | Concentration of problem/request keywords | +| Recency | 1.5x | Newer posts score higher (7-day decay) | + +## How to use + +```bash +python3 radar.py --scan # Full scan, write PROPOSALS.md +python3 radar.py --scan --lookback 7 # Scan last 7 days (default: 3) +python3 radar.py --scan --subreddits openclaw,LocalLLaMA +python3 radar.py --scan --min-score 5.0 # Only proposals scoring ≥5.0 +python3 radar.py --status # Last scan summary from state +python3 radar.py --history # Show past scan results +python3 radar.py --format json # Machine-readable output +``` + +## Cron wakeup behaviour + +Every 3 days at 9am: + +1. Fetch recent posts from each configured subreddit via Reddit's public JSON API (no auth required) +2. Filter for posts/comments containing OpenClaw-related keywords +3. Extract pain points and feature request signals +4. Score each candidate +5. Deduplicate against previously seen proposals (stored in state) +6. Write `PROPOSALS.md` to the repo root +7. Print summary to stdout + +## PROPOSALS.md format + +```markdown +# Skill Proposals — Community Radar + +*Last scanned: 2026-03-16 09:00 | 5 subreddits | 14 candidates* + +## High Signal (score ≥ 8.0) + +### 1. Skill auto-update mechanism (score: 12.4) +- **Source:** r/openclaw — "Anyone else manually pulling skill updates?" +- **Signal:** 47 upvotes, 23 comments, seen 3 times across 2 subreddits +- **Pain point:** No way to update installed skills without manual git pull +- **Potential skill:** `skill-auto-updater` — checks upstream repos for new versions + +### 2. Context window usage dashboard (score: 9.1) +- **Source:** r/LocalLLaMA — "My openclaw agent keeps losing context mid-task" +- **Signal:** 31 upvotes, 18 comments +- **Pain point:** No visibility into how much context each skill consumes +- **Potential skill:** `context-usage-dashboard` — real-time token budget display + +## Medium Signal (score 4.0–8.0) + +... + +## Previously Seen (already in state — not re-proposed) + +... +``` + +## Procedure + +**Step 1 — Let the cron run (or trigger manually)** + +```bash +python3 radar.py --scan +``` + +**Step 2 — Review PROPOSALS.md** + +Open `PROPOSALS.md` in the repo root. High-signal proposals are the ones the community is loudest about. + +**Step 3 — Act on proposals you want to build** + +For each proposal you decide to build, either: +- Ask your agent to create it: `"Build a skill for using create-skill"` +- Open a GitHub issue for the community + +**Step 4 — Mark proposals as actioned** + +```bash +python3 radar.py --mark-actioned "skill-auto-updater" +``` + +This moves the proposal to the "actioned" list in state so it won't be re-proposed on future scans. + +## State + +Scan results, seen proposals, and actioned items stored in `~/.openclaw/skill-state/community-skill-radar/state.yaml`. + +Fields: `last_scan_at`, `subreddits`, `proposals` list, `actioned` list, `scan_history`. + +## Notes + +- Uses Reddit's public JSON API at `reddit.com//search.json`. No authentication required. Rate-limited to 1 request per 2 seconds to respect Reddit's guidelines. +- Does not post, comment, or interact with Reddit in any way — read-only scanning. +- `PROPOSALS.md` is gitignored by default (local working document). Add to `.gitignore` if not already present. diff --git a/skills/openclaw-native/community-skill-radar/STATE_SCHEMA.yaml b/skills/openclaw-native/community-skill-radar/STATE_SCHEMA.yaml new file mode 100644 index 0000000..d5a2ace --- /dev/null +++ b/skills/openclaw-native/community-skill-radar/STATE_SCHEMA.yaml @@ -0,0 +1,46 @@ +version: "1.0" +description: Community radar scan results, proposal ledger, and actioned tracking. +fields: + last_scan_at: + type: datetime + subreddits: + type: list + description: Subreddits included in the last scan + items: + type: string + proposals: + type: list + description: All proposals from the most recent scan (newest first) + items: + id: { type: string, description: "slug derived from title" } + title: { type: string } + pain_point: { type: string } + potential_skill: { type: string } + score: { type: float } + sources: + type: list + items: + subreddit: { type: string } + post_title: { type: string } + url: { type: string } + upvotes: { type: integer } + comments: { type: integer } + fetched_at: { type: datetime } + first_seen_at: { type: datetime } + times_seen: { type: integer } + actioned: + type: list + description: Proposal IDs that have been acted on (built, filed as issues) + items: + id: { type: string } + actioned_at: { type: datetime } + action: { type: string, description: "built, issue-filed, rejected" } + scan_history: + type: list + description: Rolling log of past scans (last 20) + items: + scanned_at: { type: datetime } + subreddits: { type: integer } + posts_fetched: { type: integer } + candidates_found: { type: integer } + proposals_written: { type: integer } diff --git a/skills/openclaw-native/community-skill-radar/example-state.yaml b/skills/openclaw-native/community-skill-radar/example-state.yaml new file mode 100644 index 0000000..f880f33 --- /dev/null +++ b/skills/openclaw-native/community-skill-radar/example-state.yaml @@ -0,0 +1,84 @@ +# Example runtime state for community-skill-radar +last_scan_at: "2026-03-16T09:00:22.441000" +subreddits: + - openclaw + - LocalLLaMA + - ClaudeAI + - MachineLearning + - AIAgents +proposals: + - id: skill-auto-update-mechanism + title: "Anyone else manually pulling skill updates every time?" + pain_point: "No way to update installed skills without manual git pull" + potential_skill: skill-auto-updater + category: integration + score: 12.4 + sources: + - subreddit: openclaw + post_title: "Anyone else manually pulling skill updates every time?" + url: "https://reddit.com/r/openclaw/comments/abc123/..." + upvotes: 47 + comments: 23 + score: 8.2 + fetched_at: "2026-03-16T09:00:20.000000" + - subreddit: LocalLLaMA + post_title: "OpenClaw skills need an update mechanism" + url: "https://reddit.com/r/LocalLLaMA/comments/def456/..." + upvotes: 18 + comments: 9 + score: 4.2 + fetched_at: "2026-03-16T09:00:21.000000" + first_seen_at: "2026-03-13T09:00:00.000000" + times_seen: 3 + - id: context-window-usage-dashboard + title: "My openclaw agent keeps losing context mid-task" + pain_point: "No visibility into how much context each skill consumes" + potential_skill: context-usage-dashboard + category: context + score: 9.1 + sources: + - subreddit: LocalLLaMA + post_title: "My openclaw agent keeps losing context mid-task" + url: "https://reddit.com/r/LocalLLaMA/comments/ghi789/..." + upvotes: 31 + comments: 18 + score: 9.1 + fetched_at: "2026-03-16T09:00:22.000000" + first_seen_at: "2026-03-16T09:00:22.000000" + times_seen: 1 +actioned: + - id: skill-load-failure-detection + actioned_at: "2026-03-15T10:00:00.000000" + action: built +scan_history: + - scanned_at: "2026-03-16T09:00:22.000000" + subreddits: 5 + posts_fetched: 142 + candidates_found: 8 + proposals_written: 8 + - scanned_at: "2026-03-13T09:00:00.000000" + subreddits: 5 + posts_fetched: 118 + candidates_found: 5 + proposals_written: 5 +# ── Walkthrough ────────────────────────────────────────────────────────────── +# Every 3 days cron runs: python3 radar.py --scan +# +# Community Skill Radar — scanning 5 subreddits (last 3 days) +# ────────────────────────────────────────────────────────────── +# Fetching r/openclaw... 28 posts +# Fetching r/LocalLLaMA... 42 posts +# Fetching r/ClaudeAI... 35 posts +# Fetching r/MachineLearning... 22 posts +# Fetching r/AIAgents... 15 posts +# +# Posts fetched : 142 +# Candidates found: 8 +# High signal : 2 +# Medium signal : 4 +# Low signal : 2 +# +# Written to: ~/.openclaw/extensions/superpowers/PROPOSALS.md +# +# python3 radar.py --mark-actioned skill-auto-update-mechanism --action built +# ✓ Marked 'skill-auto-update-mechanism' as built. Won't be re-proposed. diff --git a/skills/openclaw-native/community-skill-radar/radar.py b/skills/openclaw-native/community-skill-radar/radar.py new file mode 100755 index 0000000..a816904 --- /dev/null +++ b/skills/openclaw-native/community-skill-radar/radar.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +""" +Community Skill Radar for openclaw-superpowers. + +Scans Reddit communities for OpenClaw pain points and feature requests. +Scores candidates by signal strength and writes a prioritized PROPOSALS.md. + +Usage: + python3 radar.py --scan # Full scan, write PROPOSALS.md + python3 radar.py --scan --lookback 7 # Scan last 7 days + python3 radar.py --scan --subreddits openclaw,LocalLLaMA + python3 radar.py --scan --min-score 5.0 + python3 radar.py --mark-actioned # Mark proposal as actioned + python3 radar.py --status # Last scan summary + python3 radar.py --history # Past scan results + python3 radar.py --format json +""" + +import argparse +import json +import math +import os +import re +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timedelta +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +OPENCLAW_DIR = Path(os.environ.get("OPENCLAW_HOME", Path.home() / ".openclaw")) +STATE_FILE = OPENCLAW_DIR / "skill-state" / "community-skill-radar" / "state.yaml" +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +PROPOSALS_FILE = SUPERPOWERS_DIR / "PROPOSALS.md" + +DEFAULT_SUBREDDITS = ["openclaw", "LocalLLaMA", "ClaudeAI", "MachineLearning", "AIAgents"] +DEFAULT_LOOKBACK_DAYS = 3 +RATE_LIMIT_SECONDS = 2.0 +MAX_POSTS_PER_SUB = 50 +MAX_HISTORY = 20 +USER_AGENT = "openclaw-superpowers:community-skill-radar:v1.0 (by /u/openclaw-bot)" + +# ── Keywords ────────────────────────────────────────────────────────────────── + +# Keywords that signal an OpenClaw-relevant post +OPENCLAW_KEYWORDS = [ + "openclaw", "open claw", "open-claw", + "skill", "skills", "superpowers", + "agent", "ai agent", "ai assistant", + "cron", "scheduled task", +] + +# Keywords that signal a pain point or feature request +SIGNAL_KEYWORDS = [ + "wish", "want", "need", "missing", "broken", "frustrat", + "bug", "issue", "problem", "annoying", "doesn't work", + "feature request", "would be nice", "someone should", + "why can't", "how do i", "is there a way", + "pain point", "struggle", "stuck", "help", + "workaround", "hack", "janky", "ugly", + "silent", "silently", "no error", "no warning", + "expensive", "cost", "budget", "bill", + "context window", "context limit", "overflow", + "memory", "forget", "lost context", +] + +# Keywords that suggest a potential skill category +SKILL_CATEGORY_KEYWORDS = { + "security": ["injection", "malicious", "credential", "secret", "vulnerability", "attack"], + "cost": ["expensive", "cost", "budget", "spend", "bill", "token", "usage", "price"], + "reliability": ["crash", "loop", "stuck", "hang", "timeout", "retry", "fail", "broken"], + "context": ["context", "memory", "forget", "window", "overflow", "limit", "compaction"], + "workflow": ["workflow", "chain", "pipeline", "orchestrat", "automat", "schedule", "cron"], + "integration": ["install", "load", "config", "setup", "compati", "version", "portab"], + "ux": ["confusing", "unclear", "verbose", "noisy", "silent", "dashboard", "status"], +} + + +# ── State helpers ───────────────────────────────────────────────────────────── + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {"proposals": [], "actioned": [], "scan_history": []} + try: + text = STATE_FILE.read_text() + return (yaml.safe_load(text) or {}) if HAS_YAML else {} + except Exception: + return {} + + +def save_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + if HAS_YAML: + with open(STATE_FILE, "w") as f: + yaml.dump(state, f, default_flow_style=False, allow_unicode=True) + + +# ── Reddit fetcher ──────────────────────────────────────────────────────────── + +def fetch_subreddit(subreddit: str, lookback_days: int) -> list[dict]: + """Fetch recent posts from a subreddit via Reddit's public JSON API.""" + url = (f"https://www.reddit.com/r/{subreddit}/new.json" + f"?limit={MAX_POSTS_PER_SUB}&t=week") + + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + posts = [] + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + + cutoff = datetime.now() - timedelta(days=lookback_days) + + for child in data.get("data", {}).get("children", []): + post = child.get("data", {}) + created = datetime.fromtimestamp(post.get("created_utc", 0)) + if created < cutoff: + continue + + posts.append({ + "subreddit": subreddit, + "post_id": post.get("id", ""), + "title": post.get("title", ""), + "selftext": post.get("selftext", "")[:2000], + "url": f"https://reddit.com{post.get('permalink', '')}", + "upvotes": post.get("score", 0), + "comments": post.get("num_comments", 0), + "created_utc": post.get("created_utc", 0), + "created_at": created.isoformat(), + }) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, + OSError) as e: + print(f" ⚠ Failed to fetch r/{subreddit}: {e}") + + return posts + + +def fetch_search(query: str, subreddit: str, lookback_days: int) -> list[dict]: + """Search a subreddit for a specific query.""" + encoded_q = urllib.parse.quote(query) + url = (f"https://www.reddit.com/r/{subreddit}/search.json" + f"?q={encoded_q}&restrict_sr=on&sort=new&t=week&limit=25") + + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + posts = [] + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + + cutoff = datetime.now() - timedelta(days=lookback_days) + + for child in data.get("data", {}).get("children", []): + post = child.get("data", {}) + created = datetime.fromtimestamp(post.get("created_utc", 0)) + if created < cutoff: + continue + + posts.append({ + "subreddit": subreddit, + "post_id": post.get("id", ""), + "title": post.get("title", ""), + "selftext": post.get("selftext", "")[:2000], + "url": f"https://reddit.com{post.get('permalink', '')}", + "upvotes": post.get("score", 0), + "comments": post.get("num_comments", 0), + "created_utc": post.get("created_utc", 0), + "created_at": created.isoformat(), + }) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, + OSError): + pass # search failures are non-critical + + return posts + + +# ── Signal analysis ─────────────────────────────────────────────────────────── + +def is_relevant(post: dict) -> bool: + """Check if a post is relevant to OpenClaw/agent skills.""" + text = (post.get("title", "") + " " + post.get("selftext", "")).lower() + return any(kw in text for kw in OPENCLAW_KEYWORDS) + + +def has_signal(post: dict) -> bool: + """Check if a post contains a pain point or feature request signal.""" + text = (post.get("title", "") + " " + post.get("selftext", "")).lower() + return any(kw in text for kw in SIGNAL_KEYWORDS) + + +def classify_category(text: str) -> str: + """Classify the likely skill category from text.""" + text_lower = text.lower() + scores = {} + for category, keywords in SKILL_CATEGORY_KEYWORDS.items(): + scores[category] = sum(1 for kw in keywords if kw in text_lower) + best = max(scores, key=scores.get) + return best if scores[best] > 0 else "general" + + +def slugify(text: str) -> str: + """Create a slug from text for proposal IDs.""" + slug = re.sub(r'[^a-z0-9\s-]', '', text.lower()) + slug = re.sub(r'[\s-]+', '-', slug).strip('-') + return slug[:60] + + +def extract_pain_point(post: dict) -> str: + """Extract a concise pain point summary from a post.""" + title = post.get("title", "") + text = post.get("selftext", "") + + # Use the title as primary signal + if len(title) > 20: + return title[:120] + + # Fall back to first sentence of selftext + sentences = re.split(r'[.!?\n]', text) + for s in sentences: + s = s.strip() + if len(s) > 20 and any(kw in s.lower() for kw in SIGNAL_KEYWORDS): + return s[:120] + + return title[:120] if title else "(no summary available)" + + +def suggest_skill_name(pain_point: str) -> str: + """Suggest a potential skill name from a pain point.""" + words = re.findall(r'[a-z]+', pain_point.lower()) + # Remove very common words + stopwords = {"the", "a", "an", "is", "are", "was", "to", "for", "in", + "on", "it", "my", "i", "how", "do", "can", "does", "when", + "not", "with", "that", "this", "have", "has", "and", "or", + "but", "be", "been", "any", "there", "just", "way", "get"} + meaningful = [w for w in words if w not in stopwords and len(w) > 2] + if len(meaningful) >= 2: + return "-".join(meaningful[:3]) + return "unnamed-skill" + + +# ── Scoring ─────────────────────────────────────────────────────────────────── + +def score_post(post: dict, lookback_days: int) -> float: + """Score a post by signal strength.""" + score = 0.0 + + # Upvotes (weight 2x) + upvotes = max(0, post.get("upvotes", 0)) + score += min(upvotes, 100) * 0.2 # cap at 100 upvotes = 20 points + + # Comment depth (weight 1.5x) + comments = max(0, post.get("comments", 0)) + score += min(comments, 50) * 0.3 # cap at 50 comments = 15 points + + # Keyword density (weight 1x) + text = (post.get("title", "") + " " + post.get("selftext", "")).lower() + signal_hits = sum(1 for kw in SIGNAL_KEYWORDS if kw in text) + score += min(signal_hits, 8) * 1.0 # cap at 8 hits = 8 points + + # Recency (weight 1.5x, 7-day decay) + age_seconds = time.time() - post.get("created_utc", time.time()) + age_days = age_seconds / 86400 + recency_factor = max(0, 1.0 - (age_days / (lookback_days + 4))) + score *= (0.5 + 0.5 * recency_factor) # decay to 50% at lookback boundary + + return round(score, 2) + + +def score_proposal(sources: list) -> float: + """Score a proposal based on its aggregated sources.""" + total = sum(s.get("score", 0) for s in sources) + + # Recurrence bonus (weight 3x) + recurrence = len(sources) + if recurrence > 1: + total += recurrence * 3.0 + + # Cross-subreddit bonus + unique_subs = len(set(s.get("subreddit", "") for s in sources)) + if unique_subs > 1: + total += unique_subs * 2.0 + + return round(total, 2) + + +# ── Proposal generation ────────────────────────────────────────────────────── + +def build_proposals(posts: list, lookback_days: int, + actioned_ids: set) -> list[dict]: + """Build scored proposals from relevant posts.""" + # Filter for relevant + signal posts + candidates = [] + for post in posts: + if is_relevant(post) and has_signal(post): + post["score"] = score_post(post, lookback_days) + candidates.append(post) + + if not candidates: + return [] + + # Group by pain point similarity (simple: by shared keywords) + proposals = {} + for post in candidates: + pain = extract_pain_point(post) + slug = slugify(pain) + + if slug in actioned_ids: + continue + + if slug not in proposals: + proposals[slug] = { + "id": slug, + "title": pain, + "pain_point": pain, + "potential_skill": suggest_skill_name(pain), + "category": classify_category(pain + " " + post.get("selftext", "")), + "sources": [], + "first_seen_at": datetime.now().isoformat(), + "times_seen": 0, + } + + proposals[slug]["sources"].append({ + "subreddit": post["subreddit"], + "post_title": post.get("title", ""), + "url": post.get("url", ""), + "upvotes": post.get("upvotes", 0), + "comments": post.get("comments", 0), + "score": post.get("score", 0), + "fetched_at": datetime.now().isoformat(), + }) + proposals[slug]["times_seen"] = len(proposals[slug]["sources"]) + + # Score each proposal + result = [] + for p in proposals.values(): + p["score"] = score_proposal(p["sources"]) + result.append(p) + + result.sort(key=lambda x: x["score"], reverse=True) + return result + + +# ── PROPOSALS.md writer ─────────────────────────────────────────────────────── + +def write_proposals_md(proposals: list, subreddits: list, + posts_fetched: int) -> None: + """Write PROPOSALS.md to the repo root.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M") + lines = [ + "# Skill Proposals — Community Radar", + "", + f"*Last scanned: {now} | {len(subreddits)} subreddits | " + f"{posts_fetched} posts fetched | {len(proposals)} candidates*", + "", + "---", + "", + ] + + high = [p for p in proposals if p["score"] >= 8.0] + medium = [p for p in proposals if 4.0 <= p["score"] < 8.0] + low = [p for p in proposals if p["score"] < 4.0] + + if high: + lines.append("## High Signal (score >= 8.0)") + lines.append("") + for i, p in enumerate(high, 1): + lines.extend(_format_proposal(i, p)) + lines.append("") + + if medium: + lines.append("## Medium Signal (score 4.0-8.0)") + lines.append("") + for i, p in enumerate(medium, 1): + lines.extend(_format_proposal(i, p)) + lines.append("") + + if low: + lines.append("## Low Signal (score < 4.0)") + lines.append("") + for i, p in enumerate(low, 1): + lines.extend(_format_proposal(i, p)) + lines.append("") + + if not proposals: + lines.append("*No new proposals found this scan.*") + lines.append("") + + lines.extend([ + "---", + "", + "*Generated by `community-skill-radar`. " + "Run `python3 radar.py --mark-actioned ` to dismiss a proposal.*", + ]) + + PROPOSALS_FILE.parent.mkdir(parents=True, exist_ok=True) + PROPOSALS_FILE.write_text("\n".join(lines) + "\n") + + +def _format_proposal(idx: int, p: dict) -> list[str]: + lines = [] + lines.append(f"### {idx}. {p['title']} (score: {p['score']})") + lines.append(f"- **Category:** {p.get('category', 'general')}") + lines.append(f"- **Potential skill name:** `{p['potential_skill']}`") + lines.append(f"- **Times seen:** {p['times_seen']}") + + for src in p.get("sources", [])[:3]: + lines.append( + f"- **Source:** r/{src['subreddit']} — " + f"\"{src['post_title'][:80]}\" " + f"({src['upvotes']} upvotes, {src['comments']} comments)" + ) + if src.get("url"): + lines.append(f" - {src['url']}") + + lines.append("") + return lines + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_scan(state: dict, subreddits: list, lookback_days: int, + min_score: float, fmt: str) -> None: + actioned = set(a.get("id", "") for a in (state.get("actioned") or [])) + all_posts = [] + + print(f"\nCommunity Skill Radar — scanning {len(subreddits)} subreddits " + f"(last {lookback_days} days)") + print("─" * 50) + + for sub in subreddits: + print(f" Fetching r/{sub}...", end=" ", flush=True) + posts = fetch_subreddit(sub, lookback_days) + time.sleep(RATE_LIMIT_SECONDS) + + # Also search for "openclaw" specifically in non-openclaw subs + if sub.lower() != "openclaw": + search_posts = fetch_search("openclaw", sub, lookback_days) + time.sleep(RATE_LIMIT_SECONDS) + # Deduplicate by post_id + seen_ids = {p["post_id"] for p in posts} + for sp in search_posts: + if sp["post_id"] not in seen_ids: + posts.append(sp) + + print(f"{len(posts)} posts") + all_posts.extend(posts) + + # Build and score proposals + proposals = build_proposals(all_posts, lookback_days, actioned) + + if min_score > 0: + proposals = [p for p in proposals if p["score"] >= min_score] + + # Merge with existing proposals (bump times_seen for recurring) + existing = {p["id"]: p for p in (state.get("proposals") or [])} + for p in proposals: + if p["id"] in existing: + old = existing[p["id"]] + p["first_seen_at"] = old.get("first_seen_at", p["first_seen_at"]) + p["times_seen"] = old.get("times_seen", 0) + len(p.get("sources", [])) + # Recurrence boost + p["score"] = round(p["score"] + old.get("times_seen", 0) * 1.5, 2) + + # Write PROPOSALS.md + write_proposals_md(proposals, subreddits, len(all_posts)) + + # Print summary + high = sum(1 for p in proposals if p["score"] >= 8.0) + medium = sum(1 for p in proposals if 4.0 <= p["score"] < 8.0) + low = sum(1 for p in proposals if p["score"] < 4.0) + + print() + print(f" Posts fetched : {len(all_posts)}") + print(f" Candidates found: {len(proposals)}") + print(f" High signal : {high}") + print(f" Medium signal : {medium}") + print(f" Low signal : {low}") + print(f"\n Written to: {PROPOSALS_FILE}") + print() + + if fmt == "json": + print(json.dumps({ + "scanned_at": datetime.now().isoformat(), + "subreddits": subreddits, + "posts_fetched": len(all_posts), + "proposals": proposals, + }, indent=2)) + + # Persist state + now = datetime.now().isoformat() + history = state.get("scan_history") or [] + history.insert(0, { + "scanned_at": now, + "subreddits": len(subreddits), + "posts_fetched": len(all_posts), + "candidates_found": len(proposals), + "proposals_written": high + medium + low, + }) + state["scan_history"] = history[:MAX_HISTORY] + state["last_scan_at"] = now + state["subreddits"] = subreddits + state["proposals"] = proposals + save_state(state) + + +def cmd_mark_actioned(state: dict, proposal_id: str, action: str) -> None: + actioned = state.get("actioned") or [] + actioned.append({ + "id": proposal_id, + "actioned_at": datetime.now().isoformat(), + "action": action, + }) + state["actioned"] = actioned + + # Remove from active proposals + proposals = state.get("proposals") or [] + state["proposals"] = [p for p in proposals if p.get("id") != proposal_id] + + save_state(state) + print(f"✓ Marked '{proposal_id}' as {action}. Won't be re-proposed.") + + +def cmd_status(state: dict) -> None: + last = state.get("last_scan_at", "never") + subs = state.get("subreddits") or [] + proposals = state.get("proposals") or [] + actioned = state.get("actioned") or [] + + print(f"\nCommunity Skill Radar — Last scan: {last}") + print(f" Subreddits : {', '.join(subs) if subs else 'none'}") + print(f" Proposals : {len(proposals)} active, {len(actioned)} actioned") + + if proposals: + top = proposals[:3] + print(f"\n Top proposals:") + for p in top: + print(f" [{p['score']:5.1f}] {p['title'][:60]}") + print() + + +def cmd_history(state: dict) -> None: + history = state.get("scan_history") or [] + if not history: + print("No scan history yet.") + return + + print(f"\nScan History ({len(history)} scans)") + print("─" * 50) + for h in history: + print(f" {h.get('scanned_at','')[:16]} " + f"{h.get('subreddits',0)} subs " + f"{h.get('posts_fetched',0)} posts " + f"{h.get('candidates_found',0)} candidates " + f"{h.get('proposals_written',0)} written") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Community Skill Radar") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--scan", action="store_true") + group.add_argument("--mark-actioned", metavar="ID") + group.add_argument("--status", action="store_true") + group.add_argument("--history", action="store_true") + parser.add_argument("--subreddits", default=None, + help="Comma-separated subreddits (default: built-in list)") + parser.add_argument("--lookback", type=int, default=DEFAULT_LOOKBACK_DAYS, + help=f"Days to look back (default: {DEFAULT_LOOKBACK_DAYS})") + parser.add_argument("--min-score", type=float, default=0.0, + help="Minimum score threshold") + parser.add_argument("--action", default="actioned", + help="Action label for --mark-actioned (built/issue-filed/rejected)") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + state = load_state() + + if args.status: + cmd_status(state) + elif args.history: + cmd_history(state) + elif args.mark_actioned: + cmd_mark_actioned(state, args.mark_actioned, args.action) + elif args.scan: + subreddits = (args.subreddits.split(",") if args.subreddits + else DEFAULT_SUBREDDITS) + cmd_scan(state, subreddits, args.lookback, args.min_score, args.format) + + +if __name__ == "__main__": + main() From 0edcf462e1ab80fd6ad87c12ebe35622f94fb8a3 Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Mon, 16 Mar 2026 00:21:15 +0530 Subject: [PATCH 11/11] Update README: add community-skill-radar (40 skills total) Adds community-skill-radar to the OpenClaw-Native table (24 skills), updates companion script list with radar.py, bumps total to 40 skills. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cfb7349..4aebc7e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Methodology skills that work in any runtime. Adapted from [obra/superpowers](htt | `skill-conflict-detector` | Detects name shadowing and description-overlap conflicts between installed skills | `detect.py` | | `skill-portability-checker` | Validates OS/binary dependencies in companion scripts; catches non-portable calls | `check.py` | -### OpenClaw-Native (23 skills) +### OpenClaw-Native (24 skills) Skills that require OpenClaw's persistent runtime — cron scheduling, session state, or long-running execution. Not useful in session-based tools. @@ -93,6 +93,7 @@ Skills that require OpenClaw's persistent runtime — cron scheduling, session s | `skill-loadout-manager` | Named skill profiles to manage active skill sets and prevent system prompt bloat | — | ✓ | `loadout.py` | | `skill-compatibility-checker` | Checks installed skills against the current OpenClaw version for feature compatibility | — | ✓ | `check.py` | | `heartbeat-governor` | Enforces per-skill execution budgets for cron skills; auto-pauses runaway skills | every hour | ✓ | `governor.py` | +| `community-skill-radar` | Scans Reddit for OpenClaw pain points and feature requests; writes prioritized PROPOSALS.md | every 3 days | ✓ | `radar.py` | ### Community (1 skill) @@ -112,7 +113,7 @@ Stateful skills commit a `STATE_SCHEMA.yaml` defining the shape of their runtime Skills marked with a script in the table above ship a small executable alongside their `SKILL.md`: -- **Python scripts** (`run.py`, `audit.py`, `check.py`, `guard.py`, `bridge.py`, `onboard.py`, `sync.py`, `doctor.py`, `loadout.py`, `governor.py`, `detect.py`, `test.py`) — run directly to manipulate state, generate reports, or trigger actions. No extra dependencies required; `pyyaml` is optional but recommended. +- **Python scripts** (`run.py`, `audit.py`, `check.py`, `guard.py`, `bridge.py`, `onboard.py`, `sync.py`, `doctor.py`, `loadout.py`, `governor.py`, `detect.py`, `test.py`, `radar.py`) — run directly to manipulate state, generate reports, or trigger actions. No extra dependencies required; `pyyaml` is optional but recommended. - **`vet.sh`** — Pure bash scanner; runs on any system with grep. - Each script supports `--help` and prints a human-readable summary. JSON output available where useful (`--format json`). Dry-run mode available on scripts that make changes. - See the `example-state.yaml` in each skill directory for sample state and a commented walkthrough of the skill's cron behaviour. @@ -142,7 +143,7 @@ obra/superpowers was built for session-based tools (Claude Code, Cursor, Codex). - Has **native cron scheduling** — skills wake up automatically on a schedule - Needs skills around **handoff, memory persistence, and self-recovery** that session tools don't require -The OpenClaw-native skills in this repo exist because of that difference. +The OpenClaw-native skills in this repo exist because of that difference. And with `community-skill-radar`, the library discovers what to build next by scanning Reddit communities automatically. ---