Skill: ziggy2socks/update-scout (version 1.1.2)

--- name: scout description: "Automate update tracking for OpenClaw and any other GitHub-released tools. Scout monitors your watchlist weekly, reviews release notes with a security lens, checks for post-release regressions, and posts a recommendation card before asking for approval — so you always know what changed and why before anything gets installed. Use when checking for available updates, reviewing a release link, running a periodic software health check, or adding new tools to monitor. Never installs anything without explicit approval." --- # Scout — Software Update Advisor Scout monitors GitHub releases for watched tools, reviews release notes, assesses risk, and recommends whether to upgrade. It never installs anything without explicit approval. ## Running a Check ```bash python3 scripts/check_updates.py # structured output: python3 scripts/check_updates.py --json ``` Config is read from `~/.config/scout/watchlist.json`. Created automatically on first run with openclaw as the default. ## Adding a Tool (conversational) When a user wants to add a tool, ask for: 1. GitHub repo (e.g. `owner/repo`) 2. How to detect the installed version (command, npm, pip, or file) Then run: ```bash python3 scripts/add_tool.py \ --name "tool-name" \ --repo "owner/repo" \ --detect-type command \ --detect-cmd "tool --version" \ --version-prefix "v" \ --notes "What this tool does" ``` See `references/watchlist.md` for supported detect types and examples. ## Verifying a Release Before recommending any upgrade, run the issue checker: ```bash python3 scripts/verify_release.py --repo owner/repo --since YYYY-MM-DD ``` Reports bug-labeled issues and regression keywords created after the release date. ## Skipping a Version When a user decides to skip an update: ```bash python3 scripts/skip_release.py --tool toolname --version v1.2.3 --reason "why" # list skipped: python3 scripts/skip_release.py --list # un-skip: python3 scripts/skip_release.py --clear --tool toolname ``` Skipped versions are stored in `~/.config/scout/skip_list.json` and suppressed from future `check_updates.py` output. ## Review Workflow For every update found, produce a full recommendation card before asking for approval: ``` 🔔 Update: <tool name> <installed> → <latest> Source: <GitHub repo> by <author/org> Released: <date> Risk: 🟢/🟡/🔴 <level> What changed: - <plain-language summary> - <note security fixes, breaking changes, new permissions> Impact on our setup: - <what this touches in config/workflow> - <anything requiring config changes or re-auth> Post-release issues: <none found / list any regressions> Recommendation: <Upgrade now / Wait / Skip> Reason: <one sentence> ``` Never ask "want me to upgrade X?" without the full card. The user needs enough context to decide without prior knowledge of the tool. **Risk levels:** - 🟢 Low — patch/fix only, no config changes, no new permissions - 🟡 Medium — new features, minor config additions, optional breaking changes - 🔴 High — breaking changes, auth changes, schema migrations, security patches ## After Approval 1. Run the appropriate install command for the tool 2. Validate config if applicable (`openclaw config validate`) 3. Restart services if needed 4. Confirm health ## Skill Health Review Periodically review your own skills against OpenClaw best practices: ```bash python3 scripts/review_skills.py [--skills-dir /path/to/skills] ``` Checks each skill for structural issues and quality improvements. Reports findings — all changes require explicit approval before applying. ## Periodic Use Add to heartbeat or weekly cron. Recommended cadence: weekly (Sundays).

path: references/watchlist.md
content: # Scout Watchlist Reference

Config file: `~/.config/scout/watchlist.json`

## Detect Types

| Type | How it works | Required fields |
|------|-------------|-----------------|
| `command` | Runs a shell command, extracts first semver from output | `cmd` (list of args) |
| `npm_global` | Runs `npm list -g <package>` | `package` |
| `pip` | Runs `pip show <package>` | `package` |
| `file` | Reads version from a JSON file | `path`, `key` (default: "version") |

## Example Entries

```json
[
  {
    "name": "openclaw",
    "repo": "openclaw/openclaw",
    "detect": {"type": "command", "cmd": ["openclaw", "--version"]},
    "version_prefix": "v",
    "notes": "Core OpenClaw runtime"
  },
  {
    "name": "my-npm-tool",
    "repo": "org/repo",
    "detect": {"type": "npm_global", "package": "my-npm-tool"},
    "version_prefix": "v",
    "notes": "Some npm tool"
  },
  {
    "name": "my-python-lib",
    "repo": "org/repo",
    "detect": {"type": "pip", "package": "my-python-lib"},
    "version_prefix": "v",
    "notes": "Some Python package"
  }
]
```

