mirror of
https://github.com/alirezarezvani/ClaudeForge.git
synced 2026-07-03 18:33:16 -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:
+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