Skill: merlinrabens/clawhealth (version 0.0.1)

--- name: clawcheck description: Two-phase audit: fast deterministic scan + LLM deep quality review for security, cron, config, and skills metadata: {"openclaw":{"emoji":"🩺","requires":{"bins":["python3"]}}} user-invocable: true --- # ClawCheck Two-phase audit: a fast deterministic scan catches structural issues, then you (the agent) do a deep quality evaluation on the flagged areas. ## When to Use - After initial setup or major config changes - Before publishing skills to ClawHub (quality gate) - Periodic health check (weekly cron or manual) - When something feels off but `openclaw doctor` says "ok" - After installing new skills or updating OpenClaw ## What This Checks vs Built-in | This skill | `openclaw doctor` (built-in) | |---|---| | Secrets exposure + token hygiene | Config JSON schema validation | | Cron ops health + prompt quality review | Plugin/skill eligibility | | Config optimization + value assessment | Channel connectivity | | Skill structural + content quality audit | State migrations, browser detection | ## How It Works: Two Phases ### Phase 1: Deterministic Scan (fast, free) Run the script to get a structural baseline: ```bash python3 {baseDir}/scripts/audit.py ``` Individual modules: ```bash python3 {baseDir}/scripts/audit.py --security python3 {baseDir}/scripts/audit.py --cron python3 {baseDir}/scripts/audit.py --config python3 {baseDir}/scripts/audit.py --skills ``` This produces JSON with scores, findings, and the bottom/top skill lists. Use this as your triage map for Phase 2. ### Phase 2: Deep Quality Audit (you, the agent) After running the script, perform these evaluations. Budget your depth based on what the user asked for ("quick check" = Phase 1 only, "full audit" or "quality review" = both phases). #### 2a. Config Quality Review Read `~/.openclaw/openclaw.json` and evaluate: - **Heartbeat prompt**: Read `agents.defaults.heartbeat.prompt`. Is it specific enough to catch real issues? Does it avoid heavy operations? A good heartbeat prompt is < 200 words, checks 2-3 things, and has clear escalation criteria. - **Model choices**: Is the primary model appropriate for the workload? Are fallbacks a meaningful step-down (not the same tier)? Is the subagent model cheaper than primary? - **Compaction thresholds**: Are `reserveTokens` and `keepRecentTokens` reasonable for the context window size? Rule of thumb: reserve should be 15-20% of contextTokens. - **Session maintenance**: Are `pruneAfter`, `maxEntries`, `rotateBytes` set to values that match the usage pattern? Heavy cron usage needs more aggressive pruning. - **Cron maxConcurrentRuns**: Is it high enough for the number of frequent jobs? Count jobs with `*/` in their schedule expression. Score each aspect 1-5. Report specific improvements. #### 2b. Cron Prompt Quality Review Read `~/.openclaw/cron/jobs.json`. Select the 5 most important enabled jobs using this heuristic: 1. Any job in error state (from Phase 1 findings) 2. Jobs with highest `frequency x timeoutSeconds` (most resource-consuming) 3. Jobs running on expensive models (opus/primary) 4. If still under 5, pick by business impact (backups, monitoring, user-facing) For each selected job evaluate: - **Prompt clarity**: Specific enough to execute without guessing? Clear steps, expected output format, error handling? - **Safety**: Has guardrails? ("NEVER run git push", "read-only", "do not edit files directly") - **Efficiency**: Token-efficient? Flag prompts > 1500 chars that run on expensive models. Could the prompt reference a skill file instead of inlining instructions? - **Output value**: Produces actionable output or just noise? - **Timeout**: `payload.timeoutSeconds` set and reasonable for scope? Score each job 1-5 on: purpose, prompt quality, safety, efficiency. Flag jobs scoring below 3. **Cross-reference**: Check if any cron prompts reference skills that scored below 70 in Phase 1. A cron job is only as reliable as the skills it depends on. #### 2c. Skill Content Quality Review From the Phase 1 results, pick: - The 3 lowest-scoring skills (from `bottom_5`) - Any skills the user specifically asks about - Skills used by failing cron jobs (cross-reference cron findings) For each selected skill, read its full SKILL.md and evaluate: - **Accuracy** (2x weight): Would following these instructions produce correct behavior? Are API references current? Are file paths real? - **Completeness** (1.5x): Are all use cases covered? Edge cases? What happens when dependencies are missing? - **Clarity** (1x): Can an agent follow this without ambiguity? No hedging, clear steps, good examples? - **Efficiency** (1x): Is the SKILL.md bloated? Could it be shorter without losing information? Does it suggest efficient patterns (batching, caching)? - **Voice alignment** (1x, content-producing skills only): Does the output match the brand/user's tone? Scoring formula depends on skill type: - **Content/marketing skills** (has voice component): `(accuracy*2 + completeness*1.5 + clarity + efficiency + voice) / 6.5` - **Utility/tool skills** (no voice): `(accuracy*2 + completeness*1.5 + clarity + efficiency) / 5.5` For skills scoring below 4.0, write specific improvement recommendations with concrete examples. #### 2d. Security Assessment Phase 1 now scans workspace files for common secret patterns (sk-, ghp_, AIzaSy, Bearer tokens, hex private keys, etc.). In Phase 2, go deeper: - Review any secrets the script found in workspace files. Are they real credentials or false positives (e.g., example/placeholder values)? - Check if any skill `scripts/` contain hardcoded credentials or API URLs with embedded tokens - Check if `.env` files exist inside skill directories - Look for credentials in cron job prompts (some prompts inline API keys instead of referencing env vars) - Check if any workspace knowledge files contain customer data, passwords, or access tokens ## Output Format ### Phase 1 (script output) ```json { "score": 82, "score_type": "structural_hygiene", "status": "healthy", "sections": { "security": {"score": 65, "finding_count": 3}, "cron": {"score": 95, "finding_count": 1}, "config": {"score": 88, "finding_count": 2}, "skills": {"score": 80, "finding_count": 1} }, "findings": [...] } ``` ### Phase 2 (your evaluation) Present as a readable report to the user: ``` ## ClawCheck Report ### Structural Baseline (Phase 1) Overall: 82/100 (healthy) Security: 65 | Cron: 95 | Config: 88 | Skills: 80 ### Deep Quality Findings (Phase 2) **Config:** - Heartbeat prompt: 4/5 (clear but could add Telegram alert on critical) - Model choices: 5/5 (opus primary, sonnet fallback, sonnet subagent) - Compaction: 4/5 (reserveTokens=150k for 800k context = 19%, good) **Cron (top concerns):** - "Morning Brief" (3/5): prompt is 400 words but lacks output format spec - "Bleeding Edge Scanner" (2/5): no safety guardrails, no error handling **Skills (bottom 3):** - marketing-automation: BROKEN (no SKILL.md) - apple-notes (62/100 structural): [content evaluation] - blucli (62/100 structural): [content evaluation] ### Recommended Actions (priority order) 1. [most impactful fix] 2. [next fix] 3. [next fix] ``` ## Scoring Weights (Phase 1 script) Security 30%, cron 25%, config 20%, skills 25%. Skill structure formula: `(structure*2 + completeness*1.5 + clarity + efficiency) / 5.5 * 20` ## Remediation For detailed fix patterns with real config examples, see `{baseDir}/references/remediation.md`. Quick fixes for common findings: ### Inline secrets ```json "GAMMA_API_KEY": {"source": "exec", "provider": "op-gamma", "id": "value"} ``` ### Plaintext bot token ```json "botToken": {"source": "exec", "provider": "op-telegram", "id": "value"} ``` ### Missing heartbeat ```json "heartbeat": {"every": "1h", "model": "sonnet", "prompt": "HEARTBEAT: Quick check..."} ``` ### Missing timezone on cron ```json "schedule": {"kind": "cron", "expr": "0 9 * * *", "tz": "Europe/Madrid"} ``` ## Error Handling - If OpenClaw dir not found: script exits with error JSON and exit code 1. - If `openclaw.json` is missing or invalid: script exits with error JSON. - If individual module fails: caught and reported as warning, other modules still run. - If bundled skills dir not accessible: skipped silently. - Phase 2 failures: if you can't read a file, note it and move on. Don't stop the whole audit. ## Non-Goals - No direct edits to config or skills (report only, user decides) - No network calls (everything is local file inspection) - No overlap with `openclaw doctor` schema validation or channel connectivity checks