## Security Review Checklist

For every update before recommending approval:

1. **Breaking changes?** — Config schema, renamed flags, removed features
2. **Auth/network/filesystem changes?** — Extra scrutiny required
3. **Post-release regressions?** — Check GitHub issues opened after release date
4. **Impact on user's setup** — What specifically does this touch?
5. **Risk level:** 🟢 Low / 🟡 Medium / 🔴 High

## Adding Tools via CLI

```bash
python3 scripts/add_tool.py \
  --name "tool-name" \
  --repo "owner/repo" \
  --detect-type command \
  --detect-cmd "tool --version" \
  --version-prefix "v" \
  --notes "Description"
```


path: scripts/add_tool.py
content: #!/usr/bin/env python3
"""
scout - add_tool.py
Adds a tool to the scout watchlist config.

Usage (agent-driven):
  python3 add_tool.py \
    --name "gogcli" \
    --repo "steipete/gogcli" \
    --detect-type command \
    --detect-cmd "gog --version" \
    --version-prefix "v" \
    --notes "Google Workspace CLI"
"""

import json
import os
import re
import shlex
import sys
import argparse

from scout_config import DEFAULT_WATCHLIST_PATH

DEFAULT_CONFIG_PATH = DEFAULT_WATCHLIST_PATH
REPO_PATTERN = re.compile(r'^[\w.\-]+/[\w.\-]+$')


def load_config(path):
    if os.path.exists(path):
        with open(path) as f:
            return json.load(f)
    return []


def save_config(path, watchlist):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        json.dump(watchlist, f, indent=2)


def main():
    parser = argparse.ArgumentParser(description="Add a tool to the scout watchlist")
    parser.add_argument("--name", required=True, help="Display name")
    parser.add_argument("--repo", required=True, help="GitHub org/repo")
    parser.add_argument("--detect-type", default="command",
                        choices=["command", "npm_global", "pip", "file"],
                        help="Version detection method")
    parser.add_argument("--detect-cmd", help="Command to run (for type=command), space-separated")
    parser.add_argument("--detect-package", help="Package name (for npm_global or pip)")
    parser.add_argument("--detect-file", help="Path to JSON file containing version (for type=file)")
    parser.add_argument("--detect-key", default="version", help="Key in JSON file (for type=file)")
    parser.add_argument("--version-prefix", default="v", help="Version prefix in GitHub tags (e.g. 'v')")
    parser.add_argument("--notes", default="", help="Optional description")
    parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="Config file path")
    args = parser.parse_args()

    if not REPO_PATTERN.match(args.repo):
        print(f"⚠️  Invalid repo format: '{args.repo}' — expected 'owner/repo'")
        sys.exit(1)

    watchlist = load_config(args.config)

    if any(t["name"] == args.name for t in watchlist):
        print(f"Tool '{args.name}' already exists in watchlist.")
        sys.exit(1)

    if any(t.get("repo") == args.repo for t in watchlist):
        print(f"⚠️  Repo '{args.repo}' is already watched under a different name.")
        sys.exit(1)

    detect = {"type": args.detect_type}
    if args.detect_type == "command":
        if not args.detect_cmd:
            print("--detect-cmd required for type=command")
            sys.exit(1)
        detect["cmd"] = shlex.split(args.detect_cmd)  # handles paths with spaces
    elif args.detect_type in ("npm_global", "pip"):
        detect["package"] = args.detect_package or args.name
    elif args.detect_type == "file":
        if not args.detect_file:
            print("--detect-file required for type=file")
            sys.exit(1)
        detect["path"] = args.detect_file
        detect["key"] = args.detect_key

    tool = {
        "name": args.name,
        "repo": args.repo,
        "detect": detect,
        "version_prefix": args.version_prefix,
        "notes": args.notes,
    }

    watchlist.append(tool)
    save_config(args.config, watchlist)
    print(f"Added '{args.name}' ({args.repo}) to {args.config}")


