diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f9173..e7ceb70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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)) + ### Added +- **Plugin-level hooks** (`hooks/hooks.json` + `hooks/validate-claude-md.py`): every `Edit`/`Write` to a `CLAUDE.md` and every `InstructionsLoaded` event (all five `load_reason` values — `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact`) runs `validate-claude-md.py`, which exits `2` with stderr feedback when the touched file exceeds the 150-line cap. Turns the cap from advisory into deterministic enforcement at load time and write time. +- **`generate_rules_file()` on `ContentGenerator`** (`skill/generator.py`): emits path-scoped `.claude/rules/*.md` instruction files with `name`, `description`, and `paths:` glob frontmatter. Claude loads these conditionally when accessing files that match the globs, so file-type-specific guidance (e.g. backend-only standards) no longer has to live in the root CLAUDE.md. +- **`AGENTS.md` / `.cursorrules` / `.windsurfrules` interop**: `command/enhance-claude-md.md` Phase 1 now detects these sibling instruction files, and `ContentGenerator.generate_root_file()` prepends an `## External Instructions` section with `@AGENTS.md`-style imports when `project_context['existing_instruction_files']` lists them. Repos already using other agent tooling can adopt ClaudeForge without losing their existing instructions. + +### Added (earlier in this Unreleased window) + - **Claude Code plugin manifest** (`.claude-plugin/plugin.json`): ClaudeForge is now installable as a Claude Code plugin via `/plugin marketplace add alirezarezvani/ClaudeForge && /plugin install claudeforge`. Manifest registers all skills, commands, and the guardian agent in one bundle. - **`/sync-claude-md` slash command** (`command/sync-claude-md.md`): walks every CLAUDE.md in the project, prunes stale references (removed dependencies, deleted paths, broken modular links), enforces the 150-line cap, and repairs the root ↔ sub chain. Designed to run after refactors, dependency changes, or before a release. - **Karpathy Guidelines skill** (`skill/karpathy-guidelines/SKILL.md`): behavioural-guardrail skill applied to every coding, review, and refactoring task. Covers four principles — Think Before Coding, Simplicity First, Surgical Changes, Goal-Driven Execution — adapted with attribution from the MIT-licensed [forrestchang/andrej-karpathy-skills](https://github.com/forrestchang/andrej-karpathy-skills) repository. diff --git a/agent/CLAUDE.md b/agent/CLAUDE.md index 18a3a0c..1327175 100644 --- a/agent/CLAUDE.md +++ b/agent/CLAUDE.md @@ -26,29 +26,34 @@ Guidelines for the `claude-md-guardian` background-maintenance agent. ## v2.0.0+ Frontmatter Reference +Hooks use Anthropic's canonical keyed-object schema (event → array of `{ matcher, hooks: [{ type, command }] }`): + ```yaml ---- -name: claude-md-guardian -permissions: [Bash, Read, Write, Edit, Grep, Glob, Skill] -model: haiku -color: purple -fork_safe: true hooks: - - SessionStart - - PreToolUse - - PostToolUse ---- + PostToolUse: + - matcher: "Write|Edit" + hooks: + - type: command + command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validate-claude-md.py" + InstructionsLoaded: + - matcher: "session_start|nested_traversal|path_glob_match|include|compact" + hooks: + - type: command + command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validate-claude-md.py" ``` +The array-of-`{event, commands}` shape used in earlier versions did not match the documented schema and silently did not fire. + ## Skill ↔ Agent Integration The agent uses `claude-md-enhancer` (the skill's frontmatter name; installed as `claudeforge-skill/`) as its core capability. Invoke it with `Skill: claude-md-enhancer` inside the agent workflow. Hook responsibilities: -- **SessionStart** — check `git diff`; if significant change is detected, invoke the skill for an incremental update. +- **SessionStart** — check `git diff`; if significant drift is detected, invoke the skill for an incremental update. - **PreToolUse** — validate before a CLAUDE.md edit lands. -- **PostToolUse** — after Edit/Write to any CLAUDE.md, run `BestPracticesValidator`, report the quality score, suggest improvements. Refuse to leave the file over 150 lines. +- **PostToolUse** — after `Edit`/`Write` to any CLAUDE.md, the plugin-level `hooks/hooks.json` runs `hooks/validate-claude-md.py`. The script exits `2` with stderr feedback when the file is over 150 lines; the guardian then proposes a `/sync-claude-md` run. +- **InstructionsLoaded** — same script fires on every `load_reason` (`session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact`), so the cap is enforced deterministically at load time, not just at write time. ## Agent ↔ Git diff --git a/agent/claude-md-guardian.md b/agent/claude-md-guardian.md index c8929ff..70e96a9 100644 --- a/agent/claude-md-guardian.md +++ b/agent/claude-md-guardian.md @@ -16,18 +16,26 @@ field: documentation expertise: intermediate fork_safe: true hooks: - - event: SessionStart - commands: - - echo "Guardian: Checking for CLAUDE.md updates..." - once: false - - event: PreToolUse - matcher: Write - commands: - - echo "Guardian: Validating CLAUDE.md changes..." - - event: PostToolUse - matcher: Write - commands: - - echo "Guardian: CLAUDE.md update complete" + SessionStart: + - matcher: "" + hooks: + - type: command + command: "echo 'Guardian: checking for CLAUDE.md drift on session start'" + PreToolUse: + - matcher: "Write|Edit" + hooks: + - type: command + command: "echo 'Guardian: validating CLAUDE.md change'" + PostToolUse: + - matcher: "Write|Edit" + hooks: + - type: command + command: "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py" + InstructionsLoaded: + - matcher: "session_start|nested_traversal|path_glob_match|include|compact" + hooks: + - type: command + command: "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py" --- # CLAUDE.md Guardian Agent diff --git a/command/enhance-claude-md.md b/command/enhance-claude-md.md index c6d0966..5598d5b 100644 --- a/command/enhance-claude-md.md +++ b/command/enhance-claude-md.md @@ -39,6 +39,12 @@ This command uses the `claude-md-enhancer` skill to initialize or enhance CLAUDE !`ls -la` +### Check for sibling agent / rule files + +If `AGENTS.md`, `.cursorrules`, or `.windsurfrules` exists, ClaudeForge will preserve it and chain it from the root CLAUDE.md via `@AGENTS.md` (or the equivalent) instead of overwriting. Detect them now: + +!`for f in AGENTS.md .cursorrules .windsurfrules; do [ -f "$f" ] && echo "found: $f ($(wc -l < "$f") lines)" || echo "absent: $f"; done` + ### Deep project scan via Explore agent For non-trivial repositories, delegate the codebase walk to the **Explore** subagent so the discovery does not bloat this command's context window. Ask it a single, scoped question — for example: diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..8999c61 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-hooks.json", + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py", + "timeout": 10 + } + ] + } + ], + "InstructionsLoaded": [ + { + "matcher": "session_start|nested_traversal|path_glob_match|include|compact", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR}}/hooks/validate-claude-md.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/hooks/validate-claude-md.py b/hooks/validate-claude-md.py new file mode 100755 index 0000000..7113240 --- /dev/null +++ b/hooks/validate-claude-md.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""ClaudeForge hook: validate every touched CLAUDE.md against the 150-line cap. + +Wired into the plugin's ``hooks/hooks.json`` for both ``PostToolUse`` (after +``Edit`` or ``Write``) and ``InstructionsLoaded`` (after any of the five +``load_reason`` values fire). Reads the hook payload from stdin, extracts any +referenced file path, and exits non-zero with stderr feedback when the file +exists and exceeds the cap. + +Exit codes follow the Claude Code hook contract: +- ``0`` pass +- ``2`` surface stderr to Claude as actionable feedback (do not block) +""" +from __future__ import annotations + +import json +import os +import sys + +MAX_LINES = 150 + + +def _candidate_paths(payload: dict) -> list[str]: + """Extract every file path the hook payload might be referring to. + + We accept several payload shapes so the same script works for ``PostToolUse`` + (tool_input.file_path) and ``InstructionsLoaded`` (path / file). + """ + paths: list[str] = [] + + tool_input = payload.get("tool_input") or {} + for key in ("file_path", "path", "target_file"): + value = tool_input.get(key) + if isinstance(value, str): + paths.append(value) + + for key in ("path", "file", "file_path"): + value = payload.get(key) + if isinstance(value, str): + paths.append(value) + + return paths + + +def _is_claude_md(path: str) -> bool: + base = os.path.basename(path) + return base == "CLAUDE.md" or base.endswith(".claude/rules") or "/.claude/rules/" in path + + +def main() -> int: + if sys.stdin.isatty(): + return 0 + + raw = sys.stdin.read().strip() + if not raw: + return 0 + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return 0 + + violations: list[tuple[str, int]] = [] + for path in _candidate_paths(payload): + if not _is_claude_md(path) 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: + violations.append((path, line_count)) + + if not violations: + return 0 + + for path, line_count in violations: + print( + 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 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skill/CLAUDE.md b/skill/CLAUDE.md index fecb17a..15e8a4d 100644 --- a/skill/CLAUDE.md +++ b/skill/CLAUDE.md @@ -31,7 +31,7 @@ Five modules live under `skill/`: - **`analyzer.py`** — `CLAUDEMDAnalyzer`: analyses existing files; quality scoring (0–100) across length, completeness, formatting, specificity, modularity. - **`validator.py`** — `BestPracticesValidator`: checks file length (hard cap 150, warning at 120), required sections, formatting, anti-patterns. - **`template_selector.py`** — `TemplateSelector`: maps project type + team size to a template; all team-size targets are ≤ 150 lines. -- **`generator.py`** — `ContentGenerator`: writes root + context files, emits `@path` chain imports, prepends sub-file back-links, idempotent `merge_with_existing`. +- **`generator.py`** — `ContentGenerator`: writes root + context files, emits `@path` chain imports, prepends sub-file back-links, idempotent `merge_with_existing`. Also exposes `generate_rules_file(name, description, paths, body)` for path-scoped `.claude/rules/*.md` files (loaded lazily by Claude when accessed files match the `paths:` globs) and prepends `@AGENTS.md`-style imports when `project_context['existing_instruction_files']` lists sibling instruction files. ## Required Output Sections diff --git a/skill/generator.py b/skill/generator.py index 4a00c2e..7a7e634 100644 --- a/skill/generator.py +++ b/skill/generator.py @@ -27,16 +27,48 @@ class ContentGenerator: """ Generate root CLAUDE.md file (navigation hub). + When the surrounding project already contains ``AGENTS.md``, + ``.cursorrules``, or ``.windsurfrules`` (passed via + ``project_context['existing_instruction_files']``), the generated root + file prepends ``@`` imports for them so Claude inherits their content + instead of duplicating it. + Returns: - Complete CLAUDE.md content as string + Complete CLAUDE.md content as string. """ template = self.template_selector.select_template() - # Use template selector's customization if template.get('modular_recommended'): - return self._generate_modular_root(template) + body = self._generate_modular_root(template) else: - return self._generate_standalone_file(template) + body = self._generate_standalone_file(template) + + return self._prepend_existing_instruction_imports(body) + + def _prepend_existing_instruction_imports(self, body: str) -> str: + """Insert ``@`` import lines for any AGENTS.md / cursor / windsurf files.""" + existing = self.project_context.get('existing_instruction_files') or [] + supported = ('AGENTS.md', '.cursorrules', '.windsurfrules') + imports = [name for name in supported if name in existing] + if not imports: + return body + + # Insert right after the intro paragraph (first blank line after the H1). + lines = body.split('\n') + out: List[str] = [] + inserted = False + for i, line in enumerate(lines): + out.append(line) + if not inserted and i > 0 and line == '' and lines[i - 1].strip(): + out.append('## External Instructions') + out.append('') + out.append('Chained from sibling instruction files in this repo:') + out.append('') + for name in imports: + out.append(f"@{name}") + out.append('') + inserted = True + return '\n'.join(out) if inserted else body def _generate_modular_root(self, template: Dict[str, Any]) -> str: """Generate root file for modular architecture (navigation hub).""" @@ -123,6 +155,48 @@ class ContentGenerator: ) return backlink + body + def generate_rules_file( + self, + name: str, + description: str, + paths: List[str], + body: str, + ) -> str: + """Emit a path-scoped ``.claude/rules/*.md`` instruction file. + + Anthropic loads these conditionally when Claude accesses files matching + any of the ``paths:`` globs. Use this for sections that would otherwise + bloat the root CLAUDE.md — e.g. backend-only standards that only matter + when editing files under ``src/backend/**``. + + Args: + name: Skill-style identifier, kebab-case (e.g. ``backend-rules``). + description: Single-sentence summary (used by Claude for matching). + paths: List of glob patterns that trigger the load. + body: Markdown content (excluding frontmatter and title). + + Returns: + Complete file content ready to write to ``.claude/rules/.md``. + Stays within the 150-line cap unless the caller passes a body that + already exceeds it; the cap is enforced by the validator hook. + """ + if not paths: + raise ValueError("paths must be a non-empty list of glob patterns") + + lines: List[str] = ["---"] + lines.append(f"name: {name}") + lines.append(f"description: {description}") + lines.append("paths:") + for glob in paths: + lines.append(f" - {glob}") + lines.append("---") + lines.append("") + lines.append(f"# {name.replace('-', ' ').title()}") + lines.append("") + lines.append(body.strip()) + lines.append("") + return "\n".join(lines) + def _generate_backend_file(self) -> str: """Generate backend-specific CLAUDE.md.""" lines = []