# Enrich `get_change_status` Response — 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:** Make `get_change_status` responses self-contained so LLM consumers never need to manually convert units, infer job states, construct URLs, or compute pipeline ETAs.

**Architecture:** All enrichment happens in `formatters.py` (pure functions, no I/O) with one exception: `stream_url` absolutification requires `base_url` context, handled in `tools.py` alongside the existing `status_url` enrichment loop. A new `_format_duration()` helper converts seconds to human-readable strings. A new `_compute_chain_summary()` function walks the dependency graph to compute critical-path ETA.

**Tech Stack:** Python 3.11+, pytest + respx (mocking), existing `clean()` helper.

**Breaking change note:** `elapsed` and `remaining` change from milliseconds to seconds. `enqueue_time` changes from ms to seconds. Consumers using raw numeric values will need to update. This is a minor version bump (0.4.0).

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `src/mcp_zuul/formatters.py` | Modify | Time normalization, `status` field, `_format_duration()`, `_compute_chain_summary()` |
| `src/mcp_zuul/tools.py` | Modify | `stream_url` absolutification in `get_change_status` enrichment loop |
| `tests/test_tools_status.py` | Modify | Update existing tests for new units, add tests for new fields |
| `tests/conftest.py` | Modify | Add `make_chained_status_item()` factory for dependency chain tests |

---

### Task 1: Normalize time units to seconds and add human-readable durations

**Files:**
- Modify: `src/mcp_zuul/formatters.py:64-120`
- Modify: `tests/test_tools_status.py`

The core change: `elapsed` and `remaining` go from ms → seconds (int). `estimated` stays seconds. `enqueue_time` goes from ms → seconds (float). Add `elapsed_str` and `remaining_str` as human-readable strings ("1h 23m", "45m 12s"). Add private `_format_duration(seconds)` helper.

- [ ] **Step 1: Write `_format_duration` tests and update existing elapsed/remaining assertions**

Add to `tests/test_tools_status.py`:

```python
from mcp_zuul.formatters import _format_duration

class TestFormatDuration:
    def test_seconds_only(self):
        assert _format_duration(45) == "45s"

    def test_minutes_and_seconds(self):
        assert _format_duration(125) == "2m 5s"

    def test_hours_and_minutes(self):
        assert _format_duration(3723) == "1h 2m"

    def test_hours_only(self):
        assert _format_duration(7200) == "2h 0m"

    def test_zero(self):
        assert _format_duration(0) == "0s"

    def test_none_returns_none(self):
        assert _format_duration(None) is None

    def test_large_duration(self):
        assert _format_duration(36000) == "10h 0m"
```

Update `test_elapsed_computed_from_start_time_for_running_jobs`:
```python
    # elapsed is now in seconds, not ms
    elapsed = result[0]["jobs"][0]["elapsed"]
    assert elapsed > 500, f"Expected ~600s, got {elapsed}"
    assert "elapsed_str" in result[0]["jobs"][0]
```

Update `test_elapsed_preserved_for_completed_jobs`:
```python
    # elapsed_time from Zuul is 300000ms → should be 300s now
    elapsed = result[0]["jobs"][0]["elapsed"]
    assert elapsed == 300, "Completed job should keep Zuul's elapsed in seconds"
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -v --no-header`
Expected: FAIL — `_format_duration` not importable, elapsed assertions wrong.

- [ ] **Step 3: Implement `_format_duration` and normalize times in `fmt_status_item`**

In `src/mcp_zuul/formatters.py`, add before `fmt_status_item`:

```python
def _format_duration(seconds: int | float | None) -> str | None:
    """Convert seconds to human-readable duration string.

    Returns "Xh Ym" for >=1h, "Xm Ys" for >=1m, "Xs" for <1m.
    Returns None if input is None.
    """
    if seconds is None:
        return None
    seconds = int(seconds)
    if seconds >= 3600:
        return f"{seconds // 3600}h {(seconds % 3600) // 60}m"
    if seconds >= 60:
        return f"{seconds // 60}m {seconds % 60}s"
    return f"{seconds}s"
```

