# Competitive Improvements v0.4.0 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:** Add health check tool, 2 new prompts, and brief mode on status tools - closing the top gaps identified in the competitive analysis of 50+ CI/CD MCP servers.

**Architecture:** Adds to existing modules without restructuring. `health_check` goes in `_status.py` (status/analytics domain). New prompts go in `prompts.py`. `brief` parameter threads through `fmt_status_item()` in `formatters.py` to `get_status()` and `get_change_status()` in `_status.py`.

**Tech Stack:** Python, FastMCP, httpx, pytest-asyncio, respx

**Spec:** `docs/superpowers/specs/2026-03-24-competitive-improvements-design.md`

---

### Task 1: Brief mode on `fmt_status_item`

**Files:**
- Modify: `src/mcp_zuul/formatters.py:331-383`
- Test: `tests/test_tools_status.py`

- [ ] **Step 1: Write failing test for brief mode**

Add to `tests/test_tools_status.py` at the end of `TestChainSummary`:

```python
class TestBriefStatusItem:
    def test_brief_omits_chain_summary(self):
        from mcp_zuul.formatters import fmt_status_item
        item = make_status_item()
        formatted = fmt_status_item(item, brief=True)
        assert "chain_summary" not in formatted

    def test_brief_compresses_jobs_running(self):
        from mcp_zuul.formatters import fmt_status_item
        item = make_status_item()
        formatted = fmt_status_item(item, brief=True)
        job = formatted["jobs"][0]
        # result is None for running jobs, clean() strips it
        assert set(job.keys()) == {"name", "status"}
        assert job["name"] == "test-job"
        assert job["status"] == "RUNNING"

    def test_brief_compresses_jobs_completed(self):
        from mcp_zuul.formatters import fmt_status_item
        item = make_status_item()
        item["jobs"][0]["result"] = "SUCCESS"
        formatted = fmt_status_item(item, brief=True)
        job = formatted["jobs"][0]
        assert set(job.keys()) == {"name", "status", "result"}
        assert job["result"] == "SUCCESS"

    def test_brief_preserves_failing_reasons(self):
        from mcp_zuul.formatters import fmt_status_item
        item = make_status_item()
        item["failing_reasons"] = ["test-job"]
        formatted = fmt_status_item(item, brief=True)
        assert formatted["failing_reasons"] == ["test-job"]

    def test_brief_keeps_item_fields(self):
        from mcp_zuul.formatters import fmt_status_item
        item = make_status_item(change=99999)
        formatted = fmt_status_item(item, brief=True)
        assert formatted["id"] == "99999,1"
        assert formatted["project"] == "org/repo"
        assert formatted["change"] == 99999
        assert "enqueue_time" in formatted

    def test_full_unchanged(self):
        from mcp_zuul.formatters import fmt_status_item
        item = make_status_item()
        formatted = fmt_status_item(item, brief=False)
        assert "chain_summary" in formatted
        job = formatted["jobs"][0]
        assert "elapsed" in job
        assert "remaining" in job
```

