# `agentsec diff` 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:** Two-input markdown diff between `agentsec evaluate` report directories — fingerprint-based findings delta + score / coverage / grade delta with regression flagging.

**Architecture:** Pure-function core (`findings_delta`, `score_delta`, `renderer`) + thin loader for I/O + typer CLI shim. New module `src/agentsec/diff/`. No edits to scoring / reports / audit packages — diff is purely additive.

**Tech Stack:** Python 3.11+ stdlib (`dataclasses`, `re`, `pathlib`, `json`), Pydantic `Finding` model, typer for CLI, pytest.

**Spec:** `docs/superpowers/specs/2026-04-30-agentsec-diff-design.md`.

---

## File structure

| Path | Responsibility | Owns types |
|------|---------------|-----------|
| `src/agentsec/diff/__init__.py` | Re-exports public API | — |
| `src/agentsec/diff/findings_delta.py` | Fingerprint match + drift clustering + sort | `FindingsDelta`, `SeverityChange`, `DriftPair` |
| `src/agentsec/diff/score_delta.py` | Numeric / grade / coverage threshold logic | `ScoreMeta`, `ScoreDelta`, `ScoreRow` |
| `src/agentsec/diff/loader.py` | Read `findings.json` + parse `combined-report.md` meta | `ReportData` |
| `src/agentsec/diff/renderer.py` | Markdown emission | — |
| `src/agentsec/cli.py` | Add `diff` subcommand (modify) | — |
| `tests/unit/test_diff_findings_delta.py` | Unit tests for findings_delta | — |
| `tests/unit/test_diff_score_delta.py` | Unit tests for score_delta | — |
| `tests/unit/test_diff_loader.py` | Unit tests for loader | — |
| `tests/unit/test_diff_renderer.py` | Unit tests for renderer | — |
| `tests/integration/test_diff_end_to_end.py` | E2E against committed sample | — |

Cross-module imports: `loader` imports `ScoreMeta` from `score_delta`; `renderer` imports types from `findings_delta`, `score_delta`, `loader`; `cli` imports `load_report_dir`, `diff_findings`, `diff_scores`, `render_diff_report` via `__init__.py`.

---

## Task 1: findings_delta — primary fingerprint match

**Files:**
- Create: `src/agentsec/diff/__init__.py`
- Create: `src/agentsec/diff/findings_delta.py`
- Create: `tests/unit/test_diff_findings_delta.py`

- [ ] **Step 1: Write failing test** (`tests/unit/test_diff_findings_delta.py`)

```python
from agentsec.audit.findings import Finding
from agentsec.diff.findings_delta import diff_findings


def _f(check: str, title: str, severity: str = "high",
       evidence: dict | None = None) -> Finding:
    return Finding(
        check=check, severity=severity, title=title,
        description="d", evidence=evidence or {}, remediation="r",
    )


def test_empty_inputs_produce_empty_delta():
    delta = diff_findings([], [])
    assert delta.added == []
    assert delta.removed == []
    assert delta.stable == []
    assert delta.severity_changed == []
    assert delta.evidence_drifted == []


def test_only_baseline_means_removed():
    a = _f("c1", "t1")
    delta = diff_findings([a], [])
    assert delta.removed == [a]
    assert delta.added == [] and delta.stable == []


def test_only_current_means_added():
    a = _f("c1", "t1")
    delta = diff_findings([], [a])
    assert delta.added == [a]
    assert delta.removed == [] and delta.stable == []


def test_same_fingerprint_means_stable():
    a = _f("c1", "t1", evidence={"k": 1})
    b = _f("c1", "t1", evidence={"k": 1})  # same fingerprint
    delta = diff_findings([a], [b])
    assert delta.stable == [b]  # current side wins
    assert delta.added == [] and delta.removed == []
```

- [ ] **Step 2: Run test to verify it fails**

```bash
.venv/bin/pytest tests/unit/test_diff_findings_delta.py -v
```

Expected: FAIL with `ModuleNotFoundError: No module named 'agentsec.diff'`.

- [ ] **Step 3: Implement scaffolding + primary pass** (`src/agentsec/diff/__init__.py`)

```python
"""Two-input diff for `agentsec evaluate` reports — Phase 5 v0.4.0.

See `docs/superpowers/specs/2026-04-30-agentsec-diff-design.md`.
"""

from .findings_delta import (
    DriftPair,
    FindingsDelta,
    SeverityChange,
    diff_findings,
)

__all__ = [
    "DriftPair",
    "FindingsDelta",
    "SeverityChange",
    "diff_findings",
]
```

(`src/agentsec/diff/findings_delta.py`)

```python
"""Fingerprint-based findings diff with second-pass (check, title) drift
clustering. Spec §5."""

from __future__ import annotations

from dataclasses import dataclass, field

from agentsec.audit.findings import Finding


@dataclass(frozen=True)
class SeverityChange:
    fingerprint: str
    title: str
    check: str
    old_severity: str
    new_severity: str


@dataclass(frozen=True)
class DriftPair:
    check: str
    title: str
    baseline: Finding
    current: Finding


@dataclass(frozen=True)
class FindingsDelta:
    added: list[Finding] = field(default_factory=list)
    removed: list[Finding] = field(default_factory=list)
    stable: list[Finding] = field(default_factory=list)
    severity_changed: list[SeverityChange] = field(default_factory=list)
    evidence_drifted: list[DriftPair] = field(default_factory=list)


def diff_findings(baseline: list[Finding], current: list[Finding]) -> FindingsDelta:
    baseline_by_fp = {f.fingerprint: f for f in baseline}
    current_by_fp = {f.fingerprint: f for f in current}

    added = [f for fp, f in current_by_fp.items() if fp not in baseline_by_fp]
    removed = [f for fp, f in baseline_by_fp.items() if fp not in current_by_fp]
    stable: list[Finding] = []
    for fp in baseline_by_fp.keys() & current_by_fp.keys():
        stable.append(current_by_fp[fp])

    return FindingsDelta(
        added=added,
        removed=removed,
        stable=stable,
        severity_changed=[],
        evidence_drifted=[],
    )
```

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

```bash
.venv/bin/pytest tests/unit/test_diff_findings_delta.py -v
```

Expected: 4 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/__init__.py src/agentsec/diff/findings_delta.py \
  tests/unit/test_diff_findings_delta.py
git commit -m "$(cat <<'EOF'
feat(diff): findings_delta primary fingerprint match (T1)

Spec §5.1 — fingerprint-keyed added/removed/stable buckets.

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

---

## Task 2: findings_delta — severity change detection

**Files:**
- Modify: `src/agentsec/diff/findings_delta.py`
- Modify: `tests/unit/test_diff_findings_delta.py`

- [ ] **Step 1: Write failing tests** (append to `tests/unit/test_diff_findings_delta.py`)

```python
def test_same_fingerprint_different_severity_goes_to_severity_changed_only():
    a = _f("c1", "t1", severity="high", evidence={"k": 1})
    b = _f("c1", "t1", severity="critical", evidence={"k": 1})
    delta = diff_findings([a], [b])
    assert delta.stable == []
    assert len(delta.severity_changed) == 1
    sc = delta.severity_changed[0]
    assert sc.old_severity == "high"
    assert sc.new_severity == "critical"
    assert sc.fingerprint == a.fingerprint
    assert sc.check == "c1" and sc.title == "t1"


def test_severity_change_does_not_affect_added_or_removed():
    # b1 has fp X, severity high; b2 has fp Y, severity low (only in baseline).
    # c1 has fp X, severity critical (severity-changed); c2 has fp Z (added).
    b1 = _f("c", "t1", severity="high", evidence={"v": 1})
    b2 = _f("c", "t2", severity="low", evidence={"v": 2})
    c1 = _f("c", "t1", severity="critical", evidence={"v": 1})
    c2 = _f("c", "t3", severity="medium", evidence={"v": 3})
    delta = diff_findings([b1, b2], [c1, c2])
    assert [f.title for f in delta.removed] == ["t2"]
    assert [f.title for f in delta.added] == ["t3"]
    assert len(delta.severity_changed) == 1
```

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

```bash
.venv/bin/pytest tests/unit/test_diff_findings_delta.py -v
```

Expected: 2 new tests FAIL (severity_changed remains empty list).

- [ ] **Step 3: Extend implementation** (replace `diff_findings` body in `src/agentsec/diff/findings_delta.py`)

```python
def diff_findings(baseline: list[Finding], current: list[Finding]) -> FindingsDelta:
    baseline_by_fp = {f.fingerprint: f for f in baseline}
    current_by_fp = {f.fingerprint: f for f in current}

    added = [f for fp, f in current_by_fp.items() if fp not in baseline_by_fp]
    removed = [f for fp, f in baseline_by_fp.items() if fp not in current_by_fp]
    stable: list[Finding] = []
    severity_changed: list[SeverityChange] = []
    for fp in baseline_by_fp.keys() & current_by_fp.keys():
        b = baseline_by_fp[fp]
        c = current_by_fp[fp]
        if b.severity == c.severity:
            stable.append(c)
        else:
            severity_changed.append(
                SeverityChange(
                    fingerprint=fp,
                    title=c.title,
                    check=c.check,
                    old_severity=b.severity,
                    new_severity=c.severity,
                )
            )

    return FindingsDelta(
        added=added,
        removed=removed,
        stable=stable,
        severity_changed=severity_changed,
        evidence_drifted=[],
    )
```