if __name__ == "__main__":
    main()


path: scripts/check_updates.py
content: #!/usr/bin/env python3
"""
scout - check_updates.py
Polls GitHub releases for watched tools, compares against installed versions,
and outputs a structured update report.

Usage:
  python3 check_updates.py [--json] [--config /path/to/watchlist.json]

Config file: ~/.config/scout/watchlist.json (created on first run)

GitHub API rate limits: 60 req/hour unauthenticated. Set GITHUB_TOKEN env var
for 5,000 req/hour (use your own token — never stored by scout).
"""

import json
import os
import re
import shlex
import subprocess
import sys
import urllib.error
from dataclasses import dataclass, asdict
from typing import Optional

from scout_config import DEFAULT_WATCHLIST_PATH, DEFAULT_SKIP_PATH, github_request

DEFAULT_WATCHLIST = [
    {
        "name": "openclaw",
        "repo": "openclaw/openclaw",
        "detect": {"type": "command", "cmd": ["openclaw", "--version"]},
        "version_prefix": "v",
        "notes": "Core OpenClaw runtime"
    }
]


def load_skip_list(path: str) -> dict:
    if os.path.exists(path):
        try:
            with open(path) as f:
                return json.load(f)
        except (json.JSONDecodeError, OSError):
            return {}
    return {}


def load_config(config_path: str):
    if os.path.exists(config_path):
        try:
            with open(config_path) as f:
                data = json.load(f)
            if not isinstance(data, list):
                print(f"⚠️  Config at {config_path} is not a list — ignoring.", file=sys.stderr)
                return None
            return data
        except (json.JSONDecodeError, OSError) as e:
            print(f"⚠️  Could not read config {config_path}: {e}", file=sys.stderr)
            return None
    return None


def save_config(config_path: str, watchlist: list):
    dirpath = os.path.dirname(config_path)
    if dirpath:
        os.makedirs(dirpath, exist_ok=True)
    with open(config_path, "w") as f:
        json.dump(watchlist, f, indent=2)


def detect_version_command(cmd: list) -> Optional[str]:
    if not cmd:
        return None
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        output = (result.stdout + result.stderr).strip()
        match = re.search(r'\b(\d+\.\d+[\.\d-]*)\b', output)
        # Return match or None — never return garbage like the first word
        return match.group(1) if match else None
    except FileNotFoundError:
        return None
    except Exception:
        return None


def detect_version_npm(package: str) -> Optional[str]:
    try:
        result = subprocess.run(
            ["npm", "list", "-g", "--depth=0", package],
            capture_output=True, text=True, timeout=10
        )
        match = re.search(rf'{re.escape(package)}@([\d\.\-]+)', result.stdout)
        return match.group(1) if match else None
    except Exception:
        return None


def detect_version_pip(package: str) -> Optional[str]:
    try:
        result = subprocess.run(
            ["pip", "show", package],
            capture_output=True, text=True, timeout=10
        )
        match = re.search(r'Version:\s*([\d\.\-]+)', result.stdout)
        return match.group(1) if match else None
    except Exception:
        return None


def detect_version_file(path: str, key: str = "version") -> Optional[str]:
    try:
        with open(os.path.expanduser(path)) as f:
            data = json.load(f)
        return data.get(key)
    except Exception:
        return None


def get_installed_version(tool: dict) -> Optional[str]:
    detect = tool.get("detect", {})
    dtype = detect.get("type", "command")
    if dtype == "command":
        cmd = detect.get("cmd", [])
        # Support both list and string forms
        if isinstance(cmd, str):
            cmd = shlex.split(cmd)
        return detect_version_command(cmd)
    elif dtype == "npm_global":
        return detect_version_npm(detect.get("package", tool.get("name", "")))
    elif dtype == "pip":
        return detect_version_pip(detect.get("package", tool.get("name", "")))
    elif dtype == "file":
        return detect_version_file(detect.get("path", ""), detect.get("key", "version"))
    return None


def get_latest_release(repo: str) -> dict:
    return github_request(f"https://api.github.com/repos/{repo}/releases/latest")


def normalize_version(v: str, prefix: str) -> str:
    v = (v or "").strip()
    if prefix and v.startswith(prefix):
        v = v[len(prefix):]
    return v


