---
phase: 04-cli-surface-generation
plan: 02
type: execute
wave: 2
depends_on: [04-01]
files_modified:
  - src/ztlctl/commands/__init__.py
  - src/ztlctl/catalogs.py
  - src/ztlctl/commands/create.py
  - src/ztlctl/commands/update.py
  - tests/mcp/test_parity.py
  - tests/commands/test_cli_parity.py
autonomous: true
requirements: [ACTN-04, ACTN-05]
must_haves:
  truths:
    - "Running ztlctl --help shows command groups matching ActionRegistry cli_group values (query, graph, session, check, ingest, export, vector, upgrade, reweave, init, create)"
    - "Generated CLI commands invoke action handlers and emit results identically to hand-written predecessors"
    - "custom_presentation commands (batch, init wizard, serve, workflow, update, garden seed) still work via hand-written implementations"
    - "Every non-custom_presentation ActionDefinition has a corresponding CLI command"
    - "Deleted hand-written command files are no longer importable"
    - "cli_command_catalog() returns a dynamic catalog derived from ActionRegistry"
    - "All existing test suites pass after migration"
  artifacts:
    - path: "src/ztlctl/commands/__init__.py"
      provides: "Generator-driven command registration + custom command wiring"
      contains: "generate_commands"
    - path: "src/ztlctl/catalogs.py"
      provides: "Dynamic CLI catalog from ActionRegistry"
      contains: "get_action_registry"
    - path: "tests/commands/test_cli_parity.py"
      provides: "CLI parity test suite"
  key_links:
    - from: "src/ztlctl/commands/__init__.py"
      to: "src/ztlctl/commands/generator.py"
      via: "generate_commands(cli) call"
      pattern: "generate_commands"
    - from: "src/ztlctl/commands/__init__.py"
      to: "src/ztlctl/commands/create.py"
      via: "batch subcommand added to generated create group"
      pattern: "batch"
    - from: "src/ztlctl/catalogs.py"
      to: "src/ztlctl/actions/registry.py"
      via: "Dynamic catalog derivation"
      pattern: "get_action_registry"
---

<objective>
Wire the CLI generator into the command registration pipeline, delete replaced hand-written command files, update the CLI catalog to be dynamic, and verify complete parity between ActionRegistry and CLI surface.

Purpose: This plan completes the Phase 4 migration by replacing the static command registration with generator-driven registration, preserving custom_presentation escape hatches, and proving the migration is correct via parity tests and full test suite validation.

Output: Working CLI driven by ActionRegistry, deleted hand-written command files, dynamic CLI catalog, parity test suite.
</objective>

<execution_context>
@/Users/shparki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shparki/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-cli-surface-generation/04-CONTEXT.md
@.planning/phases/04-cli-surface-generation/04-RESEARCH.md
@.planning/phases/04-cli-surface-generation/04-01-SUMMARY.md

<interfaces>
<!-- From Plan 01 outputs -->

From src/ztlctl/commands/generator.py (created in Plan 01):
```python
def generate_commands(cli: click.Group) -> None:
    """Register all non-custom_presentation actions as Click commands."""
    ...

def _make_command(action: ActionDefinition) -> click.Command: ...
def _param_to_click(p: ActionParam) -> click.Parameter: ...
def _derive_cli_name(action: ActionDefinition) -> str: ...
```

From src/ztlctl/commands/__init__.py (current, to be replaced):
```python
def load_plugin_commands(ctx: click.Context) -> dict[str, click.Command]: ...
def register_commands(cli: click.Group) -> None:
    # Currently imports 9 groups + 9 standalone commands manually
    ...
```

From src/ztlctl/catalogs.py (current _CLI_COMMAND_CATALOG):
```python
_CLI_COMMAND_CATALOG: tuple[CliCommandCatalogEntry, ...] = (
    {"name": "agent", "kind": "group", "summary": "..."},
    {"name": "archive", "kind": "command", "summary": "..."},
    # ... 18 entries total
)
def cli_command_catalog() -> tuple[CliCommandCatalogEntry, ...]: ...
```

