# cli.py Decomposition Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Decompose `claw_forge/cli.py` (5236 lines) by extracting its 17 top-level Typer commands and ~25 module-private helpers into their domain packages, leaving `cli.py` as a ≤300-line residue + back-compat shim.

**Architecture:** Eight PRs ordered low-risk-first. PRs 1–3 extract pure helpers with no command moves (lowest blast radius). PRs 4–6 move commands grouped by domain. PR 7 (biggest) extracts the `run` command into four files under `orchestrator/`. PR 8 is final cleanup + docs. `cli.py` re-exports the 9 test-imported helpers from their new homes so test code is untouched throughout.

**Tech Stack:** Python 3.11+, Typer, FastAPI/SQLite (state service), asyncio, `httpx`, Rich (console). Tooling: `uv` (deps), `pytest` (90% branch coverage gate), `ruff` (lint), `mypy --ignore-missing-imports` (types).

**Reference spec:** `docs/superpowers/specs/2026-05-11-cli-decomposition-design.md` — read it before starting.

**Pre-flight checks (run once before Task 1):**
```bash
git -C /Users/bowenli/development/claw-forge status                          # must be clean
uv run pytest tests/ -q --cov=claw_forge                                     # baseline must be green
uv run ruff check claw_forge/ tests/                                         # baseline must be clean
wc -l claw_forge/cli.py                                                      # baseline: 5236
```

If the baseline isn't green, stop. Do not refactor on a red main.

**Universal verification after every commit** (referred to as "Run the verification suite" in steps below):
```bash
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
```

Expected after each PR: pytest passes, coverage ≥ 90%, ruff clean, mypy clean (no new errors vs baseline), cli.py line count decreasing.

---

## Task 1: Extract config helpers into `claw_forge/config.py`

**Spec reference:** Helper-extraction map, row 1. Slice 1 in "Implementation order".

**Files:**
- Create: `claw_forge/config.py`
- Create: `tests/test_config_module.py`
- Modify: `claw_forge/cli.py` (delete moved blocks, add re-export shim)

**Helpers moving (with line numbers in current `cli.py`):**
- `_load_env_file` (lines 59–69)
- `_expand_env_vars` (lines 71–91)
- `_DEFAULT_CONFIG_YAML` constant (lines 93–290 — large multi-line string)
- `_DEFAULT_ENV_EXAMPLE` constant (lines 292–340)
- `_scaffold_config` (lines 342–364)
- `_load_config` (lines 366–387)

**Dependencies between them:** `_scaffold_config` uses `_DEFAULT_CONFIG_YAML` + `_DEFAULT_ENV_EXAMPLE`; `_load_config` uses `_scaffold_config` + `_load_env_file` + `_expand_env_vars`. **All six must move together — partial extraction is not viable.**

**External dependencies inside the helpers:** `console.print` from Rich (used by `_expand_env_vars` for the env-var-not-set warning and by `_scaffold_config` for the "scaffolded" message). The new module instantiates its own `console`.

- [ ] **Step 1: Read the existing helper definitions to confirm scope**

Run: `awk 'NR==59,NR==387' /Users/bowenli/development/claw-forge/claw_forge/cli.py > /tmp/config_block.py && wc -l /tmp/config_block.py`
Expected: ~329 lines extracted.

- [ ] **Step 2: Create `claw_forge/config.py` with the module skeleton**

Write to `claw_forge/config.py`:

```python
"""Configuration loading and scaffolding for claw-forge.

Owns `claw-forge.yaml` parsing, `.env` ingestion, env-var expansion
(`${VAR}` / `${VAR:-default}`), and the default-config / default-env
scaffolding strings used by `claw-forge init`.

Test-imported symbols (`_load_env_file`, `_load_config`, `_DEFAULT_CONFIG_YAML`)
are re-exported by `claw_forge.cli` for backwards compatibility.
"""
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Any

import yaml
from rich.console import Console

console = Console()


def _load_env_file(directory: Path) -> None:
    # [paste lines 60-69 of cli.py here — body of _load_env_file]
    ...


def _expand_env_vars(obj: object) -> object:
    # [paste lines 72-91 of cli.py here — body of _expand_env_vars]
    ...


_DEFAULT_CONFIG_YAML = """\
# [paste lines 93-290 of cli.py here — the full default config YAML string]
"""

_DEFAULT_ENV_EXAMPLE = """\
# [paste lines 292-340 of cli.py here — the full default env example string]
"""


def _scaffold_config(config_path: str) -> bool:
    # [paste lines 343-364 of cli.py here — body of _scaffold_config]
    ...


def _load_config(config_path: str, *, auto_scaffold: bool = False) -> dict[str, Any]:
    # [paste lines 367-387 of cli.py here — body of _load_config]
    ...


__all__ = [
    "_DEFAULT_CONFIG_YAML",
    "_DEFAULT_ENV_EXAMPLE",
    "_expand_env_vars",
    "_load_config",
    "_load_env_file",
    "_scaffold_config",
    "console",
]
```

Then copy the actual bodies from `cli.py:59-387` into the placeholders above, preserving indentation exactly.

- [ ] **Step 3: Add the import-shim assertion test**

Create `tests/test_config_module.py`:

```python
"""Sanity tests for the claw_forge.config module + cli.py shim back-compat."""
from __future__ import annotations


def test_config_module_exports_expected_symbols() -> None:
    """All public helpers exist on claw_forge.config."""
    from claw_forge import config

    assert callable(config._load_env_file)
    assert callable(config._expand_env_vars)
    assert callable(config._load_config)
    assert callable(config._scaffold_config)
    assert isinstance(config._DEFAULT_CONFIG_YAML, str)
    assert isinstance(config._DEFAULT_ENV_EXAMPLE, str)


def test_cli_shim_reexports_config_helpers() -> None:
    """Tests that import from claw_forge.cli keep working after the move."""
    from claw_forge.cli import (
        _DEFAULT_CONFIG_YAML,
        _load_config,
        _load_env_file,
    )

    assert callable(_load_env_file)
    assert callable(_load_config)
    assert isinstance(_DEFAULT_CONFIG_YAML, str)


def test_expand_env_vars_handles_bash_default() -> None:
    """${VAR:-default} substitutes the default when VAR is unset."""
    import os

    from claw_forge.config import _expand_env_vars

    os.environ.pop("CLAW_FORGE_TEST_UNSET", None)
    result = _expand_env_vars("${CLAW_FORGE_TEST_UNSET:-fallback}")
    assert result == "fallback"


def test_expand_env_vars_recurses_into_dicts_and_lists() -> None:
    """Recursion through dicts and lists preserves structure."""
    import os

    from claw_forge.config import _expand_env_vars

    os.environ["CLAW_FORGE_TEST_X"] = "expanded"
    result = _expand_env_vars({"a": "${CLAW_FORGE_TEST_X}", "b": ["${CLAW_FORGE_TEST_X}", 1]})
    assert result == {"a": "expanded", "b": ["expanded", 1]}
    del os.environ["CLAW_FORGE_TEST_X"]
```

- [ ] **Step 4: Delete the moved blocks from `cli.py`**

Open `claw_forge/cli.py` and delete lines 59–387 (the entire block from `def _load_env_file` through the end of `_load_config`).

Verify the deletion: `grep -n "_load_env_file\|_expand_env_vars\|_DEFAULT_CONFIG_YAML\|_DEFAULT_ENV_EXAMPLE\|_scaffold_config\|^def _load_config" claw_forge/cli.py` should now show only the call sites (no `def`s).

- [ ] **Step 5: Add the re-export shim at the top of `cli.py`**

Right after the existing `from claw_forge.pool.providers.registry import load_configs_from_yaml` line (around line 29), add:

```python
# Back-compat shim — these helpers moved to claw_forge.config; tests import them from here.
from claw_forge.config import (
    _DEFAULT_CONFIG_YAML,
    _DEFAULT_ENV_EXAMPLE,
    _expand_env_vars,
    _load_config,
    _load_env_file,
    _scaffold_config,
)
```

These imports now serve double duty: they let `cli.py`'s internal callers (10 call sites for `_load_config`, 2 for `_load_env_file`) keep using the bare name, and they re-export the symbols for tests.