Note: `make_status_item` is already imported at the top of `test_tools_status.py` (line 14).

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_tools_status.py::TestBriefStatusItem -v`
Expected: FAIL - `fmt_status_item() got an unexpected keyword argument 'brief'`

- [ ] **Step 3: Implement brief mode in `fmt_status_item`**

In `src/mcp_zuul/formatters.py`, modify `fmt_status_item` (line 331):

Change the signature from:
```python
def fmt_status_item(item: dict) -> dict:
```
to:
```python
def fmt_status_item(item: dict, brief: bool = False) -> dict:
```

After the `out` dict is built with item-level fields (after line 355), add the brief early-return path. Replace the existing job formatting block (lines 357-383) with:

```python
    jobs = item.get("jobs", [])

    if brief:
        # Compact mode: just name + status + result per job, no chain_summary
        if jobs:
            out["jobs"] = [
                clean({"name": j.get("name", ""), "status": _job_status(j), "result": j.get("result")})
                for j in jobs
            ]
        failing = item.get("failing_reasons", [])
        if failing:
            out["failing_reasons"] = failing
        return out

    # Full mode (existing behavior)
    formatted_jobs: list[dict] = []
    if jobs:
        now = _time.time()
        formatted_jobs = [_format_job(j, now) for j in jobs]

    # Compute chain summary using numeric _-prefixed fields
    summary = _compute_chain_summary(formatted_jobs)

    # Convert chain summary: replace numeric critical_path with human-readable cp_eta
    cp = summary.pop("critical_path_remaining", 0)
    summary["cp_eta"] = _format_duration(cp) or "0s"
    out["chain_summary"] = summary

    # Strip internal numeric fields from jobs before output
    for job in formatted_jobs:
        job.pop("_elapsed_secs", None)
        job.pop("_remaining_secs", None)
        job.pop("_estimated_secs", None)

    if formatted_jobs:
        out["jobs"] = formatted_jobs

    failing = item.get("failing_reasons", [])
    if failing:
        out["failing_reasons"] = failing
    return out
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_tools_status.py -v`
Expected: ALL PASS (both new brief tests and existing full-mode tests)

- [ ] **Step 5: Commit**

```bash
git add src/mcp_zuul/formatters.py tests/test_tools_status.py
git commit -s -m "[FEATURE] Add brief mode to fmt_status_item

Adds brief parameter to fmt_status_item(). When brief=True,
jobs are compressed to name/status/result only and chain_summary
is omitted. Estimated 30-50% token savings on status queries.

Assisted-By: Claude Code"
```

---

### Task 2: Wire `brief` parameter to `get_status` and `get_change_status`

**Files:**
- Modify: `src/mcp_zuul/tools/_status.py:31-86` (get_status)
- Modify: `src/mcp_zuul/tools/_status.py:88-164` (get_change_status)
- Test: `tests/test_tools_status.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_tools_status.py`:

```python
class TestGetStatusBrief:
    @respx.mock
    async def test_brief_omits_chain_summary(self, mock_ctx):
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status").mock(
            return_value=httpx.Response(
                200,
                json={
                    "zuul_version": "10.0.0",
                    "pipelines": [make_status_pipeline("check")],
                },
            )
        )
        result = json.loads(await get_status(mock_ctx, brief=True))
        item = result["pipelines"][0]["items"][0]
        assert "chain_summary" not in item
        job = item["jobs"][0]
        # Running job has result=None, clean() strips it
        assert "name" in job and "status" in job
        assert "elapsed" not in job  # Brief omits time details

    @respx.mock
    async def test_full_includes_chain_summary(self, mock_ctx):
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status").mock(
            return_value=httpx.Response(
                200,
                json={
                    "zuul_version": "10.0.0",
                    "pipelines": [make_status_pipeline("check")],
                },
            )
        )
        result = json.loads(await get_status(mock_ctx, brief=False))
        item = result["pipelines"][0]["items"][0]
        assert "chain_summary" in item


class TestGetChangeStatusBrief:
    @respx.mock
    async def test_brief_live_items(self, mock_ctx):
        item = make_status_item(change=12345)
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/12345").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "12345", brief=True))
        assert isinstance(result, list)
        assert "chain_summary" not in result[0]
        job = result[0]["jobs"][0]
        assert "name" in job and "status" in job
        assert "elapsed" not in job  # Brief omits time details

    @respx.mock
    async def test_brief_not_in_pipeline_buildset_unchanged(self, mock_ctx):
        """Brief only affects live status items, not the buildset fallback."""
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/99999").mock(
            return_value=httpx.Response(200, json=[])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/buildsets").mock(
            return_value=httpx.Response(200, json=[{"uuid": "bs-latest"}])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/buildset/bs-latest").mock(
            return_value=httpx.Response(200, json=make_buildset(uuid="bs-latest"))
        )
        result = json.loads(await get_change_status(mock_ctx, "99999", brief=True))
        assert result["status"] == "not_in_pipeline"
        # Buildset fallback is always full detail regardless of brief
        assert "latest_buildset" in result
        assert result["latest_buildset"]["uuid"] == "bs-latest"