- [ ] **Step 4: Run all tests**

```bash
.venv/bin/pytest tests/unit/test_diff_findings_delta.py -v
```

Expected: 6 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/findings_delta.py tests/unit/test_diff_findings_delta.py
git commit -m "$(cat <<'EOF'
feat(diff): severity_changed bucket (T2)

Spec §5.1 — same fingerprint, different severity → severity_changed,
NOT in stable.

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

---

## Task 3: findings_delta — evidence drift second pass

**Files:**
- Modify: `src/agentsec/diff/findings_delta.py`
- Modify: `tests/unit/test_diff_findings_delta.py`

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

```python
def test_evidence_drift_pairs_added_with_removed_by_check_title():
    # Same (check, title), different evidence → different fingerprint.
    a = _f("native_audit", "open port", evidence={"port": 8443})
    b = _f("native_audit", "open port", evidence={"port": 8444})
    delta = diff_findings([a], [b])
    assert delta.added == []
    assert delta.removed == []
    assert len(delta.evidence_drifted) == 1
    pair = delta.evidence_drifted[0]
    assert pair.check == "native_audit" and pair.title == "open port"
    assert pair.baseline == a
    assert pair.current == b


def test_evidence_drift_does_not_pair_when_check_or_title_differ():
    a = _f("c1", "t1", evidence={"v": 1})
    b = _f("c1", "t2", evidence={"v": 1})  # different title
    delta = diff_findings([a], [b])
    assert delta.evidence_drifted == []
    assert delta.removed == [a]
    assert delta.added == [b]


def test_evidence_drift_caveat_duplicate_check_title_within_side():
    # Spec §5.3: two findings with same (check, title) on same side →
    # one drift pair + residual added/removed.
    a1 = _f("c", "t", evidence={"port": 8443})
    a2 = _f("c", "t", evidence={"port": 8444})
    b1 = _f("c", "t", evidence={"port": 9443})
    b2 = _f("c", "t", evidence={"port": 9444})
    delta = diff_findings([a1, a2], [b1, b2])
    assert len(delta.evidence_drifted) == 1
    assert len(delta.added) + len(delta.removed) == 2
```

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

Expected: 3 new tests FAIL.

- [ ] **Step 3: Extend implementation** (replace `diff_findings` body)

```python
def diff_findings(baseline: list[Finding], current: list[Finding]) -> FindingsDelta:
    baseline_by_fp = {f.fingerprint: f for f in baseline}
    current_by_fp = {f.fingerprint: f for f in current}

    added_initial = [f for fp, f in current_by_fp.items() if fp not in baseline_by_fp]
    removed_initial = [f for fp, f in baseline_by_fp.items() if fp not in current_by_fp]

    stable: list[Finding] = []
    severity_changed: list[SeverityChange] = []
    for fp in baseline_by_fp.keys() & current_by_fp.keys():
        b = baseline_by_fp[fp]
        c = current_by_fp[fp]
        if b.severity == c.severity:
            stable.append(c)
        else:
            severity_changed.append(
                SeverityChange(
                    fingerprint=fp,
                    title=c.title,
                    check=c.check,
                    old_severity=b.severity,
                    new_severity=c.severity,
                )
            )

    # Second pass — pair leftover added×removed by (check, title).
    removed_by_ct: dict[tuple[str, str], Finding] = {}
    for f in removed_initial:
        removed_by_ct[(f.check, f.title)] = f  # last write wins (§5.3 caveat)

    drifted: list[DriftPair] = []
    remaining_added: list[Finding] = []
    paired_baseline_ids: set[int] = set()  # id() to avoid Finding hash issues
    for f in added_initial:
        key = (f.check, f.title)
        baseline_pair = removed_by_ct.get(key)
        if baseline_pair is not None and id(baseline_pair) not in paired_baseline_ids:
            drifted.append(
                DriftPair(check=f.check, title=f.title,
                          baseline=baseline_pair, current=f)
            )
            paired_baseline_ids.add(id(baseline_pair))
            removed_by_ct.pop(key, None)
        else:
            remaining_added.append(f)

    remaining_removed = [
        f for f in removed_initial if id(f) not in paired_baseline_ids
    ]

    return FindingsDelta(
        added=remaining_added,
        removed=remaining_removed,
        stable=stable,
        severity_changed=severity_changed,
        evidence_drifted=drifted,
    )
```

- [ ] **Step 4: Run all tests**

Expected: 9 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/findings_delta.py tests/unit/test_diff_findings_delta.py
git commit -m "$(cat <<'EOF'
feat(diff): evidence drift second-pass clustering (T3)

Spec §5.2-5.3 — pair leftover added×removed by (check, title); duplicate
keys on same side allowed (one pair + residuals).

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

---

## Task 4: findings_delta — sort / determinism

**Files:**
- Modify: `src/agentsec/diff/findings_delta.py`
- Modify: `tests/unit/test_diff_findings_delta.py`

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

```python
def test_added_sorted_by_severity_then_check_then_title():
    # critical < high < medium < low < info per §5.4
    f1 = _f("z_check", "z_title", severity="medium")
    f2 = _f("a_check", "a_title", severity="critical")
    f3 = _f("a_check", "z_title", severity="critical")
    delta = diff_findings([], [f1, f2, f3])
    assert [f.title for f in delta.added] == ["a_title", "z_title", "z_title"]
    assert [f.severity for f in delta.added] == ["critical", "critical", "medium"]


def test_severity_changed_sorted_by_new_severity():
    b1 = _f("c", "t1", severity="low", evidence={"v": 1})
    c1 = _f("c", "t1", severity="medium", evidence={"v": 1})
    b2 = _f("c", "t2", severity="low", evidence={"v": 2})
    c2 = _f("c", "t2", severity="critical", evidence={"v": 2})
    delta = diff_findings([b1, b2], [c1, c2])
    assert [sc.title for sc in delta.severity_changed] == ["t2", "t1"]


def test_diff_is_byte_deterministic_across_runs():
    import json
    fs_a = [_f("c1", "t_a", evidence={"v": 1}),
            _f("c2", "t_b", severity="critical", evidence={"v": 2})]
    fs_b = [_f("c1", "t_a", evidence={"v": 99}),
            _f("c3", "t_c", severity="high", evidence={"v": 3})]
    delta1 = diff_findings(fs_a, fs_b)
    delta2 = diff_findings(fs_a, fs_b)
    # Compare the rendered structure deterministically.
    def to_keys(d):
        return {
            "added": [f.fingerprint for f in d.added],
            "removed": [f.fingerprint for f in d.removed],
            "stable": [f.fingerprint for f in d.stable],
            "severity_changed": [(s.fingerprint, s.new_severity)
                                 for s in d.severity_changed],
            "evidence_drifted": [(p.check, p.title) for p in d.evidence_drifted],
        }
    assert json.dumps(to_keys(delta1), sort_keys=True) == \
           json.dumps(to_keys(delta2), sort_keys=True)
```

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

Expected: at least the first 2 tests FAIL (sort missing); third may pass by accident.

- [ ] **Step 3: Add sort step** (append to `src/agentsec/diff/findings_delta.py`)

```python
_SEVERITY_RANK: dict[str, int] = {
    "critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4,
}


def _finding_sort_key(f: Finding) -> tuple[int, str, str, str]:
    return (_SEVERITY_RANK.get(f.severity, 99), f.check, f.title, f.fingerprint)


def _severity_change_sort_key(s: SeverityChange) -> tuple[int, str, str]:
    return (_SEVERITY_RANK.get(s.new_severity, 99), s.check, s.title)


def _drift_sort_key(p: DriftPair) -> tuple[int, str, str]:
    return (_SEVERITY_RANK.get(p.current.severity, 99), p.check, p.title)
```

Wrap the return value in `diff_findings`:

```python
    return FindingsDelta(
        added=sorted(remaining_added, key=_finding_sort_key),
        removed=sorted(remaining_removed, key=_finding_sort_key),
        stable=sorted(stable, key=_finding_sort_key),
        severity_changed=sorted(severity_changed, key=_severity_change_sort_key),
        evidence_drifted=sorted(drifted, key=_drift_sort_key),
    )
```

- [ ] **Step 4: Run all tests + ruff**

```bash
.venv/bin/pytest tests/unit/test_diff_findings_delta.py -v
.venv/bin/ruff check src/agentsec/diff/ tests/unit/test_diff_findings_delta.py
```

Expected: 12 tests PASS, ruff clean.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/findings_delta.py tests/unit/test_diff_findings_delta.py
git commit -m "$(cat <<'EOF'
feat(diff): deterministic sort across all buckets (T4)