def summarize_notes(body: str, max_chars: int = 500) -> str:
    if not body:
        return ""
    lines = [l.strip() for l in body.splitlines() if l.strip()]
    summary = "\n".join(lines)
    return summary[:max_chars] + "..." if len(summary) > max_chars else summary


@dataclass
class UpdateResult:
    name: str
    installed: Optional[str]
    latest: Optional[str]
    up_to_date: bool
    update_available: bool
    release_url: Optional[str]
    release_notes_summary: Optional[str]
    published_at: Optional[str]
    error: Optional[str]


def check_tool(tool: dict) -> UpdateResult:
    name = tool.get("name", "(unnamed)")
    installed_raw = get_installed_version(tool)
    prefix = tool.get("version_prefix", "v")

    try:
        release = get_latest_release(tool.get("repo", ""))
    except urllib.error.HTTPError as e:
        msg = f"HTTP {e.code}: {tool.get('repo', '')}"
        if e.code == 403:
            msg += " (rate limited — set GITHUB_TOKEN for higher limits)"
        elif e.code == 404:
            msg += " (repo not found)"
        return UpdateResult(name=name, installed=installed_raw, latest=None,
                            up_to_date=False, update_available=False,
                            release_url=None, release_notes_summary=None,
                            published_at=None, error=msg)
    except Exception as e:
        return UpdateResult(name=name, installed=installed_raw, latest=None,
                            up_to_date=False, update_available=False,
                            release_url=None, release_notes_summary=None,
                            published_at=None, error=str(e))

    tag = release.get("tag_name", "")
    latest_norm = normalize_version(tag, prefix)
    installed_norm = normalize_version(installed_raw, prefix) if installed_raw else None
    up_to_date = installed_norm == latest_norm if installed_norm else False

    return UpdateResult(
        name=name,
        installed=installed_raw,
        latest=tag,
        up_to_date=up_to_date,
        update_available=bool(installed_norm) and not up_to_date,
        release_url=release.get("html_url"),
        release_notes_summary=summarize_notes(release.get("body", "")),
        published_at=(release.get("published_at") or "")[:10],
        error=None,
    )


def print_report(results: list):
    updates = [r for r in results if r.update_available]
    current = [r for r in results if r.up_to_date]
    errors = [r for r in results if r.error]

    print("=== Scout Update Report ===\n")

    if updates:
        print(f"🔔 Updates available ({len(updates)}):\n")
        for r in updates:
            print(f"  {r.name}  {r.installed} → {r.latest}  (released {r.published_at})")
            print(f"  Release: {r.release_url}")
            if r.release_notes_summary:
                preview = r.release_notes_summary.replace("\n", "\n    ")
                print(f"  Notes:\n    {preview}")
            print()
    else:
        print("✅ All tools are current.\n")

    if current:
        print(f"✅ Up to date: {', '.join(r.name for r in current)}")

    if errors:
        print(f"\n⚠️  Could not check: {', '.join(r.name + ' (' + (r.error or '') + ')' for r in errors)}")


def main():
    args = sys.argv[1:]
    as_json = "--json" in args
    config_path = DEFAULT_WATCHLIST_PATH
    if "--config" in args:
        idx = args.index("--config")
        if idx + 1 < len(args):
            config_path = args[idx + 1]

    watchlist = load_config(config_path)
    if watchlist is None:
        save_config(config_path, DEFAULT_WATCHLIST)
        watchlist = DEFAULT_WATCHLIST
        if not as_json:
            print(f"Created default config at {config_path}")
            print("Add more tools by editing that file or asking your agent to add them.\n")

    skip_list = load_skip_list(DEFAULT_SKIP_PATH)
    results = [check_tool(t) for t in watchlist]

    def is_skipped(r: UpdateResult) -> bool:
        entry = skip_list.get(r.name)
        if not entry or not isinstance(entry, dict):
            return False
        skipped_ver = normalize_version(entry.get("version", ""), "v")
        latest_ver = normalize_version(r.latest or "", "v")
        return bool(skipped_ver) and skipped_ver == latest_ver

    skipped = [r for r in results if r.update_available and is_skipped(r)]
    results = [r for r in results if not is_skipped(r)]

    if as_json:
        print(json.dumps([asdict(r) for r in results], indent=2))
    else:
        print_report(results)
        if skipped:
            print(f"\n⏭️  Skipped (use skip_release.py --list to review): {', '.join(r.name for r in skipped)}")