```

Add imports at top if needed: `from mcp_zuul.tools import get_change_status, get_status`

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_tools_status.py::TestGetStatusBrief tests/test_tools_status.py::TestGetChangeStatusBrief -v`
Expected: FAIL - `get_status() got an unexpected keyword argument 'brief'`

- [ ] **Step 3: Add `brief` parameter to `get_status`**

In `src/mcp_zuul/tools/_status.py`, modify `get_status` signature (line 33-38) to add `brief`:

```python
async def get_status(
    ctx: Context,
    tenant: str = "",
    pipeline: str = "",
    project: str = "",
    active_only: bool = True,
    brief: bool = False,
) -> str:
```

Update the docstring to add:
```
        brief: Compact output with just job names and status (saves tokens)
```

On line 64, change:
```python
            items.append(fmt_status_item(item))
```
to:
```python
            items.append(fmt_status_item(item, brief=brief))
```

- [ ] **Step 4: Add `brief` parameter to `get_change_status`**

In `src/mcp_zuul/tools/_status.py`, modify `get_change_status` signature (line 90-94) to add `brief`:

```python
async def get_change_status(
    ctx: Context,
    change: str = "",
    tenant: str = "",
    url: str = "",
    brief: bool = False,
) -> str:
```

Update the docstring to add:
```
        brief: Compact output with just job names and status (saves tokens)
```

On line 148, change:
```python
    formatted = [fmt_status_item(item) for item in data]
```
to:
```python
    formatted = [fmt_status_item(item, brief=brief) for item in data]
```

Note: The `not_in_pipeline` buildset fallback path (line 143) uses `fmt_buildset(bs_detail, brief=False)` and is intentionally NOT affected by the `brief` parameter - buildsets already have their own brief/full control.

- [ ] **Step 5: Run tests to verify they pass**

Run: `uv run pytest tests/test_tools_status.py -v`
Expected: ALL PASS

- [ ] **Step 6: Commit**

```bash
git add src/mcp_zuul/tools/_status.py tests/test_tools_status.py
git commit -s -m "[FEATURE] Add brief parameter to get_status and get_change_status

Threads the brief flag through to fmt_status_item. When brief=True,
status queries return compact job data (name/status/result only)
without chain_summary. Buildset fallback path is not affected.

Assisted-By: Claude Code"
```

---

### Task 3: Health check tool

**Files:**
- Modify: `src/mcp_zuul/tools/_status.py` (add tool at end of file)
- Modify: `src/mcp_zuul/tools/__init__.py` (re-export)
- Test: `tests/test_tools_status.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_tools_status.py`:

```python
from mcp_zuul.tools import health_check


class TestHealthCheck:
    @respx.mock
    async def test_healthy(self, mock_ctx):
        """All components running, tenants available."""
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [
                    {"hostname": "scheduler-01", "state": "running", "version": "10.0.0"},
                ],
                "executors": [
                    {"hostname": "executor-01", "state": "running", "version": "10.0.0"},
                    {"hostname": "executor-02", "state": "running", "version": "10.0.0"},
                ],
                "mergers": [
                    {"hostname": "merger-01", "state": "running", "version": "10.0.0"},
                ],
            })
        )
        respx.get("https://zuul.example.com/api/tenants").mock(
            return_value=httpx.Response(200, json=[
                {"name": "tenant-a", "projects": 5, "queue": 0},
                {"name": "tenant-b", "projects": 3, "queue": 1},
            ])
        )
        result = json.loads(await health_check(mock_ctx))
        assert result["status"] == "ok"
        assert result["components"]["schedulers"] == 1
        assert result["components"]["executors"] == 2
        assert result["components"]["mergers"] == 1
        assert result["tenants"] == 2
        assert result["issues"] == []

    @respx.mock
    async def test_warn_paused_scheduler(self, mock_ctx):
        """Paused scheduler triggers warning."""
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [
                    {"hostname": "scheduler-01", "state": "paused", "version": "10.0.0"},
                ],
                "executors": [
                    {"hostname": "executor-01", "state": "running", "version": "10.0.0"},
                ],
                "mergers": [],
            })
        )
        respx.get("https://zuul.example.com/api/tenants").mock(
            return_value=httpx.Response(200, json=[{"name": "t1", "projects": 1, "queue": 0}])
        )
        result = json.loads(await health_check(mock_ctx))
        assert result["status"] == "warn"
        assert any("paused" in i.lower() for i in result["issues"])

    @respx.mock
    async def test_with_tenant_includes_config_errors(self, mock_ctx):
        """When tenant is provided, config errors are checked."""
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
                "mergers": [{"hostname": "m1", "state": "running", "version": "10"}],
            })
        )
        respx.get("https://zuul.example.com/api/tenants").mock(
            return_value=httpx.Response(200, json=[{"name": "test-tenant", "projects": 5, "queue": 0}])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/config-errors").mock(
            return_value=httpx.Response(200, json=[
                {"error": "syntax error in .zuul.yaml", "source_context": {}},
                {"error": "unknown job foo", "source_context": {}},
            ])
        )
        result = json.loads(await health_check(mock_ctx, tenant="test-tenant"))
        assert result["status"] == "warn"
        assert any("config error" in i.lower() for i in result["issues"])

    @respx.mock
    async def test_no_tenant_no_default_still_succeeds(self, mock_ctx):
        """Health check works without tenant - skips config errors."""
        mock_ctx.request_context.lifespan_context.config.default_tenant = ""
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
                "mergers": [{"hostname": "m1", "state": "running", "version": "10"}],
            })
        )
        respx.get("https://zuul.example.com/api/tenants").mock(
            return_value=httpx.Response(200, json=[{"name": "t1", "projects": 1, "queue": 0}])
        )
        result = json.loads(await health_check(mock_ctx))
        assert result["status"] == "ok"
        assert result["tenants"] == 1

    @respx.mock
    async def test_critical_no_executors(self, mock_ctx):
        """No executors triggers critical status."""
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [],
                "mergers": [],
            })
        )
        respx.get("https://zuul.example.com/api/tenants").mock(
            return_value=httpx.Response(200, json=[{"name": "t1", "projects": 1, "queue": 0}])
        )
        result = json.loads(await health_check(mock_ctx))
        assert result["status"] == "critical"
        assert any("executor" in i.lower() for i in result["issues"])

    @respx.mock
    async def test_config_errors_failure_does_not_block(self, mock_ctx):
        """Config-errors API failure doesn't prevent health check from succeeding."""
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
                "mergers": [{"hostname": "m1", "state": "running", "version": "10"}],
            })
        )
        respx.get("https://zuul.example.com/api/tenants").mock(
            return_value=httpx.Response(200, json=[{"name": "test-tenant", "projects": 1, "queue": 0}])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/config-errors").mock(
            return_value=httpx.Response(500, text="Internal Server Error")
        )
        result = json.loads(await health_check(mock_ctx, tenant="test-tenant"))
        # Should still succeed with components + tenants
        assert result["status"] == "ok"
        assert result["tenants"] == 1
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_tools_status.py::TestHealthCheck -v`
Expected: FAIL - `cannot import name 'health_check'`

- [ ] **Step 3: Implement `health_check` tool**

Add at the end of `src/mcp_zuul/tools/_status.py`:

```python
@mcp.tool(title="Health Check", annotations=_READ_ONLY)
@handle_errors
async def health_check(ctx: Context, tenant: str = "") -> str:
    """Quick health check for Zuul API connectivity and system components.

    Returns overall status (ok, warn, critical) with component details
    and any issues detected. Checks API reachability, system components
    (schedulers, executors, mergers), and optionally tenant config errors.

    Args:
        tenant: Tenant name for config error check (optional, skipped if empty)
    """
    a = app(ctx)

    # Always fetch components + tenants
    components_coro = api(ctx, "/components")
    tenants_coro = api(ctx, "/tenants")
    components_data, tenants_data = await asyncio.gather(components_coro, tenants_coro)

    # Optionally fetch config errors (best-effort)
    config_errors: list = []
    resolved_tenant = tenant or a.config.default_tenant
    if resolved_tenant:
        try:
            config_errors = await api(
                ctx, f"/tenant/{safepath(resolved_tenant)}/config-errors"
            )
        except Exception:
            pass  # Don't fail health check over config-errors

    # Interpret component states
    issues: list[str] = []
    comp_summary: dict[str, Any] = {}
    paused: list[str] = []

    for kind, instances in components_data.items():
        comp_summary[kind] = len(instances)
        for c in instances:
            if c.get("state") == "paused":
                hostname = c.get("hostname", kind)
                paused.append(hostname)
                issues.append(f"{kind.rstrip('s').title()} '{hostname}' is paused")

    if paused:
        comp_summary["paused"] = paused

    # Check for critical conditions
    status = "ok"
    if comp_summary.get("schedulers", 0) == 0:
        status = "critical"
        issues.append("No schedulers found")
    if comp_summary.get("executors", 0) == 0:
        status = "critical"
        issues.append("No executors found")
    if comp_summary.get("mergers", 0) == 0 and status != "critical":
        status = "warn"
        issues.append("No mergers found")

    # Config errors
    if config_errors:
        if status != "critical":
            status = "warn"
        issues.append(
            f"{len(config_errors)} config error(s) in tenant '{resolved_tenant}'"
        )

    # Paused components
    if paused and status != "critical":
        status = "warn"

    result: dict[str, Any] = {
        "status": status,
        "zuul_url": a.config.base_url,
        "components": comp_summary,
        "tenants": len(tenants_data),
        "issues": issues,
    }
    return json.dumps(result)
```

- [ ] **Step 4: Add re-export in `tools/__init__.py`**

In `src/mcp_zuul/tools/__init__.py`, add `health_check` to the import from `._status`:

Change:
```python
from ._status import (
    find_flaky_jobs,
    get_build_times,
    get_change_status,
    get_job_durations,
    get_status,
    list_tenants,
)
```
to:
```python
from ._status import (
    find_flaky_jobs,
    get_build_times,
    get_change_status,
    get_job_durations,
    get_status,
    health_check,
    list_tenants,
)
```

Add `"health_check"` to the `__all__` list (alphabetical order, after `"get_tenant_info"`).

Update the module docstring from:
```python
"""Zuul MCP tool implementations - 36 tools (31 read-only + 4 write + 1 LogJuicer)."""
```
to:
```python
"""Zuul MCP tool implementations - 37 tools (32 read-only + 4 write + 1 LogJuicer)."""
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `uv run pytest tests/test_tools_status.py -v`
Expected: ALL PASS

- [ ] **Step 6: Commit**

```bash
git add src/mcp_zuul/tools/_status.py src/mcp_zuul/tools/__init__.py tests/test_tools_status.py
git commit -s -m "[FEATURE] Add health_check tool

Aggregates components, tenants, and config errors into a single
ok/warn/critical status. Config-errors fetch is best-effort -
failure doesn't block the health check. Uses asyncio.gather for
concurrent API calls.

Assisted-By: Claude Code"
```

---

### Task 4: `tenant_health` prompt

**Files:**
- Modify: `src/mcp_zuul/prompts.py`
- Test: `tests/test_prompts.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_prompts.py`:

```python
from mcp_zuul.prompts import tenant_health