Spec §5.4 — (severity_rank, check, title, fingerprint) for findings;
new_severity_rank for severity_changed; current severity for drift pairs.

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

---

## Task 5: score_delta — types + numeric metrics

**Files:**
- Create: `src/agentsec/diff/score_delta.py`
- Create: `tests/unit/test_diff_score_delta.py`
- Modify: `src/agentsec/diff/__init__.py` (re-export)

- [ ] **Step 1: Write failing tests** (`tests/unit/test_diff_score_delta.py`)

```python
from agentsec.diff.score_delta import ScoreMeta, diff_scores


def _meta(remote=70.0, server=80.0, combined=75.0, grade="B",
          verdict_coverage=1.0, error_rate=0.0) -> ScoreMeta:
    return ScoreMeta(
        remote_score=remote, server_score=server, combined_score=combined,
        grade=grade, verdict_coverage=verdict_coverage, error_rate=error_rate,
    )


def test_both_sides_full_renders_all_rows():
    delta = diff_scores(_meta(), _meta())
    metrics = [r.metric for r in delta.rows]
    assert "combined_score" in metrics
    assert "remote_score" in metrics
    assert "server_score" in metrics
    assert "grade" in metrics
    assert "verdict_coverage" in metrics
    assert "error_rate" in metrics


def test_combined_score_drop_above_5_is_regression():
    delta = diff_scores(_meta(combined=70.0), _meta(combined=62.0))
    row = next(r for r in delta.rows if r.metric == "combined_score")
    assert row.is_regression
    assert "REGRESSION" in row.delta_label
    assert "-8.0" in row.delta_label
    assert delta.has_regression is True


def test_combined_score_drop_under_threshold_is_neutral():
    delta = diff_scores(_meta(combined=70.0), _meta(combined=66.0))
    row = next(r for r in delta.rows if r.metric == "combined_score")
    assert not row.is_regression and not row.is_improvement
    assert "REGRESSION" not in row.delta_label


def test_combined_score_rise_above_5_is_improvement():
    delta = diff_scores(_meta(combined=70.0), _meta(combined=80.0))
    row = next(r for r in delta.rows if r.metric == "combined_score")
    assert row.is_improvement
    assert "IMPROVED" in row.delta_label


def test_score_metric_with_None_renders_na_no_flag():
    delta = diff_scores(_meta(remote=None), _meta(remote=70.0))
    row = next(r for r in delta.rows if r.metric == "remote_score")
    assert row.baseline_value == "n/a"
    assert not row.is_regression and not row.is_improvement


def test_has_regression_false_when_only_neutral_changes():
    delta = diff_scores(_meta(combined=70.0), _meta(combined=68.0))
    assert delta.has_regression is False
```

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

```bash
.venv/bin/pytest tests/unit/test_diff_score_delta.py -v
```

Expected: FAIL with `ModuleNotFoundError: No module named 'agentsec.diff.score_delta'`.

- [ ] **Step 3: Implement score_delta** (`src/agentsec/diff/score_delta.py`)

```python
"""Score / coverage / grade delta with regression flagging. Spec §6."""

from __future__ import annotations

from dataclasses import dataclass, field

REGRESSION_THRESHOLD: float = 5.0


@dataclass(frozen=True)
class ScoreMeta:
    remote_score: float | None
    server_score: float | None
    combined_score: float | None
    grade: str
    verdict_coverage: float | None
    error_rate: float | None


@dataclass(frozen=True)
class ScoreRow:
    metric: str
    baseline_value: str
    current_value: str
    delta_label: str
    is_regression: bool
    is_improvement: bool


@dataclass(frozen=True)
class ScoreDelta:
    rows: list[ScoreRow] = field(default_factory=list)
    has_regression: bool = False


def _fmt_score(v: float | None) -> str:
    return "n/a" if v is None else f"{v:.1f}"


def _numeric_row(metric: str, b: float | None, c: float | None) -> ScoreRow:
    if b is None or c is None:
        return ScoreRow(
            metric=metric,
            baseline_value=_fmt_score(b),
            current_value=_fmt_score(c),
            delta_label="—",
            is_regression=False,
            is_improvement=False,
        )
    delta = c - b
    if delta < -REGRESSION_THRESHOLD:
        label = f"**REGRESSION: {delta:+.1f}**"
        return ScoreRow(metric=metric, baseline_value=_fmt_score(b),
                        current_value=_fmt_score(c), delta_label=label,
                        is_regression=True, is_improvement=False)
    if delta > REGRESSION_THRESHOLD:
        label = f"**IMPROVED: {delta:+.1f}**"
        return ScoreRow(metric=metric, baseline_value=_fmt_score(b),
                        current_value=_fmt_score(c), delta_label=label,
                        is_regression=False, is_improvement=True)
    if delta == 0:
        return ScoreRow(metric=metric, baseline_value=_fmt_score(b),
                        current_value=_fmt_score(c), delta_label="—",
                        is_regression=False, is_improvement=False)
    return ScoreRow(metric=metric, baseline_value=_fmt_score(b),
                    current_value=_fmt_score(c), delta_label=f"{delta:+.1f}",
                    is_regression=False, is_improvement=False)


def diff_scores(baseline: ScoreMeta, current: ScoreMeta) -> ScoreDelta:
    rows: list[ScoreRow] = [
        _numeric_row("combined_score", baseline.combined_score, current.combined_score),
        _numeric_row("remote_score", baseline.remote_score, current.remote_score),
        _numeric_row("server_score", baseline.server_score, current.server_score),
        # grade / verdict_coverage / error_rate added in T6.
    ]
    has_regression = any(r.is_regression for r in rows)
    return ScoreDelta(rows=rows, has_regression=has_regression)
```

Update `src/agentsec/diff/__init__.py` to also export the new symbols:

```python
from .findings_delta import (
    DriftPair,
    FindingsDelta,
    SeverityChange,
    diff_findings,
)
from .score_delta import (
    ScoreDelta,
    ScoreMeta,
    ScoreRow,
    diff_scores,
)

__all__ = [
    "DriftPair",
    "FindingsDelta",
    "ScoreDelta",
    "ScoreMeta",
    "ScoreRow",
    "SeverityChange",
    "diff_findings",
    "diff_scores",
]
```

- [ ] **Step 4: Run tests + ruff**

```bash
.venv/bin/pytest tests/unit/test_diff_score_delta.py -v
.venv/bin/ruff check src/agentsec/diff/
```

Expected: 6 tests PASS, ruff clean.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/score_delta.py src/agentsec/diff/__init__.py \
  tests/unit/test_diff_score_delta.py
git commit -m "$(cat <<'EOF'
feat(diff): score_delta numeric metrics with regression flagging (T5)

Spec §6.2 — combined/remote/server score deltas, threshold 5.0 absolute,
n/a passthrough when either side is None.

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

---

## Task 6: score_delta — grade letter ranking + coverage / error_rate

**Files:**
- Modify: `src/agentsec/diff/score_delta.py`
- Modify: `tests/unit/test_diff_score_delta.py`

- [ ] **Step 1: Write failing tests** (append)

```python
def test_grade_letter_drop_is_regression():
    delta = diff_scores(_meta(grade="B"), _meta(grade="C"))
    row = next(r for r in delta.rows if r.metric == "grade")
    assert row.is_regression
    assert "B → C" in row.delta_label


def test_grade_letter_rise_is_improvement():
    delta = diff_scores(_meta(grade="C"), _meta(grade="A"))
    row = next(r for r in delta.rows if r.metric == "grade")
    assert row.is_improvement
    assert "C → A" in row.delta_label


def test_grade_same_is_neutral():
    delta = diff_scores(_meta(grade="B"), _meta(grade="B"))
    row = next(r for r in delta.rows if r.metric == "grade")
    assert not row.is_regression and not row.is_improvement


def test_grade_insufficient_coverage_when_baseline_was_real_is_regression():
    delta = diff_scores(_meta(grade="B"), _meta(grade="INSUFFICIENT_COVERAGE"))
    row = next(r for r in delta.rows if r.metric == "grade")
    assert row.is_regression


def test_grade_unknown_letter_is_neutral():
    delta = diff_scores(_meta(grade="A-"), _meta(grade="B+"))
    row = next(r for r in delta.rows if r.metric == "grade")
    assert not row.is_regression and not row.is_improvement


def test_verdict_coverage_drop_below_07_is_regression():
    delta = diff_scores(_meta(verdict_coverage=1.0),
                        _meta(verdict_coverage=0.65))
    row = next(r for r in delta.rows if r.metric == "verdict_coverage")
    assert row.is_regression


def test_verdict_coverage_rise_above_07_is_improvement():
    delta = diff_scores(_meta(verdict_coverage=0.5),
                        _meta(verdict_coverage=0.9))
    row = next(r for r in delta.rows if r.metric == "verdict_coverage")
    assert row.is_improvement


def test_error_rate_rise_above_01_is_regression():
    delta = diff_scores(_meta(error_rate=0.0), _meta(error_rate=0.2))
    row = next(r for r in delta.rows if r.metric == "error_rate")
    assert row.is_regression


def test_coverage_metric_with_None_renders_na_no_flag():
    delta = diff_scores(_meta(verdict_coverage=None),
                        _meta(verdict_coverage=0.95))
    row = next(r for r in delta.rows if r.metric == "verdict_coverage")
    assert row.baseline_value == "n/a"
    assert not row.is_regression and not row.is_improvement
```

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