if __name__ == "__main__":
    main()


path: scripts/review_skills.py
content: #!/usr/bin/env python3
"""
scout - review_skills.py
Reviews installed OpenClaw skills against structural best practices and suggests improvements.

Usage:
  python3 review_skills.py [--skills-dir /path/to/skills] [--json]

What it does:
  1. Finds all skills in the skills directory (any folder with a SKILL.md)
  2. Optionally fetches the latest OpenClaw skill-creator guidelines for context
  3. Runs each skill through a set of structural checks
  4. Outputs a health report with specific, actionable suggestions

Note: This script performs static structural checks (frontmatter, body length, script
references, etc.). It does not use AI to evaluate skill quality — the agent reviewing
the output is responsible for that judgment.

Set GITHUB_TOKEN env var for higher API rate limits (your own token, never stored).
"""

import json
import os
import re
import sys
import argparse
from pathlib import Path
from typing import Optional

from scout_config import github_request

# Optional: fetch latest skill-creator SKILL.md for context when presenting findings
SKILL_CREATOR_RAW_URL = (
    "https://raw.githubusercontent.com/openclaw/openclaw/main/"
    "skills/skill-creator/SKILL.md"
)


def fetch_best_practices() -> Optional[str]:
    """Fetch the OpenClaw skill-creator best practices doc for agent context.
    Returns the content string or None if unavailable. Not used for checks —
    the agent can reference it when explaining suggestions."""
    try:
        import urllib.request
        headers = {"User-Agent": "scout-skill/1.0"}
        [REDACTED_SECRET]"GITHUB_TOKEN")
        if [REDACTED_SECRET]"Authorization"] = f"Bearer {token}"
        req = urllib.request.Request(SKILL_CREATOR_RAW_URL, headers=headers)
        with urllib.request.urlopen(req, timeout=10) as resp:
            return resp.read().decode("utf-8")
    except Exception:
        return None


def find_skills(skills_dir: str) -> list:
    """Find all skill directories (folders containing SKILL.md)."""
    base = Path(skills_dir)
    if not base.exists():
        return []
    return sorted(
        p for p in base.iterdir()
        if p.is_dir() and not p.is_symlink() and (p / "SKILL.md").exists()
    )


def parse_frontmatter(content: str) -> dict:
    """Extract key/value pairs from YAML frontmatter. Handles simple string values only."""
    result = {}
    if not content.startswith("---"):
        return result
    end = content.find("\n---", 3)
    if end == -1:
        return result
    for line in content[3:end].strip().splitlines():
        if ":" in line:
            key, _, val = line.partition(":")
            result[key.strip()] = val.strip().strip('"').strip("'")
    return result


def extract_body(content: str) -> str:
    """Extract everything after the frontmatter block."""
    if content.startswith("---"):
        end = content.find("\n---", 3)
        if end != -1:
            return content[end + 4:].strip()
    return content.strip()


def filename_referenced(filename: str, body: str) -> bool:
    """Check if a script filename appears in the body (as a standalone token or path component)."""
    # Match filename as a complete token — allows leading path separators like scripts/
    # but not as a substring of another filename (e.g. 'config.py' != 'scout_config.py')
    pattern = re.compile(r'(?:^|[\s/`\'"])' + re.escape(filename) + r'(?:$|[\s`\'"\)])', re.MULTILINE)
    return bool(pattern.search(body))