path: README.md
content: # 🩺 ClawCheck

Two-phase installation audit for OpenClaw: a fast deterministic scan, then an LLM-powered deep quality review. Catches what `openclaw doctor` doesn't.

## Why

`openclaw doctor` validates your config schema and checks connectivity. This skill goes deeper:

- **Phase 1 (instant, free):** A Python script scans your installation for inline secrets, broken cron jobs, missing config, and structurally weak skills. 2 seconds, zero tokens.
- **Phase 2 (deep, smart):** The agent uses Phase 1 as a triage map and reads the actual content: are your heartbeat prompts effective? Are cron job instructions safe? Do your skills produce correct agent behavior?

No other tool combines both. Scripts alone can't judge prompt quality. LLM-only audits are slow and expensive. This does targeted deep dives only where the scan finds issues.

## Install

```bash
clawhub install clawcheck
```

Or via OpenClaw:
```bash
openclaw skills install clawcheck
```

Requirements: `python3`. No API keys. No network access. Everything is local.

## Usage

### Quick check (Phase 1 only)
```bash
python3 scripts/audit.py
```

### Full audit (Phase 1 + Phase 2)
Ask your agent: "Run clawcheck, full audit"

### Individual modules
```bash
python3 scripts/audit.py --security   # Secrets exposure
python3 scripts/audit.py --cron       # Cron operational health
python3 scripts/audit.py --config     # Config field completeness
python3 scripts/audit.py --skills     # Skill structural scoring
```

## What Each Phase Does

### Phase 1: Deterministic Scan

| Module | Weight | What it catches |
|--------|--------|-----------------|
| Security | 30% | Inline secrets in env.vars, plaintext bot tokens, unprotected .env files |
| Cron | 25% | Error states, stale jobs, schedule conflicts, missing timezones |
| Config | 20% | Missing heartbeat, no fallbacks, no session maintenance, no compaction |
| Skills | 25% | Broken frontmatter, missing dependencies, structural quality scoring |

Output: JSON with 0-100 scores per module and prioritized findings with fix suggestions.

### Phase 2: LLM Deep Quality Review

The agent reads what the script flagged and evaluates content quality:

- **Config values:** Is the heartbeat prompt effective? Are compaction thresholds sensible? Is the model selection cost-optimal?
- **Cron prompts:** Are job instructions clear, safe, and efficient? Do they have guardrails and error handling?
- **Skill content:** Would following the SKILL.md instructions produce correct agent behavior? Are examples accurate? APIs current?
- **Security context:** Are there secrets hiding in workspace markdown files or scripts?

Scoring dimensions for skills (Phase 2):
- **Accuracy** (2x weight): Do the instructions produce correct behavior?
- **Completeness** (1.5x): All use cases and edge cases covered?
- **Clarity** (1x): Unambiguous, imperative, well-structured?
- **Efficiency** (1x): Token-conscious, suggests caching/batching?
- **Voice** (1x, content skills only): Matches brand tone?

## Example Report