Expected: 9 new tests FAIL.

- [ ] **Step 3: Extend score_delta.py** (add helpers + extend `diff_scores`)

```python
_GRADE_RANK: dict[str, int] = {"A": 4, "B": 3, "C": 2, "D": 1, "F": 0}

LOW_COVERAGE_THRESHOLD: float = 0.7
HIGH_ERROR_RATE_THRESHOLD: float = 0.1


def _grade_rank(grade: str) -> int | None:
    if grade == "INSUFFICIENT_COVERAGE":
        return -1
    if grade == "NOT_SCORED":
        return None
    return _GRADE_RANK.get(grade)  # None for unknown letters (A- etc.)


def _grade_row(b: str, c: str) -> ScoreRow:
    rb = _grade_rank(b)
    rc = _grade_rank(c)
    if rb is None or rc is None:
        return ScoreRow(metric="grade", baseline_value=b, current_value=c,
                        delta_label="—" if b == c else f"{b} → {c}",
                        is_regression=False, is_improvement=False)
    if rc < rb:
        return ScoreRow(metric="grade", baseline_value=b, current_value=c,
                        delta_label=f"**REGRESSION: {b} → {c}**",
                        is_regression=True, is_improvement=False)
    if rc > rb:
        return ScoreRow(metric="grade", baseline_value=b, current_value=c,
                        delta_label=f"**IMPROVED: {b} → {c}**",
                        is_regression=False, is_improvement=True)
    return ScoreRow(metric="grade", baseline_value=b, current_value=c,
                    delta_label="—", is_regression=False, is_improvement=False)


def _coverage_row(b: float | None, c: float | None) -> ScoreRow:
    if b is None or c is None:
        return ScoreRow(metric="verdict_coverage",
                        baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                        delta_label="—",
                        is_regression=False, is_improvement=False)
    was_low = b < LOW_COVERAGE_THRESHOLD
    is_low = c < LOW_COVERAGE_THRESHOLD
    if not was_low and is_low:
        return ScoreRow(metric="verdict_coverage",
                        baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                        delta_label="**REGRESSION: low coverage**",
                        is_regression=True, is_improvement=False)
    if was_low and not is_low:
        return ScoreRow(metric="verdict_coverage",
                        baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                        delta_label="**IMPROVED: coverage restored**",
                        is_regression=False, is_improvement=True)
    return ScoreRow(metric="verdict_coverage",
                    baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                    delta_label="—",
                    is_regression=False, is_improvement=False)


def _error_rate_row(b: float | None, c: float | None) -> ScoreRow:
    if b is None or c is None:
        return ScoreRow(metric="error_rate",
                        baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                        delta_label="—",
                        is_regression=False, is_improvement=False)
    was_high = b > HIGH_ERROR_RATE_THRESHOLD
    is_high = c > HIGH_ERROR_RATE_THRESHOLD
    if not was_high and is_high:
        return ScoreRow(metric="error_rate",
                        baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                        delta_label="**REGRESSION: high error rate**",
                        is_regression=True, is_improvement=False)
    if was_high and not is_high:
        return ScoreRow(metric="error_rate",
                        baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                        delta_label="**IMPROVED: error rate normalized**",
                        is_regression=False, is_improvement=True)
    return ScoreRow(metric="error_rate",
                    baseline_value=_fmt_pct(b), current_value=_fmt_pct(c),
                    delta_label="—",
                    is_regression=False, is_improvement=False)


def _fmt_pct(v: float | None) -> str:
    return "n/a" if v is None else f"{v:.2%}"
```

Replace `diff_scores`:

```python
def diff_scores(baseline: ScoreMeta, current: ScoreMeta) -> ScoreDelta:
    rows: list[ScoreRow] = [
        _numeric_row("combined_score", baseline.combined_score, current.combined_score),
        _grade_row(baseline.grade, current.grade),
        _numeric_row("remote_score", baseline.remote_score, current.remote_score),
        _numeric_row("server_score", baseline.server_score, current.server_score),
        _coverage_row(baseline.verdict_coverage, current.verdict_coverage),
        _error_rate_row(baseline.error_rate, current.error_rate),
    ]
    has_regression = any(r.is_regression for r in rows)
    return ScoreDelta(rows=rows, has_regression=has_regression)
```

- [ ] **Step 4: Run all tests**

```bash
.venv/bin/pytest tests/unit/test_diff_score_delta.py -v
.venv/bin/ruff check src/agentsec/diff/
```

Expected: 15 tests PASS, ruff clean.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/score_delta.py tests/unit/test_diff_score_delta.py
git commit -m "$(cat <<'EOF'
feat(diff): grade ranking + coverage / error_rate thresholds (T6)

Spec §6.2 — A>B>C>D>F letter rank, INSUFFICIENT_COVERAGE=-1, unknown
variants neutral; verdict_coverage<0.7 and error_rate>0.1 trigger
regression / improvement transitions.

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

---

## Task 7: loader — findings.json + ReportData scaffolding

**Files:**
- Create: `src/agentsec/diff/loader.py`
- Create: `tests/unit/test_diff_loader.py`
- Modify: `src/agentsec/diff/__init__.py`

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

```python
import json
import pytest
from pathlib import Path
from agentsec.audit.findings import Finding
from agentsec.diff.loader import (
    LoaderError,
    ReportData,
    load_findings,
    load_report_dir,
)


def _write_report(tmp: Path, name: str = "report-2026-04-30") -> Path:
    d = tmp / name
    d.mkdir()
    (d / "findings.json").write_text(json.dumps([
        {"check": "c", "severity": "high", "title": "t",
         "description": "d", "evidence": {"k": 1}, "remediation": "r",
         "threat_refs": []},
    ]))
    return d


def test_load_findings_returns_finding_list(tmp_path: Path):
    d = _write_report(tmp_path)
    findings = load_findings(d / "findings.json")
    assert len(findings) == 1
    assert isinstance(findings[0], Finding)
    assert findings[0].title == "t"


def test_load_findings_missing_file_raises(tmp_path: Path):
    # Path basename is "findings.json" so the error message contains it.
    with pytest.raises(LoaderError, match="findings.json not found"):
        load_findings(tmp_path / "findings.json")


def test_load_findings_malformed_json_raises(tmp_path: Path):
    f = tmp_path / "findings.json"
    f.write_text("not json")
    with pytest.raises(LoaderError, match="malformed"):
        load_findings(f)


def test_load_findings_not_array_raises(tmp_path: Path):
    f = tmp_path / "findings.json"
    f.write_text('{"oops": "object not array"}')
    with pytest.raises(LoaderError, match="expected JSON array"):
        load_findings(f)


def test_load_report_dir_basename_label(tmp_path: Path):
    d = _write_report(tmp_path, "arbitrary-name")
    data = load_report_dir(d)
    assert isinstance(data, ReportData)
    assert data.label == "arbitrary-name"
    assert data.date is None  # no date in basename


def test_load_report_dir_date_parsed_from_basename(tmp_path: Path):
    d = _write_report(tmp_path, "report-2026-04-30")
    data = load_report_dir(d)
    assert data.date == "2026-04-30"


def test_load_report_dir_missing_directory_raises(tmp_path: Path):
    with pytest.raises(LoaderError, match="directory not found"):
        load_report_dir(tmp_path / "nope")
```

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

Expected: FAIL with `ModuleNotFoundError: No module named 'agentsec.diff.loader'`.

- [ ] **Step 3: Implement loader (no meta parsing yet)** (`src/agentsec/diff/loader.py`)

```python
"""Loader for `agentsec evaluate` report directories. Spec §6.1."""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path

from pydantic import ValidationError

from agentsec.audit.findings import Finding

from .score_delta import ScoreMeta


class LoaderError(Exception):
    """Loader-side input error (raised; cli.py converts to exit 1)."""


@dataclass(frozen=True)
class ReportData:
    findings: list[Finding]
    score_meta: ScoreMeta | None
    label: str
    date: str | None


_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")


def load_findings(path: Path) -> list[Finding]:
    if not path.exists():
        raise LoaderError(f"{path} not found")
    try:
        raw = json.loads(path.read_text())
    except json.JSONDecodeError as e:
        raise LoaderError(f"{path} malformed JSON: {e}") from e
    if not isinstance(raw, list):
        raise LoaderError(f"{path} expected JSON array, got {type(raw).__name__}")
    try:
        return [Finding(**item) for item in raw]
    except ValidationError as e:
        raise LoaderError(f"{path} malformed Finding: {e}") from e


def load_report_dir(path: Path) -> ReportData:
    if not path.exists() or not path.is_dir():
        raise LoaderError(f"directory not found: {path}")
    findings = load_findings(path / "findings.json")
    label = path.name
    m = _DATE_RE.search(label)
    date = m.group(1) if m else None
    return ReportData(findings=findings, score_meta=None, label=label, date=date)
```

