feat: agent authoring & doc generation (WP-0007, v1.4.0)
New authoring tooling and a fix for the doc-regeneration defect it exposed. Added: - src/kaizen_agentic/agent_docs.py — render + idempotent upsert of the CLAUDE.md "## Installed Agents" section (shared by installer and CLI) - `kaizen-agentic docs generate [--check]` — idempotent doc refresh / CI gate - `kaizen-agentic create-agent` — scaffold a schema-valid agent - Frontmatter schema validation in `kaizen-agentic validate` (required name/description/category, known category, valid memory/model) - tests: test_agent_docs, test_validate_schema, test_create_agent Fixed: - _update_documentation regex duplicated the Installed Agents block on every run (stopped at the first ### subheading) — now idempotent - declared frontmatter `category` is authoritative (heuristic is fallback) - list_installed_agents reads the frontmatter name, not the filename - renamed agent-project-management.md -> agent-project-assistant.md to satisfy the agent-<name>.md convention (eliminates a name/filename collision that caused install/update to write a divergent duplicate) - test_cli_error_handling no longer installs into the repo root (uses tmp) Version 1.4.0; CHANGELOG, CLI cheat sheet, agency-framework, TODO updated. Workplan KAIZEN-WP-0007 closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ It also includes a comprehensive agent distribution system for sharing
|
||||
specialized agents across projects via CLI tools and package management.
|
||||
"""
|
||||
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.4.0"
|
||||
__author__ = "Kaizen Agentic Team"
|
||||
|
||||
from .core import Agent, AgentConfig
|
||||
|
||||
73
src/kaizen_agentic/agent_docs.py
Normal file
73
src/kaizen_agentic/agent_docs.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Render and idempotently upsert the project ``## Installed Agents`` section.
|
||||
|
||||
Single source of truth for the agent documentation block written into a
|
||||
project's ``CLAUDE.md``. Both :class:`~kaizen_agentic.installer.AgentInstaller`
|
||||
and the ``kaizen-agentic docs generate`` command reuse these helpers so the
|
||||
section is produced and replaced the same way everywhere — and, critically,
|
||||
**idempotently**: regenerating N times yields the same output as once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
from .registry import AgentDefinition
|
||||
|
||||
SECTION_HEADING = "## Installed Agents"
|
||||
SECTION_FOOTER = (
|
||||
"Use these agents by referencing them in your Claude Code interactions."
|
||||
)
|
||||
|
||||
# Match the Installed Agents block up to the next *top-level* heading (``\n## ``,
|
||||
# with a trailing space so ``### Subsection`` headings inside the block do not
|
||||
# terminate the match) or end-of-file. The previous ``(?=##|\Z)`` form stopped
|
||||
# at the first ``### Category`` subheading and so duplicated the block on every
|
||||
# run (WP-0007 T01).
|
||||
_SECTION_RE = re.compile(r"## Installed Agents.*?(?=\n## (?!#)|\Z)", re.DOTALL)
|
||||
|
||||
|
||||
def render_installed_agents_section(agents: Iterable[AgentDefinition]) -> str:
|
||||
"""Render the ``## Installed Agents`` markdown block from agent metadata.
|
||||
|
||||
Agents are grouped by category in first-seen order. The returned string is
|
||||
newline-terminated and contains no trailing top-level heading, so it can be
|
||||
spliced in front of any following section.
|
||||
"""
|
||||
lines: List[str] = [
|
||||
SECTION_HEADING,
|
||||
"",
|
||||
"This project includes the following specialized agents:",
|
||||
"",
|
||||
]
|
||||
|
||||
categories: Dict[str, List[AgentDefinition]] = {}
|
||||
for agent in agents:
|
||||
categories.setdefault(agent.category.value, []).append(agent)
|
||||
|
||||
for category, members in categories.items():
|
||||
lines.append(f"### {category.replace('-', ' ').title()}")
|
||||
lines.append("")
|
||||
for agent in members:
|
||||
lines.append(f"- **{agent.name}**: {agent.description}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(SECTION_FOOTER)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def upsert_installed_agents_section(content: str, section: str) -> str:
|
||||
"""Return ``content`` with its Installed Agents block replaced or appended.
|
||||
|
||||
Idempotent: if ``content`` already contains the block, exactly that block is
|
||||
replaced (never duplicated); otherwise the section is appended. ``section``
|
||||
is normalised to end with a single trailing blank line.
|
||||
"""
|
||||
section = section.rstrip() + "\n"
|
||||
|
||||
if SECTION_HEADING in content:
|
||||
return _SECTION_RE.sub(lambda _m: section, content, count=1)
|
||||
|
||||
separator = "" if content.endswith("\n\n") or not content else "\n"
|
||||
return content + separator + section
|
||||
@@ -25,6 +25,10 @@ from .schedule import (
|
||||
schedule_path,
|
||||
validate_schedule,
|
||||
)
|
||||
from .agent_docs import (
|
||||
render_installed_agents_section,
|
||||
upsert_installed_agents_section,
|
||||
)
|
||||
|
||||
|
||||
def safe_cli_wrapper():
|
||||
@@ -418,8 +422,21 @@ def validate(target: str):
|
||||
|
||||
target_path = Path(target).resolve()
|
||||
|
||||
# Validate agent frontmatter schema
|
||||
click.echo("Validating agent frontmatter schema...")
|
||||
schema_errors = registry.validate_frontmatter_schema()
|
||||
|
||||
if schema_errors:
|
||||
click.echo("Frontmatter schema errors:")
|
||||
for agent_file, errors in schema_errors.items():
|
||||
click.echo(f" {agent_file}:")
|
||||
for error in errors:
|
||||
click.echo(f" ❌ {error}")
|
||||
else:
|
||||
click.echo(" ✅ Frontmatter schema validation passed")
|
||||
|
||||
# Validate registry agents
|
||||
click.echo("Validating agent registry...")
|
||||
click.echo("\nValidating agent registry...")
|
||||
registry_errors = registry.validate_agents()
|
||||
|
||||
if registry_errors:
|
||||
@@ -1565,6 +1582,156 @@ def _render_prepare_markdown(bundle: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@cli.command("create-agent")
|
||||
@click.argument("name")
|
||||
@click.option(
|
||||
"--category",
|
||||
"-c",
|
||||
type=click.Choice([c.value for c in AgentCategory]),
|
||||
help="Agent category (prompted if omitted)",
|
||||
)
|
||||
@click.option("--description", "-d", help="One-line description (prompted if omitted)")
|
||||
@click.option(
|
||||
"--memory",
|
||||
type=click.Choice(["enabled", "disabled"]),
|
||||
default="enabled",
|
||||
show_default=True,
|
||||
help="Project memory support",
|
||||
)
|
||||
@click.option("--model", help="Optional model hint (e.g. claude-opus-4-8)")
|
||||
@click.option(
|
||||
"--target",
|
||||
"-t",
|
||||
default=".",
|
||||
help="Project root containing agents/ (default: current)",
|
||||
)
|
||||
@click.option("--force", is_flag=True, help="Overwrite an existing agent file")
|
||||
def create_agent(
|
||||
name: str,
|
||||
category: Optional[str],
|
||||
description: Optional[str],
|
||||
memory: str,
|
||||
model: Optional[str],
|
||||
target: str,
|
||||
force: bool,
|
||||
):
|
||||
"""Scaffold a new schema-valid agent definition (agents/agent-<name>.md)."""
|
||||
if not category:
|
||||
category = click.prompt(
|
||||
"Category", type=click.Choice([c.value for c in AgentCategory])
|
||||
)
|
||||
if not description:
|
||||
description = click.prompt("One-line description")
|
||||
|
||||
agents_dir = _project_root(target) / "agents"
|
||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||||
agent_path = agents_dir / f"agent-{name}.md"
|
||||
|
||||
if agent_path.exists() and not force:
|
||||
click.echo(f"Agent already exists: {agent_path}")
|
||||
click.echo(" Use --force to overwrite.")
|
||||
sys.exit(1)
|
||||
|
||||
frontmatter = [
|
||||
"---",
|
||||
f"name: {name}",
|
||||
f"description: {description}",
|
||||
f"category: {category}",
|
||||
f"memory: {memory}",
|
||||
]
|
||||
if model:
|
||||
frontmatter.append(f"model: {model}")
|
||||
frontmatter.append("---")
|
||||
|
||||
title = name.replace("-", " ").title()
|
||||
body = f"""
|
||||
# {title} Agent
|
||||
|
||||
## Role
|
||||
|
||||
<!-- One paragraph: what this agent does and what it does not do. -->
|
||||
|
||||
## When to Use
|
||||
|
||||
<!-- Triggers and situations where this agent should be invoked. -->
|
||||
|
||||
## Instructions
|
||||
|
||||
<!-- Step-by-step guidance the agent follows. -->
|
||||
|
||||
## Output
|
||||
|
||||
<!-- What the agent produces and in what format. -->
|
||||
"""
|
||||
agent_path.write_text("\n".join(frontmatter) + "\n" + body)
|
||||
|
||||
# Validate the scaffold passes the frontmatter schema (T03).
|
||||
errors = (
|
||||
AgentRegistry(agents_dir).validate_frontmatter_schema().get(agent_path.name, [])
|
||||
)
|
||||
if errors:
|
||||
click.echo(f"⚠️ Created {agent_path} but it has schema issues:")
|
||||
for error in errors:
|
||||
click.echo(f" ❌ {error}")
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"✅ Created agent: {agent_path}")
|
||||
click.echo(" Edit the skeleton, then validate: kaizen-agentic validate")
|
||||
click.echo(" Before release: make agents-sync-package")
|
||||
|
||||
|
||||
@cli.group()
|
||||
def docs():
|
||||
"""Generate project documentation from agent metadata."""
|
||||
pass
|
||||
|
||||
|
||||
@docs.command("generate")
|
||||
@click.option("--target", "-t", default=".", help="Project root (default: current)")
|
||||
@click.option(
|
||||
"--check",
|
||||
is_flag=True,
|
||||
help="Exit non-zero if CLAUDE.md would change (do not write)",
|
||||
)
|
||||
def docs_generate(target: str, check: bool):
|
||||
"""Refresh the '## Installed Agents' section of CLAUDE.md (idempotent)."""
|
||||
target_path = _project_root(target)
|
||||
# Resolve agents from the target project's own agents/ when present, so
|
||||
# `docs generate --target other/project` documents that project's agents
|
||||
# rather than the registry resolved from the current directory.
|
||||
local_agents = target_path / "agents"
|
||||
registry = AgentRegistry(local_agents) if local_agents.exists() else _get_registry()
|
||||
installer = AgentInstaller(registry)
|
||||
|
||||
installed = installer.list_installed_agents(target_path)
|
||||
if not installed:
|
||||
click.echo("No agents installed in this project — nothing to document.")
|
||||
click.echo(" Run: kaizen-agentic install <agents>")
|
||||
return
|
||||
|
||||
agents = [a for a in (registry.get_agent(n) for n in installed) if a is not None]
|
||||
section = render_installed_agents_section(agents)
|
||||
|
||||
claude_md = target_path / "CLAUDE.md"
|
||||
current = claude_md.read_text() if claude_md.exists() else ""
|
||||
updated = upsert_installed_agents_section(current, section)
|
||||
|
||||
if check:
|
||||
if updated != current:
|
||||
click.echo(f"❌ CLAUDE.md is out of date: {claude_md}")
|
||||
click.echo(" Run: kaizen-agentic docs generate")
|
||||
sys.exit(1)
|
||||
click.echo(f"✅ CLAUDE.md is up to date ({len(agents)} agents)")
|
||||
return
|
||||
|
||||
if updated == current:
|
||||
click.echo(f"CLAUDE.md already up to date ({len(agents)} agents)")
|
||||
return
|
||||
|
||||
claude_md.write_text(updated)
|
||||
click.echo(f"Updated Installed Agents section ({len(agents)} agents): {claude_md}")
|
||||
|
||||
|
||||
def _project_root(target: str) -> Path:
|
||||
return Path(target).resolve()
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .registry import AgentRegistry
|
||||
from .registry import AgentRegistry, AgentDefinition
|
||||
from .agent_docs import (
|
||||
render_installed_agents_section,
|
||||
upsert_installed_agents_section,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -82,7 +86,15 @@ class AgentInstaller:
|
||||
|
||||
installed = []
|
||||
for agent_file in agents_dir.glob("agent-*.md"):
|
||||
agent_name = agent_file.stem.replace("agent-", "")
|
||||
# Prefer the frontmatter name (registry-authoritative); fall back to
|
||||
# the filename when frontmatter is missing/unreadable. The filename
|
||||
# encodes the category for a few agents (e.g. agent-project-
|
||||
# management.md → name: project-assistant), so a pure filename derive
|
||||
# produces names the registry cannot resolve (WP-0007 T02).
|
||||
try:
|
||||
agent_name = AgentDefinition._read_frontmatter(agent_file)["name"]
|
||||
except Exception:
|
||||
agent_name = agent_file.stem.replace("agent-", "")
|
||||
installed.append(agent_name)
|
||||
|
||||
return sorted(installed)
|
||||
@@ -235,60 +247,25 @@ agents-validate:
|
||||
try:
|
||||
claude_md = project_dir / "CLAUDE.md"
|
||||
|
||||
agent_section = "## Installed Agents\n\n"
|
||||
agent_section += (
|
||||
"This project includes the following specialized agents:\n\n"
|
||||
)
|
||||
agents = [
|
||||
agent
|
||||
for agent in (self.registry.get_agent(name) for name in agent_names)
|
||||
if agent is not None
|
||||
]
|
||||
agent_section = render_installed_agents_section(agents)
|
||||
|
||||
# Group agents by category
|
||||
categories = {}
|
||||
for agent_name in agent_names:
|
||||
agent = self.registry.get_agent(agent_name)
|
||||
if agent:
|
||||
category = agent.category.value
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append(agent)
|
||||
|
||||
# Generate documentation
|
||||
for category, agents in categories.items():
|
||||
agent_section += f"### {category.replace('-', ' ').title()}\n\n"
|
||||
for agent in agents:
|
||||
agent_section += f"- **{agent.name}**: {agent.description}\n"
|
||||
agent_section += "\n"
|
||||
|
||||
agent_section += (
|
||||
"Use these agents by referencing them in your "
|
||||
"Claude Code interactions.\n\n"
|
||||
)
|
||||
|
||||
# Update or create CLAUDE.md
|
||||
# Update or create CLAUDE.md (idempotent upsert — WP-0007 T01/T02)
|
||||
if claude_md.exists():
|
||||
with open(claude_md, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace existing agent section or append
|
||||
if "## Installed Agents" in content:
|
||||
import re
|
||||
|
||||
content = re.sub(
|
||||
r"## Installed Agents.*?(?=##|\Z)",
|
||||
agent_section,
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
else:
|
||||
content += "\n" + agent_section
|
||||
|
||||
with open(claude_md, "w") as f:
|
||||
f.write(content)
|
||||
content = claude_md.read_text()
|
||||
content = upsert_installed_agents_section(content, agent_section)
|
||||
claude_md.write_text(content)
|
||||
else:
|
||||
# Create new CLAUDE.md
|
||||
header = "# Claude Code Configuration\n\n"
|
||||
header += "This file contains Claude Code configuration and agent information.\n\n"
|
||||
|
||||
with open(claude_md, "w") as f:
|
||||
f.write(header + agent_section)
|
||||
header += (
|
||||
"This file contains Claude Code configuration and agent "
|
||||
"information.\n\n"
|
||||
)
|
||||
claude_md.write_text(header + agent_section)
|
||||
|
||||
print(f"Updated documentation: {claude_md}")
|
||||
|
||||
|
||||
@@ -60,8 +60,10 @@ class AgentDefinition:
|
||||
# Extract dependencies from frontmatter and content
|
||||
dependencies = cls._extract_dependencies(content, frontmatter)
|
||||
|
||||
# Determine category from name or content
|
||||
category = cls._determine_category(frontmatter["name"], content)
|
||||
# The declared frontmatter category is authoritative when it is a known
|
||||
# value (it is what `validate` enforces, WP-0007 T03); fall back to the
|
||||
# name/content heuristic only when absent or unrecognised.
|
||||
category = cls._resolve_category(frontmatter, content)
|
||||
|
||||
return cls(
|
||||
name=frontmatter["name"],
|
||||
@@ -118,6 +120,17 @@ class AgentDefinition:
|
||||
|
||||
return dependencies
|
||||
|
||||
@classmethod
|
||||
def _resolve_category(cls, frontmatter: dict, content: str) -> AgentCategory:
|
||||
"""Prefer the declared frontmatter category; heuristic as fallback."""
|
||||
declared = frontmatter.get("category")
|
||||
if isinstance(declared, str):
|
||||
try:
|
||||
return AgentCategory(declared.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return cls._determine_category(frontmatter["name"], content)
|
||||
|
||||
@staticmethod
|
||||
def _determine_category(name: str, content: str) -> AgentCategory:
|
||||
"""Determine agent category based on name and content."""
|
||||
@@ -288,6 +301,65 @@ class AgentRegistry:
|
||||
|
||||
return errors
|
||||
|
||||
def validate_frontmatter_schema(self) -> Dict[str, List[str]]:
|
||||
"""Validate each agent file's frontmatter against the required schema.
|
||||
|
||||
Required: ``name``, ``description``, ``category`` (non-empty strings);
|
||||
``category`` must be a known :class:`AgentCategory` value. Optional:
|
||||
``memory`` ∈ {enabled, disabled}; ``model`` a non-empty string. Keyed by
|
||||
filename so files with a missing/invalid ``name`` still surface.
|
||||
"""
|
||||
errors: Dict[str, List[str]] = {}
|
||||
valid_categories = {c.value for c in AgentCategory}
|
||||
|
||||
if not self.agents_dir.exists():
|
||||
return errors
|
||||
|
||||
for agent_file in sorted(self.agents_dir.glob("agent-*.md")):
|
||||
file_errors: List[str] = []
|
||||
try:
|
||||
content = agent_file.read_text(encoding="utf-8")
|
||||
match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
|
||||
if not match:
|
||||
errors[agent_file.name] = ["missing YAML frontmatter"]
|
||||
continue
|
||||
frontmatter = yaml.safe_load(match.group(1))
|
||||
if not isinstance(frontmatter, dict):
|
||||
errors[agent_file.name] = ["frontmatter is not a mapping"]
|
||||
continue
|
||||
|
||||
for field in ("name", "description", "category"):
|
||||
value = frontmatter.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
file_errors.append(f"missing or empty required field: {field}")
|
||||
|
||||
category = frontmatter.get("category")
|
||||
if isinstance(category, str) and category not in valid_categories:
|
||||
file_errors.append(
|
||||
f"invalid category '{category}' (expected one of "
|
||||
f"{', '.join(sorted(valid_categories))})"
|
||||
)
|
||||
|
||||
memory = frontmatter.get("memory")
|
||||
if memory is not None and memory not in ("enabled", "disabled"):
|
||||
file_errors.append(
|
||||
f"invalid memory '{memory}' "
|
||||
"(expected 'enabled' or 'disabled')"
|
||||
)
|
||||
|
||||
model = frontmatter.get("model")
|
||||
if model is not None and (
|
||||
not isinstance(model, str) or not model.strip()
|
||||
):
|
||||
file_errors.append("model must be a non-empty string when present")
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
file_errors.append(f"failed to parse frontmatter: {exc}")
|
||||
|
||||
if file_errors:
|
||||
errors[agent_file.name] = file_errors
|
||||
|
||||
return errors
|
||||
|
||||
def _has_circular_dependency(
|
||||
self, agent_name: str, visited: Optional[Set[str]] = None
|
||||
) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user