def check_skill(skill_path: Path) -> dict:
    """Run structural health checks on a single skill directory."""
    skill_md_path = skill_path / "SKILL.md"

    try:
        content = skill_md_path.read_text(encoding="utf-8")
    except Exception as e:
        return {
            "name": skill_path.name,
            "path": str(skill_path),
            "issues": [f"Could not read SKILL.md: {e}"],
            "suggestions": [],
            "healthy": False,
        }

    fm = parse_frontmatter(content)
    body = extract_body(content)
    name = fm.get("name", "")
    description = fm.get("description", "")

    issues = []
    suggestions = []

    # Frontmatter checks
    if not name:
        issues.append("Missing `name` in frontmatter")

    if not description:
        issues.append("Missing `description` in frontmatter")
    else:
        if len(description) < 80:
            suggestions.append("Description is short — expand to clearly describe when to use this skill")
        if len(description) > 600:
            suggestions.append("Description may be too long for skill card display — aim for under 400 chars for the lead sentence")
        # Check if description clearly states when to use the skill
        has_when = any(kw in description.lower() for kw in ("use when", "when to use", "when:"))
        if not has_when:
            suggestions.append("Description doesn't state when to use this skill — add 'Use when...' guidance")
        # Check if it leads with user value vs. agent-trigger boilerplate
        tech_starts = ["this skill", "provides", "handles", "manages", "wraps"]
        if any(description.lower().startswith(s) for s in tech_starts):
            suggestions.append("Description leads with technical framing — consider leading with user value (what problem it solves)")

    # Body checks
    if not body or len(body) < 100:
        issues.append("SKILL.md body is empty or very short — add workflow instructions")
    if len(body.splitlines()) > 500:
        suggestions.append("SKILL.md body exceeds 500 lines — consider moving detail to references/ files")

    # Script reference checks — only check scripts meant to be run directly
    scripts_dir = skill_path / "scripts"
    if scripts_dir.exists():
        # Glob without following symlinks
        all_scripts = [
            f for f in scripts_dir.iterdir()
            if f.is_file() and not f.is_symlink()
            and f.suffix in (".py", ".sh", ".bash")
        ]
        # Filter known internal modules (named *_config.py or starting with _)
        runnable = [
            f for f in all_scripts
            if not f.name.startswith("_")
            and not f.name.endswith("_config.py")
            and not f.name.startswith("test_")
        ]
        if runnable:
            unreferenced = [f.name for f in runnable if not filename_referenced(f.name, body)]
            if len(unreferenced) == len(runnable):
                suggestions.append("Scripts exist in scripts/ but none are referenced in SKILL.md — add usage examples")
            elif unreferenced:
                suggestions.append(f"Some scripts not referenced in SKILL.md: {', '.join(unreferenced)}")

    # Extraneous file check
    bad = [
        str(f.relative_to(skill_path)) for f in skill_path.rglob("*")
        if f.is_file() and not f.is_symlink()
        and f.name.lower() in ("readme.md", "changelog.md", "installation.md", "quick_reference.md")
    ]
    if bad:
        issues.append(f"Extraneous documentation files found (per skill spec, remove these): {', '.join(bad)}")

    return {
        "name": name or skill_path.name,
        "path": str(skill_path),
        "issues": issues,
        "suggestions": suggestions,
        "healthy": len(issues) == 0,
    }


def main():
    parser = argparse.ArgumentParser(
        description="Review OpenClaw skills against structural best practices"
    )
    parser.add_argument(
        "--skills-dir",
        default=os.path.expanduser("~/.openclaw/workspace/skills"),
        help="Path to skills directory"
    )
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    args = parser.parse_args()

    # Fetch best practices doc for agent context — not used for automated checks
    best_practices = fetch_best_practices()
    bp_status = "✅ Available" if best_practices else "⚠️  Unavailable (check GITHUB_TOKEN or network)"

    skills = find_skills(args.skills_dir)
    if not skills:
        if args.json:
            print(json.dumps({"error": f"No skills found in {args.skills_dir}"}))
        else:
            print(f"No skills found in {args.skills_dir}")
        sys.exit(1)

    results = [check_skill(p) for p in skills]

    if args.json:
        out = {
            "skills_dir": args.skills_dir,
            "best_practices_source": SKILL_CREATOR_RAW_URL,
            "best_practices_status": bp_status,
            # Include fetched content so agent can reference it when explaining suggestions
            "best_practices_content": best_practices,
            "skill_count": len(results),
            "results": results,
        }
        print(json.dumps(out, indent=2))
        return

    healthy = [r for r in results if r["healthy"] and not r["suggestions"]]
    needs_attention = [r for r in results if r["issues"] or r["suggestions"]]

    print("=== Scout Skill Health Report ===\n")
    print(f"Best practices reference: {SKILL_CREATOR_RAW_URL}")
    print(f"Status: {bp_status}")
    print(f"Skills checked: {len(results)}")
    print("Note: Checks are structural only. Agent review required for quality assessment.\n")

    if needs_attention:
        for r in needs_attention:
            marker = "❌" if r["issues"] else "💡"
            print(f"{marker} {r['name']}  ({r['path']})")
            for issue in r["issues"]:
                print(f"   ❌ {issue}")
            for suggestion in r["suggestions"]:
                print(f"   💡 {suggestion}")
            print()

    if healthy:
        print(f"✅ Healthy: {', '.join(r['name'] for r in healthy)}")

    print("\nReview complete. All changes require explicit approval before applying.")