- [ ] **Step 6: Run the verification suite**

```bash
uv run pytest tests/test_config_module.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
```

Expected:
- `test_config_module.py` passes (4 tests).
- Full pytest passes; coverage ≥ 90%.
- ruff clean.
- mypy: no new errors vs baseline.
- `cli.py` line count: ~4907 (5236 − 329 lines moved).

- [ ] **Step 7: Commit**

```bash
git add claw_forge/config.py tests/test_config_module.py claw_forge/cli.py
git commit -m "$(cat <<'EOF'
refactor(cli): extract config helpers to claw_forge/config.py

Moves _load_env_file, _expand_env_vars, _load_config, _scaffold_config,
_DEFAULT_CONFIG_YAML, _DEFAULT_ENV_EXAMPLE to a new config module.
cli.py keeps a back-compat shim re-exporting them so test imports are
unchanged.

PR 1/8 of the cli.py decomposition (spec:
docs/superpowers/specs/2026-05-11-cli-decomposition-design.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Extract state-service-client helpers into `claw_forge/state/client.py`

**Spec reference:** Helper-extraction map, rows 2–4. Slice 1 in "Implementation order".

**Files:**
- Create: `claw_forge/state/client.py`
- Create: `tests/state/test_state_client_module.py`
- Modify: `claw_forge/cli.py` (delete moved blocks, add re-export shim)

**Helpers moving:**
- `_state_url` (lines 389–391 in cli.py *after* Task 1 deletions — re-verify with `grep -n` before extracting; line numbers shift after Task 1)
- `_task_dict_to_node` (current cli.py L393–416)
- `_decorate_resumable` (L418–466)
- `_http_get` (L530–545)
- `_http_post` (L547–565)
- `_list_sessions_for_help` (L4105–4134)
- `_session_exists` (L4136–4146)
- `_count_sessions_and_tasks` (L4148–4174)
- `_print_session_hints` (L4176–4192)
- `_resolve_project_root` (L4194–4221)
- `_resolve_latest_session` (L4223–4250)
- `_port_in_use_error` (L4263–4299)
- `_ensure_state_service` (L4301–4478)

**Important: line numbers above are from the pre-Task-1 file.** After Task 1, subtract 329 from any line number > 387. Always re-verify with `grep -n` before editing.

**External dependencies inside the helpers:**
- `httpx` (already imported at top of cli.py)
- `sqlite3` (used by session-resolution helpers — currently not imported at top of cli.py; the helpers import it locally)
- `TaskNode` from `claw_forge.state.scheduler` (currently under `TYPE_CHECKING` in cli.py)
- `console` from Rich (must be available — new module instantiates its own)
- `_state_url` is used by `_ensure_state_service`, `pause`, `resume`, `input` commands

- [ ] **Step 1: Re-verify post-Task-1 line ranges**

```bash
grep -n "^def _state_url\|^def _task_dict_to_node\|^def _decorate_resumable\|^def _http_get\|^def _http_post\|^def _list_sessions_for_help\|^def _session_exists\|^def _count_sessions_and_tasks\|^def _print_session_hints\|^def _resolve_project_root\|^def _resolve_latest_session\|^def _port_in_use_error\|^def _ensure_state_service" claw_forge/cli.py
```

Expected: 13 lines printed. Note the exact line numbers — they're now ~329 lower than listed above.

- [ ] **Step 2: Create `claw_forge/state/client.py` with the module skeleton**

Write to `claw_forge/state/client.py`:

```python
"""HTTP client and session-resolution helpers for the state service.

The state service itself lives in `claw_forge.state.service`. This module
is its consumer-side counterpart: HTTP GET/POST wrappers, port-binding
diagnostics, the auto-start orchestration (`_ensure_state_service`),
session-resolution from `.claw-forge/state.db`, and dict-to-TaskNode
conversion helpers.

Test-imported symbols (`_resolve_project_root`, `_resolve_latest_session`,
`_task_dict_to_node`) are re-exported by `claw_forge.cli` for backwards
compatibility.
"""
from __future__ import annotations

import sqlite3
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any

import httpx
import yaml
from rich.console import Console

if TYPE_CHECKING:
    from claw_forge.state.scheduler import TaskNode

console = Console()


def _state_url(port: int = 8420) -> str:
    # [paste body from cli.py]
    ...


def _task_dict_to_node(payload: dict[str, Any]) -> "TaskNode":
    # [paste body — note: runtime import of TaskNode at function start, not module-level]
    from claw_forge.state.scheduler import TaskNode  # noqa: PLC0415
    ...


def _decorate_resumable(...) -> ...:
    ...


def _http_get(url: str) -> dict[str, Any] | list[Any]:
    ...


