From e33fa8326bab635a9ef28daa19e7b78584acc0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 01:07:11 +0000 Subject: [PATCH] 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. --- .gitignore | 8 +++ CHANGELOG.md | 12 +++++ agent/claude-md-guardian.md | 12 ++++- command/enhance-claude-md.md | 34 ++++++++---- command/sync-claude-md.md | 46 +++++++++++----- hooks/audit-claude-md.py | 87 ++++++++++++++++++++++++++++++ hooks/hooks-config.json | 12 +++++ hooks/hooks.json | 12 +++++ hooks/validate-claude-md.py | 50 ++++++++++++++--- skill/SKILL.md | 25 +++++++-- skill/karpathy-guidelines/SKILL.md | 28 ++++++++++ skill/validator.py | 27 +++++++++- 12 files changed, 317 insertions(+), 36 deletions(-) create mode 100755 hooks/audit-claude-md.py create mode 100644 hooks/hooks-config.json diff --git a/.gitignore b/.gitignore index 7ed86e6..4c30c19 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,11 @@ subagent-claude-md-guardian/ # Optional project files PROJECT_SUMMARY.md + +# ClaudeForge: personal / machine-local overrides outside the chained tree +CLAUDE.local.md +**/CLAUDE.local.md +.claude/settings.local.json + +# ClaudeForge: per-developer hook overrides (e.g. disable validator locally) +hooks/hooks-config.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ceb70..aae65a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added (wave 3 — adoption hardening) + +- **Command discovery metadata** (`command/enhance-claude-md.md`, `command/sync-claude-md.md`): both commands now declare `allowed-tools`, `disallowedTools` (blocks `WebFetch`/`WebSearch`), `argument-hint`, and `when_to_use` so Claude Code can auto-suggest and zero-prompt them. +- **Path-scoped Karpathy guidelines** (`skill/karpathy-guidelines/SKILL.md`): `paths:` glob on code-file extensions (`*.py`, `*.ts`, `*.go`, `*.rs`, etc.) so the guardrails load only when editing code, not when editing markdown or data. +- **Cheaper skill execution** (`skill/SKILL.md`): `model: haiku`, `effort: medium`, and `paths:` scoping the skill to CLAUDE.md / AGENTS.md / `.claude/rules/*.md` so validator + generator passes run cheaply without affecting the user-facing model. +- **`CLAUDE.local.md` personal tier**: `validator.BestPracticesValidator` now accepts `filename=` and waives the 150-line cap for any `*.local.md` file. `hooks/validate-claude-md.py` is exempt-suffix aware too. `.gitignore` excludes `CLAUDE.local.md`, `**/CLAUDE.local.md`, `.claude/settings.local.json`, and `hooks/hooks-config.local.json`. +- **Layered hook config** (`hooks/hooks-config.json` shared + `hooks/hooks-config.local.json` gitignored): `validate-claude-md.py` merges the two and honours `validateClaudeMd.enabled: false`, `maxLines`, `exemptFilenameSuffix`, and `exitCodeOnViolation`. Teams can opt out per developer without forking the shipped config. +- **`Stop` audit hook** (`hooks/audit-claude-md.py` + entry in `hooks/hooks.json`): prints a 1-line summary to stderr at session end — total CLAUDE.md tracked, count over the cap, count near it — so users see drift before the session's context is lost. +- **Fail-closed contract on guardian** (`agent/claude-md-guardian.md` Safety & Validation section): the guardian now states it invokes `claude-md-enhancer` exclusively through the Skill tool (never paraphrases SKILL.md content), aborts on missing validated output, never auto-commits, and respects the local hook config. + +Patterns adapted (with attribution and in original prose) from the MIT-licensed [shanraisshan/claude-code-best-practice](https://github.com/shanraisshan/claude-code-best-practice). + ### Fixed - **Guardian agent hook frontmatter** (`agent/claude-md-guardian.md`): rewritten from the array-of-objects shape (`hooks: [{ event, commands }]`) to Anthropic's canonical keyed-object shape (`hooks: { EventName: [{ matcher, hooks: [{ type: "command", command }] }] }`). The previous shape did not match the documented schema, so the guardian's hooks did not fire. ([docs](https://code.claude.com/docs/en/hooks)) diff --git a/agent/claude-md-guardian.md b/agent/claude-md-guardian.md index 70e96a9..00c9337 100644 --- a/agent/claude-md-guardian.md +++ b/agent/claude-md-guardian.md @@ -241,8 +241,14 @@ The slash command can invoke me: ## Safety & Validation -**Critical Validation Rule**: -"Always validate your output against official native examples before declaring complete." +**Fail-closed contract** (non-negotiable): + +- I invoke `claude-md-enhancer` exclusively through the **Skill tool**. I never read its `SKILL.md` body and act on a paraphrase of it — paraphrase drift is the most common silent-degradation mode for auto-CLAUDE.md tooling. +- If the skill returns no validated output (missing required sections, validator status ≠ `pass`, or any thrown exception), I **abort the run** and leave the existing CLAUDE.md tree untouched. Partial writes are worse than stale documentation. +- I never commit on my own. Every change lands in the working tree only; the user reviews `git diff` and chooses when to commit. +- I respect `hooks/hooks-config.local.json`. If a developer has disabled the validator locally, I treat the cap as advisory for that machine but still warn on the Stop hook. + +**Critical Validation Rule**: validate every emitted file against the reference templates in `skill/examples/` and the canonical schema before declaring success. **My validation checklist**: - ✅ Project Structure diagram present @@ -250,6 +256,8 @@ The slash command can invoke me: - ✅ Architecture section reflects actual patterns - ✅ Tech Stack lists all major dependencies - ✅ Common Commands match package.json scripts +- ✅ Every emitted CLAUDE.md ≤ 150 lines (cap waived only for `*.local.md`) +- ✅ Every sub-CLAUDE.md back-links to root; root has matching `@`-imports ## Installation diff --git a/command/enhance-claude-md.md b/command/enhance-claude-md.md index 5598d5b..2cf8596 100644 --- a/command/enhance-claude-md.md +++ b/command/enhance-claude-md.md @@ -1,18 +1,34 @@ --- -description: Initialize or enhance CLAUDE.md files using the claude-md-enhancer skill with interactive workflow and 100% native format compliance +description: Initialize or enhance a CLAUDE.md (and chained sub-CLAUDE.md files) for the current project using the claude-md-enhancer skill. Delegates deep codebase scans to the Explore subagent and stays within the 150-line cap. +argument-hint: "[--init | --enhance | ]" +when_to_use: | + Use whenever a project has no CLAUDE.md, when an existing one is over 150 lines, + when an /init result needs to be hardened against context bloat, or when a repo + already uses AGENTS.md / .cursorrules / .windsurfrules and you want a Claude- + aware root that chains to them via @-imports instead of overwriting. +allowed-tools: + - Read + - Edit + - Write + - Glob + - Grep + - Skill + - "Bash(ls:*)" + - "Bash(find:*)" + - "Bash(git status:*)" + - "Bash(git diff:*)" + - "Bash(wc:*)" +disallowedTools: + - WebFetch + - WebSearch permissions: allow: - - Bash(ls:*) - - Bash(find:*) - - Bash(git status:*) + - "Bash(ls:*)" + - "Bash(find:*)" + - "Bash(git status:*)" - Read - Glob - Skill -hooks: - - matcher: "" - once: true - commands: - - echo "Starting CLAUDE.md enhancement workflow" --- # CLAUDE.md Enhancer Command diff --git a/command/sync-claude-md.md b/command/sync-claude-md.md index 393e110..a2206ed 100644 --- a/command/sync-claude-md.md +++ b/command/sync-claude-md.md @@ -1,26 +1,44 @@ --- -description: Walk every CLAUDE.md in the project, prune stale references, enforce the 150-line cap, and re-chain root ↔ subdirectory files. +description: Walk every CLAUDE.md in the project, prune stale references (removed deps, deleted paths, broken modular links), enforce the 150-line cap by splitting into sub-files, and repair the root ↔ subdirectory chain (markdown links + @path imports). +argument-hint: "[--dry-run | --paths-only | ]" +when_to_use: | + Run after refactors, dependency changes, deleted directories, or when any single + CLAUDE.md is near the 150-line cap. Also run before cutting a release so the + documentation tag-snapshot is truthful. +allowed-tools: + - Read + - Edit + - Write + - Glob + - Grep + - Skill + - "Bash(ls:*)" + - "Bash(find:*)" + - "Bash(git status:*)" + - "Bash(git diff:*)" + - "Bash(wc:*)" + - "Bash(grep:*)" + - "Bash(cat:*)" + - "Bash(test:*)" +disallowedTools: + - WebFetch + - WebSearch permissions: allow: - - Bash(ls:*) - - Bash(find:*) - - Bash(git status:*) - - Bash(git diff:*) - - Bash(wc:*) - - Bash(grep:*) - - Bash(cat:*) - - Bash(test:*) + - "Bash(ls:*)" + - "Bash(find:*)" + - "Bash(git status:*)" + - "Bash(git diff:*)" + - "Bash(wc:*)" + - "Bash(grep:*)" + - "Bash(cat:*)" + - "Bash(test:*)" - Read - Edit - Write - Glob - Grep - Skill -hooks: - - matcher: "" - once: true - commands: - - echo "Starting CLAUDE.md sync workflow" --- # /sync-claude-md — CLAUDE.md Sync & Cleanup diff --git a/hooks/audit-claude-md.py b/hooks/audit-claude-md.py new file mode 100755 index 0000000..b9175fa --- /dev/null +++ b/hooks/audit-claude-md.py @@ -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()) diff --git a/hooks/hooks-config.json b/hooks/hooks-config.json new file mode 100644 index 0000000..aaa2711 --- /dev/null +++ b/hooks/hooks-config.json @@ -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 + } +} diff --git a/hooks/hooks.json b/hooks/hooks.json index 8999c61..3ebfa03 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -24,6 +24,18 @@ } ] } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/audit-claude-md.py", + "timeout": 10 + } + ] + } ] } } diff --git a/hooks/validate-claude-md.py b/hooks/validate-claude-md.py index 7113240..a3419d2 100755 --- a/hooks/validate-claude-md.py +++ b/hooks/validate-claude-md.py @@ -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__": diff --git a/skill/SKILL.md b/skill/SKILL.md index 3ebcccf..2fe588d 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,6 +1,25 @@ --- name: claude-md-enhancer description: Analyzes, generates, and enhances CLAUDE.md files for any project type using best practices, modular architecture support, and tech stack customization. Use when setting up new projects, improving existing CLAUDE.md files, or establishing AI-assisted development standards. +model: haiku +effort: medium +paths: + - "**/CLAUDE.md" + - "**/CLAUDE.local.md" + - "**/AGENTS.md" + - "**/.cursorrules" + - "**/.windsurfrules" + - "**/.claude/rules/*.md" +allowed-tools: + - Read + - Write + - Edit + - Glob + - Grep + - "Bash(ls:*)" + - "Bash(find:*)" + - "Bash(git:*)" + - "Bash(wc:*)" permissions: allow: - Read @@ -8,9 +27,9 @@ permissions: - Edit - Glob - Grep - - Bash(ls:*) - - Bash(find:*) - - Bash(git:*) + - "Bash(ls:*)" + - "Bash(find:*)" + - "Bash(git:*)" --- # CLAUDE.md File Enhancer diff --git a/skill/karpathy-guidelines/SKILL.md b/skill/karpathy-guidelines/SKILL.md index 7dc434b..2c10a6d 100644 --- a/skill/karpathy-guidelines/SKILL.md +++ b/skill/karpathy-guidelines/SKILL.md @@ -2,6 +2,34 @@ name: karpathy-guidelines description: Behavioral guardrails for LLM-assisted coding. Use when writing, reviewing, or refactoring code in any project to avoid overcomplication, keep changes surgical, surface assumptions early, and execute against verifiable success criteria. license: MIT +paths: + - "**/*.py" + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" + - "**/*.go" + - "**/*.rs" + - "**/*.java" + - "**/*.kt" + - "**/*.rb" + - "**/*.php" + - "**/*.swift" + - "**/*.c" + - "**/*.cc" + - "**/*.cpp" + - "**/*.h" + - "**/*.hpp" + - "**/*.cs" + - "**/*.scala" + - "**/*.sh" + - "**/*.bash" + - "**/*.zsh" + - "**/*.sql" +allowed-tools: + - Read + - Glob + - Grep permissions: allow: - Read diff --git a/skill/validator.py b/skill/validator.py index 592c802..7e3b1e7 100644 --- a/skill/validator.py +++ b/skill/validator.py @@ -66,18 +66,24 @@ class BestPracticesValidator: } ] - def __init__(self, content: str, project_context: Dict[str, Any] = None): + def __init__(self, content: str, project_context: Dict[str, Any] = None, filename: str = None): """ Initialize validator with CLAUDE.md content. Args: content: Full text content of CLAUDE.md file project_context: Optional project context for advanced validation + filename: Optional path or basename. When the basename ends with + ``.local.md`` (e.g. ``CLAUDE.local.md``), the 150-line cap is + relaxed because the file is a personal/gitignored override + outside the chained team-shared tree. """ self.content = content self.lines = content.split('\n') self.line_count = len(self.lines) self.project_context = project_context or {} + self.filename = filename or "" + self.is_local_override = self.filename.endswith('.local.md') def validate_all(self) -> Dict[str, Any]: """ @@ -112,6 +118,25 @@ class BestPracticesValidator: message = f"File length is appropriate ({self.line_count} lines)" severity = "info" + # CLAUDE.local.md (and any *.local.md sibling) is a personal, + # gitignored override outside the chained team-shared tree. Skip the + # 150-line cap — only flag underuse. + if self.is_local_override: + if self.line_count < self.MIN_LINES: + status = "fail" + message = f"Personal override is too short ({self.line_count} lines, minimum {self.MIN_LINES})" + severity = "low" + else: + message = f"Personal override ({self.line_count} lines, cap waived)" + return { + "check": "file_length", + "status": status, + "message": message, + "severity": severity, + "actual_value": self.line_count, + "expected_range": f"{self.MIN_LINES}+ lines (cap waived for *.local.md)", + } + if self.line_count > self.MAX_RECOMMENDED_LINES: status = "fail" message = f"File exceeds maximum recommended length ({self.line_count} > {self.MAX_RECOMMENDED_LINES} lines)"