mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-03 02:13:15 -04:00
feat(plugin): fix guardian hooks, add InstructionsLoaded enforcement, .claude/rules emitter, AGENTS.md interop
Closes the gap between ClaudeForge and the Anthropic docs at
code.claude.com/docs/en/memory (and its linked hooks / skills / plugins
pages). Five tightly-scoped changes, each verified by smoke test.
A. Fix guardian hook frontmatter shape (agent/claude-md-guardian.md)
The previous array-of-{event, commands} shape did not match the
documented schema (hooks: { EventName: [{ matcher, hooks: [{ type,
command }] }] }), so the guardian's hooks silently did not fire.
Rewritten to the canonical keyed-object shape with PostToolUse,
PreToolUse, SessionStart, and the new InstructionsLoaded event.
B+C. Plugin-level hooks/hooks.json + hooks/validate-claude-md.py
New deterministic enforcement of the 150-line cap. The validator
script reads the hook payload from stdin, extracts any referenced
CLAUDE.md path (supports both PostToolUse tool_input.file_path and
InstructionsLoaded path / file shapes), and exits 2 with stderr
feedback when the file is over 150 lines. Wired to both PostToolUse
on Write|Edit and InstructionsLoaded on every load_reason
(session_start, nested_traversal, path_glob_match, include, compact).
The cap is now enforced at every load *and* write, not only when the
guardian decides to run sync.
D. ContentGenerator.generate_rules_file() (skill/generator.py)
Emits path-scoped .claude/rules/*.md instruction files with name,
description, and paths: glob frontmatter. Claude loads these
lazily — only when accessing files matching the globs — so
file-type-specific guidance no longer has to live in the root
CLAUDE.md. Validates that paths is non-empty (ValueError otherwise).
E. AGENTS.md / .cursorrules / .windsurfrules interop
command/enhance-claude-md.md Phase 1 now lists which sibling
instruction files exist. ContentGenerator.generate_root_file() reads
project_context['existing_instruction_files'] and prepends an
## External Instructions section with @AGENTS.md (etc.) imports, so
repos already using other agent tooling can adopt ClaudeForge
without losing their existing instructions.
Smoke tests (all pass):
- Guardian hooks frontmatter parses as a dict with 4 events, each
carrying matcher + nested hooks array of {type, command} entries.
- hooks.json is valid JSON; PostToolUse matcher = Write|Edit;
InstructionsLoaded matcher covers all five load_reason values.
- validate-claude-md.py: small file -> rc 0, bloated file (200 lines)
-> rc 2 with stderr referencing the 150 cap, InstructionsLoaded
payload shape also handled, non-CLAUDE.md paths ignored, no stdin
-> rc 0.
- generate_rules_file emits valid frontmatter with paths glob and
raises ValueError when paths is empty.
- generate_root_file inserts @AGENTS.md and @.cursorrules imports
when existing_instruction_files lists them; omits the section
otherwise.
- Regression: large-fullstack root still 52 lines with chain imports
intact; all five sub-CLAUDE.md files in this repo still pass
validator (status = pass).
Docs:
- agent/CLAUDE.md updated to show the canonical hook shape and the
hook-driven validator wiring.
- skill/CLAUDE.md notes generate_rules_file and AGENTS.md interop.
- CHANGELOG.md documents all five changes under Unreleased.
This commit is contained in:
@@ -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.
|
||||
|
||||
+17
-12
@@ -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
|
||||
|
||||
|
||||
+20
-12
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+87
@@ -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())
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+78
-4
@@ -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/<name>.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 = []
|
||||
|
||||
Reference in New Issue
Block a user