#!/usr/bin/env python3 """agent_rules_scan.py — do your coding agents' rule files agree with each other? If you run more than one coding agent (Claude Code, Codex, Cursor, Copilot, Gemini CLI, Windsurf, Cline ...), each one reads its own instruction file: CLAUDE.md, AGENTS.md, .cursor/rules, GEMINI.md and so on. This script finds every such file under a folder, counts them, and reports where two files that sit side by side in the same repo no longer say the same thing, plus instructions that look like opposites. What it does and does not do * Read-only. It opens files for reading and prints to your terminal. It writes nothing. * No network. Standard library only: argparse, os, re, sys, json, hashlib. Read it; it is one short file. * The default report contains NO paths, repo names or file contents, only counts, so it is safe to paste. * `--show` adds paths and the actual differing lines. That view is for your eyes; do not paste it. Usage python3 agent_rules_scan.py ~/code # paste-safe summary python3 agent_rules_scan.py ~/code --show # local detail: which repos, which lines python3 agent_rules_scan.py ~/code --json # the summary as JSON (still no paths) """ from __future__ import annotations import argparse import hashlib import json import os import re import sys from collections import Counter, defaultdict VERSION = "0.2" NAMES = {"CLAUDE.md", "AGENTS.md", "AGENT.md", "GEMINI.md", "QWEN.md", "CRUSH.md", "CONVENTIONS.md", "WORKFLOW.md", "copilot-instructions.md", ".cursorrules", ".windsurfrules", ".clinerules", ".goosehints", ".rules"} RULE_DIRS = (os.path.join(".cursor", "rules"), os.path.join(".windsurf", "rules"), ".clinerules", os.path.join(".github", "instructions")) SKIP_DIRS = {"node_modules", ".git", ".venv", "venv", "dist", "build", ".next", "target", "vendor", "__pycache__", ".worktrees", ".claude-worktrees", "_archive", ".archive", "Library", ".Trash", ".cache"} HOME_FILES = ("~/.claude/CLAUDE.md", "~/.codex/AGENTS.md", "~/.codex/instructions.md", "~/.gemini/GEMINI.md", "~/.config/opencode/AGENTS.md", "~/.cursor/rules", "~/.grok/GROK.md") # Groups where a repo should pick one. Two different picks in one repo's rule files is a likely conflict. CHOICES = { "package manager": ("pnpm", "yarn", "bun", "npm"), "python installer": ("uv", "poetry", "pipenv", "pip"), "test runner": ("vitest", "jest", "mocha"), } PICK = re.compile(r"\b(?:use|using|prefer|always use|run|install with|via)\s+`?(%s)\b", re.I) NEVER = re.compile(r"\b(?:never|do not|don't|must not|should not|avoid)\s+(?:ever\s+)?([a-z][a-z0-9 _./-]{6,60})", re.I) ALWAYS = re.compile(r"\b(?:always|must|make sure to|be sure to)\s+([a-z][a-z0-9 _./-]{6,60})", re.I) IMPORT = re.compile(r"^\s*@(?:\./)?([\w./-]*AGENTS?\.md)\s*$", re.I | re.M) STOP = {"the", "a", "an", "to", "and", "or", "any", "all", "of", "in", "on", "for", "with", "your", "you", "it", "is"} def read(path: str) -> str: try: with open(path, errors="ignore") as fh: return fh.read(400_000) except OSError: return "" def is_rule_file(dirpath: str, name: str) -> bool: if name in NAMES: return True norm = dirpath.replace("\\", "/") + "/" return name.endswith((".md", ".mdc")) and any(("/" + d.replace(os.sep, "/") + "/") in norm for d in RULE_DIRS) def rule_lines(text: str) -> int: n = 0 for line in text.splitlines(): s = line.strip() if s.startswith(("-", "*")) or (s[:1].isdigit() and "." in s[:4]): n += 1 return n def norm_lines(text: str) -> set[str]: return {" ".join(l.split()) for l in text.splitlines() if l.strip()} def key(phrase: str) -> str: words = [w for w in re.findall(r"[a-z0-9]+", phrase.lower()) if w not in STOP] return " ".join(words[:4]) def repo_root(path: str, stop: str) -> str: """Nearest ancestor holding a .git entry; else the folder itself.""" d = path while True: if os.path.exists(os.path.join(d, ".git")): return d parent = os.path.dirname(d) if parent == d or not d.startswith(stop): return path d = parent def scan(root: str) -> dict: root = os.path.abspath(os.path.expanduser(root)) files, by_name, by_dir, by_repo = [], Counter(), defaultdict(dict), defaultdict(list) for dp, dn, fn in os.walk(root): dn[:] = [d for d in dn if d not in SKIP_DIRS] for f in fn: if not is_rule_file(dp, f): continue p = os.path.join(dp, f) text = read(p) rec = {"path": p, "name": f, "link": os.path.islink(p), "text": text, "rules": rule_lines(text)} files.append(rec) by_name[f if f in NAMES else "(rules dir file)"] += 1 by_dir[dp][f] = rec by_repo[repo_root(dp, root)].append(rec) pairs = linked = identical = 0 drift = [] for d, recs in by_dir.items(): c, a = recs.get("CLAUDE.md"), recs.get("AGENTS.md") if not (c and a): continue pairs += 1 imports = bool(IMPORT.search(c["text"])) or bool(IMPORT.search(a["text"])) same_target = os.path.realpath(c["path"]) == os.path.realpath(a["path"]) if c["link"] or a["link"] or same_target or imports: linked += 1 continue la, lb = norm_lines(c["text"]), norm_lines(a["text"]) if la == lb: identical += 1 continue drift.append({"dir": d, "diff": len(la ^ lb), "only_claude": sorted(la - lb), "only_agents": sorted(lb - la)}) drift.sort(key=lambda x: -x["diff"]) conflicts = [] for repo, recs in by_repo.items(): if len(recs) < 2: continue for group, options in CHOICES.items(): pat = re.compile(PICK.pattern % "|".join(options), re.I) picks = defaultdict(set) for r in recs: for m in pat.finditer(r["text"]): picks[m.group(1).lower()].add(r["path"]) per_file = defaultdict(set) for option, paths in picks.items(): for path in paths: per_file[path].add(option) sets = list(per_file.values()) # a conflict needs two files whose picks do not overlap at all; one file naming both tools is a nuance if any(not (x & y) for i, x in enumerate(sets) for y in sets[i + 1:]): conflicts.append({"repo": repo, "kind": group, "detail": {k: sorted(v) for k, v in picks.items()}}) nev, alw = defaultdict(set), defaultdict(set) for r in recs: for m in NEVER.finditer(r["text"]): nev[key(m.group(1))].add(r["path"]) for m in ALWAYS.finditer(r["text"]): alw[key(m.group(1))].add(r["path"]) for k in set(nev) & set(alw): if len(k.split()) >= 3 and nev[k] != alw[k]: conflicts.append({"repo": repo, "kind": "always vs never", "detail": {"phrase": k, "never": sorted(nev[k]), "always": sorted(alw[k])}}) home = [h for h in HOME_FILES if os.path.exists(os.path.expanduser(h))] repos_multi = sum(1 for recs in by_repo.values() if len({r["name"] for r in recs}) > 1) summary = { "version": VERSION, "files": len(files), "rule_lines": sum(f["rules"] for f in files), "by_name": dict(by_name.most_common()), "repos_with_rule_files": len(by_repo), "repos_with_two_or_more_kinds": repos_multi, "claude_agents_pairs": pairs, "pairs_linked_or_imported": linked, "pairs_identical": identical, "pairs_diverged": len(drift), "pairs_diverged_over_10_lines": sum(1 for d in drift if d["diff"] > 10), "worst_divergence_lines": drift[0]["diff"] if drift else 0, "possible_conflicts": len(conflicts), "possible_conflicts_by_kind": dict(Counter(c["kind"] for c in conflicts)), "home_level_files": len(home), } summary["fingerprint"] = hashlib.sha256(json.dumps(summary, sort_keys=True).encode()).hexdigest()[:10] return {"summary": summary, "drift": drift, "conflicts": conflicts, "home": home, "root": root} def print_summary(s: dict) -> None: print(f"agent rules scan v{s['version']} · paste-safe summary (no paths, no file contents)") print(f" {s['files']} rule files · {s['rule_lines']} rule lines · {s['repos_with_rule_files']} repos · " f"{s['repos_with_two_or_more_kinds']} repos with two or more kinds of rule file") for k, v in s["by_name"].items(): print(f" {v:5d} {k}") print(f" CLAUDE.md + AGENTS.md in the same folder: {s['claude_agents_pairs']}") print(f" linked or imported (one source): {s['pairs_linked_or_imported']}") print(f" separate copies, identical: {s['pairs_identical']}") print(f" separate copies, diverged: {s['pairs_diverged']} " f"(over 10 lines: {s['pairs_diverged_over_10_lines']}, worst: {s['worst_divergence_lines']} lines)") kinds = ", ".join(f"{k}: {v}" for k, v in s["possible_conflicts_by_kind"].items()) or "none found" print(f" possible conflicting instructions inside one repo: {s['possible_conflicts']} ({kinds})") print(f" home-level instruction files: {s['home_level_files']}") print(f" fingerprint: {s['fingerprint']}") def print_detail(res: dict, top: int) -> None: home = os.path.expanduser("~") short = lambda p: p.replace(home, "~") print("\n--- local detail (contains paths and file contents; do not paste) ---") for d in res["drift"][:top]: print(f"\n{d['diff']} lines differ · {short(d['dir'])}") for label, lines in (("only in CLAUDE.md", d["only_claude"]), ("only in AGENTS.md", d["only_agents"])): for line in lines[:6]: print(f" {label}: {line[:140]}") if len(lines) > 6: print(f" {label}: ... and {len(lines) - 6} more") for c in res["conflicts"][:top * 2]: print(f"\npossible conflict ({c['kind']}) · {short(c['repo'])}") for k, v in c["detail"].items(): v = v if isinstance(v, str) else ", ".join(short(p) for p in v) print(f" {k}: {v}") if res["home"]: print("\nhome-level files: " + ", ".join(res["home"])) def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("root", nargs="?", default=".", help="folder that holds your repos (default: current folder)") ap.add_argument("--show", action="store_true", help="add local detail with paths and differing lines") ap.add_argument("--json", action="store_true", help="print the paste-safe summary as JSON") ap.add_argument("--top", type=int, default=5) a = ap.parse_args(argv) res = scan(a.root) if a.json: print(json.dumps(res["summary"], indent=1)) return 0 print_summary(res["summary"]) if a.show: print_detail(res, a.top) else: print(" (run again with --show to see which repos and which lines, on your machine only)") return 0 if __name__ == "__main__": sys.exit(main())