feat: agent authoring & doc generation (WP-0007, v1.4.0)
Some checks failed
ci / test (push) Failing after 40s
Publish Python package / publish (push) Successful in 4m46s

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:
2026-06-18 02:06:14 +02:00
parent 7058859e5c
commit 843cf4eee0
19 changed files with 847 additions and 90 deletions

97
tests/test_agent_docs.py Normal file
View File

@@ -0,0 +1,97 @@
"""Tests for idempotent agent-docs generation (WP-0007 T01/T02)."""
from __future__ import annotations
from pathlib import Path
import pytest
from click.testing import CliRunner
from kaizen_agentic.agent_docs import (
SECTION_FOOTER,
SECTION_HEADING,
render_installed_agents_section,
upsert_installed_agents_section,
)
from kaizen_agentic.cli import cli
from kaizen_agentic.registry import AgentRegistry
AGENTS_DIR = Path(__file__).parent.parent / "agents"
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
def _section() -> str:
agents = AgentRegistry(AGENTS_DIR).list_agents()[:4]
return render_installed_agents_section(agents)
class TestUpsertIdempotency:
def test_upsert_is_idempotent(self):
section = _section()
base = f"# Project\n\nintro\n\n{section}\n## Keep Me\nbody\n"
once = upsert_installed_agents_section(base, section)
twice = upsert_installed_agents_section(once, section)
assert once == twice
assert once.count(SECTION_HEADING) == 1
assert once.count(SECTION_FOOTER) == 1
# A following top-level section must survive the replace
assert once.count("## Keep Me") == 1
def test_subheadings_do_not_truncate_section(self):
section = _section()
# The block contains '### Category' subheadings; the replace must not
# stop at the first one (the original regex bug).
merged = upsert_installed_agents_section("# P\n\n", section)
assert merged.count(SECTION_FOOTER) == 1
assert "### " in merged # categories rendered
def test_append_when_absent(self):
section = _section()
merged = upsert_installed_agents_section("# Project\n\nbody\n", section)
assert SECTION_HEADING in merged
assert merged.count(SECTION_FOOTER) == 1
class TestDocsGenerateCli:
def test_generate_then_check_clean(self, runner: CliRunner, tmp_path: Path):
# A project with two installed agents
proj = tmp_path / "proj"
(proj / "agents").mkdir(parents=True)
for name, cat in (("alpha", "testing"), ("beta", "code-quality")):
(proj / "agents" / f"agent-{name}.md").write_text(
f"---\nname: {name}\ndescription: d{name}\ncategory: {cat}\n---\nx\n"
)
gen = runner.invoke(cli, ["docs", "generate", "--target", str(proj)])
assert gen.exit_code == 0
claude = (proj / "CLAUDE.md").read_text()
assert claude.count(SECTION_HEADING) == 1
assert "**alpha**" in claude and "**beta**" in claude
# Second generate is a no-op
again = runner.invoke(cli, ["docs", "generate", "--target", str(proj)])
assert "already up to date" in again.output
# --check passes on a synced repo
check = runner.invoke(
cli, ["docs", "generate", "--check", "--target", str(proj)]
)
assert check.exit_code == 0
assert "up to date" in check.output
def test_check_fails_when_stale(self, runner: CliRunner, tmp_path: Path):
proj = tmp_path / "proj"
(proj / "agents").mkdir(parents=True)
(proj / "agents" / "agent-alpha.md").write_text(
"---\nname: alpha\ndescription: d\ncategory: testing\n---\nx\n"
)
(proj / "CLAUDE.md").write_text("# Proj\n\nno agents section yet\n")
check = runner.invoke(
cli, ["docs", "generate", "--check", "--target", str(proj)]
)
assert check.exit_code == 1
assert "out of date" in check.output

View File

@@ -40,10 +40,15 @@ class TestClickWorkaround:
assert "Got unexpected extra argument" not in stdout_content
assert "Got unexpected extra argument" not in stderr_content
def test_update_command_error_suppression(self):
def test_update_command_error_suppression(self, tmp_path):
"""Test that spurious 'unexpected extra argument' errors are suppressed for update commands."""
# Seed a temp project so `update` does not rewrite the repo's own agents/
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "agent-tdd-workflow.md").write_text(
"---\nname: tdd-workflow\ndescription: d\ncategory: testing\n---\nx\n"
)
# Test the update command that also shows spurious errors
with patch("sys.argv", ["kaizen-agentic", "update"]):
with patch("sys.argv", ["kaizen-agentic", "update", "--target", str(tmp_path)]):
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
with patch("sys.stderr", new_callable=StringIO) as mock_stderr:
try:
@@ -116,9 +121,12 @@ class TestClickWorkaround:
class TestInstallCommandSpecifics:
"""Test specific install command scenarios."""
def test_install_with_valid_agent(self):
def test_install_with_valid_agent(self, tmp_path):
"""Test install command with a valid agent name."""
with patch("sys.argv", ["kaizen-agentic", "install", "tdd-workflow"]):
with patch(
"sys.argv",
["kaizen-agentic", "install", "tdd-workflow", "--target", str(tmp_path)],
):
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
with patch("sys.stderr", new_callable=StringIO) as mock_stderr:
try:

100
tests/test_create_agent.py Normal file
View File

@@ -0,0 +1,100 @@
"""Tests for the create-agent scaffold (WP-0007 T04)."""
from __future__ import annotations
from pathlib import Path
import pytest
from click.testing import CliRunner
from kaizen_agentic.cli import cli
from kaizen_agentic.registry import AgentRegistry
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
class TestCreateAgent:
def test_scaffold_is_schema_valid_and_loads(
self, runner: CliRunner, tmp_path: Path
):
result = runner.invoke(
cli,
[
"create-agent",
"demo-helper",
"-c",
"testing",
"-d",
"Demo helper for tests",
"--target",
str(tmp_path),
],
)
assert result.exit_code == 0, result.output
agent_path = tmp_path / "agents" / "agent-demo-helper.md"
assert agent_path.exists()
registry = AgentRegistry(tmp_path / "agents")
# Passes the schema and is loadable by the registry.
assert registry.validate_frontmatter_schema() == {}
agent = registry.get_agent("demo-helper")
assert agent is not None
assert agent.category.value == "testing"
assert agent.memory == "enabled"
def test_interactive_prompts_when_flags_missing(
self, runner: CliRunner, tmp_path: Path
):
result = runner.invoke(
cli,
["create-agent", "prompted", "--target", str(tmp_path)],
input="testing\nA prompted agent\n",
)
assert result.exit_code == 0, result.output
content = (tmp_path / "agents" / "agent-prompted.md").read_text()
assert "category: testing" in content
assert "description: A prompted agent" in content
def test_refuses_overwrite_without_force(self, runner: CliRunner, tmp_path: Path):
args = [
"create-agent",
"dup",
"-c",
"meta",
"-d",
"first",
"--target",
str(tmp_path),
]
assert runner.invoke(cli, args).exit_code == 0
second = runner.invoke(cli, args)
assert second.exit_code == 1
assert "already exists" in second.output
def test_force_overwrites(self, runner: CliRunner, tmp_path: Path):
base = ["create-agent", "dup", "--target", str(tmp_path)]
runner.invoke(cli, base + ["-c", "meta", "-d", "first"])
result = runner.invoke(cli, base + ["-c", "meta", "-d", "second", "--force"])
assert result.exit_code == 0
assert (
"description: second" in (tmp_path / "agents" / "agent-dup.md").read_text()
)
def test_rejects_invalid_category(self, runner: CliRunner, tmp_path: Path):
result = runner.invoke(
cli,
[
"create-agent",
"x",
"-c",
"nonsense",
"-d",
"d",
"--target",
str(tmp_path),
],
)
assert result.exit_code != 0

View File

@@ -0,0 +1,74 @@
"""Tests for agent frontmatter schema validation (WP-0007 T03)."""
from __future__ import annotations
from pathlib import Path
from kaizen_agentic.registry import AgentRegistry
REPO_AGENTS = Path(__file__).parent.parent / "agents"
def _registry(tmp_path: Path, files: dict) -> AgentRegistry:
agents = tmp_path / "agents"
agents.mkdir(parents=True)
for filename, content in files.items():
(agents / filename).write_text(content)
return AgentRegistry(agents)
class TestFrontmatterSchema:
def test_repo_agents_are_schema_valid(self):
# The shipped agents/ must always pass the schema.
assert AgentRegistry(REPO_AGENTS).validate_frontmatter_schema() == {}
def test_good_agent_passes(self, tmp_path: Path):
reg = _registry(
tmp_path,
{
"agent-good.md": (
"---\nname: good\ndescription: A fine agent\n"
"category: testing\nmemory: enabled\n---\nbody\n"
)
},
)
assert reg.validate_frontmatter_schema() == {}
def test_missing_required_fields(self, tmp_path: Path):
reg = _registry(
tmp_path,
{"agent-x.md": "---\nname: x\ncategory: testing\n---\nbody\n"},
)
errors = reg.validate_frontmatter_schema()["agent-x.md"]
assert any("description" in e for e in errors)
def test_invalid_category(self, tmp_path: Path):
reg = _registry(
tmp_path,
{
"agent-x.md": (
"---\nname: x\ndescription: d\ncategory: nonsense\n---\nb\n"
)
},
)
errors = reg.validate_frontmatter_schema()["agent-x.md"]
assert any("invalid category" in e for e in errors)
def test_invalid_memory(self, tmp_path: Path):
reg = _registry(
tmp_path,
{
"agent-x.md": (
"---\nname: x\ndescription: d\ncategory: testing\n"
"memory: maybe\n---\nb\n"
)
},
)
errors = reg.validate_frontmatter_schema()["agent-x.md"]
assert any("invalid memory" in e for e in errors)
def test_missing_frontmatter(self, tmp_path: Path):
reg = _registry(tmp_path, {"agent-x.md": "just text, no frontmatter\n"})
assert reg.validate_frontmatter_schema()["agent-x.md"] == [
"missing YAML frontmatter"
]