Add `LoaderError`, `ReportData`, `load_findings`, `load_report_dir` to `__init__.py` `__all__`.

- [ ] **Step 4: Run tests + ruff**

Expected: 7 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/loader.py src/agentsec/diff/__init__.py \
  tests/unit/test_diff_loader.py
git commit -m "$(cat <<'EOF'
feat(diff): loader for findings.json + ReportData scaffolding (T7)

Spec §3,§8 — typed errors, dirname date parsing, score_meta defers to T8.

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

---

## Task 8: loader — combined-report.md meta parsing

**Files:**
- Modify: `src/agentsec/diff/loader.py`
- Modify: `tests/unit/test_diff_loader.py`

- [ ] **Step 1: Write failing tests** (append)

```python
def _write_combined_md(d: Path, *, with_meta: bool = True,
                       with_coverage: bool = True,
                       remote: str = "70.0", server: str = "80.0",
                       combined: str = "75.0", grade: str = "B",
                       verdict_pct: str = "100.00", error_pct: str = "0.00") -> Path:
    parts = ["# Combined report — test", "**Date**: 2026-04-30", "", "## Score", ""]
    if with_meta:
        parts += [
            f"<!-- meta:remote_score value:{remote} -->",
            f"<!-- meta:server_score value:{server} -->",
            f"<!-- meta:combined_score value:{combined} -->",
            f"<!-- meta:grade value:{grade} -->",
            "",
        ]
    if with_coverage:
        parts += [
            "### Coverage", "",
            "- Planned: 3",
            "- Executed: 3 (execution_coverage=100.00%)",
            f"- Scored: 3 (verdict_coverage={verdict_pct}%)",
            f"- Errors: 0 (error_rate={error_pct}%)",
        ]
    p = d / "combined-report.md"
    p.write_text("\n".join(parts) + "\n")
    return p


def test_load_report_dir_parses_meta_when_present(tmp_path: Path):
    d = _write_report(tmp_path)
    _write_combined_md(d)
    data = load_report_dir(d)
    assert data.score_meta is not None
    assert data.score_meta.remote_score == 70.0
    assert data.score_meta.combined_score == 75.0
    assert data.score_meta.grade == "B"
    assert data.score_meta.verdict_coverage == 1.0
    assert data.score_meta.error_rate == 0.0


def test_load_report_dir_meta_value_None_parses_to_None(tmp_path: Path):
    d = _write_report(tmp_path)
    _write_combined_md(d, remote="None", combined="None")
    data = load_report_dir(d)
    assert data.score_meta.remote_score is None
    assert data.score_meta.combined_score is None
    assert data.score_meta.server_score == 80.0  # still populated


def test_load_report_dir_combined_md_missing_returns_None_meta(tmp_path: Path):
    d = _write_report(tmp_path)
    # no combined-report.md written
    data = load_report_dir(d)
    assert data.score_meta is None


def test_load_report_dir_meta_lines_absent_returns_None_meta(tmp_path: Path):
    d = _write_report(tmp_path)
    _write_combined_md(d, with_meta=False)
    data = load_report_dir(d)
    assert data.score_meta is None


def test_load_report_dir_partial_meta_treated_as_None(tmp_path: Path):
    # Spec §6.1 mode (1): all-or-nothing on meta lines.
    d = _write_report(tmp_path)
    parts = ["## Score", "",
             "<!-- meta:remote_score value:70.0 -->",
             "<!-- meta:grade value:B -->",  # missing server / combined
             ""]
    (d / "combined-report.md").write_text("\n".join(parts))
    data = load_report_dir(d)
    assert data.score_meta is None


def test_load_report_dir_coverage_lines_absent_falls_back_to_None(tmp_path: Path):
    # Spec §6.1: coverage-section absent is independent of meta block.
    d = _write_report(tmp_path)
    _write_combined_md(d, with_coverage=False)
    data = load_report_dir(d)
    assert data.score_meta is not None
    assert data.score_meta.combined_score == 75.0
    assert data.score_meta.verdict_coverage is None
    assert data.score_meta.error_rate is None


def test_load_report_dir_coverage_error_rate_NA_parses_None(tmp_path: Path):
    d = _write_report(tmp_path)
    parts = ["## Score", "",
             "<!-- meta:remote_score value:70.0 -->",
             "<!-- meta:server_score value:80.0 -->",
             "<!-- meta:combined_score value:75.0 -->",
             "<!-- meta:grade value:B -->",
             "",
             "### Coverage", "",
             "- Planned: 0",
             "- Executed: 0 (execution_coverage=N/A)",
             "- Scored: 0 (verdict_coverage=N/A)",
             "- Errors: 0 (error_rate=N/A)"]
    (d / "combined-report.md").write_text("\n".join(parts) + "\n")
    data = load_report_dir(d)
    assert data.score_meta is not None
    assert data.score_meta.error_rate is None
    assert data.score_meta.verdict_coverage is None
```

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

Expected: 7 new tests FAIL.

- [ ] **Step 3: Extend loader.py** (add meta parser + integrate)

```python
_META_RE = re.compile(r"<!--\s*meta:(\w+)\s+value:([^ ]+)\s*-->")
_COVERAGE_VERDICT_RE = re.compile(r"verdict_coverage=([\d.]+%|N/A)")
_COVERAGE_ERROR_RE = re.compile(r"error_rate=([\d.]+%|N/A)")

_REQUIRED_META_KEYS = {"remote_score", "server_score", "combined_score", "grade"}


def _parse_score_value(raw: str) -> float | None:
    if raw == "None":
        return None
    return float(raw)


def _parse_pct(raw: str) -> float | None:
    if raw == "N/A":
        return None
    # raw is like "100.00%"
    return float(raw.rstrip("%")) / 100.0


def _parse_score_meta(text: str) -> ScoreMeta | None:
    head = text[:8000]  # first ~200 lines is enough; cap for safety
    found: dict[str, str] = {m.group(1): m.group(2) for m in _META_RE.finditer(head)}
    if not _REQUIRED_META_KEYS.issubset(found):
        return None

    try:
        remote = _parse_score_value(found["remote_score"])
        server = _parse_score_value(found["server_score"])
        combined = _parse_score_value(found["combined_score"])
    except ValueError:
        return None
    grade = found["grade"]

    vc_match = _COVERAGE_VERDICT_RE.search(text)
    er_match = _COVERAGE_ERROR_RE.search(text)
    verdict_coverage = _parse_pct(vc_match.group(1)) if vc_match else None
    error_rate = _parse_pct(er_match.group(1)) if er_match else None

    return ScoreMeta(
        remote_score=remote, server_score=server, combined_score=combined,
        grade=grade, verdict_coverage=verdict_coverage, error_rate=error_rate,
    )
```

Update `load_report_dir` to read combined-report.md:

```python
def load_report_dir(path: Path) -> ReportData:
    if not path.exists() or not path.is_dir():
        raise LoaderError(f"directory not found: {path}")
    findings = load_findings(path / "findings.json")
    label = path.name
    m = _DATE_RE.search(label)
    date = m.group(1) if m else None

    score_meta: ScoreMeta | None = None
    combined_md = path / "combined-report.md"
    if combined_md.exists():
        score_meta = _parse_score_meta(combined_md.read_text())

    return ReportData(findings=findings, score_meta=score_meta,
                      label=label, date=date)
```

- [ ] **Step 4: Run all loader tests**

```bash
.venv/bin/pytest tests/unit/test_diff_loader.py -v
.venv/bin/ruff check src/agentsec/diff/
```

Expected: 14 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/loader.py tests/unit/test_diff_loader.py
git commit -m "$(cat <<'EOF'
feat(diff): combined-report.md meta + coverage parsing (T8)

Spec §6.1 — regex parser for meta comments and coverage bullets;
all-or-nothing on the 4 meta keys (mode 1 degraded → score_meta=None);
coverage lines independent (fallback to None on legacy reports).

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

---

## Task 9: renderer — header + score table + LOW_COVERAGE banner

**Files:**
- Create: `src/agentsec/diff/renderer.py`
- Create: `tests/unit/test_diff_renderer.py`
- Modify: `src/agentsec/diff/__init__.py`

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