```
## ClawCheck Report

### Structural Baseline (Phase 1)
Overall: 82/100 (healthy)
Security: 65 | Cron: 95 | Config: 88 | Skills: 80

### Deep Quality Findings (Phase 2)

Config:
- Heartbeat prompt: 4/5 (clear but should add escalation to Telegram)
- Model choices: 5/5 (opus primary, sonnet fallback+subagent)

Cron (concerns):
- "Morning Brief" (3/5): no output format spec, prompt is verbose
- "Bleeding Edge Scanner" (2/5): no safety guardrails

Skills (bottom 3):
- marketing-automation: BROKEN (no SKILL.md)
- apple-notes (2/5 accuracy): references deprecated API
- blucli (2/5 completeness): no error handling, no examples

### Recommended Actions
1. Move 7 inline secrets to SecretRefs (critical)
2. Add safety guardrails to Bleeding Edge Scanner prompt
3. Fix or remove marketing-automation skill
```

## How It Differs

| | ClawCheck | `openclaw doctor` |
|---|---|---|
| Secrets audit | Yes (inline, tokens, .env) | No |
| Cron health | Yes (errors, staleness, conflicts) | No |
| Config optimization | Yes (values + quality) | Schema validation only |
| Skill quality | Yes (structural + LLM content review) | Eligibility count only |
| Cost | Phase 1: free. Phase 2: ~1 agent turn | Free |
| Speed | Phase 1: 2s. Phase 2: 1-3 min | Instant |

## License

MIT


path: _meta.json
content: {
  "name": "clawcheck",
  "version": "2.0.0",
  "description": "Two-phase audit: fast deterministic scan + LLM deep quality review for security, cron, config, and skills",
  "author": "merlinrabens",
  "license": "MIT",
  "tags": ["health", "security", "audit", "cron", "config", "skills", "quality"],
  "openclaw": {
    "emoji": "🩺",
    "requires": {
      "bins": ["python3"]
    }
  }
}


path: references/remediation.md
content: # Remediation Guide

Fix patterns for every finding type, verified against OpenClaw v2026.3.x docs.

## Security Findings

### Inline secrets in env.vars

**Problem:** API keys stored as plaintext strings in `openclaw.json` under `env.vars`.

**Fix:** Replace with 1Password SecretRefs. First, add a secrets provider:

```json
"secrets": {
  "providers": {
    "op-gamma": {
      "source": "exec",
      "command": "/usr/local/bin/op",
      "args": ["read", "op://VaultName/ItemName/credential"],
      "passEnv": ["HOME", "OP_SERVICE_ACCOUNT_TOKEN"]
    }
  }
}
```

Then reference it in env.vars:
```json
"env": {
  "vars": {
    "GAMMA_API_KEY": {"source": "exec", "provider": "op-gamma", "id": "value"}
  }
}
```

### Plaintext bot token

**Problem:** `channels.telegram.botToken` is a raw string.

**Fix:**
```json
"channels": {
  "telegram": {
    "botToken": {"source": "exec", "provider": "op-telegram", "id": "value"}
  }
}
```

### Plaintext gateway password

**Problem:** `gateway.auth.password` is a raw string.

**Fix:**
```json
"gateway": {
  "auth": {
    "mode": "password",
    "password": {"source": "exec", "provider": "op-gateway", "id": "value"}
  }
}
```

### Unprotected .env files

**Problem:** `.env` files exist but `.gitignore` doesn't cover them.

**Fix:** Add to `.gitignore`:
```
.env
.env.*
*.env
```

---

## Cron Findings

### Jobs in error state

**Problem:** `state.lastStatus == "error"` on enabled jobs.

**Fix:** Check the job's last run output. Common causes:
- Skill dependency missing (check `requires.bins`)
- API key expired or missing
- Prompt referencing files that moved

Run the job manually to debug: trigger it via the OpenClaw dashboard or `openclaw cron run <job-id>`.

### Stale jobs

**Problem:** Enabled job hasn't run in 48+ hours (or 10+ days for weekly).

**Fix:** Check if:
- `cron.enabled` is `true` in `openclaw.json`
- The gateway is running (`openclaw gateway status`)
- The schedule expression is valid
- The job isn't blocked by `maxConcurrentRuns`

### Missing timezone

**Problem:** `schedule.tz` not set. Defaults to UTC, which shifts with DST.

**Fix:** Add timezone to each job's schedule:
```json
"schedule": {
  "kind": "cron",
  "expr": "0 9 * * *",
  "tz": "Europe/Madrid"
}
```

### Schedule conflicts

**Problem:** Multiple jobs share the same cron expression.

**Fix:** Stagger by 5-10 minutes:
```
Job A: "0 9 * * *"   ->  "0 9 * * *"
Job B: "0 9 * * *"   ->  "10 9 * * *"
```

### Missing timeout

**Problem:** `payload.timeoutSeconds` not set. Runaway jobs block subsequent runs.

**Fix:** Add a timeout to the payload:
```json
"payload": {
  "kind": "agentTurn",
  "message": "...",
  "timeoutSeconds": 300
}
```

---

## Config Findings

### No heartbeat

**Problem:** `agents.defaults.heartbeat` not configured.

**Fix:**
```json
"agents": {
  "defaults": {
    "heartbeat": {
      "every": "1h",
      "model": "sonnet",
      "prompt": "HEARTBEAT: Quick check. Report CRITICAL issues only."
    }
  }
}
```

### No model fallbacks

**Problem:** `agents.defaults.model.fallbacks` empty.

**Fix:**
```json
"agents": {
  "defaults": {
    "model": {
      "primary": "anthropic/claude-opus-4-6",
      "fallbacks": ["anthropic/claude-sonnet-4-6"]
    }
  }
}
```

### Expensive subagent model

**Problem:** No `subagents.model` set; subagents use the primary (often opus).

**Fix:**
```json
"agents": {
  "defaults": {
    "subagents": {
      "model": "sonnet",
      "maxConcurrent": 2
    }
  }
}
```

### No session maintenance

**Problem:** Old sessions accumulate without cleanup.

**Fix:**
```json
"session": {
  "maintenance": {
    "mode": "enforce",
    "pruneAfter": "7d",
    "maxEntries": 50,
    "rotateBytes": "8mb",
    "maxDiskBytes": "250mb"
  }
}
```