class TestTenantHealth:
    @respx.mock
    async def test_healthy_tenant(self, mock_ctx):
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
            })
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/config-errors").mock(
            return_value=httpx.Response(200, json=[])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/nodes").mock(
            return_value=httpx.Response(200, json=[
                {"state": "ready"}, {"state": "in-use"}, {"state": "building"},
            ])
        )
        result = await tenant_health(ctx=mock_ctx)
        assert "System Components" in result
        assert "Config Errors (0)" in result
        assert "Node Pool" in result
        assert "Analysis" in result

    @respx.mock
    async def test_degraded_tenant(self, mock_ctx):
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "paused", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
            })
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/config-errors").mock(
            return_value=httpx.Response(200, json=[
                {"error": "syntax error", "source_context": {}},
            ])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/nodes").mock(
            return_value=httpx.Response(200, json=[])
        )
        result = await tenant_health(ctx=mock_ctx)
        assert "Config Errors (1)" in result
        assert "paused" in result
        assert '"ready": 0' in result

    @respx.mock
    async def test_empty_node_pool(self, mock_ctx):
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
            })
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/config-errors").mock(
            return_value=httpx.Response(200, json=[])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/nodes").mock(
            return_value=httpx.Response(200, json=[])
        )
        result = await tenant_health(ctx=mock_ctx)
        assert "Node Pool" in result
        assert '"total": 0' in result
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_prompts.py::TestTenantHealth -v`
Expected: FAIL - `cannot import name 'tenant_health'`

- [ ] **Step 3: Implement `tenant_health` prompt**

In `src/mcp_zuul/prompts.py`, add `import asyncio` at the top (after `import json`).

Add at the end of the file:

```python
@mcp.prompt()
async def tenant_health(tenant: str = "", ctx: Context | None = None) -> str:
    """Assess overall health of a Zuul tenant - components, config errors, and node pool status."""
    assert ctx is not None
    t = _tenant(ctx, tenant)

    components, config_errors, nodes = await asyncio.gather(
        api(ctx, "/components"),
        api(ctx, f"/tenant/{safepath(t)}/config-errors"),
        api(ctx, f"/tenant/{safepath(t)}/nodes"),
    )

    # Summarize node pool by state
    node_counts: dict[str, int] = {}
    for n in nodes:
        state = n.get("state", "unknown")
        # Normalize Zuul's "in-use" to "in_use" for JSON key consistency
        key = state.replace("-", "_")
        node_counts[key] = node_counts.get(key, 0) + 1
    node_counts["total"] = len(nodes)

    parts = [
        f'Assess the health of Zuul tenant "{t}":\n',
        f"## System Components\n```json\n{json.dumps(components, indent=2)}\n```\n",
        f"## Config Errors ({len(config_errors)})\n```json\n{json.dumps(config_errors, indent=2)}\n```\n",
        f"## Node Pool\n```json\n{json.dumps(node_counts, indent=2)}\n```\n",
        "## Analysis\n"
        "1. Check component states - are all schedulers/executors running?\n"
        "2. Review config errors - these block jobs from running\n"
        "3. Assess node pool - low ready count may cause queue delays\n"
        f'4. For deeper investigation use `get_status(tenant="{t}")` for live pipeline state',
    ]
    return "\n".join(parts)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_prompts.py -v`
Expected: ALL PASS

- [ ] **Step 5: Commit**

```bash
git add src/mcp_zuul/prompts.py tests/test_prompts.py
git commit -s -m "[FEATURE] Add tenant_health prompt

Pre-loads components, config errors, and node pool status in
parallel via asyncio.gather. Synthesizes cross-cutting tenant
health view that would otherwise require 3 separate tool calls.

Assisted-By: Claude Code"
```

---

### Task 5: `diagnose_queue_delay` prompt

**Files:**
- Modify: `src/mcp_zuul/prompts.py`
- Test: `tests/test_prompts.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_prompts.py`:

```python
from mcp_zuul.prompts import diagnose_queue_delay