```python
from agentsec.diff.findings_delta import FindingsDelta
from agentsec.diff.loader import ReportData
from agentsec.diff.renderer import render_diff_report
from agentsec.diff.score_delta import ScoreMeta, diff_scores


def _data(label: str, date: str | None = None,
          score_meta: ScoreMeta | None = None) -> ReportData:
    return ReportData(findings=[], score_meta=score_meta, label=label, date=date)


def _meta(**kwargs) -> ScoreMeta:
    base = {"remote_score": 70.0, "server_score": 80.0, "combined_score": 75.0,
            "grade": "B", "verdict_coverage": 1.0, "error_rate": 0.0}
    base.update(kwargs)
    return ScoreMeta(**base)


def test_header_includes_both_labels_and_dates():
    out = render_diff_report(
        baseline=_data("old", "2026-01-01", _meta()),
        current=_data("new", "2026-04-30", _meta()),
        findings_delta=FindingsDelta(),
    )
    assert "# AgentSec diff: old → new" in out
    assert "**Baseline**: old (2026-01-01)" in out
    assert "**Current**:  new (2026-04-30)" in out


def test_header_omits_date_when_none():
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(),
    )
    assert "**Baseline**: a" in out
    assert "(20" not in out  # no date


def test_score_table_renders_when_both_meta_present():
    out = render_diff_report(
        baseline=_data("old", None, _meta(combined_score=70.0)),
        current=_data("new", None, _meta(combined_score=62.0)),
        findings_delta=FindingsDelta(),
    )
    assert "## Score delta" in out
    assert "| combined_score | 70.0 | 62.0 | **REGRESSION:" in out


def test_low_coverage_banner_when_current_grade_insufficient():
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta(grade="INSUFFICIENT_COVERAGE")),
        findings_delta=FindingsDelta(),
    )
    assert "LOW_COVERAGE" in out


def test_score_section_degraded_when_either_meta_None():
    out = render_diff_report(
        baseline=_data("a", None, None),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(),
    )
    assert "## Score delta" in out
    assert "scores unavailable for a" in out
```

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

Expected: FAIL with `ModuleNotFoundError`.

- [ ] **Step 3: Implement renderer.py** (initial — header + score; findings sections in T10)

```python
"""Markdown renderer for diff reports. Spec §7."""

from __future__ import annotations

from .findings_delta import FindingsDelta
from .loader import ReportData
from .score_delta import ScoreDelta, diff_scores


def render_diff_report(
    *,
    baseline: ReportData,
    current: ReportData,
    findings_delta: FindingsDelta,
) -> str:
    lines: list[str] = [f"# AgentSec diff: {baseline.label} → {current.label}", ""]
    lines.append(_label_line("Baseline", baseline))
    lines.append(_label_line("Current ", current))
    lines.append("")

    if _is_low_coverage(current) or _is_low_coverage(baseline):
        side = "current" if _is_low_coverage(current) else "baseline"
        lines.append(
            f"> **Warning**: {side} report has LOW_COVERAGE — "
            "score deltas may be misleading."
        )
        lines.append("")

    lines += _score_section(baseline, current)
    lines += [""]
    lines += _findings_section(findings_delta)  # stub for now
    return "\n".join(lines).rstrip() + "\n"


def _label_line(name: str, data: ReportData) -> str:
    suffix = f" ({data.date})" if data.date else ""
    return f"**{name}**: {data.label}{suffix}"


def _is_low_coverage(data: ReportData) -> bool:
    return data.score_meta is not None and data.score_meta.grade == "INSUFFICIENT_COVERAGE"


def _score_section(baseline: ReportData, current: ReportData) -> list[str]:
    if baseline.score_meta is None or current.score_meta is None:
        missing = "baseline" if baseline.score_meta is None else "current"
        return [
            "## Score delta", "",
            f"scores unavailable for {missing} "
            "(combined-report.md missing or malformed)",
        ]
    sd: ScoreDelta = diff_scores(baseline.score_meta, current.score_meta)
    out = ["## Score delta", "",
           "| Metric | Baseline | Current | Δ |",
           "|--------|----------|---------|---|"]
    for r in sd.rows:
        out.append(
            f"| {r.metric} | {r.baseline_value} | "
            f"{r.current_value} | {r.delta_label} |"
        )
    return out


def _findings_section(delta: FindingsDelta) -> list[str]:
    # Stub for T9; T10 fills in.
    return ["## Findings delta", "",
            "No findings on either side." if not _has_any(delta) else "_(T10)_"]


def _has_any(delta: FindingsDelta) -> bool:
    return bool(delta.added or delta.removed or delta.stable
                or delta.severity_changed or delta.evidence_drifted)
```

Add `render_diff_report` to `__init__.py` `__all__`.

- [ ] **Step 4: Run renderer tests + ruff**

Expected: 5 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/renderer.py src/agentsec/diff/__init__.py \
  tests/unit/test_diff_renderer.py
git commit -m "$(cat <<'EOF'
feat(diff): renderer header + score table + LOW_COVERAGE banner (T9)

Spec §7.1 — degraded score section when either side meta missing;
findings section stubbed (T10).

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

---

## Task 10: renderer — findings sections (added/removed/severity/drift/stable)

**Files:**
- Modify: `src/agentsec/diff/renderer.py`
- Modify: `tests/unit/test_diff_renderer.py`

- [ ] **Step 1: Write failing tests** (append)

```python
from agentsec.audit.findings import Finding
from agentsec.diff.findings_delta import DriftPair, SeverityChange


def _f(check="c", title="t", severity="high", evidence=None):
    return Finding(check=check, severity=severity, title=title,
                   description="d", evidence=evidence or {}, remediation="r")


def test_findings_summary_line_renders_counts():
    delta = FindingsDelta(
        added=[_f(title="a")],
        removed=[_f(title="r"), _f(title="r2")],
        stable=[_f(title="s")],
        severity_changed=[SeverityChange(fingerprint="x", title="t",
                                          check="c", old_severity="high",
                                          new_severity="critical")],
        evidence_drifted=[],
    )
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=delta,
    )
    assert "**Added: 1**" in out
    assert "**Removed: 2**" in out
    assert "**Stable: 1**" in out
    assert "**Severity-changed: 1**" in out
    assert "**Evidence-drifted: 0**" in out


def test_added_section_renders_table_with_truncated_fingerprint():
    f = _f(severity="critical", check="native_audit",
           title="open port", evidence={"port": 8443})
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(added=[f]),
    )
    assert "### Added findings (1)" in out
    assert "| critical | native_audit | open port |" in out
    assert f.fingerprint[:8] in out
    # Empty buckets omitted
    assert "### Removed findings" not in out


def test_severity_section_bolds_higher_new_severity():
    sc = SeverityChange(fingerprint="abcd1234" * 8, title="t", check="c",
                        old_severity="high", new_severity="critical")
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(severity_changed=[sc]),
    )
    assert "### Severity changes (1)" in out
    assert "high → **critical**" in out


def test_evidence_drift_renders_paired_json_blocks():
    a = _f(evidence={"port": 8443})
    b = _f(evidence={"port": 8444})
    pair = DriftPair(check="c", title="t", baseline=a, current=b)
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(evidence_drifted=[pair]),
    )
    assert "### Evidence drift (1)" in out
    assert "Baseline:" in out and "Current:" in out
    assert '"port": 8443' in out
    assert '"port": 8444' in out


def test_stable_section_always_collapsed_via_details():
    f = _f(title="stable")
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(stable=[f]),
    )
    assert "<details>" in out
    assert "<summary>Stable: 1" in out


def test_no_findings_message_when_all_empty():
    out = render_diff_report(
        baseline=_data("a", None, _meta()),
        current=_data("b", None, _meta()),
        findings_delta=FindingsDelta(),
    )
    assert "No findings on either side" in out
```

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

Expected: 6 new tests FAIL (current renderer is stub).

- [ ] **Step 3: Replace `_findings_section` and add helpers**

```python
import json as _json


def _findings_section(delta: FindingsDelta) -> list[str]:
    if not _has_any(delta):
        return ["## Findings delta", "", "No findings on either side."]

    out = [
        "## Findings delta", "",
        f"**Added: {len(delta.added)}** | "
        f"**Removed: {len(delta.removed)}** | "
        f"**Stable: {len(delta.stable)}** | "
        f"**Severity-changed: {len(delta.severity_changed)}** | "
        f"**Evidence-drifted: {len(delta.evidence_drifted)}**",
        "",
    ]
    if delta.added:
        out += _findings_table("Added findings", delta.added)
    if delta.removed:
        out += _findings_table("Removed findings", delta.removed)
    if delta.severity_changed:
        out += _severity_table(delta.severity_changed)
    if delta.evidence_drifted:
        out += _drift_section(delta.evidence_drifted)
    if delta.stable:
        out += _stable_section(delta.stable)
    return out


_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}


def _findings_table(heading: str, fs: list[Finding]) -> list[str]:
    out = [f"### {heading} ({len(fs)})", "",
           "| Severity | Check | Title | Fingerprint |",
           "|----------|-------|-------|-------------|"]
    for f in fs:
        out.append(
            f"| {f.severity} | {f.check} | {f.title} | "
            f"`{f.fingerprint[:8]}` |"
        )
    out.append("")
    return out


def _severity_table(scs: list) -> list[str]:
    out = [f"### Severity changes ({len(scs)})", "",
           "| Severity old → new | Check | Title | Fingerprint |",
           "|--------------------|-------|-------|-------------|"]
    for s in scs:
        old_rank = _SEVERITY_RANK.get(s.old_severity, 99)
        new_rank = _SEVERITY_RANK.get(s.new_severity, 99)
        new_cell = (f"**{s.new_severity}**" if new_rank < old_rank
                    else s.new_severity)
        out.append(
            f"| {s.old_severity} → {new_cell} | {s.check} | {s.title} | "
            f"`{s.fingerprint[:8]}` |"
        )
    out.append("")
    return out


def _drift_section(pairs: list) -> list[str]:
    out = [f"### Evidence drift ({len(pairs)})", ""]
    for p in pairs:
        out += [
            f"**`{p.check}` — {p.title}** "
            "*(possibly the same issue, evidence changed)*", "",
            "Baseline:", "```json",
            _json.dumps(p.baseline.evidence, indent=2, sort_keys=True),
            "```", "",
            "Current:", "```json",
            _json.dumps(p.current.evidence, indent=2, sort_keys=True),
            "```", "",
        ]
    return out