In `fmt_status_item`, change the job formatting loop:

```python
        for j in jobs:
            elapsed = j.get("elapsed_time")
            start = j.get("start_time")
            remaining = j.get("remaining_time")
            estimated = j.get("estimated_time")
            if start and not j.get("result"):
                elapsed = int(now - start)  # seconds (was ms)
            elif elapsed is not None:
                elapsed = elapsed // 1000  # ms → seconds
            if remaining is not None:
                remaining = remaining // 1000  # ms → seconds
            formatted_jobs.append(
                clean(
                    {
                        "name": j.get("name", ""),
                        "uuid": j.get("uuid"),
                        "result": j.get("result"),
                        "voting": j.get("voting", True),
                        "pre_fail": j.get("pre_fail"),
                        "elapsed": elapsed,
                        "elapsed_str": _format_duration(elapsed),
                        "remaining": remaining,
                        "remaining_str": _format_duration(remaining),
                        "estimated": estimated,
                        "start_time": start,
                        "report_url": j.get("report_url"),
                        "stream_url": j.get("url"),
                        "dependencies": j.get("dependencies") or None,
                        "waiting_status": j.get("waiting_status"),
                        "queued": j.get("queued"),
                        "tries": j.get("tries"),
                    }
                )
            )
```

Also normalize `enqueue_time` (ms → seconds):
```python
    enqueue_time = item.get("enqueue_time")
    if enqueue_time:
        out["enqueue_time"] = enqueue_time / 1000  # ms → seconds
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -v --no-header`
Expected: All PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/imatza/git/mcp-zuul
git add src/mcp_zuul/formatters.py tests/test_tools_status.py
git commit -s -m "feat: normalize status times to seconds, add human-readable durations

Zuul API returns elapsed/remaining in ms and estimated in seconds.
Normalize all to seconds for consistency. Add elapsed_str/remaining_str
as human-readable strings (e.g. '1h 23m') to eliminate manual conversion
by LLM consumers. Also normalize enqueue_time from ms to seconds.

BREAKING: elapsed, remaining, enqueue_time now in seconds (were ms).

Assisted-By: Claude Code"
```

---

### Task 2: Add computed `status` field per job

**Files:**
- Modify: `src/mcp_zuul/formatters.py:64-120`
- Modify: `tests/test_tools_status.py`

Jobs currently have `result` (None for running/waiting, cleaned away by `clean()`). Add a `status` field that's always present: "SUCCESS", "FAILURE", "RUNNING", "WAITING", "QUEUED".

- [ ] **Step 1: Write tests for the new `status` field**

Add to `tests/test_tools_status.py` in `TestGetChangeStatus`:

```python
    @respx.mock
    async def test_running_job_has_status_running(self, mock_ctx):
        item = make_status_item(change=11111)
        # Default: result=None, start_time set, waiting_status=None → RUNNING
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/11111").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "11111"))
        assert result[0]["jobs"][0]["status"] == "RUNNING"

    @respx.mock
    async def test_waiting_job_has_status_waiting(self, mock_ctx):
        item = make_status_item(change=22222, jobs=[
            {
                "name": "deploy-ocp",
                "result": None,
                "voting": True,
                "waiting_status": "dependencies: deploy-infra",
                "queued": False,
                "tries": 0,
            }
        ])
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/22222").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "22222"))
        assert result[0]["jobs"][0]["status"] == "WAITING"

    @respx.mock
    async def test_queued_job_has_status_queued(self, mock_ctx):
        item = make_status_item(change=33333, jobs=[
            {
                "name": "test-job",
                "uuid": "job-uuid-q",
                "result": None,
                "voting": True,
                "queued": True,
                "tries": 1,
                "start_time": None,
                "waiting_status": None,
            }
        ])
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/33333").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "33333"))
        assert result[0]["jobs"][0]["status"] == "QUEUED"

    @respx.mock
    async def test_completed_job_has_result_as_status(self, mock_ctx):
        item = make_status_item(change=44400)
        item["jobs"][0]["result"] = "FAILURE"
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/44400").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "44400"))
        assert result[0]["jobs"][0]["status"] == "FAILURE"
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -k "status_running or status_waiting or status_queued or result_as_status" -v`
Expected: FAIL — no `status` key in output.

- [ ] **Step 3: Implement computed `status` in `fmt_status_item`**

In `formatters.py`, inside the job formatting loop, add status computation:

```python
            # Compute status — always present, unlike result which is cleaned
            result_val = j.get("result")
            if result_val:
                status = result_val  # "SUCCESS", "FAILURE", etc.
            elif j.get("waiting_status"):
                status = "WAITING"
            elif j.get("queued") and not start:
                status = "QUEUED"
            elif start:
                status = "RUNNING"
            else:
                status = "QUEUED"