class TestDiagnoseQueueDelay:
    @respx.mock
    async def test_node_exhaustion(self, mock_ctx):
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status").mock(
            return_value=httpx.Response(200, json={
                "zuul_version": "10.0.0",
                "pipelines": [make_status_pipeline("check")],
            })
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/nodes").mock(
            return_value=httpx.Response(200, json=[
                {"state": "in-use"}, {"state": "in-use"},
            ])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/semaphores").mock(
            return_value=httpx.Response(200, json=[])
        )
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
            })
        )
        result = await diagnose_queue_delay(ctx=mock_ctx)
        assert "Pipeline Status" in result
        assert "Node Pool" in result
        assert "Semaphores" in result
        assert "System Components" in result
        assert "Analysis" in result

    @respx.mock
    async def test_semaphore_blocking(self, mock_ctx):
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status").mock(
            return_value=httpx.Response(200, json={
                "zuul_version": "10.0.0",
                "pipelines": [make_status_pipeline("gate")],
            })
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/nodes").mock(
            return_value=httpx.Response(200, json=[{"state": "ready"}])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/semaphores").mock(
            return_value=httpx.Response(200, json=[
                {"name": "deploy-lock", "global": False, "max": 1, "holders": {"count": 1}},
            ])
        )
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
            })
        )
        result = await diagnose_queue_delay(ctx=mock_ctx)
        assert "deploy-lock" in result

    @respx.mock
    async def test_pipeline_filter(self, mock_ctx):
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status").mock(
            return_value=httpx.Response(200, json={
                "zuul_version": "10.0.0",
                "pipelines": [
                    make_status_pipeline("check"),
                    make_status_pipeline("gate"),
                ],
            })
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/nodes").mock(
            return_value=httpx.Response(200, json=[])
        )
        respx.get("https://zuul.example.com/api/tenant/test-tenant/semaphores").mock(
            return_value=httpx.Response(200, json=[])
        )
        respx.get("https://zuul.example.com/api/components").mock(
            return_value=httpx.Response(200, json={
                "schedulers": [{"hostname": "s1", "state": "running", "version": "10"}],
                "executors": [{"hostname": "e1", "state": "running", "version": "10"}],
            })
        )
        result = await diagnose_queue_delay(pipeline="gate", ctx=mock_ctx)
        assert "gate" in result
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_prompts.py::TestDiagnoseQueueDelay -v`
Expected: FAIL - `cannot import name 'diagnose_queue_delay'`

- [ ] **Step 3: Implement `diagnose_queue_delay` prompt**

Add at the end of `src/mcp_zuul/prompts.py`:

```python
@mcp.prompt()
async def diagnose_queue_delay(
    tenant: str = "", pipeline: str = "", ctx: Context | None = None
) -> str:
    """Diagnose why jobs are queued or delayed - checks nodes, semaphores, and system state."""
    assert ctx is not None
    t = _tenant(ctx, tenant)

    status_data, nodes, semaphores, components = await asyncio.gather(
        api(ctx, f"/tenant/{safepath(t)}/status"),
        api(ctx, f"/tenant/{safepath(t)}/nodes"),
        api(ctx, f"/tenant/{safepath(t)}/semaphores"),
        api(ctx, "/components"),
    )

    # Summarize status to item counts per pipeline (not raw items)
    all_pipelines = status_data.get("pipelines", [])
    if pipeline:
        all_pipelines = [p for p in all_pipelines if p.get("name") == pipeline]
    status_summary = []
    for p in all_pipelines:
        item_count = 0
        for queue in p.get("change_queues", []):
            for heads_group in queue.get("heads", []):
                item_count += len(heads_group)
        if item_count > 0:
            status_summary.append({"pipeline": p.get("name"), "items": item_count})

    # Summarize nodes by state
    node_counts: dict[str, int] = {}
    for n in nodes:
        state = n.get("state", "unknown").replace("-", "_")
        node_counts[state] = node_counts.get(state, 0) + 1
    node_counts["total"] = len(nodes)

    # Filter semaphores to non-zero holders only
    active_semaphores = []
    for s in semaphores:
        holders = s.get("holders", {})
        count = holders.get("count", 0) if isinstance(holders, dict) else 0
        if count > 0:
            active_semaphores.append({
                "name": s.get("name"),
                "max": s.get("max"),
                "holders": count,
            })

    # Summarize components
    comp_summary = {}
    for kind, instances in components.items():
        comp_summary[kind] = len(instances)
        paused = [c.get("hostname") for c in instances if c.get("state") == "paused"]
        if paused:
            comp_summary[f"{kind}_paused"] = paused

    pipeline_filter = f" (pipeline: {pipeline})" if pipeline else ""
    parts = [
        f'Diagnose queue delays in Zuul tenant "{t}"{pipeline_filter}:\n',
        f"## Pipeline Status\n```json\n{json.dumps(status_summary, indent=2)}\n```\n",
        f"## Node Pool\n```json\n{json.dumps(node_counts, indent=2)}\n```\n",
        f"## Semaphores\n```json\n{json.dumps(active_semaphores, indent=2)}\n```\n",
        f"## System Components\n```json\n{json.dumps(comp_summary, indent=2)}\n```\n",
        "## Analysis\n"
        "1. Check node pool: if ready=0 and building>0, nodes are being provisioned (wait)\n"
        "2. Check node pool: if ready=0 and building=0, pool may be exhausted (investigate nodepool)\n"
        "3. Check semaphores: if max reached, jobs wait for lock release\n"
        "4. Check components: paused scheduler stops all enqueueing\n"
        "5. If queue is large but nodes available, check for job dependencies or window constraints\n"
        f'6. Use `list_nodes(detail=true, tenant="{t}")` for per-node state details',
    ]
    return "\n".join(parts)