From tests/mcp/test_parity.py (to extend):
```python
def test_all_actions_have_mcp_tools(parity_server): ...
# 6 parity tests verifying 59/59 MCP coverage
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Wire generator into __init__.py, restructure custom commands, delete replaced files, update catalog</name>
  <files>
    src/ztlctl/commands/__init__.py
    src/ztlctl/catalogs.py
    src/ztlctl/commands/create.py
    src/ztlctl/commands/update.py
    src/ztlctl/commands/garden.py
  </files>
  <read_first>
    src/ztlctl/commands/__init__.py
    src/ztlctl/commands/generator.py
    src/ztlctl/catalogs.py
    src/ztlctl/commands/create.py
    src/ztlctl/commands/update.py
    src/ztlctl/commands/garden.py
    src/ztlctl/commands/serve.py
    src/ztlctl/commands/workflow.py
    src/ztlctl/commands/init_cmd.py
    src/ztlctl/commands/query.py
    src/ztlctl/commands/agent.py
    src/ztlctl/commands/graph.py
    src/ztlctl/commands/archive.py
    src/ztlctl/commands/extract.py
    src/ztlctl/commands/reweave.py
    src/ztlctl/commands/check.py
    src/ztlctl/commands/upgrade.py
    src/ztlctl/commands/export.py
    src/ztlctl/commands/ingest.py
    src/ztlctl/commands/vector.py
    src/ztlctl/commands/supersede.py
    src/ztlctl/commands/session.py
    src/ztlctl/actions/_register_core.py
  </read_first>
  <action>
**Step 1: Rewrite `src/ztlctl/commands/__init__.py`**

Replace the current `register_commands()` which imports 18 hand-written modules with:

```python
"""Subcommand modules for ztlctl.

Provides register_commands() which uses the CLI generator for standard
actions and manually registers custom_presentation commands.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import click


def load_plugin_commands(ctx: click.Context) -> dict[str, click.Command]:
    """Resolve plugin CLI commands for the current invocation context."""
    from ztlctl.actions.registry import get_action_registry
    from ztlctl.config.settings import ZtlSettings
    from ztlctl.plugins.manager import PluginManager

    try:
        settings = ZtlSettings.from_cli(
            config_path=ctx.params.get("config_path"),
            json_output=bool(ctx.params.get("json_output", False)),
            quiet=bool(ctx.params.get("quiet", False)),
            verbose=bool(ctx.params.get("verbose", False)),
            log_json=bool(ctx.params.get("log_json", False)),
            no_interact=bool(ctx.params.get("no_interact", False)),
            no_reweave=bool(ctx.params.get("no_reweave", False)),
            sync=bool(ctx.params.get("sync", False)),
        )
    except Exception:
        return {}

    pm = PluginManager()
    pm.discover_and_load(local_dir=settings.vault_root / ".ztlctl" / "plugins")

    import ztlctl.actions  # noqa: F401 — ensure registry is populated
    reserved = {a.name for a in get_action_registry().list_actions()}
    return {
        entry.name: entry.command
        for entry in pm.cli_command_contributions(reserved_names=reserved)
    }


def register_commands(cli: click.Group) -> None:
    """Register all command groups and standalone commands on the root CLI group.

    Uses the generator for standard ActionDefinitions, then manually adds
    custom_presentation commands that require hand-written implementations.
    """
    from ztlctl.commands.generator import generate_commands

    # Auto-generate standard commands from ActionRegistry
    generate_commands(cli)

    # --- Custom presentation commands (hand-written) ---

    # create group: batch subcommand added to the generator-created 'create' group
    from ztlctl.commands.create import batch
    create_group = cli.commands.get("create")
    if create_group is not None:
        create_group.add_command(batch)  # type: ignore[union-attr]

    # update: decomposed --title/--status/--tags/--body/--maturity flags (custom_presentation)
    from ztlctl.commands.update import update
    cli.add_command(update)

    # garden: not in ActionRegistry (calls CreateService with maturity=seed directly)
    from ztlctl.commands.garden import garden
    cli.add_command(garden)

    # init wizard (custom_presentation)
    from ztlctl.commands.init_cmd import init_cmd
    cli.add_command(init_cmd)

    # serve (custom_presentation)
    from ztlctl.commands.serve import serve
    cli.add_command(serve)

    # workflow (custom_presentation: init_workflow, update_workflow, export_assets)
    from ztlctl.commands.workflow import workflow
    cli.add_command(workflow)
```

**Step 2: Restructure `src/ztlctl/commands/create.py`**

The generator will create the `create` group with `note`, `reference`, `task` subcommands (from ActionDefinitions with cli_group="create"). The hand-written create.py only needs to export the `batch` command.

Restructure create.py to:
1. Remove the `create` group definition (generator creates it)
2. Remove the `note`, `reference`, `task` command definitions (generator creates them)
3. Keep only the `batch` command as a standalone click.command (not under a group)
4. Keep `_is_interactive` helper if needed by batch, otherwise remove
5. The batch command stays as `@click.command(cls=ZtlCommand, ...)` with name "batch"

The file should look like:
```python
"""Custom presentation: batch create command."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import click
from ztlctl.commands._base import ZtlCommand