if __name__ == "__main__":
    main()


path: scripts/scout_config.py
content: """
scout - scout_config.py
Shared constants and GitHub API helper used by all scout scripts.
"""

import json
import os
import urllib.request
from typing import Union

DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/scout")
DEFAULT_WATCHLIST_PATH = os.path.join(DEFAULT_CONFIG_DIR, "watchlist.json")
DEFAULT_SKIP_PATH = os.path.join(DEFAULT_CONFIG_DIR, "skip_list.json")


def github_request(url: str) -> Union[dict, list]:
    """Make an authenticated (if GITHUB_TOKEN set) GitHub API request."""
    headers = {"User-Agent": "scout-skill/1.0"}
    [REDACTED_SECRET]"GITHUB_TOKEN")
    if [REDACTED_SECRET]"Authorization"] = f"Bearer {token}"
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.loads(resp.read())


path: scripts/skip_release.py
content: #!/usr/bin/env python3
"""
scout - skip_release.py
Mark a specific version as skipped so check_updates.py stops surfacing it.

Usage:
  python3 skip_release.py --tool openclaw --version v2026.3.13-1 --reason "Not needed"
  python3 skip_release.py --list
  python3 skip_release.py --clear --tool openclaw

Note: --version must be the exact tag string (e.g. v1.2.3). When a newer release
comes out, it will appear in check_updates again automatically.
"""

import json
import os
import argparse
from datetime import date

from scout_config import DEFAULT_SKIP_PATH


def load_skip_list(path: str) -> dict:
    if os.path.exists(path):
        try:
            with open(path) as f:
                return json.load(f)
        except (json.JSONDecodeError, OSError):
            return {}
    return {}


def save_skip_list(path: str, data: dict):
    dirpath = os.path.dirname(path)
    if dirpath:
        os.makedirs(dirpath, exist_ok=True)
    with open(path, "w") as f:
        json.dump(data, f, indent=2)


def main():
    parser = argparse.ArgumentParser(description="Manage scout skip list")
    parser.add_argument("--tool", help="Tool name")
    parser.add_argument("--version", help="Version to skip (use 'latest' to skip current latest)")
    parser.add_argument("--reason", default="", help="Why skipping")
    parser.add_argument("--list", action="store_true", help="List all skipped versions")
    parser.add_argument("--clear", action="store_true", help="Remove skip entry for a tool")
    parser.add_argument("--config", default=DEFAULT_SKIP_PATH, help="Skip list path")
    args = parser.parse_args()

    skip_list = load_skip_list(args.config)

    if args.list:
        if not skip_list:
            print("No skipped versions.")
        else:
            print("Skipped versions:\n")
            for tool, entry in skip_list.items():
                print(f"  {tool}: {entry['version']} — {entry.get('reason', '(no reason)')} (skipped {entry.get('date', '?')})")
        return

    if not args.tool:
        parser.error("--tool is required")

    if args.clear:
        if args.tool in skip_list:
            del skip_list[args.tool]
            save_skip_list(args.config, skip_list)
            print(f"Cleared skip entry for '{args.tool}'")
        else:
            print(f"No skip entry found for '{args.tool}'")
        return

    if not args.version:
        parser.error("--version is required")

    skip_list[args.tool] = {
        "version": args.version,
        "reason": args.reason,
        "date": str(date.today()),
    }
    save_skip_list(args.config, skip_list)
    print(f"Skipping {args.tool} {args.version}: {args.reason or '(no reason given)'}")


if __name__ == "__main__":
    main()