def _stable_section(stable: list[Finding]) -> list[str]:
    out = [
        "<details>",
        f"<summary>Stable: {len(stable)} findings (unchanged across runs)</summary>",
        "",
        "| Severity | Check | Title | Fingerprint |",
        "|----------|-------|-------|-------------|",
    ]
    for f in stable:
        out.append(
            f"| {f.severity} | {f.check} | {f.title} | "
            f"`{f.fingerprint[:8]}` |"
        )
    out += ["", "</details>", ""]
    return out
```

- [ ] **Step 4: Run all renderer tests + ruff**

```bash
.venv/bin/pytest tests/unit/test_diff_renderer.py -v
.venv/bin/ruff check src/agentsec/diff/
```

Expected: 11 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/diff/renderer.py tests/unit/test_diff_renderer.py
git commit -m "$(cat <<'EOF'
feat(diff): renderer findings sections (T10)

Spec §7.2 — summary line; per-bucket tables; severity bold on
upgrade; drift renders paired JSON blocks; stable wrapped in
<details> collapsed.

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

---

## Task 11: CLI subcommand wiring

**Files:**
- Modify: `src/agentsec/cli.py`
- Create: `tests/unit/test_diff_cli.py`

- [ ] **Step 1: Write failing test** (`tests/unit/test_diff_cli.py`)

```python
import json
from pathlib import Path
from typer.testing import CliRunner
from agentsec.cli import app

runner = CliRunner()


def _make_report(d: Path):
    d.mkdir()
    (d / "findings.json").write_text(json.dumps([
        {"check": "c", "severity": "high", "title": "t",
         "description": "d", "evidence": {"v": 1}, "remediation": "r",
         "threat_refs": []},
    ]))
    (d / "combined-report.md").write_text(
        "## Score\n\n"
        "<!-- meta:remote_score value:70.0 -->\n"
        "<!-- meta:server_score value:80.0 -->\n"
        "<!-- meta:combined_score value:75.0 -->\n"
        "<!-- meta:grade value:B -->\n"
    )


def test_diff_writes_to_stdout_when_no_output_flag(tmp_path: Path):
    a = tmp_path / "report-a"
    b = tmp_path / "report-b"
    _make_report(a)
    _make_report(b)
    result = runner.invoke(app, ["diff", str(a), str(b)])
    assert result.exit_code == 0, result.output
    assert "# AgentSec diff:" in result.output


def test_diff_writes_to_output_file_when_flag_set(tmp_path: Path):
    a = tmp_path / "report-a"
    b = tmp_path / "report-b"
    _make_report(a)
    _make_report(b)
    out_path = tmp_path / "diff.md"
    result = runner.invoke(app, ["diff", str(a), str(b),
                                  "--output", str(out_path)])
    assert result.exit_code == 0, result.output
    assert out_path.exists()
    assert "# AgentSec diff:" in out_path.read_text()


def test_diff_exit_1_when_baseline_dir_missing(tmp_path: Path):
    b = tmp_path / "report-b"
    _make_report(b)
    result = runner.invoke(app, ["diff", str(tmp_path / "nope"), str(b)])
    assert result.exit_code == 1
    assert "directory not found" in result.output


def test_diff_exit_1_when_findings_json_missing(tmp_path: Path):
    a = tmp_path / "incomplete"
    a.mkdir()
    b = tmp_path / "report-b"
    _make_report(b)
    result = runner.invoke(app, ["diff", str(a), str(b)])
    assert result.exit_code == 1
    assert "findings.json" in result.output


def test_diff_exit_0_even_with_regression(tmp_path: Path):
    a = tmp_path / "report-a"
    b = tmp_path / "report-b"
    _make_report(a)
    _make_report(b)
    # Mutate b's combined to trigger REGRESSION
    md = b / "combined-report.md"
    md.write_text(md.read_text().replace("value:75.0", "value:50.0"))
    result = runner.invoke(app, ["diff", str(a), str(b)])
    assert result.exit_code == 0, "diff is a report, not a gate (spec §3)"
    assert "REGRESSION" in result.output
```

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

Expected: FAIL — `diff` subcommand not registered.

- [ ] **Step 3: Add `diff` command** (append near end of `src/agentsec/cli.py`, before `if __name__`)

```python
@app.command(help="Compare two `agentsec evaluate` report directories.")
def diff(
    baseline: Path = typer.Argument(..., help="Baseline report directory"),
    current: Path = typer.Argument(..., help="Current report directory"),
    output: Path | None = typer.Option(
        None, "--output", help="Write markdown to file instead of stdout"
    ),
) -> None:
    from .diff.findings_delta import diff_findings
    from .diff.loader import LoaderError, load_report_dir
    from .diff.renderer import render_diff_report

    try:
        baseline_data = load_report_dir(baseline)
        current_data = load_report_dir(current)
    except LoaderError as e:
        typer.echo(f"error: {e}", err=True)
        raise typer.Exit(code=1) from e

    findings_delta = diff_findings(baseline_data.findings, current_data.findings)
    md = render_diff_report(
        baseline=baseline_data,
        current=current_data,
        findings_delta=findings_delta,
    )

    if output is not None:
        if not output.parent.exists():
            typer.echo(f"error: parent directory does not exist: {output.parent}",
                       err=True)
            raise typer.Exit(code=1)
        output.write_text(md)
    else:
        typer.echo(md, nl=False)
```

- [ ] **Step 4: Run CLI tests + ruff**

```bash
.venv/bin/pytest tests/unit/test_diff_cli.py -v
.venv/bin/ruff check src/agentsec/
```

Expected: 5 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/cli.py tests/unit/test_diff_cli.py
git commit -m "$(cat <<'EOF'
feat(diff): typer subcommand wiring with stdout / --output (T11)

Spec §3 — exit 0 even on regression (diff is report not gate);
exit 1 on loader errors.

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

---

## Task 12: Integration test against committed sample report

**Files:**
- Create: `tests/integration/test_diff_end_to_end.py`

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

```python
import json
import shutil
from pathlib import Path

from agentsec.diff.findings_delta import diff_findings
from agentsec.diff.loader import load_report_dir
from agentsec.diff.renderer import render_diff_report

SAMPLE = Path(__file__).resolve().parents[2] / "docs" / "samples" / "report-2026-04-28"


def _render(baseline: Path, current: Path) -> str:
    b = load_report_dir(baseline)
    c = load_report_dir(current)
    return render_diff_report(
        baseline=b, current=c,
        findings_delta=diff_findings(b.findings, c.findings),
    )


def test_diff_self_yields_no_deltas():
    out = _render(SAMPLE, SAMPLE)
    assert "**Added: 0**" in out
    assert "**Removed: 0**" in out
    assert "**Severity-changed: 0**" in out
    # Score rows should all be neutral (no REGRESSION / IMPROVED)
    assert "REGRESSION" not in out
    assert "IMPROVED" not in out


def test_diff_against_synthetic_altered_copy(tmp_path: Path):
    # Copy sample to tmp_path/altered/, mutate three things.
    altered = tmp_path / "report-altered"
    shutil.copytree(SAMPLE, altered)

    findings_path = altered / "findings.json"
    findings = json.loads(findings_path.read_text())
    assert len(findings) >= 2, "sample has at least 2 findings (precondition)"

    # 1. Drop the last finding (→ removed)
    dropped = findings.pop()
    # 2. Append a new finding (→ added)
    findings.append({
        "check": "synthetic_check", "severity": "high",
        "title": "synthetic added", "description": "d",
        "evidence": {"k": "v"}, "remediation": "r", "threat_refs": [],
    })
    # 3. Mutate first finding's evidence (→ evidence_drifted, since
    #    (check, title) preserved but evidence differs)
    findings[0]["evidence"] = {**findings[0].get("evidence", {}), "extra": 1}
    findings_path.write_text(json.dumps(findings))

    # 4. Mutate combined_score by -8 (→ REGRESSION)
    md_path = altered / "combined-report.md"
    md = md_path.read_text()
    import re
    md = re.sub(
        r"<!-- meta:combined_score value:([\d.]+) -->",
        lambda m: f"<!-- meta:combined_score value:{float(m.group(1)) - 8.0} -->",
        md,
    )
    md_path.write_text(md)

    out = _render(SAMPLE, altered)
    assert "**Added: 1**" in out
    assert "**Removed: 1**" in out
    assert "**Evidence-drifted: 1**" in out
    assert "REGRESSION" in out  # combined_score dropped