if TYPE_CHECKING:
    from ztlctl.commands._context import AppContext

@click.command(
    cls=ZtlCommand,
    examples="""...""",
)
@click.argument("file", type=click.Path(exists=True))
@click.option("--partial", is_flag=True, help="Continue on errors (partial mode).")
@click.pass_obj
def batch(app: AppContext, file: str, partial: bool) -> None:
    """Create multiple items from a JSON file."""
    # ... existing batch implementation
```

**Step 3: Keep `src/ztlctl/commands/update.py` as-is**

The update command is now custom_presentation=True (set in Plan 01). It stays unchanged — `register_commands` adds it directly to cli.

**Step 4: Delete replaced hand-written command files**

Delete these files (the generator produces their equivalent commands):
- `src/ztlctl/commands/query.py` (replaced by generated query group)
- `src/ztlctl/commands/graph.py` (replaced by generated graph group)
- `src/ztlctl/commands/agent.py` (replaced by generated session group + init group)
- `src/ztlctl/commands/archive.py` (replaced by generated standalone)
- `src/ztlctl/commands/supersede.py` (replaced by generated standalone)
- `src/ztlctl/commands/extract.py` (replaced by generated session extract)
- `src/ztlctl/commands/reweave.py` (replaced by generated reweave group)
- `src/ztlctl/commands/check.py` (replaced by generated check group)
- `src/ztlctl/commands/upgrade.py` (replaced by generated upgrade group)
- `src/ztlctl/commands/export.py` (replaced by generated export group)
- `src/ztlctl/commands/ingest.py` (replaced by generated ingest group)
- `src/ztlctl/commands/vector.py` (replaced by generated vector group)
- `src/ztlctl/commands/session.py` (if exists, replaced by generated session group)

Do NOT delete:
- `_base.py`, `_context.py` (infrastructure)
- `create.py` (batch command), `update.py` (custom_presentation)
- `garden.py` (not in registry)
- `init_cmd.py`, `serve.py`, `workflow.py` (custom_presentation)
- `generator.py` (new)

**Step 5: Update `src/ztlctl/catalogs.py`**

Replace the static `_CLI_COMMAND_CATALOG` with a dynamic derivation:

```python
def cli_command_catalog() -> tuple[CliCommandCatalogEntry, ...]:
    """Return the core CLI command catalog, derived from ActionRegistry."""
    import ztlctl.actions  # noqa: F401 — ensure registry populated
    from ztlctl.actions.registry import get_action_registry

    registry = get_action_registry()
    seen_groups: set[str] = set()
    entries: list[CliCommandCatalogEntry] = []

    for action in registry.list_actions():
        if action.cli_group and action.cli_group not in seen_groups:
            seen_groups.add(action.cli_group)
            entries.append({
                "name": action.cli_group,
                "kind": "group",
                "summary": action.description,  # First action in group
            })
        elif action.cli_group is None and not action.custom_presentation:
            entries.append({
                "name": action.name.replace("_", "-"),
                "kind": "command",
                "summary": action.description,
            })

    # Add custom_presentation entries that don't come from registry iteration
    for name, summary in [
        ("garden", "Cultivate knowledge with the garden persona."),
        ("init", "Initialize a new ztlctl vault."),
        ("serve", "Start the MCP server."),
        ("update", "Update content metadata or body."),
        ("workflow", "Manage workflow templates and exports."),
    ]:
        if name not in seen_groups and not any(e["name"] == name for e in entries):
            entries.append({"name": name, "kind": "command" if name != "workflow" else "group", "summary": summary})

    return tuple(sorted(entries, key=lambda e: e["name"]))