### No compaction

**Problem:** No context window management. Long sessions degrade quality.

**Fix:**
```json
"agents": {
  "defaults": {
    "compaction": {
      "mode": "safeguard",
      "reserveTokens": 150000,
      "keepRecentTokens": 120000
    }
  }
}
```

### No context pruning

**Problem:** Large tool results stay in context forever.

**Fix:**
```json
"agents": {
  "defaults": {
    "contextPruning": {
      "mode": "cache-ttl",
      "ttl": "4h",
      "softTrim": {"maxChars": 12000},
      "hardClear": {"enabled": true}
    }
  }
}
```

---

## Skill Quality Findings

### Missing frontmatter

Every SKILL.md must start with YAML frontmatter:
```yaml
---
name: my-skill
description: One-line description (50-200 chars) shown to the agent
---
```

### Missing metadata.openclaw

Add gating metadata as a single-line JSON in frontmatter:
```yaml
metadata: {"openclaw":{"emoji":"🔧","requires":{"bins":["python3"],"env":["MY_API_KEY"]}}}
```

### Required binary not found

If `metadata.openclaw.requires.bins` lists a binary that isn't installed, the skill won't be eligible. Install the binary or remove it from requirements.

### Scripts not executable

Run:
```bash
chmod +x skills/my-skill/scripts/*.py
chmod +x skills/my-skill/scripts/*.sh
```

### Low completeness score

Add to your SKILL.md:
- Code examples (```bash or ```json blocks)
- Command documentation
- Output format description
- Error handling guidance ("if X fails, do Y")
- Scope definition ("When to use" / "When NOT to use")

### Low clarity score

- Replace "you might want to" with "Do X"
- Add numbered steps for multi-step workflows
- Use bullet lists for options
- Add section headers (## H2) for logical grouping


path: scripts/audit.py
content: #!/usr/bin/env python3
"""
openclaw-doctor: Comprehensive OpenClaw installation audit.

Four modules:
  --security  Secrets exposure, token hygiene, SecretRef usage
  --cron      Cron operational health (errors, staleness, conflicts)
  --config    Config optimization (heartbeat, models, maintenance)
  --skills    Skill structural quality + heuristic scoring

No flags = run all four.

Outputs JSON. Zero external dependencies (stdlib only).
"""

import argparse
import json
import os
import re
import shutil
import stat
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def find_openclaw_dir():
    env_dir = os.environ.get("OPENCLAW_DIR") or os.environ.get("OPENCLAW_STATE_DIR")
    if env_dir:
        p = Path(env_dir)
        return p if p.is_dir() else None
    for d in [Path.home() / ".openclaw", Path.home() / "openclaw",
              Path.home() / ".config" / "openclaw"]:
        if d.is_dir():
            return d
    return None


def load_json(path):
    try:
        return json.loads(Path(path).read_text()), None
    except (json.JSONDecodeError, OSError) as e:
        return None, str(e)


def is_secret_ref(value):
    """A SecretRef is a dict with 'source' key (exec, env, etc.)."""
    return isinstance(value, dict) and "source" in value


def finding(severity, category, message, fix=None):
    f = {"severity": severity, "category": category, "message": message}
    if fix:
        f["fix"] = fix
    return f


def deep_get(d, *keys, default=None):
    for k in keys:
        if isinstance(d, dict):
            d = d.get(k, default)
        else:
            return default
    return d


# ---------------------------------------------------------------------------
# Module 1: Security Audit
# ---------------------------------------------------------------------------

def audit_security(oc_dir, config):
    findings = []
    score = 100

    # 1. Inline secrets in env.vars
    env_vars = deep_get(config, "env", "vars", default={})
    secret_keys = []
    for key, val in env_vars.items():
        if isinstance(val, str) and len(val) > 10:
            kl = key.lower()
            if any(t in kl for t in ("key", "token", "secret", "password", "private")):
                secret_keys.append(key)
    if secret_keys:
        score -= 15
        findings.append(finding(
            "critical", "security",
            f"Inline secrets in env.vars: {', '.join(secret_keys[:8])}{'...' if len(secret_keys) > 8 else ''}",
            "Move to 1Password SecretRefs or environment variables"
        ))

    # 2. Plaintext bot tokens in channels
    channels = config.get("channels", {})
    for ch_name, ch_conf in channels.items():
        if not isinstance(ch_conf, dict):
            continue
        [REDACTED_SECRET]"botToken")
        if isinstance(token, str) and len(token) > 20:
            score -= 10
            findings.append(finding(
                "warning", "security",
                f"Plaintext bot token in channels.{ch_name}.botToken",
                'Use SecretRef: {"source": "exec", "provider": "op-telegram", "id": "value"}'
            ))

    # 3. Plaintext gateway password
    gw_auth = deep_get(config, "gateway", "auth", "password", default=None)
    if isinstance(gw_auth, str) and len(gw_auth) > 5:
        score -= 10
        findings.append(finding(
            "warning", "security",
            "Plaintext password in gateway.auth.password",
            "Use SecretRef for gateway auth password"
        ))

    # 4. Skills with inline apiKey strings
    entries = deep_get(config, "skills", "entries", default={})
    inline_skill_keys = [name for name, conf in entries.items()
                         if isinstance(conf, dict) and isinstance(conf.get("apiKey"), str)]
    if inline_skill_keys:
        score -= 5
        findings.append(finding(
            "info", "security",
            f"Inline apiKey strings in skills.entries: {', '.join(inline_skill_keys[:5])}",
            "Use SecretRef objects instead of plaintext strings"
        ))

    # 5. .env files without .gitignore
    env_files = list(oc_dir.rglob(".env"))
    if env_files:
        gitignore = oc_dir / ".gitignore"
        protected = False
        if gitignore.is_file():
            try:
                protected = ".env" in gitignore.read_text()
            except OSError:
                pass
        if not protected:
            score -= 5
            findings.append(finding(
                "warning", "security",
                f"{len(env_files)} .env file(s) found without .gitignore protection",
                "Add '.env' to .gitignore"
            ))

    # 6. Secrets in workspace files (markdown, scripts, JSON, YAML)
    secret_patterns = [
        (r'sk-[A-Za-z0-9]{20,}', "OpenAI-style API key (sk-...)"),
        (r'sk-proj-[A-Za-z0-9_-]{20,}', "OpenAI project key"),
        (r'ghp_[A-Za-z0-9]{36,}', "GitHub personal token (ghp_)"),
        (r'ghs_[A-Za-z0-9]{36,}', "GitHub server token (ghs_)"),
        (r'xoxb-[0-9]+-[A-Za-z0-9]+', "Slack bot token (xoxb-)"),
        (r'AIzaSy[A-Za-z0-9_-]{33}', "Google API key"),
        (r'pplx-[A-Za-z0-9]{40,}', "Perplexity API key"),
        (r'pcp_[a-f0-9]{40,}', "Paperclip API key"),
        (r'fc-[a-f0-9]{30,}', "Firecrawl API key"),
        (r'ops_ey[A-Za-z0-9+/=]{50,}', "1Password service account token"),
        (r'(?i)(?:private.?key|secret|signing)["\s:=]+0x[a-f0-9]{64}', "Private key (hex)"),
        (r'(?i)Bearer\s+[A-Za-z0-9._~+/=-]{20,}', "Bearer token"),
    ]
    workspace_dir = oc_dir / "workspace"
    skip_dirs = {".git", "node_modules", "venv", "__pycache__", "archive", "browser"}
    scan_exts = {".md", ".py", ".sh", ".json", ".yaml", ".yml", ".txt", ".env", ".toml"}
    workspace_secrets = []  # (file, pattern_label)

    if workspace_dir.is_dir():
        files_scanned = 0
        for root, dirs, files in os.walk(str(workspace_dir)):
            dirs[:] = [d for d in dirs if d not in skip_dirs]
            for fname in files:
                fp = Path(root) / fname
                if fp.suffix not in scan_exts:
                    continue
                if files_scanned >= 500:
                    break
                files_scanned += 1
                try:
                    content = fp.read_text(errors="replace")[:16384]
                except OSError:
                    continue
                for pattern, label in secret_patterns:
                    if re.search(pattern, content):
                        rel = str(fp.relative_to(oc_dir))
                        workspace_secrets.append((rel, label))
                        break  # one finding per file

    if workspace_secrets:
        score -= min(15, 3 * len(workspace_secrets))
        # Dedupe by label for cleaner output
        by_label = defaultdict(list)
        for path, label in workspace_secrets:
            by_label[label].append(path)
        for label, paths in list(by_label.items())[:5]:
            findings.append(finding(
                "critical", "security",
                f"{label} in workspace: {', '.join(paths[:3])}{'...' if len(paths) > 3 else ''}",
                "Remove secrets from workspace files. Use SecretRefs or environment variables."
            ))

    if not findings:
        findings.append(finding("ok", "security", "No security issues found"))

    return max(0, score), findings