def test_diff_is_byte_deterministic():
    a = _render(SAMPLE, SAMPLE)
    b = _render(SAMPLE, SAMPLE)
    assert a == b
```

- [ ] **Step 2: Run tests + ruff**

```bash
.venv/bin/pytest tests/integration/test_diff_end_to_end.py -v
.venv/bin/ruff check tests/
```

Expected: 3 tests PASS. (If `dropped` triggers a (check, title) collision with the appended one such that findings_delta classifies as drift not added/removed, adjust — but synthetic_check / synthetic added has neither check nor title from existing sample.)

- [ ] **Step 3: Commit**

```bash
git add tests/integration/test_diff_end_to_end.py
git commit -m "$(cat <<'EOF'
test(diff): e2e against committed sample report (T12)

Spec §9.2 — self-diff yields zero deltas + REGRESSION-free; synthetic
mutation triggers expected counts; byte-deterministic.

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

---

## Task 13: Docs sync + reference sample diff + final verification

**Files:**
- Modify: `ROADMAP.md`
- Modify: `CHANGELOG.md`
- Modify: `CLAUDE.md`
- Modify: `docs/guide/getting-started.md`
- Create: `docs/samples/diff-2026-04-30-self/diff.md` (reference sample)

- [ ] **Step 1: Generate the reference sample diff**

```bash
.venv/bin/agentsec diff docs/samples/report-2026-04-28 \
                         docs/samples/report-2026-04-28 \
  --output docs/samples/diff-2026-04-30-self/diff.md
```

(First create parent directory: `mkdir -p docs/samples/diff-2026-04-30-self`.)

Verify the file is non-empty and starts with `# AgentSec diff:`.

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

Find the Phase 5 section. Replace the `agentsec diff` line:

```diff
-- [ ] `agentsec diff` 历史趋势对比子命令
+- [x] `agentsec diff` 历史趋势对比子命令 (v0.4.0, PR #__)
```

(Leave PR number as `__` — the PR opener will fill it.)

- [ ] **Step 3: Update CHANGELOG.md**

Add a v0.4.0 section at the top (under the latest tagged version):

```markdown
## v0.4.0 — TBD

### Added
- `agentsec diff <baseline-dir> <current-dir> [--output PATH]` — markdown
  delta report between two `agentsec evaluate` runs. Compares
  `findings.json` (fingerprint primary + (check, title) drift clustering)
  and `combined-report.md` meta (score / grade / coverage). Regression
  flagged on absolute deltas (combined_score Δ>5, grade letter drop,
  verdict_coverage<0.7, error_rate>0.1). Spec:
  `docs/superpowers/specs/2026-04-30-agentsec-diff-design.md`.
- Reference sample diff at `docs/samples/diff-2026-04-30-self/diff.md`
  (sample-vs-itself; useful as a renderer-output reference).
```

- [ ] **Step 4: Update CLAUDE.md** (append to the `Commands` section)

```diff
 # Run a server-side audit (v0.2.0) — requires AUTHORIZATION.txt
 .venv/bin/agentsec server-audit \
   --host openclaw.example.com --user ubuntu --key ~/.ssh/id_rsa \
   --consent-file AUTHORIZATION.txt --output ./report-2026-04-27/
+
+# Compare two evaluate runs (v0.4.0) — markdown delta report
+.venv/bin/agentsec diff old-report-dir/ new-report-dir/ --output diff.md
```

Add a brief section after the existing `## ioc-update CLI (v0.3.0)` block:

```markdown
## diff CLI (v0.4.0)

`agentsec diff <baseline-dir> <current-dir>` compares two report
directories produced by `agentsec evaluate` (spec §9.3 artifact set).
Output is a markdown delta report covering:

- **Findings delta** — added / removed / stable / severity-changed /
  evidence-drifted (same `(check, title)`, different fingerprint).
- **Score delta** — combined / remote / server scores, grade,
  verdict_coverage, error_rate; regression flagged when combined_score
  drops > 5.0 absolute, grade letter regresses, verdict_coverage drops
  below 0.7, or error_rate rises above 0.1.

Exit code is 0 even when regressions are present — diff is a report,
not a gate. Operators consume the markdown for PR comments / digests.

Spec: `docs/superpowers/specs/2026-04-30-agentsec-diff-design.md`.
```

- [ ] **Step 5: Update docs/guide/getting-started.md** (append a "Comparing two runs" section near the bottom)

```markdown
## Comparing two runs

Once you have two `agentsec evaluate` outputs (e.g., from yesterday and
today's cron run), compare them with:

```bash
agentsec diff path/to/report-2026-04-29/ path/to/report-2026-04-30/ \
  --output diff.md
```

The output is a single markdown file showing what changed: new and
resolved findings, severity escalations from threat-intel updates, and
score / coverage regressions. Use it for PR comments or weekly digests.
```

- [ ] **Step 6: Run all gates**

```bash
.venv/bin/pytest -q
.venv/bin/ruff check src/ tests/
.venv/bin/python scripts/check_threat_refs.py
.venv/bin/python scripts/build_cve_db.py --check
git grep -nE '/var/log/openclaw|~/.openclaw|/etc/systemd|/Library/' src/agentsec/audit/checks/ || echo "OK no hardcoded paths"
```

Expected: pytest passes (583 baseline + 36–40 new diff tests), ruff clean, threat refs / cve db checks pass, no hardcoded paths.

- [ ] **Step 7: Commit**

```bash
git add ROADMAP.md CHANGELOG.md CLAUDE.md docs/guide/getting-started.md \
  docs/samples/diff-2026-04-30-self/diff.md
git commit -m "$(cat <<'EOF'
docs(diff): ROADMAP / CHANGELOG / CLAUDE.md / getting-started + sample (T13)

Spec §11-12 — final docs sync, committed reference sample diff
(sample-vs-itself), green gates.

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

- [ ] **Step 8: Verify branch state**

```bash
git log --oneline phase-5/agentsec-diff
```

Expected: 14 commits — `b2d7e1d docs(spec): ...` + 13 task commits.

```bash
.venv/bin/pytest -q --tb=no
```

Expected: all green, ≥ ~620 total tests.

---

## Self-review notes (against spec)

**Spec coverage** (each spec section → task that covers it):

- §1 Goals / Non-goals — informs scope; nothing to implement.
- §2 Architecture (5 modules) — T1 (findings_delta), T5–6 (score_delta), T7–8 (loader), T9–10 (renderer), T11 (cli).
- §3 CLI surface — T11 (positional args, --output, exit codes).
- §4 Data shapes — T1 (FindingsDelta etc.), T5 (ScoreMeta etc.), T7 (ReportData).
- §5 Findings algorithm — T1 (5.1 primary), T2 (severity), T3 (5.2–5.3 drift), T4 (5.4 sort).
- §6 Score algorithm — T8 (6.1 parsing), T5 (6.2 numeric thresholds), T6 (6.2 grade + coverage).
- §7 Markdown layout — T9 (header + score + LOW_COVERAGE), T10 (findings sections).
- §8 Edge cases — T7 (missing dir / json), T8 (missing meta both modes), T11 (missing parent dir on --output, exit codes).
- §9 Testing — T1–T11 unit, T12 integration.
- §10 Out of scope — informational; no tasks.
- §11 File / commit plan — T13 covers ROADMAP / CHANGELOG / CLAUDE.md / getting-started.md.
- §12 Acceptance criteria — T13 final gates + sample.

**Type consistency**: `diff_findings` returns `FindingsDelta`, `diff_scores` returns `ScoreDelta`. `render_diff_report` is keyword-only (`baseline=, current=, findings_delta=`) consistently in T9/T10/T11/T12. `LoaderError` consistently raised. `ScoreMeta` dataclass field names (`remote_score`, `server_score`, `combined_score`, `grade`, `verdict_coverage`, `error_rate`) match between T5, T6, T7, T8, T9, T11, T12.

**Placeholder scan**: One PR-number placeholder `PR #__` in ROADMAP T13 — the human PR opener fills it post-merge per repo convention. No "TBD"/"TODO" left in code.

---

## Post-implementation: PR + 3-reviewer pass (out-of-band, per Stage E+ convention)

After T13:

1. Push branch: `git push -u origin phase-5/agentsec-diff`
2. Open PR: `gh pr create --title "feat(diff): agentsec diff (Phase 5)" --body "..."`
3. Three parallel reviewer subagents (correctness / spec-compliance / security) — bundle findings into a single `fix(diff): N-reviewer batch findings` commit.
4. Roger gates merge — never auto-`gh pr merge`.