```

Remove the static `_CLI_COMMAND_CATALOG` tuple. Keep the `CliCommandCatalogEntry` TypedDict.

**Step 6: Update existing test imports**

After deleting command files, some test files may import from them. Check and update:
- Tests that import command functions (e.g., `from ztlctl.commands.query import query`) need to be updated to use CliRunner invocations instead. Most tests already use CliRunner so this should be minimal.
- The test for `query` group help, `graph` examples, etc. should still work via CliRunner since the generated commands are registered.

Run `uv run pytest -x -q` after each deletion to catch import errors immediately.

**Step 7: Run full validation**

```bash
uv run ruff check . && uv run ruff format --check . && uv run pytest -x -q && uv run mypy src/
```

Fix any issues. Some existing tests will need command path updates:
- `["query", "list"]` may need to stay the same (generator uses cli_name="list")
- `["agent", "session", "start"]` becomes `["session", "start"]`
- `["agent", "context"]` becomes `["session", "context"]`
- `["agent", "brief"]` becomes `["session", "brief"]`
- `["agent", "regenerate"]` becomes `["init", "regenerate"]`
- `["extract"]` becomes `["session", "extract"]`
- `["graph", "materialize"]` stays same (cli_name="materialize")
- `["reweave"]` becomes `["reweave", "run"]`

Update test invocations to match the new command paths. Track all changes.
  </action>
  <verify>
    <automated>uv run pytest -x -q && uv run mypy src/ && uv run ruff check .</automated>
  </verify>
  <acceptance_criteria>
    - grep "generate_commands" src/ztlctl/commands/__init__.py returns at least 1 match
    - File src/ztlctl/commands/query.py does NOT exist (deleted)
    - File src/ztlctl/commands/graph.py does NOT exist (deleted)
    - File src/ztlctl/commands/agent.py does NOT exist (deleted)
    - File src/ztlctl/commands/archive.py does NOT exist (deleted)
    - File src/ztlctl/commands/extract.py does NOT exist (deleted)
    - File src/ztlctl/commands/create.py exists and contains "def batch" but NOT "def note" or "def reference"
    - File src/ztlctl/commands/update.py exists (custom_presentation preserved)
    - File src/ztlctl/commands/garden.py exists (not in registry)
    - grep "get_action_registry" src/ztlctl/catalogs.py returns at least 1 match (dynamic catalog)
    - grep "_CLI_COMMAND_CATALOG" src/ztlctl/catalogs.py returns 0 matches (static catalog removed)
    - uv run pytest -x -q exits 0 (all tests pass)
    - uv run mypy src/ exits 0
  </acceptance_criteria>
  <done>CLI is driven by ActionRegistry-generated commands. Hand-written files deleted. Custom_presentation commands preserved. Dynamic CLI catalog. All tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: CLI parity test suite verifying ActionRegistry-to-CLI mapping</name>
  <files>
    tests/commands/test_cli_parity.py
    tests/mcp/test_parity.py
  </files>
  <read_first>
    tests/mcp/test_parity.py
    src/ztlctl/commands/generator.py
    src/ztlctl/actions/registry.py
    src/ztlctl/commands/__init__.py
  </read_first>
  <behavior>
    - Test: Every non-custom_presentation ActionDefinition has a registered CLI command
    - Test: All generated commands have help text (non-empty)
    - Test: Generated commands are under the correct group (matching cli_group)
    - Test: CLI command names match _derive_cli_name() output
    - Test: custom_presentation actions (create_batch, init_vault, init_workflow, update_workflow, export_assets, update) do NOT appear in generated commands
    - Test: custom_presentation commands (batch, init, serve, workflow, update, garden) ARE registered via hand-written path
    - Test: CLI command count matches expected total (non-custom actions + custom commands)
    - Test (in test_parity.py extension): CLI parity — every action that has an MCP tool also has a CLI command
  </behavior>
  <action>
Create `tests/commands/test_cli_parity.py`:

```python
"""CLI parity tests — verify every ActionDefinition has a CLI command.

Mirrors tests/mcp/test_parity.py for the CLI surface. Uses Click's
command tree inspection to verify registration without invoking commands.
"""