```

Then add `"status": status` to the job dict (BEFORE `result` in the dict literal so it appears first).

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -v --no-header`
Expected: All PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/imatza/git/mcp-zuul
git add src/mcp_zuul/formatters.py tests/test_tools_status.py
git commit -s -m "feat: add computed status field per job in change status

Always-present 'status' field: SUCCESS, FAILURE, RUNNING, WAITING,
QUEUED. Unlike 'result' (None for non-completed, cleaned away), status
is always set — consumers don't need to infer state from waiting_status
or queued fields.

Assisted-By: Claude Code"
```

---

### Task 3: Make `stream_url` absolute

**Files:**
- Modify: `src/mcp_zuul/tools.py:231-242`
- Modify: `tests/test_tools_status.py`

When Zuul returns a relative `stream_url` (e.g., `stream/<uuid>?logfile=console.log`), prepend `base_url/t/{tenant}/`. If already absolute (starts with `ws://` or `https://`), leave it unchanged.

- [ ] **Step 1: Write tests for absolute stream_url**

Add to `tests/test_tools_status.py` in `TestGetChangeStatus`:

```python
    @respx.mock
    async def test_relative_stream_url_made_absolute(self, mock_ctx):
        item = make_status_item(change=70001)
        item["jobs"][0]["url"] = "stream/job-uuid-1?logfile=console.log"
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/70001").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "70001"))
        stream_url = result[0]["jobs"][0]["stream_url"]
        assert stream_url == "https://zuul.example.com/t/test-tenant/stream/job-uuid-1?logfile=console.log"

    @respx.mock
    async def test_absolute_stream_url_unchanged(self, mock_ctx):
        item = make_status_item(change=70002)
        item["jobs"][0]["url"] = "wss://zuul.example.com/console"
        respx.get("https://zuul.example.com/api/tenant/test-tenant/status/change/70002").mock(
            return_value=httpx.Response(200, json=[item])
        )
        result = json.loads(await get_change_status(mock_ctx, "70002"))
        assert result[0]["jobs"][0]["stream_url"] == "wss://zuul.example.com/console"
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -k "stream_url" -v`
Expected: FAIL — relative URL not absolutified.

- [ ] **Step 3: Implement stream_url absolutification in `get_change_status`**

In `tools.py`, in the `get_change_status` enrichment loop (line 234), add stream_url absolutification alongside status_url:

```python
    for raw, fmt in zip(data, formatted, strict=True):
        refs = raw.get("refs", [])
        if refs:
            ref_id = refs[0].get("id", "")
            if ref_id:
                fmt["status_url"] = (
                    f"{base}/t/{safepath(t)}/status/change/{quote(ref_id, safe='/,')}"
                )
        # Make relative stream_urls absolute
        for job in fmt.get("jobs", []):
            su = job.get("stream_url", "")
            if su and not su.startswith(("http://", "https://", "ws://", "wss://")):
                job["stream_url"] = f"{base}/t/{safepath(t)}/{su}"
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -v --no-header`
Expected: All PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/imatza/git/mcp-zuul
git add src/mcp_zuul/tools.py tests/test_tools_status.py
git commit -s -m "feat: make relative stream_url absolute in change status

