Files
ClaudeForge/hooks/audit-claude-md.py
T
Claude e33fa8326b feat(plugin): command metadata, scoped skills, local-tier support, layered hooks, Stop audit
Wave 3 - adoption hardening. Patterns adapted (in original prose, with
attribution) from MIT-licensed shanraisshan/claude-code-best-practice.

Commands (command/enhance-claude-md.md, command/sync-claude-md.md):
- Add allowed-tools / disallowedTools / argument-hint / when_to_use so the
  commands auto-suggest in the slash menu and avoid permission prompts.
- disallowedTools blocks WebFetch + WebSearch on both commands.
- Drop the previous broken hooks block (array-of-{matcher, commands} shape
  did not match canonical schema; was never firing).

Skills:
- skill/karpathy-guidelines/SKILL.md: paths: glob over 23 code-file
  extensions, so the guardrails auto-load only when editing source, not
  markdown or data.
- skill/SKILL.md: model: haiku, effort: medium, paths: scoped to CLAUDE.md
  + AGENTS.md + .claude/rules/*.md so validator/generator passes run
  cheaply without changing the user-facing model.

CLAUDE.local.md personal tier:
- skill/validator.py BestPracticesValidator now accepts filename=; any
  *.local.md basename waives the 150-line cap.
- hooks/validate-claude-md.py reads the exempt suffix from hooks-config.
- .gitignore covers CLAUDE.local.md, **/CLAUDE.local.md,
  .claude/settings.local.json, hooks/hooks-config.local.json.

Layered hook config:
- hooks/hooks-config.json: committed defaults
  (validateClaudeMd.enabled/maxLines/exemptFilenameSuffix/exitCodeOnViolation,
  stopAuditLine.enabled).
- hooks/validate-claude-md.py merges hooks-config.json +
  hooks-config.local.json key-by-key; honours enabled=false (silent
  exit 0), configurable cap, configurable exit code.

Stop audit hook:
- hooks/audit-claude-md.py walks the project tree, prints one stderr
  line: total tracked / OVER cap / near cap (>=80%). Respects
  stopAuditLine.enabled from config.
- hooks/hooks.json registers Stop event with matcher "".

Guardian fail-closed contract:
- agent/claude-md-guardian.md Safety & Validation section now explicitly
  requires Skill-tool invocation (no inline paraphrase of SKILL.md),
  abort on missing validated output, never auto-commit, and respect
  local hook config.

Verified (8/8 smoke tests):
- Both commands parse with new fields and no broken hooks block.
- karpathy paths: 23 globs, includes .py/.ts/.go/.rs.
- skill model=haiku effort=medium with CLAUDE.md path scope.
- Validator: *.local.md (300 lines) -> pass; CLAUDE.md (300) -> fail;
  legacy ctor without filename -> default behavior preserved.
- hooks-config.json valid; validateClaudeMd.enabled=true, maxLines=150.
- Hook validator: default rc=2 on bloated, rc=0 when local override
  disables it, rc=0 on *.local.md (exempt).
- Stop hook entry present; audit script: rc=0 with "5 CLAUDE.md tracked".
- Regression: large-fullstack root still 52 lines with chain imports.
2026-05-19 02:04:00 +00:00

88 lines
2.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""ClaudeForge Stop hook: print a 1-line CLAUDE.md health summary.
Walks the project tree from ``CLAUDE_PROJECT_DIR`` (or the cwd) and prints a
single line to stderr summarising how many CLAUDE.md files exist, how close
they are to the 150-line cap, and whether any are over. Designed to be the
last signal before a session's context is lost — drift visible to the user
without forcing them to run ``/sync-claude-md`` blindly.
Honours ``hooks/hooks-config.json`` and ``hooks/hooks-config.local.json``:
when ``stopAuditLine.enabled`` is ``false``, this script exits silently.
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_MAX_LINES = 150
def _load_config() -> dict:
cfg: dict = {}
for name in ("hooks-config.json", "hooks-config.local.json"):
path = os.path.join(HERE, name)
if not os.path.exists(path):
continue
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
continue
cfg.update(data.get("stopAuditLine") or {})
cfg.setdefault("maxLines", (data.get("validateClaudeMd") or {}).get("maxLines"))
return cfg
def _project_root() -> str:
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
def _iter_claude_md(root: str):
skip_dirs = {".git", "node_modules", ".venv", "venv", "dist", "build", "vendor"}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
for name in filenames:
if name == "CLAUDE.md":
yield os.path.join(dirpath, name)
def main() -> int:
cfg = _load_config()
if cfg.get("enabled") is False:
return 0
cap = int(cfg.get("maxLines") or DEFAULT_MAX_LINES)
warn_at = max(1, int(cap * 0.8))
total = 0
over = 0
near = 0
for path in _iter_claude_md(_project_root()):
try:
with open(path, encoding="utf-8") as fh:
lines = sum(1 for _ in fh)
except OSError:
continue
total += 1
if lines > cap:
over += 1
elif lines >= warn_at:
near += 1
if total == 0:
return 0
suffix = ""
if over:
suffix = f" — {over} OVER {cap}-line cap; run /sync-claude-md"
elif near:
suffix = f" — {near} near cap ({warn_at}+)"
print(f"ClaudeForge: {total} CLAUDE.md tracked{suffix}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())