mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-03 10:23:15 -04:00
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.
This commit is contained in:
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$comment": "ClaudeForge shared hook config. Commit this file. Per-developer overrides go in hooks/hooks-config.local.json (gitignored).",
|
||||
"validateClaudeMd": {
|
||||
"enabled": true,
|
||||
"maxLines": 150,
|
||||
"exemptFilenameSuffix": ".local.md",
|
||||
"exitCodeOnViolation": 2
|
||||
},
|
||||
"stopAuditLine": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/audit-claude-md.py",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,32 @@ import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
MAX_LINES = 150
|
||||
DEFAULT_MAX_LINES = 150
|
||||
DEFAULT_EXEMPT_SUFFIX = ".local.md"
|
||||
DEFAULT_VIOLATION_RC = 2
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Merge ``hooks-config.json`` and optional ``hooks-config.local.json``.
|
||||
|
||||
Local file overrides the shared one key-by-key inside ``validateClaudeMd``.
|
||||
Missing files are silently ignored — the script falls back to defaults.
|
||||
"""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
shared = os.path.join(here, "hooks-config.json")
|
||||
local = os.path.join(here, "hooks-config.local.json")
|
||||
cfg: dict = {}
|
||||
for path in (shared, local):
|
||||
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
|
||||
validate_block = data.get("validateClaudeMd") or {}
|
||||
cfg.update(validate_block)
|
||||
return cfg
|
||||
|
||||
|
||||
def _candidate_paths(payload: dict) -> list[str]:
|
||||
@@ -42,9 +67,13 @@ def _candidate_paths(payload: dict) -> list[str]:
|
||||
return paths
|
||||
|
||||
|
||||
def _is_claude_md(path: str) -> bool:
|
||||
def _is_claude_md(path: str, exempt_suffix: str) -> bool:
|
||||
base = os.path.basename(path)
|
||||
return base == "CLAUDE.md" or base.endswith(".claude/rules") or "/.claude/rules/" in path
|
||||
# Personal-tier overrides (CLAUDE.local.md and any matching suffix) are
|
||||
# exempt from the cap — they live outside the chained team-shared tree.
|
||||
if base.endswith(exempt_suffix):
|
||||
return False
|
||||
return base == "CLAUDE.md" or "/.claude/rules/" in path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -59,16 +88,23 @@ def main() -> int:
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
|
||||
cfg = _load_config()
|
||||
if cfg.get("enabled") is False:
|
||||
return 0
|
||||
max_lines = int(cfg.get("maxLines", DEFAULT_MAX_LINES))
|
||||
exempt_suffix = str(cfg.get("exemptFilenameSuffix", DEFAULT_EXEMPT_SUFFIX))
|
||||
violation_rc = int(cfg.get("exitCodeOnViolation", DEFAULT_VIOLATION_RC))
|
||||
|
||||
violations: list[tuple[str, int]] = []
|
||||
for path in _candidate_paths(payload):
|
||||
if not _is_claude_md(path) or not os.path.exists(path):
|
||||
if not _is_claude_md(path, exempt_suffix) or not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
except OSError:
|
||||
continue
|
||||
if line_count > MAX_LINES:
|
||||
if line_count > max_lines:
|
||||
violations.append((path, line_count))
|
||||
|
||||
if not violations:
|
||||
@@ -76,11 +112,11 @@ def main() -> int:
|
||||
|
||||
for path, line_count in violations:
|
||||
print(
|
||||
f"ClaudeForge: {path} is {line_count} lines (cap is {MAX_LINES}). "
|
||||
f"ClaudeForge: {path} is {line_count} lines (cap is {max_lines}). "
|
||||
"Run /sync-claude-md to split into chained sub-files.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
return violation_rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user