# ---------------------------------------------------------------------------
# Module 2: Cron Operations Audit
# ---------------------------------------------------------------------------

def audit_cron(oc_dir, config):
    findings = []
    score = 100

    jobs_file = oc_dir / "cron" / "jobs.json"
    if not jobs_file.is_file():
        findings.append(finding("info", "cron", "No cron/jobs.json found"))
        return score, findings

    data, err = load_json(jobs_file)
    if err:
        findings.append(finding("warning", "cron", f"Cannot parse cron/jobs.json: {err}"))
        return 80, findings

    jobs = data.get("jobs", []) if isinstance(data, dict) else []
    if not jobs:
        findings.append(finding("info", "cron", "No cron jobs defined"))
        return score, findings

    enabled = [j for j in jobs if j.get("enabled", True)]
    disabled = [j for j in jobs if not j.get("enabled", True)]
    findings.append(finding("ok", "cron",
                            f"{len(jobs)} jobs ({len(enabled)} enabled, {len(disabled)} disabled)"))

    now_ms = int(datetime.now().timestamp() * 1000)

    # Per-job checks
    error_jobs = []
    stale_jobs = []
    no_tz = []
    no_timeout = []
    no_isolated = []

    for job in enabled:
        name = job.get("name", "unnamed")
        state = job.get("state", {})
        sched = job.get("schedule", {})
        payload = job.get("payload", {})
        expr = sched.get("expr", "") if isinstance(sched, dict) else ""

        # Error state
        if state.get("lastStatus") == "error":
            error_jobs.append(name)

        # Consecutive errors
        consec = state.get("consecutiveErrors", 0)
        if isinstance(consec, int) and consec > 3:
            findings.append(finding("warning", "cron",
                                    f"'{name}' has {consec} consecutive errors"))
            score -= 5

        # Staleness
        last_run = state.get("lastRunAtMs", 0)
        if last_run > 0:
            age_h = (now_ms - last_run) / (1000 * 3600)
            # Weekly jobs get longer threshold
            is_weekly = bool(re.search(r'\b[0-6](,[0-6])*\b', expr.split()[-1])) if len(expr.split()) >= 5 else False
            threshold = 240 if is_weekly else 48
            if age_h > threshold:
                stale_jobs.append((name, int(age_h)))

        # Missing timezone
        if isinstance(sched, dict) and not sched.get("tz"):
            no_tz.append(name)

        # Missing timeout
        if isinstance(payload, dict) and not payload.get("timeoutSeconds"):
            no_timeout.append(name)

        # Missing sessionTarget
        if not job.get("sessionTarget"):
            no_isolated.append(name)

    if error_jobs:
        score -= 10
        findings.append(finding("warning", "cron",
                                f"Jobs in error state: {', '.join(error_jobs)}",
                                "Check job logs, trigger manual run, or review the job prompt for issues"))

    if stale_jobs:
        score -= 5
        for name, hours in stale_jobs[:5]:
            findings.append(finding("warning", "cron",
                                    f"'{name}' hasn't run in {hours}h"))

    # Schedule conflicts
    sched_groups = defaultdict(list)
    for job in enabled:
        sched = job.get("schedule", {})
        expr = sched.get("expr", "") if isinstance(sched, dict) else ""
        if expr:
            sched_groups[expr].append(job.get("name", "unnamed"))
    for expr, names in sched_groups.items():
        if len(names) > 1:
            score -= 2
            findings.append(finding("info", "cron",
                                    f"Schedule conflict '{expr}': {', '.join(names)}",
                                    "Stagger by 5-10 minutes"))

    if no_tz:
        score -= 3
        findings.append(finding("info", "cron",
                                f"{len(no_tz)} jobs without timezone (DST drift risk): "
                                f"{', '.join(no_tz[:5])}{'...' if len(no_tz) > 5 else ''}",
                                'Add "tz": "Your/Timezone" to schedule'))

    if len(no_timeout) > len(enabled) // 2:
        findings.append(finding("info", "cron",
                                f"{len(no_timeout)}/{len(enabled)} jobs without timeout",
                                "Add payload.timeoutSeconds to prevent runaway jobs"))

    # Concurrent pressure
    max_concurrent = deep_get(config, "cron", "maxConcurrentRuns", default=2)
    frequent = [j for j in enabled
                if "*/" in (j.get("schedule", {}).get("expr", "") if isinstance(j.get("schedule"), dict) else "")]
    if len(frequent) > max_concurrent:
        findings.append(finding("info", "cron",
                                f"{len(frequent)} frequent jobs but maxConcurrentRuns={max_concurrent}",
                                "Increase cron.maxConcurrentRuns or stagger jobs"))

    return max(0, score), findings


