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:
Claude
2026-05-19 00:52:42 +00:00
parent c374cc28a1
commit 0a34178e22
8 changed files with 248 additions and 29 deletions
+78 -4
View File
@@ -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 = []