CCGM Doctor

workflow no always-loaded rules -- loads on demand updated 2026-08-04

Audit tool for Claude Code installs. Three subcommands: check-resolvable (hook refs, command descriptions, script refs), dry (lexical overlap between command descriptions), and resolver-eval (runs a routing suite of {intent, expected} assertions against keyword-overlap scoring). Ships a default routing suite covering common slash commands.

Tags

  • audit
  • health-check
  • resolver
  • reachability
  • evals

README

ccgm-doctor

Audit tool for Claude Code installs. Reports dark or broken entries so they can be fixed before a user hits them.

What It Does

Installs a ccgm-doctor CLI. Subcommands:

  • check-resolvable — reachability audit (hook refs, command descriptions, script refs)
  • dry — DRY/overlap audit (pairs of commands whose triggers are lexically similar)
  • resolver-eval — run a routing suite of {intent, expected} assertions

check-resolvable

Walks a Claude install and reports three classes of issue:

Check Severity What it catches
hook-refs error settings.json hook command points at a file that does not exist
command-descriptions warn command .md has no frontmatter description: and no first-line heading (the model will not reliably discover it)
script-refs error a command's bash fenced block invokes a ccgm-* script that does not exist on PATH or in {claude_dir}/bin

Each finding includes: check name, severity, path, and a one-line detail.

dry

Compares every pair of command trigger descriptions using Jaccard similarity over content tokens (stopwords and short tokens filtered out). Pairs above the threshold are flagged as likely ambiguous routing candidates.

Check Severity What it catches
dry-overlap warn two commands whose trigger descriptions share > threshold tokens (default 0.5)

Lexical overlap is a conservative signal — it catches copy-paste descriptions and near-duplicates, not semantic synonyms. For semantic routing analysis, use resolver-eval (below) — it is better suited to paraphrase-style overlap.

resolver-eval

Runs a suite of {intent, expected} assertions against the commands dir. For each intent, a keyword-overlap scorer ranks candidate commands by Jaccard similarity between intent tokens and (description + filename stem) tokens. Passes if the expected command appears in the top k candidates.

[PASS] stage all my changes and commit them
       expected: commit
       top:      commit(0.50)
[FAIL] debug this failing test
       expected: debug
       top:      user-test(0.12)

Suite format (JSON array):

[
  {"intent": "stage all my changes and commit them", "expected": "commit"},
  {"intent": "review this pull request",             "expected": "review"}
]

The module ships evals/routing.json as a default suite covering ~18 common intents. Extend it by pointing at your own file with --suite.

Usage

# Reachability audit
ccgm-doctor check-resolvable
ccgm-doctor check-resolvable --claude-dir /path/to/.claude
ccgm-doctor check-resolvable --json

# DRY audit
ccgm-doctor dry
ccgm-doctor dry --threshold 0.3          # flag more overlapping pairs
ccgm-doctor dry --threshold 0.8          # only flag near-duplicates
ccgm-doctor dry --json

# Routing assertions
ccgm-doctor resolver-eval                         # uses default bundled suite
ccgm-doctor resolver-eval --suite my-evals.json   # your own suite
ccgm-doctor resolver-eval --top-k 3               # pass if expected is in top 3
ccgm-doctor resolver-eval --json

Exit codes:

  • 0 — no issues
  • 1 — issues found (see output)
  • 2 — environment error (e.g., --claude-dir does not exist)

Design Notes