# ---------------------------------------------------------------------------
# Module 3: Config Optimization
# ---------------------------------------------------------------------------

def audit_config(config):
    findings = []
    score = 100

    defaults = deep_get(config, "agents", "defaults", default={})

    # Heartbeat
    if not defaults.get("heartbeat"):
        score -= 10
        findings.append(finding("warning", "config",
                                "No heartbeat at agents.defaults.heartbeat",
                                'Add heartbeat with "every", "model", and "prompt" fields'))

    # Model fallbacks
    fallbacks = deep_get(defaults, "model", "fallbacks", default=[])
    if not fallbacks:
        score -= 3
        findings.append(finding("info", "config",
                                "No model fallbacks at agents.defaults.model.fallbacks",
                                "Add fallback models for provider outage resilience"))

    # Subagent model
    sub_model = deep_get(defaults, "subagents", "model", default=None)
    primary = deep_get(defaults, "model", "primary", default="")
    if not sub_model and primary and "opus" in str(primary).lower():
        score -= 3
        findings.append(finding("info", "config",
                                "No subagent model set; subagents use expensive primary (opus)",
                                'Set agents.defaults.subagents.model to "sonnet" to save costs'))

    # Session maintenance
    if not deep_get(config, "session", "maintenance", default=None):
        score -= 3
        findings.append(finding("info", "config",
                                "No session maintenance configured",
                                "Add session.maintenance with pruneAfter, maxEntries, rotateBytes"))

    # Compaction
    if not defaults.get("compaction"):
        score -= 3
        findings.append(finding("info", "config",
                                "No compaction config at agents.defaults.compaction",
                                "Configure compaction for context window management"))

    # Context pruning
    if not defaults.get("contextPruning"):
        score -= 2
        findings.append(finding("info", "config",
                                "No context pruning at agents.defaults.contextPruning",
                                "Configure pruning to manage large tool results"))

    # Memory search
    mem = defaults.get("memorySearch", {})
    if isinstance(mem, dict) and not mem.get("enabled", False):
        findings.append(finding("info", "config",
                                "Memory search is disabled",
                                "Enable agents.defaults.memorySearch for cross-session recall"))

    # Cron enabled vs jobs exist
    cron_enabled = deep_get(config, "cron", "enabled", default=True)
    if not cron_enabled:
        findings.append(finding("warning", "config",
                                "Cron is disabled in config",
                                "Set cron.enabled: true if you have scheduled jobs"))

    if not findings:
        findings.append(finding("ok", "config", "Config looks well-optimized"))

    return max(0, score), findings


# ---------------------------------------------------------------------------
# Module 4: Skill Quality Scorer
# ---------------------------------------------------------------------------

def parse_frontmatter(content):
    """Extract YAML frontmatter from SKILL.md."""
    fm = {}
    if not content.startswith("---"):
        return fm
    end = content.find("---", 3)
    if end < 0:
        return fm
    block = content[3:end].strip()
    for line in block.split("\n"):
        line = line.strip()
        if ":" in line:
            key, _, val = line.partition(":")
            key = key.strip()
            val = val.strip().strip('"').strip("'")
            fm[key] = val
    return fm