def _http_post(url: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
    ...


def _list_sessions_for_help(...) -> ...:
    ...


def _session_exists(db_path: Path, session_id: str) -> bool:
    ...


def _count_sessions_and_tasks(...) -> ...:
    ...


def _print_session_hints(db_path: Path, project_path: Path) -> None:
    ...


def _resolve_project_root(...) -> ...:
    ...


def _resolve_latest_session(db_path: Path, project_path: Path | None = None) -> str:
    ...


def _port_in_use_error(port: int, service: str = "state service") -> None:
    ...


def _ensure_state_service(...) -> ...:
    ...


__all__ = [
    "_count_sessions_and_tasks",
    "_decorate_resumable",
    "_ensure_state_service",
    "_http_get",
    "_http_post",
    "_list_sessions_for_help",
    "_port_in_use_error",
    "_print_session_hints",
    "_resolve_latest_session",
    "_resolve_project_root",
    "_session_exists",
    "_state_url",
    "_task_dict_to_node",
    "console",
]
```

Then copy the actual bodies from `cli.py` (at the line numbers from Step 1) into the placeholders, preserving indentation exactly.

- [ ] **Step 3: Add the import-shim assertion test**

Create `tests/state/test_state_client_module.py`:

```python
"""Sanity tests for the claw_forge.state.client module + cli.py shim."""
from __future__ import annotations

import sqlite3
from pathlib import Path


def test_state_client_module_exports_expected_symbols() -> None:
    """All public helpers exist on claw_forge.state.client."""
    from claw_forge.state import client

    for name in (
        "_state_url",
        "_task_dict_to_node",
        "_decorate_resumable",
        "_http_get",
        "_http_post",
        "_resolve_project_root",
        "_resolve_latest_session",
        "_port_in_use_error",
        "_ensure_state_service",
    ):
        assert hasattr(client, name), f"missing symbol: {name}"


def test_cli_shim_reexports_state_client_helpers() -> None:
    """Tests that import from claw_forge.cli keep working after the move."""
    from claw_forge.cli import (
        _resolve_latest_session,
        _resolve_project_root,
        _task_dict_to_node,
    )

    assert callable(_resolve_latest_session)
    assert callable(_resolve_project_root)
    assert callable(_task_dict_to_node)


def test_state_url_default_port() -> None:
    """_state_url uses port 8420 by default and accepts an override."""
    from claw_forge.state.client import _state_url

    assert _state_url() == "http://localhost:8420"
    assert _state_url(9999) == "http://localhost:9999"


def test_resolve_latest_session_filters_by_project_path(tmp_path: Path) -> None:
    """_resolve_latest_session only returns sessions matching the project_path."""
    from claw_forge.state.client import _resolve_latest_session

    db = tmp_path / "state.db"
    conn = sqlite3.connect(db)
    conn.execute(
        "CREATE TABLE sessions (id TEXT PRIMARY KEY, project_path TEXT, created_at REAL)"
    )
    conn.execute(
        "INSERT INTO sessions VALUES ('a', '/proj-a', 1000), ('b', '/proj-b', 2000)"
    )
    conn.commit()
    conn.close()

    assert _resolve_latest_session(db, Path("/proj-a")) == "a"
    assert _resolve_latest_session(db, Path("/proj-b")) == "b"
```

- [ ] **Step 4: Delete the moved blocks from `cli.py`**

There are **two non-contiguous regions** in cli.py to delete:

1. The lower block: `_state_url`, `_task_dict_to_node`, `_decorate_resumable` (~line 389 area, then `_http_get`/`_http_post` around 530).
2. The upper block: `_list_sessions_for_help` through `_ensure_state_service` (~line 4105 area).

Strategy: use the line numbers gathered in Step 1, delete from highest to lowest (so earlier line numbers don't shift after each deletion).

Verify after deletion: `grep -n "^def _state_url\|^def _http_get\|^def _ensure_state_service" claw_forge/cli.py` should print nothing.

- [ ] **Step 5: Add the re-export shim**

Right after the Task-1 shim block in `cli.py`, add:

```python
from claw_forge.state.client import (
    _count_sessions_and_tasks,
    _decorate_resumable,
    _ensure_state_service,
    _http_get,
    _http_post,
    _list_sessions_for_help,
    _port_in_use_error,
    _print_session_hints,
    _resolve_latest_session,
    _resolve_project_root,
    _session_exists,
    _state_url,
    _task_dict_to_node,
)
```

This serves both internal callers (`_load_config`, `_ensure_state_service` are used throughout the rest of cli.py) and test back-compat.

- [ ] **Step 6: Run the verification suite**

```bash
uv run pytest tests/state/test_state_client_module.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
```

Expected:
- New test module passes (4 tests).
- Full pytest passes; coverage ≥ 90%.
- ruff + mypy clean.
- `cli.py` line count: ~4490 (further reduction of ~417).

- [ ] **Step 7: Commit**

```bash
git add claw_forge/state/client.py tests/state/test_state_client_module.py claw_forge/cli.py
git commit -m "$(cat <<'EOF'
refactor(cli): extract state-service client to claw_forge/state/client.py

Moves _http_get, _http_post, _state_url, session-resolution helpers,
_task_dict_to_node, _decorate_resumable, _ensure_state_service, and
_port_in_use_error to a new state.client module. cli.py keeps a
back-compat shim re-exporting the test-imported helpers.

PR 2/8 of the cli.py decomposition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Move sandbox + schema-migration helpers to their domain modules

**Spec reference:** Helper-extraction map, rows 5 + 8. Slice 2 in "Implementation order".

**Files:**
- Modify: `claw_forge/cli.py` (delete two helpers, update shim if needed)
- Modify: `claw_forge/agent/sandbox.py` (receive `_make_sdk_sandbox_settings`)
- Modify: `claw_forge/state/service.py` (receive `_migrate_schema`)
- Modify: `tests/test_cli_commands.py::TestMakeSdkSandboxSettings` if it imports from cli (verify with grep)
- Create or extend tests for the moved helpers if not already covered

**Helpers moving:**
- `_make_sdk_sandbox_settings` (in current cli.py: search via `grep -n "^def _make_sdk_sandbox_settings"`)
- `_migrate_schema` (search: `grep -n "^async def _migrate_schema"`)

- [ ] **Step 1: Verify what currently tests `_make_sdk_sandbox_settings`**

```bash
grep -rn "_make_sdk_sandbox_settings\|_migrate_schema" claw_forge/ tests/
```

Note: per CLAUDE.md, `_make_sdk_sandbox_settings` is pinned by `tests/test_cli_commands.py::TestMakeSdkSandboxSettings`. The test currently imports it from `claw_forge.cli`. **Decision: update the test's import path to point at `claw_forge.agent.sandbox` once moved** — `_make_sdk_sandbox_settings` is not in the back-compat shim list (it's tested via the cli module path but isn't part of the documented test-imported set; updating the import is acceptable scope).

- [ ] **Step 2: Move `_make_sdk_sandbox_settings` to `claw_forge/agent/sandbox.py`**

Append to `claw_forge/agent/sandbox.py` (before any trailing `__all__` if present; if not, just at the end):

```python
def _make_sdk_sandbox_settings(*, isolation_active: bool) -> Any:
    # [paste body from current cli.py — find with grep -n "^def _make_sdk_sandbox_settings"]
    ...
```

Verify the new file has the right imports at the top (the function uses `SandboxSettings` from `claude_agent_sdk` — check the current cli.py import block; `agent/sandbox.py` may already import what it needs).

Delete the function definition from `cli.py`.

- [ ] **Step 3: Update the test import path**

In `tests/test_cli_commands.py::TestMakeSdkSandboxSettings`, change:

```python
# old
from claw_forge.cli import _make_sdk_sandbox_settings
# new
from claw_forge.agent.sandbox import _make_sdk_sandbox_settings
```

Use `grep -n "_make_sdk_sandbox_settings" tests/test_cli_commands.py` to find every occurrence and update each.

- [ ] **Step 4: Move `_migrate_schema` to `claw_forge/state/service.py`**

Append to `claw_forge/state/service.py` (or merge with its existing schema-creation logic if a similar helper exists — check first with `grep -n "CREATE TABLE\|_migrate" claw_forge/state/service.py`):

```python
async def _migrate_schema(engine: Any) -> None:
    # [paste body from cli.py]
    ...
```

Delete from `cli.py`. Update any internal callers in `cli.py` to use `from claw_forge.state.service import _migrate_schema` (or just inline the call as the service module is already imported elsewhere — find with grep).

- [ ] **Step 5: Add a fresh-eyes test for `_migrate_schema` if not covered**

```bash
grep -rn "_migrate_schema" tests/
```

If no test covers it, add a smoke test to `tests/state/test_service_schema.py` (create if missing):

```python
"""Smoke test for state-service schema migration."""
from __future__ import annotations

import asyncio


def test_migrate_schema_creates_expected_columns(tmp_path) -> None:
    """Calling _migrate_schema on a fresh DB doesn't raise."""
    from sqlalchemy.ext.asyncio import create_async_engine

    from claw_forge.state.service import _migrate_schema

    db = tmp_path / "test.db"
    engine = create_async_engine(f"sqlite+aiosqlite:///{db}")

    async def _run() -> None:
        await _migrate_schema(engine)

    asyncio.run(_run())
```

- [ ] **Step 6: Run the verification suite**

```bash
uv run pytest tests/test_cli_commands.py::TestMakeSdkSandboxSettings tests/state/ -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
```

Expected:
- Sandbox test passes against the new import path.
- Schema test passes.
- Full pytest passes; coverage ≥ 90%.
- `cli.py` line count: small further reduction (~20 lines).

- [ ] **Step 7: Commit**

```bash
git add claw_forge/agent/sandbox.py claw_forge/state/service.py claw_forge/cli.py tests/
git commit -m "$(cat <<'EOF'
refactor(cli): move _make_sdk_sandbox_settings + _migrate_schema to domain modules

_make_sdk_sandbox_settings → agent/sandbox.py (joins the OS-level sandbox
domain). _migrate_schema → state/service.py (joins its schema-creation
sibling). Updates TestMakeSdkSandboxSettings to import from the new path.

PR 3/8 of the cli.py decomposition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Extract state-service commands into `claw_forge/state/cli.py`

**Spec reference:** Target file map rows 2–4 (status, state, pause, resume, input, add → state/cli.py).

**Files:**
- Create: `claw_forge/state/cli.py`
- Modify: `claw_forge/cli.py` (delete 6 command definitions, add `add_typer(state_app)`)
- Create: `tests/state/test_state_cli_app.py`

**Commands moving:**
- `status` (search: `grep -n '^def status' cli.py`)
- `state` (search: `grep -n '^def state'`)
- `pause` (search: `grep -n '^def pause'`)
- `resume` (search: `grep -n '^def resume'`)
- `input` / `human_input` (search: `grep -n '^def human_input'`)
- `add` (search: `grep -n '^def add'`)

- [ ] **Step 1: Locate the 6 command function boundaries**

```bash
grep -n "^@app.command\|^def status\|^def state\|^def pause\|^def resume\|^def human_input\|^def add" claw_forge/cli.py
```

For each command, the function starts at the `@app.command()` decorator line and ends at the next `@app.command()` (or end of file). Note the line ranges for each.

- [ ] **Step 2: Create `claw_forge/state/cli.py`**

Write to `claw_forge/state/cli.py`:

```python
"""Typer commands that interact with the state service.

This module owns six top-level claw-forge commands: `status`, `state`,
`pause`, `resume`, `input`, and `add`. All hit the state-service REST
API (auto-started via `_ensure_state_service`).

The Typer instance `state_app` is mounted on the main app in
`claw_forge.cli` without a `name=` argument so its commands surface as
top-level (`claw-forge status` etc., not `claw-forge state status`).
"""
from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Any

import typer
from rich.console import Console
from rich.table import Table

from claw_forge.config import _load_config, _load_env_file
from claw_forge.state.client import (
    _ensure_state_service,
    _http_get,
    _http_post,
    _resolve_latest_session,
    _resolve_project_root,
    _state_url,
)

state_app = typer.Typer(help="State-service commands")
console = Console()


@state_app.command()
def status(...) -> None:
    # [paste body from cli.py]
    ...


@state_app.command()
def state(...) -> None:
    ...


@state_app.command()
def pause(...) -> None:
    ...


@state_app.command()
def resume(...) -> None:
    ...


@state_app.command(name="input")
def human_input(...) -> None:
    ...


@state_app.command()
def add(...) -> None:
    ...
```

Then copy the actual function bodies (with their full Typer decorations like `@state_app.command()` replacing `@app.command()`) from cli.py.

**Important:** the original decorator is `@app.command()` — change each to `@state_app.command()`. The `input` command uses `@app.command(name="input")` — change to `@state_app.command(name="input")` and keep the function name `human_input`.

- [ ] **Step 3: Delete the 6 command definitions from `cli.py`**

Delete from highest line number to lowest to avoid offset shifts. Use the line ranges from Step 1.

Verify: `grep -n "^def status\|^def state\|^def pause\|^def resume\|^def human_input\|^def add" claw_forge/cli.py` should return nothing.

- [ ] **Step 4: Wire `state_app` into `cli.py`**

In `cli.py`'s subapp-registration block (around line 35–45 in the original, may have shifted), add:

```python
from claw_forge.state.cli import state_app  # noqa: E402

app.add_typer(state_app)  # no name= — commands surface as top-level
```

Place after the existing `add_typer(traces_app, name="traces")` line.

- [ ] **Step 5: Create the smoke test for the new commands**

Create `tests/state/test_state_cli_app.py`:

```python
"""Smoke tests asserting state_app commands are wired into the main CLI."""
from __future__ import annotations

from typer.testing import CliRunner


def test_state_app_commands_appear_in_main_cli_help() -> None:
    """All 6 state commands should appear in `claw-forge --help` output."""
    from claw_forge.cli import app

    runner = CliRunner()
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    for cmd in ("status", "state", "pause", "resume", "input", "add"):
        assert cmd in result.stdout, f"missing command: {cmd}"


def test_state_command_has_help() -> None:
    """Each state command's --help works after extraction."""
    from claw_forge.cli import app

    runner = CliRunner()
    for cmd in ("status", "state", "pause", "resume", "input", "add"):
        result = runner.invoke(app, [cmd, "--help"])
        assert result.exit_code == 0, f"{cmd} --help failed: {result.stdout}"
```

- [ ] **Step 6: Run the verification suite**

```bash
uv run pytest tests/state/test_state_cli_app.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
claw-forge --help 2>&1 | head -30  # eyeball: 6 commands still there
claw-forge status --help 2>&1 | head -5
```

Expected:
- New smoke tests pass (2 tests, plus the cmd-help loop covers 6).
- Full pytest passes; coverage ≥ 90%.
- `cli.py` line count: ~3900–4100.
- CLI surface unchanged.

- [ ] **Step 7: Commit**

```bash
git add claw_forge/state/cli.py tests/state/test_state_cli_app.py claw_forge/cli.py
git commit -m "$(cat <<'EOF'
refactor(cli): extract state-service commands to claw_forge/state/cli.py

Moves the 6 state-service-client commands (status, state, pause, resume,
input, add) into a domain module. state_app is mounted without name= so
the commands stay top-level (claw-forge status etc., unchanged surface).

PR 4/8 of the cli.py decomposition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Extract spec commands into `claw_forge/spec/cli.py`

**Spec reference:** Target file map row 6 (plan, validate-spec, import).

**Files:**
- Create: `claw_forge/spec/cli.py`
- Modify: `claw_forge/cli.py`
- Create: `tests/spec/test_spec_cli_app.py`

**Commands + helper moving:**
- `plan` command (search: `grep -n '^def plan' cli.py`)
- `validate-spec` command (it's defined as `def validate_spec_cmd` with `@app.command("validate-spec")`)
- `import` command (it's `def import_spec_cmd` with `@app.command("import")`)
- `_write_plan_to_db` helper (search: `grep -n '^async def _write_plan_to_db'`)

- [ ] **Step 1: Locate command + helper boundaries**

```bash
grep -n "^def plan\|^def validate_spec_cmd\|^def import_spec_cmd\|^async def _write_plan_to_db" claw_forge/cli.py
```

- [ ] **Step 2: Create `claw_forge/spec/cli.py`**

Write to `claw_forge/spec/cli.py`:

```python
"""Typer commands for spec processing.

Owns three top-level claw-forge commands: `plan` (seeds the task DAG
from app_spec.{txt,xml}), `validate-spec` (Layer 4 shape validator),
and `import` (imports a spec from a remote URL or path).

Also owns `_write_plan_to_db` — the helper that writes a parsed
ProjectSpec into the state-service DB; test-imported.
"""
from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Any

import typer
from rich.console import Console

from claw_forge.config import _load_config
from claw_forge.spec.parser import parse_spec  # or whatever the actual import is
from claw_forge.state.client import (
    _ensure_state_service,
    _http_post,
    _state_url,
)

spec_app = typer.Typer(help="Spec processing commands")
console = Console()


async def _write_plan_to_db(...) -> ...:
    # [paste body from cli.py]
    ...


@spec_app.command()
def plan(...) -> None:
    ...


@spec_app.command("validate-spec")
def validate_spec_cmd(...) -> None:
    ...


@spec_app.command("import")
def import_spec_cmd(...) -> None:
    ...
```

Copy the actual bodies from `cli.py`. Convert `@app.command()` → `@spec_app.command()` (preserving the explicit `name=` argument on `validate-spec` and `import`).

- [ ] **Step 3: Update test imports for `_write_plan_to_db`**

```bash
grep -rn "_write_plan_to_db" tests/
```

The spec lists `_write_plan_to_db` among the back-compat-shim symbols. To preserve test imports, keep the shim — DO NOT update test imports. Instead, in `cli.py`, add:

```python
from claw_forge.spec.cli import _write_plan_to_db
```

This re-exports the symbol from the new location.

- [ ] **Step 4: Delete moved code from `cli.py`**

Delete the 3 command definitions and the `_write_plan_to_db` helper. Highest-to-lowest deletion order.

- [ ] **Step 5: Wire `spec_app` into `cli.py`**

Add to the subapp-registration block:

```python
from claw_forge.spec.cli import spec_app  # noqa: E402
app.add_typer(spec_app)  # no name= — commands top-level
```

And confirm the `_write_plan_to_db` re-export from Step 3 is present.

- [ ] **Step 6: Create the smoke test**

Create `tests/spec/test_spec_cli_app.py`:

```python
"""Smoke tests for the spec subapp."""
from __future__ import annotations

from typer.testing import CliRunner


def test_spec_commands_appear_in_main_cli_help() -> None:
    from claw_forge.cli import app

    runner = CliRunner()
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    for cmd in ("plan", "validate-spec", "import"):
        assert cmd in result.stdout


def test_cli_shim_reexports_write_plan_to_db() -> None:
    """test_three_phase_pipeline.py imports _write_plan_to_db from cli — must still work."""
    from claw_forge.cli import _write_plan_to_db
    assert callable(_write_plan_to_db)
```

- [ ] **Step 7: Run the verification suite**

```bash
uv run pytest tests/spec/test_spec_cli_app.py tests/test_three_phase_pipeline.py tests/test_cli_validate_spec_strict_shape.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
claw-forge plan --help 2>&1 | head -5
claw-forge validate-spec --help 2>&1 | head -5
claw-forge import --help 2>&1 | head -5
```

Expected:
- All spec-related tests pass (including the pre-existing test_three_phase_pipeline.py and test_cli_validate_spec_strict_shape.py which import test_imported helpers via the shim).
- Full pytest passes; coverage ≥ 90%.
- `cli.py` line count: ~3500–3700.

- [ ] **Step 8: Commit**

```bash
git add claw_forge/spec/cli.py tests/spec/ claw_forge/cli.py
git commit -m "$(cat <<'EOF'
refactor(cli): extract spec commands to claw_forge/spec/cli.py

Moves plan, validate-spec, import commands plus _write_plan_to_db helper
into the spec domain package. cli.py re-exports _write_plan_to_db for
test back-compat.

PR 5/8 of the cli.py decomposition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Extract misc commands (pool-status, fix, merge, ui, dev)

**Spec reference:** Target file map rows 5, 7, 8, 9. Slice 5 in "Implementation order".

This task creates 4 new modules in one PR because each is small (one or two commands). Each gets its own commit-on-merge worthy of inspection, but they ship together to keep PR count manageable.

**Files:**
- Create: `claw_forge/pool/cli.py` — pool-status
- Create: `claw_forge/bugfix/cli.py` — fix
- Create: `claw_forge/git/merge_cli.py` — merge
- Create: `claw_forge/ui_cli.py` — ui, dev + `_build_session_redirect_js`
- Modify: `claw_forge/cli.py`
- Create: `tests/test_misc_command_subapps.py`

- [ ] **Step 1: Create `claw_forge/pool/cli.py`**

```python
"""Typer command for inspecting the provider pool."""
from __future__ import annotations

import json
from typing import Any

import typer
from rich.console import Console
from rich.table import Table

from claw_forge.state.client import _ensure_state_service, _http_get, _state_url

console = Console()


def pool_status(...) -> None:
    # [paste body from cli.py]
    ...
```

Note: **single command, no `Typer()` wrapper**. Just the function. Wiring happens in cli.py via `app.command(name="pool-status")(pool_status)`.

Delete `pool_status` definition from cli.py.

- [ ] **Step 2: Create `claw_forge/bugfix/cli.py`**

```python
"""Typer command for the bugfix workflow."""
from __future__ import annotations

import typer
from rich.console import Console

from claw_forge.config import _load_config

console = Console()


def fix(...) -> None:
    # [paste body from cli.py]
    ...
```

Delete `fix` from cli.py.

- [ ] **Step 3: Create `claw_forge/git/merge_cli.py`**

```python
"""Typer command for manual squash-merge of feature branches."""
from __future__ import annotations

import typer
from rich.console import Console

from claw_forge.config import _load_config

console = Console()


def merge(...) -> None:
    # [paste body from cli.py]
    ...
```

Delete `merge` from cli.py.

- [ ] **Step 4: Create `claw_forge/ui_cli.py`**

```python
"""Typer commands for the UI server (`ui` and `dev`).

Mirrors the top-level `claw_forge/export_cli.py` pattern — single file,
no domain package, hosts multiple commands as a Typer subapp.
"""
from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Any

import typer
from rich.console import Console

from claw_forge.config import _load_config, _load_env_file
from claw_forge.state.client import (
    _ensure_state_service,
    _port_in_use_error,
    _resolve_latest_session,
    _state_url,
)

ui_app = typer.Typer(help="UI server commands")
console = Console()


def _build_session_redirect_js(session_id: str) -> str:
    # [paste body from cli.py]
    ...


@ui_app.command()
def ui(...) -> None:
    ...


@ui_app.command()
def dev(...) -> None:
    ...
```

Delete `ui`, `dev`, `_build_session_redirect_js` from cli.py.

- [ ] **Step 5: Wire all four into `cli.py`**

In the subapp-registration block:

```python
# Single-command modules — register function directly
from claw_forge.bugfix.cli import fix as _fix_cmd  # noqa: E402
from claw_forge.git.merge_cli import merge as _merge_cmd  # noqa: E402
from claw_forge.pool.cli import pool_status as _pool_status_cmd  # noqa: E402

app.command(name="pool-status")(_pool_status_cmd)
app.command(name="fix")(_fix_cmd)
app.command(name="merge")(_merge_cmd)

# Multi-command module
from claw_forge.ui_cli import ui_app  # noqa: E402
app.add_typer(ui_app)
```

- [ ] **Step 6: Create the smoke test**

Create `tests/test_misc_command_subapps.py`:

```python
"""Smoke tests for the misc-command extraction PR."""
from __future__ import annotations

from typer.testing import CliRunner


def test_all_misc_commands_appear_in_main_cli_help() -> None:
    from claw_forge.cli import app

    runner = CliRunner()
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    for cmd in ("pool-status", "fix", "merge", "ui", "dev"):
        assert cmd in result.stdout


def test_misc_commands_help_works() -> None:
    from claw_forge.cli import app

    runner = CliRunner()
    for cmd in ("pool-status", "fix", "merge", "ui", "dev"):
        result = runner.invoke(app, [cmd, "--help"])
        assert result.exit_code == 0, f"{cmd} --help failed"
```

- [ ] **Step 7: Run the verification suite**

```bash
uv run pytest tests/test_misc_command_subapps.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
claw-forge pool-status --help 2>&1 | head -5
claw-forge fix --help 2>&1 | head -5
claw-forge merge --help 2>&1 | head -5
claw-forge ui --help 2>&1 | head -5
claw-forge dev --help 2>&1 | head -5
```

Expected:
- All 5 commands work via `--help`.
- Full pytest passes; coverage ≥ 90%.
- `cli.py` line count: ~2700–3000 (most of the remaining bulk is `run`).

- [ ] **Step 8: Commit**

```bash
git add claw_forge/pool/cli.py claw_forge/bugfix/cli.py claw_forge/git/merge_cli.py claw_forge/ui_cli.py tests/test_misc_command_subapps.py claw_forge/cli.py
git commit -m "$(cat <<'EOF'
refactor(cli): extract pool-status, fix, merge, ui, dev to domain modules

Five commands moved in one PR (each small enough not to warrant its own).
Pool, bugfix, git/merge_cli created as single-command modules registered
directly via app.command(). ui_cli.py is a top-level multi-command module
mirroring the export_cli.py precedent.

PR 6/8 of the cli.py decomposition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Extract the `run` command to `claw_forge/orchestrator/`

**Spec reference:** "The `run` command (special case)". Slice 6 — highest blast radius.

This is the biggest PR. The `run` command (~2400 lines, currently lines 603–2998 of original cli.py) splits across four files.

**Files:**
- Create: `claw_forge/orchestrator/run_cli.py` — the Typer command + dispatcher loop
- Create: `claw_forge/orchestrator/task_handler.py` — the per-task coroutine (former closure) + agent prep helpers
- Create: `claw_forge/orchestrator/signals.py` — emergency-commit signal handlers + `_active_worktrees` registry
- Create: `claw_forge/orchestrator/checkpointing.py` — periodic auto-checkpoint asyncio task
- Modify: `claw_forge/cli.py`
- Create: `tests/orchestrator/test_run_extraction_smoke.py`

This task has more steps than the others because of the closure-to-top-level conversion described in the spec's Risks section.

- [ ] **Step 1: Read the current `run` body end-to-end**

Open `claw_forge/cli.py` in your editor and find `def run(...):` (use `grep -n '^def run' cli.py`). Read from there to the next `@app.command()` decorator. Note these structural landmarks:
- Where arg parsing ends and setup begins
- Where the inner `async def task_handler(...)` is defined (closure start)
- Where `task_handler` is registered with the dispatcher
- Where signal handlers are installed
- Where the periodic checkpoint task is started
- Where finalisation / cleanup happens

Make a one-page mental map before writing any new code.

- [ ] **Step 2: Identify the closure's captured locals**

Inside `task_handler`, scan for every name that's *read* but not *assigned* and not in the function's parameter list. These are the captured locals. Common suspects:
- `scheduler` (TaskScheduler instance)
- `session_id` (str)
- `console` (Rich Console)
- `cfg` (loaded config dict)
- `git_ops` (GitOps instance or None)
- `project_dir` (Path)
- `target_branch` (str)
- `_active_worktrees` (the registry — also captured by signal handlers)
- `state_port` (int)
- various `httpx.AsyncClient` instances

List every one in a comment block before refactoring. **Estimated 15–20 captured names.**

- [ ] **Step 3: Define `RunContext` dataclass in `task_handler.py`**

Create `claw_forge/orchestrator/task_handler.py` with a `RunContext` dataclass at the top:

```python
"""Per-task lifecycle coroutine for the `run` command's dispatcher.

`task_handler` was originally a closure inside `run()`. It's been promoted
to a top-level coroutine taking an explicit `RunContext` instead of
capturing 15+ locals by reference. This is the only refactor permitted
inside this decomposition — the rest is mechanical relocation.

The `RunContext` is constructed once in `run_cli.py` after all setup
completes; `task_handler` mutates only the per-task state (not the
context itself).
"""
from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

import httpx
from rich.console import Console

if TYPE_CHECKING:
    from claw_forge.git import GitOps
    from claw_forge.state.scheduler import TaskScheduler


@dataclass
class RunContext:
    """Immutable per-run state passed to every task_handler invocation."""
    scheduler: "TaskScheduler"
    session_id: str
    console: Console
    cfg: dict[str, Any]
    git_ops: "GitOps | None"
    project_dir: Path
    target_branch: str
    active_worktrees: dict[str, Path]
    state_port: int
    http_client: httpx.AsyncClient
    # Add additional captured locals as discovered in Step 2.
```

**Refine this dataclass as Step 6 surfaces missing fields.**

- [ ] **Step 4: Move agent-prep helpers to `task_handler.py`**

Append to `task_handler.py`:

```python
async def _create_and_sync_worktree(...) -> ...:
    # [paste body from cli.py]
    ...


async def _try_claim_files(...) -> ...:
    # [paste body from cli.py]
    ...


def _resolve_sdk_credentials(...) -> ...:
    # [paste body from cli.py]
    ...


async def _set_merged_after_merge(...) -> ...:
    # [paste body from cli.py — NOTE: this is the helper test_cli_merge_gating
    # imports via the cli.py shim. Must be re-exported from cli.py later.]
    ...
```

Delete these helpers from `cli.py`.

- [ ] **Step 5: Promote `task_handler` from closure to top-level coroutine in `task_handler.py`**

```python
async def task_handler(task: dict[str, Any], ctx: RunContext) -> dict[str, Any]:
    """Per-task lifecycle: claim files → create worktree → sync → run agent → merge."""
    # [paste body of the former closure, replacing every captured local with
    # ctx.<name> — e.g., scheduler → ctx.scheduler, session_id → ctx.session_id]
    ...
```

The body is ~800–1000 lines. Move it as a single block, then run a find-replace inside this function:
- `scheduler` (when unqualified) → `ctx.scheduler`
- `session_id` (when unqualified) → `ctx.session_id`
- `cfg` → `ctx.cfg`
- `git_ops` → `ctx.git_ops`
- `project_dir` → `ctx.project_dir`
- `target_branch` → `ctx.target_branch`
- `_active_worktrees` → `ctx.active_worktrees`
- `state_port` → `ctx.state_port`
- ... and any others surfaced by Step 2.

**Be careful with name collisions** — if a local also reads `console` from the outer scope, use `ctx.console`. If a name appears as both a function parameter AND a captured local in the original closure, the parameter wins (don't rewrite it).

- [ ] **Step 6: Move `_active_worktrees` registry and signal handlers to `signals.py`**

Create `claw_forge/orchestrator/signals.py`:

```python
"""Signal handlers + active-worktree registry for the run dispatcher.

SIGTERM and SIGINT handlers do a best-effort `git add -A && git commit`
on every active worktree before the process exits. The
`_active_worktrees` registry is mutated by `task_handler` as worktrees
are created/destroyed.
"""
from __future__ import annotations

import signal
from collections.abc import Callable
from pathlib import Path

from claw_forge.git.commits import emergency_commit


_active_worktrees: dict[str, Path] = {}


def register_active_worktree(slug: str, path: Path) -> None:
    """Called by task_handler when a worktree is created."""
    _active_worktrees[slug] = path


def deregister_active_worktree(slug: str) -> None:
    """Called by task_handler in its finally block."""
    _active_worktrees.pop(slug, None)


def install_handlers() -> Callable[[], None]:
    """Install SIGTERM/SIGINT handlers; return a function that restores originals.

    [paste from cli.py — the existing signal-installation logic]
    """
    ...
```

Delete the signal-handler code and `_active_worktrees` definition from cli.py.

**Note:** the `task_handler` and `RunContext` should now reference `signals._active_worktrees` (or, cleaner, `RunContext.active_worktrees` populated from the signal module's registry). Decision: keep both in sync — `RunContext.active_worktrees` *is* the same dict as `signals._active_worktrees` (shared reference). Initialize once in `run_cli.py`:

```python
from claw_forge.orchestrator.signals import _active_worktrees
ctx = RunContext(..., active_worktrees=_active_worktrees, ...)
```

- [ ] **Step 7: Move periodic checkpoint task to `checkpointing.py`**

Create `claw_forge/orchestrator/checkpointing.py`:

```python
"""Periodic auto-checkpoint asyncio task for the run dispatcher.

Best-effort: any commit failure is caught and logged; the loop continues.
Cancelled when the dispatcher exits.
"""
from __future__ import annotations

import asyncio
import logging
from pathlib import Path

from claw_forge.git.commits import commit_checkpoint

logger = logging.getLogger(__name__)


async def periodic_checkpoint_loop(
    interval_seconds: int,
    active_worktrees: dict[str, Path],
) -> None:
    """Loop forever, sleeping interval_seconds, committing dirty state.

    [paste body from cli.py — the existing asyncio.Task loop]
    """
    ...
```

Delete the inline checkpoint loop code from cli.py / the inner `run` body.

- [ ] **Step 8: Create the slim `run_cli.py`**

Create `claw_forge/orchestrator/run_cli.py`:

```python
"""Typer command for `claw-forge run` — the dispatcher entrypoint.

The bulk of run's behaviour lives in:
- task_handler.py — per-task coroutine + agent prep helpers + RunContext
- signals.py — emergency-commit handlers + active-worktree registry
- checkpointing.py — periodic auto-checkpoint loop

run_cli.py is the arg-parsing + setup + dispatcher-loop coordinator.
"""
from __future__ import annotations

import asyncio
from pathlib import Path

import typer
from rich.console import Console

from claw_forge.config import _load_config, _load_env_file
from claw_forge.orchestrator.checkpointing import periodic_checkpoint_loop
from claw_forge.orchestrator.signals import _active_worktrees, install_handlers
from claw_forge.orchestrator.task_handler import RunContext, task_handler
from claw_forge.state.client import _ensure_state_service

console = Console()


def run(...) -> None:
    """[paste the @app.command()-decorated run function from cli.py — but
    rewired to:
    - construct a RunContext after setup
    - call install_handlers() from signals
    - register `task_handler` (passing `ctx` via functools.partial or a small
      lambda) with the dispatcher
    - spawn the periodic_checkpoint_loop asyncio task
    - finalise via the install_handlers' returned restore function
    ]
    """
    ...
```

The decorator: leave OFF in this file (this is a single-command module). cli.py will register it via `app.command(name="run")(run)`.

Delete the `run` function definition from cli.py.

- [ ] **Step 9: Wire `run` into `cli.py`**

Add to the subapp-registration block:

```python
from claw_forge.orchestrator.run_cli import run as _run_cmd  # noqa: E402

app.command(name="run")(_run_cmd)
```

And update the `_set_merged_after_merge` shim:

```python
from claw_forge.orchestrator.task_handler import _set_merged_after_merge  # noqa: E402
```

Add this to the shim block at the top of cli.py.

- [ ] **Step 10: Create smoke + closure-promotion tests**

Create `tests/orchestrator/test_run_extraction_smoke.py`:

```python
"""Smoke tests for the run command's post-extraction shape."""
from __future__ import annotations

from typer.testing import CliRunner


def test_run_command_appears_in_main_cli_help() -> None:
    from claw_forge.cli import app

    runner = CliRunner()
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    assert "run" in result.stdout


def test_run_command_help_unchanged() -> None:
    """run --help should still list every flag it had before extraction."""
    from claw_forge.cli import app

    runner = CliRunner()
    result = runner.invoke(app, ["run", "--help"])
    assert result.exit_code == 0
    # Spot-check critical flags
    for flag in ("--config", "--project", "--task", "--model", "--max-concurrency"):
        assert flag in result.stdout


def test_run_context_dataclass_constructable() -> None:
    """RunContext can be constructed with the fields task_handler reads."""
    from claw_forge.orchestrator.task_handler import RunContext
    # Don't construct it (requires httpx client and scheduler); just verify import.
    assert RunContext.__name__ == "RunContext"


def test_active_worktrees_registry_is_singleton() -> None:
    """signals._active_worktrees is shared by RunContext + signal handlers."""
    from claw_forge.orchestrator import signals

    signals._active_worktrees["smoke-test"] = "/tmp/smoke"
    try:
        assert "smoke-test" in signals._active_worktrees
    finally:
        signals._active_worktrees.pop("smoke-test", None)


def test_cli_shim_reexports_set_merged_after_merge() -> None:
    """test_cli_merge_gating.py imports _set_merged_after_merge from cli."""
    from claw_forge.cli import _set_merged_after_merge

    assert callable(_set_merged_after_merge)
```

- [ ] **Step 11: Run the verification suite — pay extra attention this time**

```bash
uv run pytest tests/orchestrator/test_run_extraction_smoke.py tests/test_cli_merge_gating.py tests/test_cli_run_flags.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -30
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py
wc -l claw_forge/orchestrator/*.py
claw-forge run --help 2>&1 | wc -l  # compare against baseline `claw-forge run --help | wc -l` from before
```

Expected:
- All smoke tests pass.
- `test_cli_run_flags.py` (which exercises the Typer parsing of run's args) passes.
- `test_cli_merge_gating.py` passes (uses `_set_merged_after_merge` via shim).
- Full pytest passes; coverage ≥ 90%.
- `claw_forge/orchestrator/task_handler.py` line count: ≤ 1200 (≤ 1000 ideal).
- `claw_forge/orchestrator/run_cli.py` line count: ≤ 800.
- `cli.py` line count: ~400–500.

- [ ] **Step 12: Smoke-test `claw-forge run` end-to-end on a real spec**

If you have a small claw-forge project handy, run a fresh session against it. Confirm:
- Dispatcher starts, claims a task, runs an agent.
- Periodic checkpoint commits land at the configured interval.
- A SIGINT (Ctrl-C) triggers the emergency-commit handler.
- Squash-merge completes after a task succeeds.

If you don't have a project handy, at minimum run `claw-forge run --help` and compare its output to a snapshot from before extraction (capture it pre-Task 7 with `claw-forge run --help > /tmp/run_help_before.txt`).

- [ ] **Step 13: Commit**

```bash
git add claw_forge/orchestrator/ claw_forge/cli.py tests/orchestrator/
git commit -m "$(cat <<'EOF'
refactor(cli): extract `run` command to claw_forge/orchestrator/

Largest decomposition step: the ~2400-line run command splits into four
files under claw_forge/orchestrator/:
- run_cli.py: Typer command + dispatcher loop coordinator
- task_handler.py: per-task coroutine (former closure, now top-level
  with explicit RunContext dataclass) + agent prep helpers
- signals.py: emergency-commit handlers + _active_worktrees registry
- checkpointing.py: periodic auto-checkpoint asyncio task

The closure-to-top-level conversion replaces ~15+ captured locals with
explicit RunContext fields. _set_merged_after_merge is re-exported from
cli.py for test back-compat.

PR 7/8 of the cli.py decomposition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Final cleanup, shim assertion, docs

**Spec reference:** "Implementation order" slice 7; "Verification" section.

**Files:**
- Modify: `claw_forge/cli.py` (final pass — re-order shims, remove dead code, add docstring)
- Create: `tests/test_cli_shim_completeness.py`
- Modify: `CLAUDE.md` (update architecture section)

- [ ] **Step 1: Verify the final cli.py residue**

```bash
wc -l claw_forge/cli.py
grep -c "^def \|^async def \|^class " claw_forge/cli.py
grep -c "^@app\." claw_forge/cli.py
```

Expected:
- Line count: ≤ 300.
- Top-level defs: 2–4 (just `version`, `init`, maybe one or two helpers I missed).
- `@app.command()` decorators: 2 (just `version` and `init`).

If line count > 300, scan for missed extractions: `grep -n "^def _" claw_forge/cli.py` — any underscored helper still there should have moved with its caller in earlier tasks. Move it now and update the appropriate task's commit retroactively if you have the energy, or fold the cleanup into this PR.

- [ ] **Step 2: Tidy `cli.py`'s top-of-file structure**

The final shape should be:

```python
"""Typer entrypoint for claw-forge.

Top-level commands live in their domain packages:
- run, pool-status         → orchestrator/, pool/
- plan, validate-spec,     → spec/
  import
- status, state, pause,    → state/
  resume, input, add
- merge                    → git/
- ui, dev                  → ui_cli.py (sibling)
- fix                      → bugfix/
- boundaries, worktrees,   → boundaries/, git/, training/, export_cli.py
  stash, traces, export

This module owns:
- The Typer `app` instance + subapp wiring
- Two residue commands: `version` and `init`
- Back-compat re-exports of the 9 test-imported helpers from their new
  homes (so test imports continue to work without modification).
"""
from __future__ import annotations

import typer
from rich.console import Console

from claw_forge import __version__

app = typer.Typer(name="claw-forge", help="Multi-provider autonomous coding agent harness")
console = Console()

# --- Back-compat re-exports ---
from claw_forge.config import (  # noqa: E402
    _DEFAULT_CONFIG_YAML,
    _load_config,
    _load_env_file,
)
from claw_forge.orchestrator.task_handler import _set_merged_after_merge  # noqa: E402
from claw_forge.spec.cli import _write_plan_to_db  # noqa: E402
from claw_forge.state.client import (  # noqa: E402
    _resolve_latest_session,
    _resolve_project_root,
    _task_dict_to_node,
)

# --- Subapp / command registration ---
from claw_forge.boundaries.cli import boundaries_app  # noqa: E402
from claw_forge.bugfix.cli import fix as _fix_cmd  # noqa: E402
from claw_forge.export_cli import export_app  # noqa: E402
from claw_forge.git.cli import worktrees_app  # noqa: E402
from claw_forge.git.merge_cli import merge as _merge_cmd  # noqa: E402
from claw_forge.git.stash_cli import stash_app  # noqa: E402
from claw_forge.orchestrator.run_cli import run as _run_cmd  # noqa: E402
from claw_forge.pool.cli import pool_status as _pool_status_cmd  # noqa: E402
from claw_forge.spec.cli import spec_app  # noqa: E402
from claw_forge.state.cli import state_app  # noqa: E402
from claw_forge.training.cli import traces_app  # noqa: E402
from claw_forge.ui_cli import ui_app  # noqa: E402

# Subcommand groups (claw-forge boundaries audit etc.)
app.add_typer(boundaries_app, name="boundaries")
app.add_typer(export_app, name="export")
app.add_typer(worktrees_app, name="worktrees")
app.add_typer(stash_app, name="stash")
app.add_typer(traces_app, name="traces")

# Multi-command modules surfaced as top-level commands
app.add_typer(spec_app)
app.add_typer(state_app)
app.add_typer(ui_app)

# Single-command modules
app.command(name="run")(_run_cmd)
app.command(name="merge")(_merge_cmd)
app.command(name="fix")(_fix_cmd)
app.command(name="pool-status")(_pool_status_cmd)


# --- Residue commands ---
@app.command()
def version() -> None:
    """Print the installed claw-forge version."""
    console.print(f"claw-forge {__version__}")


@app.command()
def init(...) -> None:
    """Scaffold claw-forge.yaml in the current directory."""
    # [keep the existing body — calls _scaffold_config from config.py]
    ...


__all__ = [
    "_DEFAULT_CONFIG_YAML",
    "_load_config",
    "_load_env_file",
    "_resolve_latest_session",
    "_resolve_project_root",
    "_set_merged_after_merge",
    "_task_dict_to_node",
    "_write_plan_to_db",
    "app",
]
```

- [ ] **Step 3: Add the shim-completeness assertion test**

Create `tests/test_cli_shim_completeness.py`:

```python
"""Asserts cli.py back-compat shim covers every test-imported symbol.

If a future PR moves a helper but forgets to update cli.py's __all__ /
re-exports, this test fails immediately rather than silently breaking
downstream test imports.
"""
from __future__ import annotations


def test_cli_exports_every_documented_back_compat_symbol() -> None:
    """The 9 symbols tests import from claw_forge.cli must all be present."""
    from claw_forge import cli

    expected = {
        "_DEFAULT_CONFIG_YAML",
        "_load_config",
        "_load_env_file",
        "_resolve_latest_session",
        "_resolve_project_root",
        "_set_merged_after_merge",
        "_task_dict_to_node",
        "_write_plan_to_db",
        "app",
    }
    actual = set(cli.__all__)
    missing = expected - actual
    assert not missing, f"missing from cli.__all__: {missing}"

    for name in expected:
        assert hasattr(cli, name), f"hasattr failed for: {name}"


def test_every_export_is_callable_or_string_or_typer_app() -> None:
    """Each re-exported symbol resolves to a usable object."""
    import typer

    from claw_forge import cli

    for name in cli.__all__:
        value = getattr(cli, name)
        if name == "app":
            assert isinstance(value, typer.Typer)
        elif name == "_DEFAULT_CONFIG_YAML":
            assert isinstance(value, str)
        else:
            assert callable(value), f"{name} is not callable: {type(value)}"
```

- [ ] **Step 4: Update CLAUDE.md architecture section**

Open `CLAUDE.md` and find the "Architecture Overview" → "Three-Layer Stack" section. It currently reads:

```
CLI (Typer)  ──────────────────────────────────────────
  claw_forge/cli.py — 18+ commands (plan, run, add, fix, ui, status, export …)
  claw_forge/boundaries/cli.py — Typer subapp: boundaries audit | apply | status
  ...
```

Replace `claw_forge/cli.py — 18+ commands` with an itemised breakdown:

```
CLI (Typer)  ──────────────────────────────────────────
  claw_forge/cli.py — Typer app + 2 residue commands (version, init) + back-compat shim
  claw_forge/config.py — config loading, env-var expansion, default-config scaffolding
  claw_forge/state/cli.py — Typer subapp: status | state | pause | resume | input | add
  claw_forge/state/client.py — state-service HTTP client + session-resolution helpers
  claw_forge/spec/cli.py — Typer subapp: plan | validate-spec | import + _write_plan_to_db
  claw_forge/orchestrator/run_cli.py — `claw-forge run` Typer command + dispatcher loop
  claw_forge/orchestrator/task_handler.py — per-task coroutine + RunContext + agent prep
  claw_forge/orchestrator/signals.py — emergency-commit handlers + _active_worktrees registry
  claw_forge/orchestrator/checkpointing.py — periodic auto-checkpoint asyncio task
  claw_forge/pool/cli.py — `claw-forge pool-status` Typer command
  claw_forge/bugfix/cli.py — `claw-forge fix` Typer command
  claw_forge/git/merge_cli.py — `claw-forge merge` Typer command
  claw_forge/git/cli.py — Typer subapp: worktrees list | prune
  claw_forge/git/stash_cli.py — Typer subapp: stash list | drop
  claw_forge/ui_cli.py — Typer subapp: ui | dev + _build_session_redirect_js
  claw_forge/export_cli.py — Typer subapp: export csv | sql | json | training
  claw_forge/training/cli.py — Typer subapp: traces status | prune
  claw_forge/boundaries/cli.py — Typer subapp: boundaries audit | apply | status
```

- [ ] **Step 5: Run the final verification suite**

```bash
uv run pytest tests/test_cli_shim_completeness.py -v
uv run pytest tests/ -q --cov=claw_forge --cov-report=term-missing | tail -25
uv run ruff check claw_forge/ tests/
uv run mypy claw_forge/ --ignore-missing-imports
wc -l claw_forge/cli.py claw_forge/config.py claw_forge/state/cli.py claw_forge/state/client.py \
     claw_forge/spec/cli.py claw_forge/orchestrator/*.py claw_forge/pool/cli.py \
     claw_forge/bugfix/cli.py claw_forge/git/merge_cli.py claw_forge/ui_cli.py
claw-forge --help 2>&1 | head -40
```

Expected:
- Shim test passes (2 tests).
- Full pytest passes; coverage ≥ 90%.
- `cli.py` line count: ≤ 300.
- No file under `claw_forge/` exceeds 1200 lines; ideally none > 1000.
- `claw-forge --help` output matches pre-refactor command list.

- [ ] **Step 6: Compare `claw-forge <cmd> --help` output against baseline**

If you snapshotted `claw-forge --help` output before Task 1 (e.g., into `/tmp/help_before/`), compare now:

```bash
for cmd in version run plan validate-spec import add pause resume input status state ui dev merge fix pool-status init; do
  claw-forge "$cmd" --help > "/tmp/help_after_$cmd.txt" 2>&1 || true
  diff "/tmp/help_before_$cmd.txt" "/tmp/help_after_$cmd.txt" || echo "DIFF in $cmd"
done
```

Any diff is a behavioural change and must be fixed before commit.

- [ ] **Step 7: Commit**

```bash
git add claw_forge/cli.py tests/test_cli_shim_completeness.py CLAUDE.md
git commit -m "$(cat <<'EOF'
refactor(cli): final cleanup + shim assertion + CLAUDE.md update

cli.py is now ≤300 lines: the Typer app instance, subapp wiring, the
back-compat shim, and the residue version/init commands. Adds an
assertion test pinning the shim to its 9 documented symbols so a future
PR that moves a helper without updating cli.py fails fast.

CLAUDE.md architecture section updated to reflect the new package map.

PR 8/8 of the cli.py decomposition — done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Self-Review Checklist (post-plan)

**Spec coverage:** every section in `docs/superpowers/specs/2026-05-11-cli-decomposition-design.md` maps to a Task here:

- Target file map (17 commands) — covered across Tasks 4–7.
- Helper-extraction map — covered across Tasks 1, 2, 3.
- `run` decomposition (special case) — covered in Task 7.
- Back-compat shim — covered in every task (each adds its own shim line) + Task 8 (assertion test).
- Typer wiring patterns — illustrated in Tasks 4–7; finalised in Task 8.
- Implementation order (7 PRs) — actually 8 in this plan (added Task 8 for final cleanup + docs as a dedicated step rather than folding it into Task 7).
- Verification — embedded after every commit + final sweep in Task 8.

**Risks coverage:**
- Circular imports — addressed by Task 2 Step 2 (`TaskNode` imported under `TYPE_CHECKING` + runtime function-local import where needed).
- Coverage drop — addressed by every Task's verification step including `--cov-report=term-missing`.
- `run` closure-to-top-level — explicit two-step process in Task 7 Steps 3 + 5 (`RunContext` first, then the move).
- Test-import shim drift — pinned by Task 8 Step 3 (`test_cli_shim_completeness.py`).
- Hidden coupling — already verified pre-plan (`grep -rn "from claw_forge.cli import" claw_forge/` returned empty).
- CLAUDE.md staleness — addressed in Task 8 Step 4.

**No placeholders:** every step has either complete code, an exact line-range reference (with the search command to re-locate after offset shifts), or an explicit verification command with expected output.

**Type consistency:**
- `_set_merged_after_merge` (Task 3 doesn't move it; Task 7 moves it to `orchestrator/task_handler.py`). Consistent across tasks.
- `_write_plan_to_db` moves in Task 5; shim added in same task.
- `_task_dict_to_node`, `_resolve_project_root`, `_resolve_latest_session` move in Task 2; shim added in same task.
- `_load_env_file`, `_load_config`, `_DEFAULT_CONFIG_YAML` move in Task 1; shim added in same task.
- All 9 shim symbols accounted for and consistently named throughout.
