mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-04 02:43: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:
@@ -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())
|
||||
Reference in New Issue
Block a user