Zuul returns stream_url as a relative path on some deployments
(e.g. 'stream/<uuid>?logfile=console.log'). Absolutify by prepending
base_url/t/{tenant}/ when the URL lacks a scheme. Already-absolute
URLs (wss://, https://) are left unchanged.

Assisted-By: Claude Code"
```

---

### Task 4: Add `chain_summary` with critical path ETA

**Files:**
- Modify: `src/mcp_zuul/formatters.py`
- Modify: `tests/test_tools_status.py`
- Modify: `tests/conftest.py`

Add a `chain_summary` dict at the item level with: `completed` (count), `total` (count), `progress_pct` (int 0-100), `critical_path_remaining` (seconds), `critical_path_remaining_str` (human-readable), and `eta_seconds` (Unix timestamp of estimated completion).

The critical path algorithm walks the dependency graph backward from leaf jobs, finding the longest path of remaining work.

- [ ] **Step 1: Add `make_chained_status_item` factory to conftest**

In `tests/conftest.py`, add:

```python
def make_chained_status_item(change: int = 12345) -> dict:
    """Create a status item with a realistic adoption chain.

    Chain topology:
                     +-> deploy-ocp (RUNNING, 13m elapsed, est 109m) --> install-operators (WAITING, est 54m)
    deploy-infra --> |                                                                                       +--> run-adoption (WAITING, est 179m) --> run-after (WAITING, est 40m)
    (SUCCESS, 49m)   +-> deploy-osp (RUNNING, 13m elapsed, est 142m) --> install-shiftstack (WAITING, est 94m)
    """
    import time
    now = time.time()
    return {
        "id": f"{change},1",
        "active": True,
        "live": True,
        "refs": [
            {
                "project": "ci-framework/ci-framework-testproject",
                "change": change,
                "ref": f"refs/merge-requests/{change}/head",
                "id": f"{change},abc123",
                "url": f"https://gitlab.example.com/merge_requests/{change}",
            }
        ],
        "zuul_ref": "Zbuildset-chain-uuid",
        "enqueue_time": int((now - 3600) * 1000),
        "jobs": [
            {
                "name": "deploy-infra",
                "uuid": "uuid-infra",
                "result": "SUCCESS",
                "voting": True,
                "elapsed_time": 2940000,  # 49m in ms
                "start_time": now - 3600,
                "dependencies": [],
            },
            {
                "name": "deploy-ocp",
                "uuid": "uuid-ocp",
                "result": None,
                "voting": True,
                "elapsed_time": 780000,  # stale
                "remaining_time": 5760000,  # 96m in ms
                "estimated_time": 6540,  # 109m in seconds
                "start_time": now - 780,
                "dependencies": ["deploy-infra"],
            },
            {
                "name": "deploy-osp",
                "uuid": "uuid-osp",
                "result": None,
                "voting": True,
                "elapsed_time": 780000,
                "remaining_time": 7740000,  # 129m in ms
                "estimated_time": 8520,  # 142m in seconds
                "start_time": now - 780,
                "dependencies": ["deploy-infra"],
            },
            {
                "name": "install-operators",
                "result": None,
                "voting": True,
                "estimated_time": 3240,  # 54m
                "dependencies": ["deploy-ocp"],
                "waiting_status": "dependencies: deploy-ocp",
                "queued": False,
                "tries": 0,
            },
            {
                "name": "install-shiftstack",
                "result": None,
                "voting": True,
                "estimated_time": 5640,  # 94m
                "dependencies": ["deploy-osp"],
                "waiting_status": "dependencies: deploy-osp",
                "queued": False,
                "tries": 0,
            },
            {
                "name": "run-adoption",
                "result": None,
                "voting": True,
                "estimated_time": 10740,  # 179m
                "dependencies": ["install-operators", "install-shiftstack"],
                "waiting_status": "dependencies: install-operators, install-shiftstack",
                "queued": False,
                "tries": 0,
            },
            {
                "name": "run-after",
                "result": None,
                "voting": True,
                "estimated_time": 2400,  # 40m
                "dependencies": ["run-adoption"],
                "waiting_status": "dependencies: run-adoption",
                "queued": False,
                "tries": 0,
            },
        ],
        "failing_reasons": [],
    }
```

- [ ] **Step 2: Write tests for `_compute_chain_summary`**

Add to `tests/test_tools_status.py`:

```python
from mcp_zuul.formatters import _compute_chain_summary
from tests.conftest import make_chained_status_item

class TestChainSummary:
    def test_chain_progress(self):
        item = make_chained_status_item()
        from mcp_zuul.formatters import fmt_status_item
        formatted = fmt_status_item(item)
        summary = formatted["chain_summary"]
        assert summary["completed"] == 1  # deploy-infra
        assert summary["total"] == 7
        assert summary["running"] == 2  # deploy-ocp + deploy-osp
        assert summary["waiting"] == 4
        assert 0 < summary["progress_pct"] < 100

    def test_critical_path_remaining(self):
        item = make_chained_status_item()
        from mcp_zuul.formatters import fmt_status_item
        formatted = fmt_status_item(item)
        summary = formatted["chain_summary"]
        # Critical path: deploy-osp(remaining ~129m) → install-shiftstack(94m) → run-adoption(179m) → run-after(40m)
        # = ~129 + 94 + 179 + 40 = ~442m = ~26520s
        # But remaining is dynamic (recomputed from start_time), so allow range
        assert summary["critical_path_remaining"] > 20000  # > ~5.5h
        assert summary["critical_path_remaining"] < 35000  # < ~9.7h
        assert "critical_path_remaining_str" in summary
        assert "h" in summary["critical_path_remaining_str"]

    def test_all_completed(self):
        item = make_chained_status_item()
        for j in item["jobs"]:
            j["result"] = "SUCCESS"
            j["elapsed_time"] = 300000
            j.pop("remaining_time", None)
            j.pop("waiting_status", None)
            j.pop("estimated_time", None)
        from mcp_zuul.formatters import fmt_status_item
        formatted = fmt_status_item(item)
        summary = formatted["chain_summary"]
        assert summary["completed"] == 7
        assert summary["progress_pct"] == 100
        assert summary["critical_path_remaining"] == 0

    def test_single_job_no_chain(self):
        """Single-job items still get a chain_summary."""
        item = make_status_item()
        from mcp_zuul.formatters import fmt_status_item
        formatted = fmt_status_item(item)
        summary = formatted["chain_summary"]
        assert summary["total"] == 1
        assert summary["running"] == 1

    def test_no_estimated_time_uses_zero(self):
        """Jobs without estimated_time don't break the calculation."""
        item = make_chained_status_item()
        for j in item["jobs"]:
            j.pop("estimated_time", None)
        from mcp_zuul.formatters import fmt_status_item
        formatted = fmt_status_item(item)
        summary = formatted["chain_summary"]
        assert summary["critical_path_remaining"] >= 0
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -k "TestChainSummary" -v`
Expected: FAIL — `_compute_chain_summary` not importable.

- [ ] **Step 4: Implement `_compute_chain_summary` in formatters.py**

Add to `formatters.py`:

```python
def _compute_chain_summary(jobs: list[dict]) -> dict:
    """Compute pipeline chain progress and critical-path ETA.

    Walks the dependency graph to find the longest remaining path.
    All times are in seconds.
    """
    completed = sum(1 for j in jobs if j.get("result"))
    running = sum(1 for j in jobs if j.get("status") == "RUNNING")
    waiting = sum(1 for j in jobs if j.get("status") in ("WAITING", "QUEUED"))
    total = len(jobs)

    if total == 0:
        return {"completed": 0, "total": 0, "running": 0, "waiting": 0, "progress_pct": 0,
                "critical_path_remaining": 0, "critical_path_remaining_str": "0s"}

    progress_pct = int((completed / total) * 100) if total > 0 else 0

    # Build name→job lookup for dependency resolution
    by_name = {j["name"]: j for j in jobs}

    # Compute "time remaining until this job finishes" for each job
    # using memoized DFS on the dependency graph
    cache: dict[str, int | float] = {}

    def _remaining_through(name: str) -> int | float:
        if name in cache:
            return cache[name]
        job = by_name.get(name)
        if not job:
            cache[name] = 0
            return 0

        result = job.get("result")
        if result:
            # Already completed
            cache[name] = 0
            return 0

        remaining = job.get("remaining")  # Already in seconds from Task 1
        estimated = job.get("estimated", 0) or 0

        if job.get("status") == "RUNNING":
            # Use remaining if available, else estimate - elapsed
            own_remaining = remaining if remaining is not None else max(0, estimated - (job.get("elapsed", 0) or 0))
        else:
            # WAITING/QUEUED — full estimated duration is ahead
            own_remaining = estimated
            # Plus waiting for dependencies to finish
            deps = job.get("dependencies") or []
            if deps:
                dep_max = max((_remaining_through(d) for d in deps), default=0)
                own_remaining += dep_max

        cache[name] = own_remaining
        return own_remaining

    # Critical path = max remaining across all leaf-to-root paths
    critical_path = max((_remaining_through(j["name"]) for j in jobs), default=0)
    critical_path = int(critical_path)

    return {
        "completed": completed,
        "total": total,
        "running": running,
        "waiting": waiting,
        "progress_pct": progress_pct,
        "critical_path_remaining": critical_path,
        "critical_path_remaining_str": _format_duration(critical_path),
    }
```

Then in `fmt_status_item`, after building `formatted_jobs` and setting `out["jobs"]`, add:

```python
        out["chain_summary"] = _compute_chain_summary(formatted_jobs)
```

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/test_tools_status.py -v --no-header`
Expected: All PASS.

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run pytest tests/ -v --no-header`
Expected: All PASS (no regressions).

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

Run: `cd /Users/imatza/git/mcp-zuul && uv run ruff check src/ tests/`
Expected: Clean.

- [ ] **Step 8: Commit**

```bash
cd /Users/imatza/git/mcp-zuul
git add src/mcp_zuul/formatters.py tests/test_tools_status.py tests/conftest.py
git commit -s -m "feat: add chain_summary with critical-path ETA to change status

Compute pipeline chain progress from the dependency graph: completed/
running/waiting counts, progress percentage, and critical-path remaining
time (longest dependency path to completion). Eliminates ~20 lines of
manual LLM reasoning per babysit check.

Assisted-By: Claude Code"
```

---

### Task 5: Version bump and changelog

**Files:**
- Modify: `pyproject.toml`
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Bump version to 0.4.0**

In `pyproject.toml`, change `version = "0.3.2"` to `version = "0.4.0"`.

- [ ] **Step 2: Add changelog entry**

Prepend to CHANGELOG.md after the header:

```markdown
## [0.4.0] - 2026-03-23

### Changed
- **BREAKING**: `elapsed`, `remaining`, `enqueue_time` in `get_change_status` now in seconds (were milliseconds)
- Jobs in `get_change_status` now include always-present `status` field: "SUCCESS", "FAILURE", "RUNNING", "WAITING", "QUEUED"
- Relative `stream_url` values are absolutified with the Zuul base URL

### Added
- `elapsed_str`, `remaining_str` — human-readable duration strings ("1h 23m") per job
- `chain_summary` at the item level — pipeline progress, critical-path remaining time with dependency-graph walk
```

- [ ] **Step 3: Commit**

```bash
cd /Users/imatza/git/mcp-zuul
git add pyproject.toml CHANGELOG.md
git commit -s -m "chore: bump version to 0.4.0

BREAKING: elapsed/remaining/enqueue_time now in seconds (were ms).
New: status field, chain_summary, human-readable durations,
absolute stream_url.

Assisted-By: Claude Code"
```

---

### Task 6: Final validation

- [ ] **Step 1: Run full test suite + lint + typecheck**

```bash
cd /Users/imatza/git/mcp-zuul && uv run pytest tests/ -v && uv run ruff check src/ tests/ && uv run mypy src/
```

- [ ] **Step 2: Reinstall local and verify with live data**

```bash
cd /Users/imatza/git/mcp-zuul && uv tool install --force --reinstall ".[kerberos]"
```

Then restart Claude Code and call `get_change_status(change="1925")` to verify the enriched response against the live MR.