The checks are pure functions of the filesystem. They run in milliseconds and require no model calls. This is intentional: the point of this tool is to catch deterministic reachability problems before they become silent drift. A model-backed check would be appropriate for ambiguous trigger-overlap problems (see #385 DRY audit and #386 resolver evals).

What's covered

  • hook-refs: parses settings.json, walks every hooks[event][].hooks[].command, extracts path-like tokens, expands $HOME/~, checks existence.
  • command-descriptions: prefers YAML frontmatter description: (how Claude Code advertises commands). Falls back to first-line Markdown heading for CCGM-style commands. Flags absent or suspiciously short (< 10 chars).
  • script-refs: only scans fenced bash/sh blocks, not prose, so directory names like ~/code/ccgm-repos/ccgm-1 do not false-flag. Checks PATH + {claude_dir}/bin for executability.

What's not covered (yet)

  • Orphan detection — "script in bin/ that no command or hook references". CCGM does not track install provenance, so detecting orphans from source-module removal is non-trivial. Deferred.
  • Model-backed routing — the current resolver-eval is a structural scorer. The model may choose differently, especially on paraphrases or short intents. A future enhancement can invoke claude -p or the API to ask the model directly and compare — the eval file format and pass contract stay the same.

Manual Installation

mkdir -p ~/.claude/bin
cp bin/ccgm-doctor ~/.claude/bin/ccgm-doctor
chmod +x ~/.claude/bin/ccgm-doctor

mkdir -p ~/.claude/lib
cp lib/doctor.py ~/.claude/lib/doctor.py

mkdir -p ~/.claude/evals
cp evals/routing.json ~/.claude/evals/routing.json

ccgm-doctor expects doctor.py to sit in ../lib/ relative to the executable. If you install it elsewhere, adjust sys.path accordingly or run via python3 -m doctor with the lib dir on your PYTHONPATH.

Files

File Description
bin/ccgm-doctor Python CLI that dispatches subcommands
lib/doctor.py Pure check functions — take paths, return findings
evals/routing.json Default routing suite for resolver-eval
tests/test_doctor.py 47 unit tests covering all checks with tempdir fixtures

Will install

Path Action Target Type
bin/ccgm-doctor bin/ccgm-doctor script
lib/doctor.py lib/doctor.py lib
evals/routing.json evals/routing.json doc

Dependencies

No dependencies.

Required by

No other module depends on this one.

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/ccgm-doctor.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install ccgm-doctor@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

lib (1)

lib/doctor.py

"""
ccgm-doctor checks: pure functions that take paths and return findings.

A finding is a dict:
    {"check": str, "severity": "warn"|"error", "path": str, "detail": str}

The CLI in bin/ccgm-doctor composes these into a report. Checks are pure so
they can be tested in isolation with tempdir fixtures (see tests/test_doctor.py).
"""

from __future__ import annotations

import json
import os
import re
import shlex
from pathlib import Path
from typing import Iterable


Finding = dict

# Paths like $HOME/.claude/hooks/foo.py, ~/.claude/hooks/foo.py, or absolute paths.
_PATH_TOKEN_RE = re.compile(r'(?:\$HOME|~|/)[^\s"\';]+')

# Fenced bash block: the only context where `ccgm-*` tokens are treated as
# script invocations. Scanning outside bash blocks picks up directory names
# like `ccgm-repos/` and false-flags them.
_BASH_BLOCK_RE = re.compile(r'```(?:bash|sh|shell)\n(.*?)```', re.DOTALL)

# Inside a bash block, a `ccgm-*` token preceded by a non-path character and
# followed by whitespace, end-of-line, or shell punctuation is a script
# invocation. The lookbehind excludes `/` and `-` so path segments like
# `code/ccgm-repos/ccgm-1` stay filtered.
_SCRIPT_REF_RE = re.compile(
    r'(?:^|(?<=[\s`;|&()$]))(ccgm-[a-z0-9][a-z0-9-]*)(?=[\s;|&()<>]|$)',
    re.MULTILINE,
)


def expand_path(raw: str, home: Path) -> Path:
    """
    Expand $HOME, ${HOME}, and leading ~ to the given `home`. Relative paths
    become absolute against `home`.

    `home` here is the user's actual HOME dir (e.g. /Users/foo), NOT the
    Claude install dir (~/.claude). Hook commands in settings.json
    reference paths via $HOME and expect it to expand to the OS HOME.
    """
    expanded = raw.replace("$HOME", str(home)).replace("${HOME}", str(home))
    if expanded.startswith("~"):
        expanded = str(home) + expanded[1:]
    p = Path(expanded)
    if not p.is_absolute():
        p = home / p
    return p


def _iter_hook_commands(settings: dict) -> Iterable[tuple[str, str]]:
    """Yield (event_name, command_string) for every hook entry in settings.json."""
    hooks = settings.get("hooks") or {}
    if not isinstance(hooks, dict):
        return
    for event, entries in hooks.items():
        if not isinstance(entries, list):
            continue
        for entry in entries:
            inner = entry.get("hooks") if isinstance(entry, dict) else None
            if not isinstance(inner, list):
                continue
            for hook in inner:
                if not isinstance(hook, dict):
                    continue
                cmd = hook.get("command")
                if isinstance(cmd, str) and cmd.strip():
                    yield event, cmd


def check_hook_refs(settings_path: Path, user_home: Path) -> list[Finding]:
    """
    Verify every hook command references an existing file.

    `user_home` is the user's OS home (for $HOME / ~ expansion). The claude
    install dir usually lives AT `user_home/.claude` and its own settings.json
    references paths back through $HOME.
    """
    findings: list[Finding] = []
    if not settings_path.exists():
        return findings

    try:
        settings = json.loads(settings_path.read_text())
    except json.JSONDecodeError as e:
        findings.append({
            "check": "hook-refs",
            "severity": "error",
            "path": str(settings_path),
            "detail": f"settings.json is not valid JSON: {e}",
        })
        return findings

    for event, cmd in _iter_hook_commands(settings):
        # Extract path-like tokens. A hook command typically has one file
        # path; we check every path-like token to be safe.
        paths = _PATH_TOKEN_RE.findall(cmd)
        if not paths:
            continue
        for raw in paths:
            # Strip any trailing shell-like characters that slipped into the regex match.
            raw = raw.rstrip(".,;)")
            resolved = expand_path(raw, user_home)
            if not resolved.exists():
                findings.append({
                    "check": "hook-refs",
                    "severity": "error",
                    "path": str(resolved),
                    "detail": f"{event} hook references missing file: {cmd.strip()}",
                })
    return findings


_FRONTMATTER_DESC_RE = re.compile(r'^description:\s*(.+?)\s*$', re.MULTILINE)


def _extract_trigger_description(text: str) -> str | None:
    """
    Return the text the model will use to decide whether to reach for this
    command. Two valid sources, in priority order:

        1. YAML frontmatter `description:` field (how Claude Code advertises
           commands in the slash-command picker).
        2. First-line Markdown heading (used by CCGM's hand-written commands
           that do not have frontmatter).

    Returns None when neither is present.
    """
    stripped = text.lstrip("\n")
    if stripped.startswith("---\n"):
        # Find the closing --- on its own line.
        end_idx = stripped.find("\n---", 4)
        if end_idx != -1:
            frontmatter = stripped[4:end_idx]
            m = _FRONTMATTER_DESC_RE.search(frontmatter)
            if m:
                desc = m.group(1).strip().strip('"').strip("'")
                return desc if desc else None

    first_line = next((ln for ln in text.splitlines() if ln.strip()), "")
    if first_line.startswith("#"):
        heading = first_line.lstrip("#").strip()
        return heading if heading else None

    return None


def check_command_descriptions(commands_dir: Path) -> list[Finding]:
    """
    Every command file should have a discoverable trigger description so the
    model knows when to reach for it. Accepts either a YAML frontmatter
    `description:` field or a first-line Markdown heading. Flag files with
    neither, or whose description is suspiciously short.
    """
    findings: list[Finding] = []
    if not commands_dir.is_dir():
        return findings

    for md in sorted(commands_dir.glob("*.md")):
        try:
            text = md.read_text()
        except OSError as e:
            findings.append({
                "check": "command-descriptions",
                "severity": "error",
                "path": str(md),
                "detail": f"cannot read command file: {e}",
            })
            continue

        desc = _extract_trigger_description(text)

        if desc is None:
            findings.append({
                "check": "command-descriptions",
                "severity": "warn",
                "path": str(md),
                "detail": "no frontmatter description or first-line heading; model may not discover its trigger",
            })
            continue

        if len(desc) < 10:
            findings.append({
                "check": "command-descriptions",
                "severity": "warn",
                "path": str(md),
                "detail": f"description is very short ('{desc}'); model may not discover its trigger",
            })
    return findings


def check_script_refs(commands_dir: Path, claude_dir: Path) -> list[Finding]:
    """
    Command markdown that references a `ccgm-*` script should point at a
    script that actually exists in `{claude_dir}/bin` or on PATH.
    """
    findings: list[Finding] = []
    if not commands_dir.is_dir():
        return findings

    bin_dir = claude_dir / "bin"

    # PATH dirs (from env) plus the claude install's bin dir.
    path_dirs = [Path(p) for p in os.environ.get("PATH", "").split(":") if p]
    path_dirs.append(bin_dir)

    def script_exists(name: str) -> bool:
        for d in path_dirs:
            candidate = d / name
            if candidate.exists() and os.access(candidate, os.X_OK):
                return True
        return False

    for md in sorted(commands_dir.glob("*.md")):
        try:
            text = md.read_text()
        except OSError:
            continue
        # Only scan inside fenced bash blocks. Prose mentions and paths like
        # `~/code/ccgm-repos/` should not trigger this check.
        referenced: set[str] = set()
        for block in _BASH_BLOCK_RE.findall(text):
            referenced.update(_SCRIPT_REF_RE.findall(block))
        for name in sorted(referenced):
            if not script_exists(name):
                findings.append({
                    "check": "script-refs",
                    "severity": "error",
                    "path": str(md),
                    "detail": f"references missing script: {name}",
                })
    return findings


def run_all_checks(claude_dir: Path, user_home: Path | None = None) -> list[Finding]:
    """
    Run every check_resolvable check against a Claude install at `claude_dir`.

    `user_home` is the OS home for $HOME / ~ expansion in hook refs. Defaults
    to `claude_dir.parent` (since the install conventionally lives at
    `~/.claude`, its parent IS the user home).
    """
    if user_home is None:
        user_home = claude_dir.parent
    settings = claude_dir / "settings.json"
    commands = claude_dir / "commands"
    return (
        check_hook_refs(settings, user_home)
        + check_command_descriptions(commands)
        + check_script_refs(commands, claude_dir)
    )


# --- DRY / overlap audit ---

# Stopwords for command-trigger tokenization. Words too generic to signal
# command identity (the, a), common CLI/workflow verbs (run, use, add),
# and filler. Kept deliberately tight so meaningful nouns like "calendar",
# "commit", "review" stay in.
_STOPWORDS = frozenset({
    "the", "and", "any", "all", "are", "but", "can", "did", "does", "done",
    "during", "else", "for", "from", "get", "had", "has", "have", "here",
    "how", "its", "just", "make", "must", "new", "not", "one", "only",
    "should", "some", "that", "the", "then", "this", "those", "two", "use",
    "using", "via", "was", "were", "what", "when", "where", "why", "will",
    "with", "would", "you", "your", "set", "run", "task", "command",
    "commands", "skill", "workflow", "claude", "code", "agent", "before",
    "after", "because", "current", "previous", "next", "these",
})

_TOKEN_RE = re.compile(r'[a-z][a-z0-9]{2,}')


def _trigger_tokens(text: str) -> set[str]:
    """
    Extract the identity-bearing tokens of a command's trigger description.
    Lowercase, strip punctuation, drop stopwords and tokens shorter than 3.
    """
    desc = _extract_trigger_description(text) or ""
    return {tok for tok in _TOKEN_RE.findall(desc.lower()) if tok not in _STOPWORDS}


def _jaccard(a: set[str], b: set[str]) -> float:
    if not a or not b:
        return 0.0
    intersection = len(a & b)
    union = len(a | b)
    return intersection / union if union else 0.0


def check_dry_overlap(commands_dir: Path, threshold: float = 0.5) -> list[Finding]:
    """
    Flag pairs of commands whose trigger-description tokens overlap above
    `threshold` (Jaccard similarity). Catches ambiguous routing: two skills
    the model might plausibly pick for the same intent.

    Commands whose trigger description is empty (already flagged by
    check_command_descriptions) are skipped so this check stays orthogonal.
    """
    findings: list[Finding] = []
    if not commands_dir.is_dir():
        return findings

    commands: list[tuple[Path, set[str]]] = []
    for md in sorted(commands_dir.glob("*.md")):
        try:
            text = md.read_text()
        except OSError:
            continue
        tokens = _trigger_tokens(text)
        if tokens:
            commands.append((md, tokens))

    # Pairwise comparison. O(n^2) but n is small (dozens, not thousands).
    for i in range(len(commands)):
        for j in range(i + 1, len(commands)):
            path_a, tokens_a = commands[i]
            path_b, tokens_b = commands[j]
            sim = _jaccard(tokens_a, tokens_b)
            if sim >= threshold:
                shared = sorted(tokens_a & tokens_b)
                findings.append({
                    "check": "dry-overlap",
                    "severity": "warn",
                    "path": f"{path_a.name} <-> {path_b.name}",
                    "detail": (
                        f"Jaccard={sim:.2f} over {len(shared)} shared tokens "
                        f"({', '.join(shared[:5])}"
                        f"{'...' if len(shared) > 5 else ''}). "
                        "Review for merge/deprecate, or sharpen descriptions."
                    ),
                })
    return findings


# --- Resolver evals ---

def _tokenize_intent(text: str) -> set[str]:
    """Same tokenizer as _trigger_tokens but over arbitrary text, not a file."""
    return {tok for tok in _TOKEN_RE.findall(text.lower()) if tok not in _STOPWORDS}


def _command_tokens(path: Path, text: str) -> set[str]:
    """
    Tokens the scorer uses to rank a command against an intent: the trigger
    description PLUS the filename stem (since users often hit a command
    by name). Skill name tokens help disambiguate when descriptions are thin.
    """
    tokens = _trigger_tokens(text)
    stem_tokens = {tok for tok in _TOKEN_RE.findall(path.stem.lower()) if tok not in _STOPWORDS}
    return tokens | stem_tokens


def score_intent_against_commands(
    intent: str, commands_dir: Path
) -> list[tuple[str, float]]:
    """
    Rank every command in `commands_dir` against `intent` by Jaccard similarity
    of intent tokens against (description + filename stem) tokens.

    Returns a list of (command_name, score) pairs sorted high-to-low. Ties at
    score 0 are dropped (no evidence either way).
    """
    if not commands_dir.is_dir():
        return []

    intent_tokens = _tokenize_intent(intent)
    if not intent_tokens:
        return []

    scored: list[tuple[str, float]] = []
    for md in sorted(commands_dir.glob("*.md")):
        try:
            text = md.read_text()
        except OSError:
            continue
        cmd_tokens = _command_tokens(md, text)
        if not cmd_tokens:
            continue
        sim = _jaccard(intent_tokens, cmd_tokens)
        if sim > 0:
            scored.append((md.stem, sim))

    scored.sort(key=lambda pair: pair[1], reverse=True)
    return scored


def run_resolver_evals(
    suite: list[dict], commands_dir: Path, top_k: int = 1
) -> list[dict]:
    """
    Run every {intent, expected} entry in `suite` against the commands dir.

    Pass rule: `expected` is in the top `top_k` candidates returned by the
    scorer. Ties at the k-th position are counted as part of the top (a pass
    should not flip based on an arbitrary tiebreak in sorted()).

    Returns one result dict per entry:
        intent, expected, top_candidates (list of (name, score)), passed (bool)
    """
    results: list[dict] = []
    for entry in suite:
        intent = entry["intent"]
        expected = entry["expected"]
        ranked = score_intent_against_commands(intent, commands_dir)

        if ranked:
            cutoff_idx = min(top_k - 1, len(ranked) - 1)
            cutoff_score = ranked[cutoff_idx][1]
            # All candidates tied at or above the cutoff score.
            qualifying = [pair for pair in ranked if pair[1] >= cutoff_score]
            passed = any(name == expected for name, _ in qualifying)
            top_candidates = qualifying
        else:
            passed = False
            top_candidates = []

        results.append({
            "intent": intent,
            "expected": expected,
            "top_candidates": top_candidates,
            "passed": passed,
        })
    return results
script (1)

bin/ccgm-doctor

#!/usr/bin/env python3
"""
ccgm-doctor — audit a Claude Code install for reachability problems.

Subcommands:
    check-resolvable    Report dark/orphaned/broken entries in the install.
    dry                 Report pairs of commands whose trigger descriptions
                        overlap (ambiguous routing candidates).
    resolver-eval       Run a routing eval suite: does each intent resolve
                        to the expected skill by keyword-overlap scoring?

Usage:
    ccgm-doctor check-resolvable [--claude-dir PATH] [--json]
    ccgm-doctor dry [--claude-dir PATH] [--threshold N] [--json]
    ccgm-doctor resolver-eval [--claude-dir PATH] [--suite PATH] [--top-k N] [--json]

Exit codes:
    0   no issues
    1   issues found
    2   environment error (e.g., --claude-dir does not exist)
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE.parent / "lib"))

import doctor  # noqa: E402


def _format_findings(findings: list[dict], scope_label: str) -> None:
    if not findings:
        print(f"OK: no issues found in {scope_label}")
        return
    by_severity: dict[str, list[dict]] = {}
    for f in findings:
        by_severity.setdefault(f["severity"], []).append(f)
    for sev in ("error", "warn"):
        items = by_severity.get(sev, [])
        if not items:
            continue
        print(f"{sev.upper()} ({len(items)}):")
        for f in items:
            print(f"  [{f['check']}] {f['path']}")
            print(f"    {f['detail']}")
    print()
    print(f"Total: {len(findings)} issue(s) across {scope_label}")


def _resolve_claude_dir(raw: str) -> Path | None:
    path = Path(raw).expanduser().resolve()
    if not path.is_dir():
        print(f"error: --claude-dir does not exist: {path}", file=sys.stderr)
        return None
    return path


def _cmd_check_resolvable(args: argparse.Namespace) -> int:
    home = _resolve_claude_dir(args.claude_dir)
    if home is None:
        return 2

    findings = doctor.run_all_checks(home)

    if args.json:
        json.dump(findings, sys.stdout, indent=2)
        sys.stdout.write("\n")
    else:
        _format_findings(findings, str(home))

    return 1 if findings else 0


def _cmd_dry(args: argparse.Namespace) -> int:
    home = _resolve_claude_dir(args.claude_dir)
    if home is None:
        return 2

    commands_dir = home / "commands"
    findings = doctor.check_dry_overlap(commands_dir, threshold=args.threshold)

    if args.json:
        json.dump(findings, sys.stdout, indent=2)
        sys.stdout.write("\n")
    else:
        _format_findings(findings, f"{commands_dir} (threshold={args.threshold})")

    return 1 if findings else 0


def _cmd_resolver_check(args: argparse.Namespace) -> int:
    home = _resolve_claude_dir(args.claude_dir)
    if home is None:
        return 2

    suite_path = Path(args.suite).expanduser().resolve()
    if not suite_path.is_file():
        print(f"error: --suite does not exist: {suite_path}", file=sys.stderr)
        return 2

    try:
        suite = json.loads(suite_path.read_text())
    except json.JSONDecodeError as e:
        print(f"error: suite is not valid JSON: {e}", file=sys.stderr)
        return 2

    if not isinstance(suite, list) or not all(
        isinstance(e, dict) and "intent" in e and "expected" in e for e in suite
    ):
        print(
            "error: suite must be a JSON array of {\"intent\": str, \"expected\": str} entries",
            file=sys.stderr,
        )
        return 2

    commands_dir = home / "commands"
    results = doctor.run_resolver_evals(suite, commands_dir, top_k=args.top_k)

    passed = sum(1 for r in results if r["passed"])
    failed = len(results) - passed

    if args.json:
        json.dump(results, sys.stdout, indent=2)
        sys.stdout.write("\n")
    else:
        for r in results:
            mark = "PASS" if r["passed"] else "FAIL"
            cands = ", ".join(f"{n}({s:.2f})" for n, s in r["top_candidates"][:3])
            cands = cands or "(no matches)"
            print(f"  [{mark}] {r['intent']}")
            print(f"         expected: {r['expected']}")
            print(f"         top:      {cands}")
        print()
        print(f"Results: {passed} passed, {failed} failed (top_k={args.top_k})")

    return 1 if failed else 0


def main() -> int:
    parser = argparse.ArgumentParser(
        prog="ccgm-doctor",
        description="Audit a Claude Code install for reachability problems.",
    )
    subs = parser.add_subparsers(dest="subcommand", required=True)

    chk = subs.add_parser(
        "check-resolvable",
        help="Report dark/orphaned/broken entries in the install.",
    )
    chk.add_argument(
        "--claude-dir",
        default="~/.claude",
        help="Path to the Claude Code install dir (default: ~/.claude)",
    )
    chk.add_argument(
        "--json",
        action="store_true",
        help="Emit findings as a JSON array instead of a human-readable report.",
    )
    chk.set_defaults(func=_cmd_check_resolvable)

    dry = subs.add_parser(
        "dry",
        help="Report command pairs whose trigger descriptions overlap.",
    )
    dry.add_argument(
        "--claude-dir",
        default="~/.claude",
        help="Path to the Claude Code install dir (default: ~/.claude)",
    )
    dry.add_argument(
        "--threshold",
        type=float,
        default=0.5,
        help="Jaccard similarity threshold for flagging pairs (default: 0.5)",
    )
    dry.add_argument(
        "--json",
        action="store_true",
        help="Emit findings as a JSON array instead of a human-readable report.",
    )
    dry.set_defaults(func=_cmd_dry)

    rev = subs.add_parser(
        "resolver-eval",
        help="Run a routing suite: does each intent resolve to the expected skill?",
    )
    rev.add_argument(
        "--claude-dir",
        default="~/.claude",
        help="Path to the Claude Code install dir (default: ~/.claude)",
    )
    rev.add_argument(
        "--suite",
        default=str(_HERE.parent / "evals" / "routing.json"),
        help="Path to the JSON suite file (default: module's bundled routing.json)",
    )
    rev.add_argument(
        "--top-k",
        type=int,
        default=1,
        help="Pass if expected skill appears in the top-k scored candidates (default: 1)",
    )
    rev.add_argument(
        "--json",
        action="store_true",
        help="Emit results as a JSON array instead of a human-readable report.",
    )
    rev.set_defaults(func=_cmd_resolver_check)

    args = parser.parse_args()
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
doc (1)

evals/routing.json

[
  {"intent": "stage all my changes and commit them", "expected": "commit"},
  {"intent": "commit push and merge this branch", "expected": "cpm"},
  {"intent": "create a pull request for this work", "expected": "pr"},
  {"intent": "review this pull request", "expected": "review"},
  {"intent": "run a comprehensive security review", "expected": "security-review"},
  {"intent": "debug this failing test", "expected": "debug"},
  {"intent": "reflect on what I learned in this session", "expected": "reflect"},
  {"intent": "consolidate and clean up my learnings", "expected": "consolidate"},
  {"intent": "write a retrospective over the last week", "expected": "retro"},
  {"intent": "summarize where I left off at session start", "expected": "startup"},
  {"intent": "remember this capability and turn it into a permanent skill", "expected": "skillify"},
  {"intent": "do deep research across the web on a topic", "expected": "deepresearch"},
  {"intent": "audit this codebase for issues", "expected": "audit"},
  {"intent": "update the project documentation to match the code", "expected": "docupdate"},
  {"intent": "generate playwright tests for a feature", "expected": "e2e"},
  {"intent": "check the health of my claude install", "expected": "ccgm-sync"},
  {"intent": "brainstorm ideas for a new feature", "expected": "brainstorm"},
  {"intent": "submit this chrome extension to the store", "expected": "cws-submit"}
]