def score_structure(content, fm, skill_dir):
    """Deterministic structural quality (1-5)."""
    score = 1
    issues = []

    # Required: name + description in frontmatter
    has_name = bool(fm.get("name"))
    has_desc = bool(fm.get("description"))

    if has_name and has_desc:
        score = 3
    elif has_name or has_desc:
        score = 2
        if not has_name:
            issues.append("Missing 'name' in frontmatter")
        if not has_desc:
            issues.append("Missing 'description' in frontmatter")
    else:
        issues.append("Missing frontmatter (name + description required)")
        return score, issues

    # Description length
    desc = fm.get("description", "")
    if len(desc) < 30:
        issues.append(f"Description too short ({len(desc)} chars, aim for 50-200)")
    elif len(desc) > 250:
        issues.append(f"Description too long ({len(desc)} chars, may get truncated)")

    # Metadata.openclaw
    metadata_str = fm.get("metadata", "")
    has_metadata = False
    if metadata_str:
        try:
            meta = json.loads(metadata_str)
            has_metadata = "openclaw" in meta
            # Verify declared bins exist
            bins = deep_get(meta, "openclaw", "requires", "bins", default=[])
            for b in bins:
                if not shutil.which(b):
                    score = max(1, score - 1)
                    issues.append(f"Required binary '{b}' not found on PATH")
        except (json.JSONDecodeError, TypeError):
            pass

    if has_metadata:
        score = min(5, score + 1)

    # Section headers
    sections = len(re.findall(r"^#{2,4}\s+", content, re.MULTILINE))
    if sections >= 3:
        score = min(5, score + 1)
    elif sections < 2:
        issues.append("Fewer than 2 section headers; add structure")

    # Scripts executability
    scripts_dir = skill_dir / "scripts"
    if scripts_dir.is_dir():
        for sf in scripts_dir.iterdir():
            if sf.is_file() and not sf.name.startswith("."):
                if not os.access(sf, os.X_OK):
                    issues.append(f"Script not executable: scripts/{sf.name}")

    return min(5, max(1, score)), issues


def score_completeness(content):
    """Heuristic content completeness (1-5)."""
    score = 1
    issues = []

    checks = {
        "examples": bool(re.search(r"(?i)(example|e\.g\.|for instance|usage|```)", content)),
        "commands": bool(re.search(r"(?i)(command|action|run |execute|invoke|```bash|```sh)", content)),
        "output": bool(re.search(r"(?i)(output|result|returns|response|format|```json)", content)),
        "setup": bool(re.search(r"(?i)(setup|install|prerequisite|require|depend|config)", content)),
        "errors": bool(re.search(r"(?i)(error|fail|fallback|missing|invalid|not found|recover)", content)),
        "scope": bool(re.search(r"(?i)(when to use|scope|purpose|this skill|does not|won't|boundary)", content)),
    }

    passed = sum(checks.values())
    score = min(5, 1 + passed)

    if not checks["examples"]:
        issues.append("Add examples or code blocks")
    if not checks["errors"]:
        issues.append("Add error handling guidance")
    if not checks["scope"]:
        issues.append("Define when to use this skill (scope/boundaries)")

    return score, issues


def score_clarity(content):
    """Heuristic clarity scoring (1-5)."""
    score = 2
    issues = []

    # Imperative language
    imperative = len(re.findall(
        r"(?im)^(do|run|check|use|add|create|set|ensure|verify|scan|read|write|"
        r"output|return|call|execute|send|log|update|remove|delete|skip|avoid|never|always)\b",
        content))

    # Hedging
    hedging = len(re.findall(
        r"(?i)(you might want to|you could|perhaps|maybe|consider|it.s possible)", content))

    # Structure
    has_lists = bool(re.search(r"^[\s]*[-*]\s", content, re.MULTILINE))
    has_numbered = bool(re.search(r"^[\s]*\d+\.\s", content, re.MULTILINE))
    sections = len(re.findall(r"^#{2,4}\s+", content, re.MULTILINE))

    if sections >= 3:
        score = 3
    if imperative >= 5:
        score = min(5, score + 1)
    if (has_lists or has_numbered) and hedging < 2:
        score = min(5, score + 1)
    if hedging >= 3:
        score = max(1, score - 1)
        issues.append(f"Reduce hedging language ({hedging} instances)")
    if imperative < 3:
        issues.append("Use more imperative verbs (Do X, Run Y, Check Z)")

    return min(5, max(1, score)), issues


def score_efficiency(content):
    """Heuristic efficiency scoring (1-5)."""
    score = 3
    issues = []
    words = len(content.split())

    # Token footprint
    if words > 5000:
        score = max(1, score - 1)
        issues.append(f"SKILL.md is {words} words; consider trimming")
    elif words < 800:
        score = min(5, score + 1)

    # Efficiency patterns
    eff_terms = ["cache", "lazy", "conditional", "skip if", "only when",
                 "batch", "minimize", "efficient", "structured output", "json"]
    eff_count = sum(1 for t in eff_terms if t in content.lower())
    if eff_count >= 2:
        score = min(5, score + 1)

    # Cross-skill references
    if re.search(r"(?i)(related skill|chain|pipe|combine with|see also|/\w+)", content):
        score = min(5, score + 1)

    return min(5, max(1, score)), issues