from __future__ import annotations

import click
import pytest

from ztlctl.actions.registry import get_action_registry
from ztlctl.commands._base import ZtlGroup
from ztlctl.commands.generator import _derive_cli_name, generate_commands


@pytest.fixture()
def cli_with_generated_commands() -> click.Group:
    """Build a Click group with all generated commands registered."""
    import ztlctl.actions  # noqa: F401
    cli = click.Group(name="ztlctl")
    generate_commands(cli)
    return cli


class TestCliParity:
    """Verify ActionRegistry <-> CLI command mapping."""

    def test_all_non_custom_actions_have_cli_commands(
        self, cli_with_generated_commands: click.Group
    ) -> None:
        """Every non-custom_presentation action has a CLI command."""
        import ztlctl.actions  # noqa: F401
        registry = get_action_registry()
        non_custom = registry.list_actions(custom_presentation=False)

        all_commands: set[str] = set()
        for name, cmd in cli_with_generated_commands.commands.items():
            if isinstance(cmd, click.Group):
                for sub_name in cmd.list_commands(click.Context(cmd)):
                    all_commands.add(f"{name}.{sub_name}")
            else:
                all_commands.add(name)

        for action in non_custom:
            cli_name = _derive_cli_name(action)
            if action.cli_group:
                expected = f"{action.cli_group}.{cli_name}"
            else:
                expected = cli_name
            assert expected in all_commands, (
                f"Action {action.name!r} (cli_name={cli_name!r}, "
                f"cli_group={action.cli_group!r}) not found in CLI. "
                f"Available: {sorted(all_commands)}"
            )

    def test_custom_presentation_not_in_generated(
        self, cli_with_generated_commands: click.Group
    ) -> None:
        """custom_presentation actions are NOT generated."""
        import ztlctl.actions  # noqa: F401
        registry = get_action_registry()
        custom = registry.list_actions(custom_presentation=True)

        generated_names: set[str] = set()
        for name, cmd in cli_with_generated_commands.commands.items():
            if isinstance(cmd, click.Group):
                for sub_name in cmd.list_commands(click.Context(cmd)):
                    generated_names.add(sub_name)
            else:
                generated_names.add(name)

        for action in custom:
            cli_name = _derive_cli_name(action)
            # custom_presentation commands should not appear
            # (they are added manually in register_commands)
            # Just verify they aren't in the generated set
            assert cli_name not in generated_names or action.cli_group is not None

    def test_all_generated_commands_have_help(
        self, cli_with_generated_commands: click.Group
    ) -> None:
        """Every generated command has non-empty help text."""
        for name, cmd in cli_with_generated_commands.commands.items():
            if isinstance(cmd, click.Group):
                ctx = click.Context(cmd)
                for sub_name in cmd.list_commands(ctx):
                    sub_cmd = cmd.get_command(ctx, sub_name)
                    assert sub_cmd is not None
                    assert sub_cmd.help, f"{name} {sub_name} has no help text"
            else:
                assert cmd.help, f"{name} has no help text"

    def test_generated_group_count(
        self, cli_with_generated_commands: click.Group
    ) -> None:
        """Generator creates the expected number of groups."""
        groups = [
            name for name, cmd in cli_with_generated_commands.commands.items()
            if isinstance(cmd, click.Group)
        ]
        # Expected groups from cli_group values: query, graph, session,
        # create, check, ingest, export, vector, upgrade, reweave, init
        assert len(groups) >= 10, f"Expected >=10 groups, got {len(groups)}: {groups}"

    def test_command_names_use_hyphens(
        self, cli_with_generated_commands: click.Group
    ) -> None:
        """All generated command names use hyphens, not underscores."""
        for name, cmd in cli_with_generated_commands.commands.items():
            assert "_" not in name, f"Group name {name!r} contains underscore"
            if isinstance(cmd, click.Group):
                ctx = click.Context(cmd)
                for sub_name in cmd.list_commands(ctx):
                    assert "_" not in sub_name, (
                        f"Command {name} {sub_name!r} contains underscore"
                    )

    def test_category_coverage(
        self, cli_with_generated_commands: click.Group
    ) -> None:
        """All action categories are represented in the CLI."""
        import ztlctl.actions  # noqa: F401
        registry = get_action_registry()
        categories = {a.category for a in registry.list_actions(custom_presentation=False)}

        group_names = set(cli_with_generated_commands.commands.keys())
        # Each category should map to a group or have standalone commands
        for cat in categories:
            actions_in_cat = [
                a for a in registry.list_actions(custom_presentation=False)
                if a.category == cat
            ]
            # At least one action in this category should be registered
            for action in actions_in_cat:
                cli_name = _derive_cli_name(action)
                if action.cli_group:
                    assert action.cli_group in group_names, (
                        f"Category {cat!r} action {action.name!r} expects "
                        f"group {action.cli_group!r} but it's not registered"
                    )
                    break