```

Note: The existing `prompts.py` does not use type annotations on local variables, so follow the same pattern - no `dict[str, Any]` annotation needed. The code below uses plain untyped `dict` assignments matching the existing style.

- [ ] **Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_prompts.py -v`
Expected: ALL PASS

- [ ] **Step 5: Commit**

```bash
git add src/mcp_zuul/prompts.py tests/test_prompts.py
git commit -s -m "[FEATURE] Add diagnose_queue_delay prompt

Pre-loads pipeline status (summarized), node pool, semaphores,
and components in parallel. Helps diagnose why jobs are waiting
by correlating 4 data sources in a single prompt template.

Assisted-By: Claude Code"
```

---

### Task 6: Update docs and validate

**Files:**
- Modify: `CLAUDE.md`
- Run: Full test suite + lint + type check

- [ ] **Step 1: Update CLAUDE.md tool and prompt counts**

In `CLAUDE.md`, update the "What This Is" section:

Change:
```
MCP server for Zuul CI — 36 tools (31 read-only + 4 write + 1 LogJuicer), 3 prompts
```
to:
```
MCP server for Zuul CI — 37 tools (32 read-only + 4 write + 1 LogJuicer), 5 prompts
```

In the Module Dependency Flow section, update:
```
tools/             →  Package: 36 @mcp.tool() functions split by domain
```
to:
```
tools/             →  Package: 37 @mcp.tool() functions split by domain
```

Update:
```
_status.py       →  6 status/analytics: list_tenants, get_status, get_change_status, find_flaky_jobs, etc.
```
to:
```
_status.py       →  7 status/analytics: list_tenants, get_status, get_change_status, find_flaky_jobs, health_check, etc.
```

Update:
```
prompts.py         →  3 @mcp.prompt() templates (debug_build, compare_builds, check_change)
```
to:
```
prompts.py         →  5 @mcp.prompt() templates (debug_build, compare_builds, check_change, tenant_health, diagnose_queue_delay)
```

- [ ] **Step 2: Update MEMORY.md**

In project memory file, update the Architecture section:
- Tool count: 36 → 37 (32 read-only + 4 write + 1 LogJuicer)
- Prompt count: 3 → 5
- Add: `health_check` to the tool mentions
- Add: `tenant_health`, `diagnose_queue_delay` to prompt mentions

- [ ] **Step 3: Run full test suite**

Run: `uv run pytest tests/ -v`
Expected: ALL PASS, coverage >= 85%

- [ ] **Step 4: Run linter**

Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/`
Expected: No errors. If format issues, fix with `uv run ruff format src/ tests/`

- [ ] **Step 5: Run type checker**

Run: `uv run mypy src/mcp_zuul/`
Expected: No new errors

- [ ] **Step 6: Commit docs update**

```bash
git add CLAUDE.md
git commit -s -m "[DOCS] Update tool and prompt counts for v0.4.0

37 tools (32 read-only + 4 write + 1 LogJuicer), 5 prompts.
New: health_check tool, tenant_health and diagnose_queue_delay prompts,
brief mode on get_status and get_change_status.

Assisted-By: Claude Code"
```

---

## Task Dependency Graph

```
Task 1 (fmt_status_item brief)
    └── Task 2 (wire brief to tools) ── depends on Task 1
Task 3 (health_check) ────────────── independent of 1-2
Task 4 (tenant_health prompt) ────── independent of 1-3
    └── Task 5 (diagnose_queue_delay) ── depends on Task 4 (both modify prompts.py)
Task 6 (docs + validation) ────────── depends on ALL above
```

Tasks 1→2 must be sequential. Tasks 3, 4, 5 are independent of each other and of 1→2. Task 6 is the final gate.