def audit_skills(oc_dir):
    findings = []
    score = 100

    # All skill locations
    locations = [
        ("workspace", oc_dir / "workspace" / "skills"),
        ("managed", oc_dir / "skills"),
        ("personal", Path.home() / ".agents" / "skills"),
    ]

    # Also scan bundled skills if accessible
    bundled_dir = Path("/usr/local/Cellar/node") / os.listdir("/usr/local/Cellar/node")[0] / "lib/node_modules/openclaw/skills" if Path("/usr/local/Cellar/node").is_dir() else None
    if bundled_dir and bundled_dir.is_dir():
        locations.append(("bundled", bundled_dir))

    all_skills = {}
    for source, sdir in locations:
        if not sdir.is_dir():
            continue
        for d in sdir.iterdir():
            if d.is_dir() and not d.name.startswith("_") and d.name not in all_skills:
                all_skills[d.name] = (d, source)

    if not all_skills:
        findings.append(finding("info", "skills", "No skills found"))
        return score, findings, []

    skill_results = []
    total_score = 0
    scored_count = 0

    for name, (skill_dir, source) in sorted(all_skills.items()):
        skill_md = skill_dir / "SKILL.md"

        if not skill_md.is_file():
            skill_results.append({
                "name": name, "source": source, "status": "broken",
                "scores": {}, "overall": 0,
                "top_issues": ["Missing SKILL.md"]
            })
            continue

        try:
            content = skill_md.read_text(errors="replace")
        except OSError:
            skill_results.append({
                "name": name, "source": source, "status": "error",
                "scores": {}, "overall": 0,
                "top_issues": ["Cannot read SKILL.md"]
            })
            continue

        if not content.strip():
            skill_results.append({
                "name": name, "source": source, "status": "empty",
                "scores": {}, "overall": 0,
                "top_issues": ["SKILL.md is empty"]
            })
            continue

        fm = parse_frontmatter(content)
        s_struct, i_struct = score_structure(content, fm, skill_dir)
        s_comp, i_comp = score_completeness(content)
        s_clar, i_clar = score_clarity(content)
        s_eff, i_eff = score_efficiency(content)

        # Weighted: structure 2x, completeness 1.5x, clarity 1x, efficiency 1x
        overall_raw = (s_struct * 2 + s_comp * 1.5 + s_clar + s_eff) / 5.5
        overall = round(overall_raw * 20)

        all_issues = i_struct + i_comp + i_clar + i_eff
        skill_results.append({
            "name": name,
            "source": source,
            "status": "scored",
            "scores": {
                "structure": s_struct,
                "completeness": s_comp,
                "clarity": s_clar,
                "efficiency": s_eff,
            },
            "overall": overall,
            "top_issues": all_issues[:5],
        })
        total_score += overall
        scored_count += 1

    broken = sum(1 for s in skill_results if s["status"] in ("broken", "empty"))
    avg = round(total_score / scored_count) if scored_count else 0

    # Convert average skill score to section score
    section_score = min(100, avg + 10) if avg > 0 else 50
    if broken > 5:
        section_score -= 10

    findings.append(finding("ok", "skills",
                            f"{scored_count} skills scored, {broken} broken, avg={avg}/100"))

    if broken > 0:
        broken_names = [s["name"] for s in skill_results if s["status"] in ("broken", "empty")]
        findings.append(finding("warning", "skills",
                                f"Broken skills (missing/empty SKILL.md): {', '.join(broken_names[:10])}",
                                "Add a SKILL.md with frontmatter (name + description) or remove the skill directory"))

    low_skills = [s for s in skill_results if s["status"] == "scored" and s["overall"] < 50]
    if low_skills:
        names = ", ".join(s["name"] for s in low_skills[:5])
        findings.append(finding("info", "skills",
                                f"{len(low_skills)} skills scored below 50: {names}{'...' if len(low_skills) > 5 else ''}"))

    return max(0, section_score), findings, skill_results


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="OpenClaw Doctor: security, cron, config, and skill quality audits")
    parser.add_argument("--security", action="store_true", help="Security audit only")
    parser.add_argument("--cron", action="store_true", help="Cron operations audit only")
    parser.add_argument("--config", action="store_true", help="Config optimization audit only")
    parser.add_argument("--skills", action="store_true", help="Skill quality scoring only")
    parser.add_argument("--all", action="store_true", help="Run all audits (default)")
    args = parser.parse_args()

    run_all = not (args.security or args.cron or args.config or args.skills) or args.all

    oc_dir = find_openclaw_dir()
    if not oc_dir:
        print(json.dumps({
            "score": 0, "status": "error",
            "message": "OpenClaw installation not found. Set OPENCLAW_DIR to override.",
        }, indent=2))
        sys.exit(1)

    config, err = load_json(oc_dir / "openclaw.json")
    if err:
        print(json.dumps({
            "score": 0, "status": "error",
            "message": f"Cannot load openclaw.json: {err}",
        }, indent=2))
        sys.exit(1)

    sections = {}
    all_findings = []
    skill_details = []

    if run_all or args.security:
        s, f = audit_security(oc_dir, config)
        sections["security"] = {"score": s, "findings": f}
        all_findings.extend(f)

    if run_all or args.cron:
        s, f = audit_cron(oc_dir, config)
        sections["cron"] = {"score": s, "findings": f}
        all_findings.extend(f)

    if run_all or args.config:
        s, f = audit_config(config)
        sections["config"] = {"score": s, "findings": f}
        all_findings.extend(f)

    if run_all or args.skills:
        s, f, details = audit_skills(oc_dir)
        sections["skills"] = {"score": s, "findings": f, "skills": details}
        all_findings.extend(f)
        skill_details = details

    # Overall weighted score
    weights = {"security": 30, "cron": 25, "config": 20, "skills": 25}
    total_w = sum(weights[k] for k in sections)
    overall = round(sum(sections[k]["score"] * weights[k] for k in sections) / total_w) if total_w else 0

    result = {
        "score": overall,
        "score_type": "structural_hygiene",
        "score_note": "Measures structural correctness and hygiene, not content quality. Use compound-learning or LLM-based review for deep quality audits.",
        "status": "healthy" if overall >= 80 else "needs_attention" if overall >= 50 else "needs_work",
        "openclaw_dir": str(oc_dir),
        "checked_at": datetime.now().isoformat(),
        "sections": {k: {"score": v["score"], "finding_count": len(v["findings"])} for k, v in sections.items()},
        "summary": {
            "critical": sum(1 for f in all_findings if f["severity"] == "critical"),
            "warnings": sum(1 for f in all_findings if f["severity"] == "warning"),
            "info": sum(1 for f in all_findings if f["severity"] == "info"),
        },
        "findings": [f for f in all_findings if f["severity"] != "ok"],
    }

    if skill_details:
        scored = [s for s in skill_details if s["status"] == "scored"]
        result["skills_summary"] = {
            "total": len(skill_details),
            "scored": len(scored),
            "broken": sum(1 for s in skill_details if s["status"] in ("broken", "empty")),
            "average": round(sum(s["overall"] for s in scored) / len(scored)) if scored else 0,
            "top_5": sorted(scored, key=lambda x: -x["overall"])[:5],
            "bottom_5": sorted(scored, key=lambda x: x["overall"])[:5],
        }

    print(json.dumps(result, indent=2))
    sys.exit(0 if overall >= 50 else 1)


if __name__ == "__main__":
    main()


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