path: scripts/verify_release.py
content: #!/usr/bin/env python3
"""
scout - verify_release.py
Checks GitHub for bug reports created after a release date.
Helps confirm a release is stable before upgrading.

Usage:
  python3 verify_release.py --repo owner/repo --since YYYY-MM-DD [--json]

Note: GitHub's API `since` parameter filters by *updated* date (not created).
An old issue that received a new comment will appear. Filter results by
`created_at` to focus on genuinely new reports.

Set GITHUB_TOKEN env var for higher API rate limits (your own token, never stored).
"""

import json
import re
import sys
import urllib.error
import urllib.parse
import argparse
from datetime import datetime

from scout_config import github_request


def validate_date(date_str: str) -> bool:
    try:
        datetime.strptime(date_str, "%Y-%m-%d")
        return True
    except ValueError:
        return False


def fetch_issues(repo: str, since: str, labels: str = "bug") -> list:
    params = urllib.parse.urlencode({
        "state": "open",
        "since": f"{since}T00:00:00Z",
        "per_page": 30,
        "labels": labels,
    })
    url = f"https://api.github.com/repos/{repo}/issues?{params}"
    try:
        return github_request(url)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            raise ValueError(f"Repo not found: {repo}")
        raise


def fetch_all_issues(repo: str, since: str) -> list:
    labeled = fetch_issues(repo, since, labels="bug")
    # Filter labeled results to issues only (not PRs)
    labeled = [i for i in labeled if i.get("pull_request") is None]

    params = urllib.parse.urlencode({
        "state": "open",
        "since": f"{since}T00:00:00Z",
        "per_page": 50,
    })
    url = f"https://api.github.com/repos/{repo}/issues?{params}"
    try:
        all_issues = github_request(url)
    except Exception:
        all_issues = []

    # Use word boundaries to reduce false positives
    keyword_pattern = re.compile(
        r'\b(broken|regression|crash|crashed|crashes|fail|failed|fails|'
        r'bug|error|errors)\b',
        re.IGNORECASE
    )
    keyword_issues = [
        i for i in all_issues
        if keyword_pattern.search(i.get("title", ""))
        and i.get("pull_request") is None
    ]

    seen = {i["number"] for i in labeled}
    combined = labeled + [i for i in keyword_issues if i["number"] not in seen]

    # Filter to only issues *created* after since date (not just updated)
    combined = [i for i in combined if i.get("created_at", "")[:10] >= since]

    return combined


def main():
    parser = argparse.ArgumentParser(
        description="Check for post-release bug reports on GitHub"
    )
    parser.add_argument("--repo", required=True, help="GitHub org/repo (e.g. owner/repo)")
    parser.add_argument("--since", required=True, help="Release date YYYY-MM-DD")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    args = parser.parse_args()

    if not validate_date(args.since):
        print(f"⚠️  Invalid date format: '{args.since}' — expected YYYY-MM-DD", file=sys.stderr)
        sys.exit(1)

    try:
        issues = fetch_all_issues(args.repo, args.since)
    except ValueError as e:
        if args.json:
            print(json.dumps({"error": str(e), "repo": args.repo, "since": args.since}))
        else:
            print(f"⚠️  {e}")
        sys.exit(1)
    except Exception as e:
        if args.json:
            print(json.dumps({"error": str(e), "repo": args.repo, "since": args.since}))
        else:
            print(f"⚠️  Could not fetch issues for {args.repo}: {e}")
        sys.exit(1)

    result = {
        "repo": args.repo,
        "since": args.since,
        "issue_count": len(issues),
        "issues": [
            {
                "number": i["number"],
                "title": i["title"],
                "url": i["html_url"],
                "created_at": i["created_at"][:10],
                "labels": [l["name"] for l in i.get("labels", [])],
            }
            for i in issues
        ]
    }

    if args.json:
        print(json.dumps(result, indent=2))
        return

    print(f"Post-release issues for {args.repo} (since {args.since}):\n")
    if not issues:
        print("  ✅ No bug reports or regressions found.")
    else:
        print(f"  ⚠️  {len(issues)} issue(s) created after release:\n")
        for i in result["issues"]:
            labels = f" [{', '.join(i['labels'])}]" if i["labels"] else ""
            print(f"  #{i['number']} ({i['created_at']}){labels}: {i['title']}")
            print(f"    {i['url']}")
    print()


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)