```

Then extend `tests/mcp/test_parity.py` by adding a test that verifies CLI and MCP surfaces cover the same actions (cross-surface parity):

Add to the existing test_parity.py:

```python
def test_cli_and_mcp_cover_same_actions():
    """CLI and MCP surfaces both cover all ActionDefinitions."""
    import ztlctl.actions  # noqa: F401
    registry = get_action_registry()
    all_actions = {a.name for a in registry.list_actions()}
    # Both surfaces should cover the full registry
    # (MCP covers all; CLI covers all non-custom + custom hand-written)
    assert len(all_actions) == 59  # or whatever the current count is
```

Run `uv run pytest tests/commands/test_cli_parity.py tests/mcp/test_parity.py -x -v` to verify.
  </action>
  <verify>
    <automated>uv run pytest tests/commands/test_cli_parity.py tests/mcp/test_parity.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - File tests/commands/test_cli_parity.py exists with at least 6 test methods
    - grep "test_all_non_custom_actions_have_cli_commands" tests/commands/test_cli_parity.py returns 1 match
    - grep "custom_presentation" tests/commands/test_cli_parity.py returns at least 2 matches
    - uv run pytest tests/commands/test_cli_parity.py -x -v exits 0
    - uv run pytest tests/mcp/test_parity.py -x -v exits 0
    - uv run pytest -x -q exits 0 (full suite green)
  </acceptance_criteria>
  <done>CLI parity test suite proves every non-custom_presentation ActionDefinition has a registered CLI command. MCP parity test extended with cross-surface check. Full test suite passes.</done>
</task>

</tasks>

<verification>
After both tasks complete, run full validation:
```bash
uv run ruff check . && uv run ruff format --check . && uv run pytest && uv run mypy src/
```

Manual verification:
- `uv run ztlctl --help` shows grouped commands
- `uv run ztlctl query --help` shows search, get, list, etc.
- `uv run ztlctl session --help` shows start, close, context, brief, etc.
- `uv run ztlctl create batch --help` still works
- `uv run ztlctl update --help` still shows decomposed flags
- `uv run ztlctl garden seed --help` still works
</verification>

<success_criteria>
1. CLI is driven by ActionRegistry — generate_commands() replaces 12+ hand-written command files
2. custom_presentation commands (batch, init, serve, workflow, update, garden) preserved via hand-written implementations
3. cli_command_catalog() is dynamic from ActionRegistry (no static tuple)
4. CLI parity tests prove every non-custom action has a CLI command
5. All existing tests pass (with updated command paths where CLI structure changed)
6. `uv run pytest && uv run mypy src/ && uv run ruff check .` all green
</success_criteria>

<output>
After completion, create `.planning/phases/04-cli-surface-generation/04-02-SUMMARY.md`
</output>
