# DeckLint v0.2 Proof Loop 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:** 发布中文优先的 DeckLint v0.2“质量闭环”，用真实 Ultimate PPT Master 前后 PPT 证明生成、审计、局部重做与复验能够形成可追踪的改进证据。

**Architecture:** `decklint compare` 只读取两个经过校验的 `decklint-report/v1` JSON，通过确定性多重集合匹配生成 `decklint-comparison/v1`，再输出中文单文件 HTML。PPT 修复仍由 Ultimate PPT Master 完成；DeckLint 不修改 PPTX，也不调用模型。公开案例、Pages、README 和 Release 只消费同一组已验证的 before/after/comparison artifacts。

**Tech Stack:** Python 3.10–3.13、标准库 JSON/dataclasses、defusedxml、Pillow、pypdfium2、pytest、jsonschema、GitHub Actions/Pages、Ultimate PPT Master SVG→PPTX 管线。

---

## 文件结构

### 新增

- `src/decklint/comparison.py`：报告读取、验证、finding 匹配、分数/严重度变化和门禁。
- `src/decklint/comparison_report.py`：比较 JSON 与中文离线 HTML。
- `schema/decklint-comparison-v1.schema.json`：公开比较合同。
- `tests/report_factory.py`：最小合法审计报告工厂。
- `tests/test_comparison.py`：比较算法和安全边界。
- `tests/test_comparison_report.py`：Schema、中文 HTML、确定性和路径脱敏。
- `tests/test_ai_generated_titles.py`：语义标题规则。
- `examples/ultimate-ppt-master-proof/`：before/after PPTX、审计、比较和修复台账。
- `site/index.html`、`site/assets/`：中文案例站。
- `.github/workflows/pages.yml`：Pages 部署。
- `docs/agent-repair-loop.md`：Agent 闭环说明。
- `docs/launch-kit.md`：中文主发布文案与英文摘要。

### 修改

- `src/decklint/cli.py`：增加 `compare` 路由。
- `src/decklint/model.py`：记录标题来源。
- `src/decklint/rules.py`：`ai-generated` 语义标题回退。
- `src/decklint/report.py`：页面 `titleSource` 和中文人类可读标签。
- `schema/decklint-report-v1.schema.json`：向后兼容增加 `titleSource`。
- `tests/test_cli.py`、`tests/test_public_contract.py`：CLI 与公开表面合同。
- `pyproject.toml`、`src/decklint/__init__.py`、`CHANGELOG.md`、`ROADMAP.md`、`README.md`、`SKILL.md`：0.2.0 发布表面。
- `.github/workflows/ci.yml`、`action.yml`：比较样本和 v0.2 合同验证。

---

### Task 1: 建立合法审计报告工厂和安全读取器

**Files:**
- Create: `tests/report_factory.py`
- Create: `tests/test_comparison.py`
- Create: `src/decklint/comparison.py`

- [ ] **Step 1: 写最小合法报告工厂**

```python
# tests/report_factory.py
from __future__ import annotations

from copy import deepcopy


def make_report(*, name: str = "deck.pptx", score: int = 100, findings=None, slides=None):
    categories = {name: score for name in ("integrity", "readability", "editability", "consistency", "accessibility")}
    report = {
        "schemaVersion": "decklint-report/v1",
        "toolVersion": "0.2.0",
        "profile": "ai-generated",
        "file": {"name": name, "sha256": "a" * 64, "slides": 1},
        "renderer": {"requested": "wireframe", "used": "wireframe", "status": "ok", "detail": ""},
        "scores": {"overall": score, "categories": categories},
        "summary": {"critical": 0, "high": 0, "medium": 0, "low": 0},
        "findings": list(findings or []),
        "slides": slides or [{"index": 1, "title": "标题", "titleSource": "inferred", "preview": ""}],
    }
    return deepcopy(report)
```

- [ ] **Step 2: 写读取器失败测试**

```python
# tests/test_comparison.py
import json
from pathlib import Path

import pytest

from decklint.comparison import ComparisonError, load_audit_report
from .report_factory import make_report


def test_load_audit_report_rejects_wrong_schema(tmp_path: Path) -> None:
    path = tmp_path / "report.json"
    report = make_report()
    report["schemaVersion"] = "unknown/v9"
    path.write_text(json.dumps(report), encoding="utf-8")
    with pytest.raises(ComparisonError, match="decklint-report/v1"):
        load_audit_report(path)


def test_load_audit_report_rejects_non_object_root(tmp_path: Path) -> None:
    path = tmp_path / "report.json"
    path.write_text("[]", encoding="utf-8")
    with pytest.raises(ComparisonError, match="JSON object"):
        load_audit_report(path)
```

- [ ] **Step 3: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison.py -q`
Expected: FAIL，提示 `decklint.comparison` 不存在。

- [ ] **Step 4: 实现有大小上限的读取器**

```python
# src/decklint/comparison.py
from __future__ import annotations

import json
from pathlib import Path

MAX_REPORT_BYTES = 100 * 1024 * 1024


class ComparisonError(ValueError):
    """Raised when audit reports cannot be compared safely."""


def load_audit_report(path: Path) -> dict[str, object]:
    path = Path(path)
    if not path.is_file():
        raise ComparisonError(f"Audit report not found: {path.name}")
    if path.stat().st_size > MAX_REPORT_BYTES:
        raise ComparisonError("Audit report exceeds the 100 MiB safety limit")
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        raise ComparisonError(f"Invalid audit report JSON: {path.name}") from exc
    if not isinstance(payload, dict):
        raise ComparisonError("Audit report root must be a JSON object")
    if payload.get("schemaVersion") != "decklint-report/v1":
        raise ComparisonError("Audit report must use decklint-report/v1")
    for key in ("file", "scores", "summary", "findings", "slides", "profile", "renderer"):
        if key not in payload:
            raise ComparisonError(f"Audit report is missing required field: {key}")
    return payload
```

- [ ] **Step 5: 运行测试并确认 GREEN**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison.py -q`
Expected: PASS。

- [ ] **Step 6: 提交**

```bash
git add src/decklint/comparison.py tests/report_factory.py tests/test_comparison.py
git commit -m "feat: validate comparison inputs"
```

### Task 2: 实现确定性 finding 匹配

**Files:**
- Modify: `src/decklint/comparison.py`
- Modify: `tests/test_comparison.py`

- [ ] **Step 1: 写重复项、移动项和 shape ID 变化测试**

```python
def finding(rule: str, slide: int, finding_id: str, *, shape: str = "1", severity: str = "high"):
    return {
        "id": finding_id,
        "rule_id": rule,
        "category": "readability",
        "severity": severity,
        "confidence": "high",
        "message": "问题",
        "evidence": finding_id,
        "remediation": "修复",
        "slide_index": slide,
        "shape_id": shape,
        "bbox": {"x": 0.1, "y": 0.1, "w": 0.5, "h": 0.2},
    }


def test_match_findings_uses_rule_and_slide_multisets() -> None:
    before = [finding("readability.small-font", 1, "a"), finding("readability.small-font", 1, "b")]
    after = [finding("readability.small-font", 1, "c", shape="99")]
    result = match_findings(before, after)
    assert len(result.persistent) == 1
    assert len(result.resolved) == 1
    assert result.new == []


def test_moved_finding_is_resolved_then_new() -> None:
    result = match_findings([finding("readability.small-font", 1, "a")], [finding("readability.small-font", 2, "b")])
    assert len(result.resolved) == len(result.new) == 1
    assert result.persistent == []
```

- [ ] **Step 2: 运行两个测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison.py -k 'match_findings or moved_finding' -q`
Expected: FAIL，`match_findings` 未定义。

- [ ] **Step 3: 实现排序键和多重集合配对**

```python
from collections import defaultdict
from dataclasses import dataclass


@dataclass(frozen=True)
class FindingMatch:
    resolved: list[dict[str, object]]
    persistent: list[dict[str, object]]
    new: list[dict[str, object]]


def _group_key(item):
    return (str(item.get("rule_id", "")), int(item.get("slide_index") or 0))


def _sort_key(item):
    bbox = item.get("bbox") if isinstance(item.get("bbox"), dict) else {}
    return (
        float(bbox.get("x", 0)), float(bbox.get("y", 0)),
        float(bbox.get("w", 0)), float(bbox.get("h", 0)),
        str(item.get("shape_id") or ""), str(item.get("evidence", "")), str(item.get("id", "")),
    )


def match_findings(before, after):
    grouped_before, grouped_after = defaultdict(list), defaultdict(list)
    for item in before:
        grouped_before[_group_key(item)].append(item)
    for item in after:
        grouped_after[_group_key(item)].append(item)
    resolved, persistent, new = [], [], []
    for key in sorted(set(grouped_before) | set(grouped_after)):
        left, right = sorted(grouped_before[key], key=_sort_key), sorted(grouped_after[key], key=_sort_key)
        paired = min(len(left), len(right))
        persistent.extend({"before": left[i], "after": right[i]} for i in range(paired))
        resolved.extend(left[paired:])
        new.extend(right[paired:])
    return FindingMatch(resolved, persistent, new)
```

- [ ] **Step 4: 运行比较测试并确认 GREEN**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison.py -q`
Expected: PASS。

- [ ] **Step 5: 提交**

```bash
git add src/decklint/comparison.py tests/test_comparison.py
git commit -m "feat: classify resolved and new findings"
```

### Task 3: 计算分数变化和回归门禁

**Files:**
- Modify: `src/decklint/comparison.py`
- Modify: `tests/test_comparison.py`

- [ ] **Step 1: 写门禁测试**

```python
def test_compare_reports_fails_for_score_drop() -> None:
    result = compare_reports(make_report(score=90), make_report(score=89), threshold="high")
    assert result["gate"]["passed"] is False
    assert "overall-score-decreased" in result["gate"]["failureReasons"]


def test_compare_reports_fails_for_new_high_confidence_finding() -> None:
    new_item = finding("readability.small-font", 1, "new-high")
    result = compare_reports(make_report(score=90), make_report(score=95, findings=[new_item]), threshold="high")
    assert result["gate"]["passed"] is False
    assert "new-high-confidence-finding" in result["gate"]["failureReasons"]


def test_none_threshold_is_observational() -> None:
    result = compare_reports(make_report(score=90), make_report(score=80), threshold="none")
    assert result["gate"]["passed"] is True
```

- [ ] **Step 2: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison.py -k 'compare_reports or none_threshold' -q`
Expected: FAIL，`compare_reports` 未定义。

- [ ] **Step 3: 实现比较结果**

```python
SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4}


def _delta_map(before, after):
    keys = sorted(set(before) | set(after))
    return {key: {"before": int(before.get(key, 0)), "after": int(after.get(key, 0)), "delta": int(after.get(key, 0)) - int(before.get(key, 0))} for key in keys}


def compare_reports(before, after, *, threshold="high"):
    matches = match_findings(before["findings"], after["findings"])
    score_before, score_after = int(before["scores"]["overall"]), int(after["scores"]["overall"])
    reasons = []
    if threshold != "none" and score_after < score_before:
        reasons.append("overall-score-decreased")
    if threshold != "none":
        rank = SEVERITY_RANK[threshold]
        if any(item.get("confidence") == "high" and SEVERITY_RANK[item["severity"]] >= rank for item in matches.new):
            reasons.append("new-high-confidence-finding")
    return {
        "schemaVersion": "decklint-comparison/v1",
        "scores": {
            "overall": {"before": score_before, "after": score_after, "delta": score_after - score_before},
            "categories": _delta_map(before["scores"]["categories"], after["scores"]["categories"]),
        },
        "severity": _delta_map(before["summary"], after["summary"]),
        "resolved": matches.resolved,
        "persistent": matches.persistent,
        "new": matches.new,
        "gate": {"threshold": threshold, "passed": not reasons, "failureReasons": reasons},
    }
```

- [ ] **Step 4: 运行测试并确认 GREEN**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison.py -q`
Expected: PASS。

- [ ] **Step 5: 提交**

```bash
git add src/decklint/comparison.py tests/test_comparison.py
git commit -m "feat: add comparison regression gates"
```

### Task 4: 发布 Comparison Schema 和中文离线报告

**Files:**
- Create: `src/decklint/comparison_report.py`
- Create: `schema/decklint-comparison-v1.schema.json`
- Create: `tests/test_comparison_report.py`
- Modify: `tests/test_public_contract.py`

- [ ] **Step 1: 写 Schema、中文和离线性测试**

```python
def test_comparison_report_is_chinese_offline_and_deterministic(tmp_path: Path) -> None:
    comparison = build_comparison_report(make_report(score=80), make_report(score=95), threshold="high")
    first = write_comparison_reports(tmp_path / "proof", comparison)
    first_html = first[0].read_bytes()
    first_json = first[1].read_bytes()
    second = write_comparison_reports(tmp_path / "proof-2", comparison)
    assert first_html == second[0].read_bytes()
    assert first_json == second[1].read_bytes()
    text = first_html.decode("utf-8")
    assert '<html lang="zh-CN">' in text
    assert "已解决" in text and "新增问题" in text
    assert "http://" not in text and "https://" not in text


def test_comparison_matches_public_schema() -> None:
    payload = build_comparison_report(make_report(score=80), make_report(score=95), threshold="high")
    schema = json.loads((ROOT / "schema/decklint-comparison-v1.schema.json").read_text(encoding="utf-8"))
    jsonschema.validate(payload, schema)
```

- [ ] **Step 2: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison_report.py -q`
Expected: FAIL，模块和 Schema 不存在。

- [ ] **Step 3: 实现完整 comparison payload**

实现 `build_comparison_report()`，在 Task 3 结果上补充 `toolVersion`、before/after 文件摘要、profile、renderer、逐页预览和正式中文 `agentSummary`。所有列表按 slide/rule/id 排序。

```python
def build_comparison_report(before, after, *, threshold):
    core = compare_reports(before, after, threshold=threshold)
    return {
        "schemaVersion": "decklint-comparison/v1",
        "toolVersion": __version__,
        "before": _report_identity(before),
        "after": _report_identity(after),
        **core,
        "slides": _pair_slides(before["slides"], after["slides"]),
        "agentSummary": _agent_summary(core),
    }
```

- [ ] **Step 4: 创建严格 JSON Schema**

`schema/decklint-comparison-v1.schema.json` 使用 Draft 2020-12，所有固定对象设置 `additionalProperties: false`，明确 scores、severity、gate、finding、paired finding 和 slide preview 的 required/properties；`schemaVersion` 使用 `const: decklint-comparison/v1`。

- [ ] **Step 5: 实现中文 HTML 与原子输出**

```python
def write_comparison_reports(output_stem: Path, report: dict[str, object]) -> tuple[Path, Path]:
    output_stem.parent.mkdir(parents=True, exist_ok=True)
    html_path, json_path = Path(f"{output_stem}.html"), Path(f"{output_stem}.json")
    html_text = _render_comparison_html(report)
    json_text = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
    html_path.write_text(html_text, encoding="utf-8")
    json_path.write_text(json_text, encoding="utf-8")
    return html_path, json_path
```

- [ ] **Step 6: 运行测试并确认 GREEN**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_comparison_report.py tests/test_public_contract.py -q`
Expected: PASS。

- [ ] **Step 7: 提交**

```bash
git add src/decklint/comparison_report.py schema/decklint-comparison-v1.schema.json tests/test_comparison_report.py tests/test_public_contract.py
git commit -m "feat: render offline comparison reports"
```

### Task 5: 接入 `decklint compare` CLI

**Files:**
- Modify: `src/decklint/cli.py`
- Modify: `tests/test_cli.py`

- [ ] **Step 1: 写退出码和输出测试**

```python
def test_compare_cli_writes_both_reports(tmp_path: Path) -> None:
    before, after = tmp_path / "before.json", tmp_path / "after.json"
    before.write_text(json.dumps(make_report(score=80)), encoding="utf-8")
    after.write_text(json.dumps(make_report(score=95)), encoding="utf-8")
    code = main(["compare", str(before), str(after), "--output", str(tmp_path / "comparison")])
    assert code == 0
    assert (tmp_path / "comparison.html").is_file()
    assert (tmp_path / "comparison.json").is_file()


def test_compare_cli_returns_one_for_regression(tmp_path: Path) -> None:
    before, after = tmp_path / "before.json", tmp_path / "after.json"
    before.write_text(json.dumps(make_report(score=90)), encoding="utf-8")
    after.write_text(json.dumps(make_report(score=80)), encoding="utf-8")
    assert main(["compare", str(before), str(after), "--output", str(tmp_path / "comparison")]) == 1


def test_compare_cli_returns_two_without_partial_outputs(tmp_path: Path) -> None:
    bad = tmp_path / "bad.json"
    bad.write_text("[]", encoding="utf-8")
    code = main(["compare", str(bad), str(bad), "--output", str(tmp_path / "comparison")])
    assert code == 2
    assert not (tmp_path / "comparison.json").exists()
```

- [ ] **Step 2: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_cli.py -k compare -q`
Expected: FAIL，parser 不识别 `compare`。

- [ ] **Step 3: 增加 parser 和独立路由函数**

```python
compare = subcommands.add_parser("compare", help="Compare two DeckLint audit reports.")
compare.add_argument("before", type=Path)
compare.add_argument("after", type=Path)
compare.add_argument("--output", type=Path, default=Path("decklint-comparison"))
compare.add_argument("--fail-on-regression", choices=("none", "low", "medium", "high", "critical"), default="high")
```

将 `main()` 拆为 `_run_audit(args)` 和 `_run_compare(args)`。`_run_compare` 先读取两份报告，再组装比较、写输出并根据 `report["gate"]["passed"]` 返回 `0/1`；捕获 `ComparisonError/OSError/ValueError` 返回 `2`。

- [ ] **Step 4: 运行 CLI 与全套测试**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_cli.py -q`
Expected: PASS。

- [ ] **Step 5: 提交**

```bash
git add src/decklint/cli.py tests/test_cli.py
git commit -m "feat: expose decklint compare CLI"
```

### Task 6: 修复 AI 生成页面标题误报

**Files:**
- Create: `tests/test_ai_generated_titles.py`
- Modify: `src/decklint/model.py`
- Modify: `src/decklint/rules.py`
- Modify: `src/decklint/report.py`
- Modify: `schema/decklint-report-v1.schema.json`

- [ ] **Step 1: 写五类语义标题测试**

```python
def test_ai_profile_accepts_large_top_native_text_as_title(tmp_path: Path) -> None:
    source = write_pptx(tmp_path / "semantic-title.pptx", slides=[slide_xml(title=None, body_size=3200, body_y=200000, body_w=6000000)])
    deck = load_deck(source)
    findings = audit_deck(deck, profile="ai-generated")
    assert "accessibility.missing-title" not in {item.rule_id for item in findings}
    assert deck.slides[0].title_source == "inferred"


@pytest.mark.parametrize("body_size,body_y,body_w", [(1800, 200000, 6000000), (3200, 3000000, 6000000), (3200, 200000, 1000000)])
def test_ai_profile_rejects_small_low_or_narrow_text(tmp_path: Path, body_size, body_y, body_w) -> None:
    source = write_pptx(tmp_path / "not-title.pptx", slides=[slide_xml(title=None, body_size=body_size, body_y=body_y, body_w=body_w)])
    assert "accessibility.missing-title" in rule_ids(source, profile="ai-generated")


def test_baseline_still_requires_title_placeholder(tmp_path: Path) -> None:
    source = write_pptx(tmp_path / "baseline.pptx", slides=[slide_xml(title=None, body_size=3200, body_y=200000, body_w=6000000)])
    assert "accessibility.missing-title" in rule_ids(source, profile="baseline")
```

- [ ] **Step 2: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_ai_generated_titles.py -q`
Expected: FAIL，`title_source` 不存在且 AI profile 仍报缺标题。

- [ ] **Step 3: 在模型中记录标题来源**

为 `Slide` 增加 `title_source: str = "none"`。加载页面时优先使用 placeholder；否则只记录首个非空文本作为展示标题，不提前推断语义来源。

- [ ] **Step 4: 在规则层实现 profile 专属推断**

```python
def _semantic_title_shape(slide: Slide, width: int, height: int) -> Shape | None:
    candidates = [
        shape for shape in slide.shapes
        if shape.text and len(shape.text) <= 160
        and shape.bbox.y < height * 0.25
        and shape.bbox.w >= width * 0.20
        and shape.font_sizes and max(shape.font_sizes) >= 24
    ]
    return min(candidates, key=lambda shape: (shape.bbox.y, shape.bbox.x, shape.shape_id), default=None)
```

在 `audit_deck` 中，placeholder 命中时设 `slide.title_source = "placeholder"`；AI profile 语义命中时设 `"inferred"`；其余保持 `"none"` 并产生既有 finding。

- [ ] **Step 5: 发布 `titleSource` 并更新 v1 Schema**

`build_report()` 的每个 slide 增加 `"titleSource": slide.title_source`。Schema 将 `titleSource` 定义为可选枚举 `placeholder|inferred|none`，不得加入 required；新增一份不含该字段的 0.1 兼容报告测试。重新生成 committed proof reports，并同时证明旧报告仍通过同一 v1 Schema。

- [ ] **Step 6: 运行标题、规则和公开合同测试**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_ai_generated_titles.py tests/test_rules.py tests/test_public_contract.py -q`
Expected: PASS。

- [ ] **Step 7: 提交**

```bash
git add src/decklint/model.py src/decklint/rules.py src/decklint/report.py schema/decklint-report-v1.schema.json tests/test_ai_generated_titles.py tests/test_public_contract.py examples/reports
git commit -m "fix: infer semantic titles for generated decks"
```

### Task 7: 固化 0.2.0 公共合同和中文文档

**Files:**
- Modify: `pyproject.toml`
- Modify: `src/decklint/__init__.py`
- Modify: `README.md`
- Modify: `SKILL.md`
- Modify: `CHANGELOG.md`
- Modify: `ROADMAP.md`
- Create: `docs/agent-repair-loop.md`
- Modify: `tests/test_public_contract.py`

- [ ] **Step 1: 写中文首屏、版本和 Skill 合同测试**

```python
def test_v02_public_surface_is_chinese_first_and_exposes_compare() -> None:
    readme = (ROOT / "README.md").read_text(encoding="utf-8")
    assert "PPT 质量基础设施" in "\n".join(readme.splitlines()[:15])
    assert "decklint compare" in readme
    assert "Release wheel" in readme or "GitHub Release" in readme
    assert "uvx decklint audit" not in "\n".join(readme.splitlines()[:40])
    skill = (ROOT / "SKILL.md").read_text(encoding="utf-8")
    assert "decklint compare" in skill
    assert len(skill.splitlines()) <= 100


def test_version_is_020() -> None:
    assert decklint.__version__ == "0.2.0"
```

- [ ] **Step 2: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_public_contract.py -k 'v02 or version' -q`
Expected: FAIL，当前版本为 0.1.0 且 README 英文优先。

- [ ] **Step 3: 更新版本和中文主文档**

将 `pyproject.toml` 与 `src/decklint/__init__.py` 更新为 `0.2.0`。README 首屏依次放置：中文一句承诺、真实 Proof Loop 预留位置、Release wheel 安装命令、audit、compare、离线/隐私边界。将现有英文说明移入后半部分的 “English summary”。

- [ ] **Step 4: 更新 Agent Skill 和修复闭环文档**

`SKILL.md` 只说明 audit、读取 JSON、将 finding 交给生成 Agent、再次 audit、compare；不得复制匹配算法。`docs/agent-repair-loop.md` 给出固定命令、输入输出及“不要直接修改源 PPTX”的边界。

- [ ] **Step 5: 更新 Changelog/Roadmap**

Changelog 增加 0.2.0 正式条目；Roadmap 将 compare/title semantics 标为完成，并保留 brand policy/SARIF 为候选 0.3。

- [ ] **Step 6: 运行公开合同测试并提交**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_public_contract.py -q`
Expected: PASS。

```bash
git add pyproject.toml src/decklint/__init__.py README.md SKILL.md CHANGELOG.md ROADMAP.md docs/agent-repair-loop.md tests/test_public_contract.py
git commit -m "docs: define Chinese-first v0.2 workflow"
```

### Task 8: 完成 Ultimate PPT Master 八项确认并生成合法初稿

**Files:**
- Create outside repo through Skill: `/Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711/design_spec.md`
- Create outside repo through Skill: `/Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711/spec_lock.md`
- Create outside repo through Skill: `/Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711/svg_output/*.svg`
- Create outside repo through Skill: `/Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711/notes/total.md`
- Copy verified deliverable to: `examples/ultimate-ppt-master-proof/before.pptx`

- [ ] **Step 1: 切换到 Ultimate PPT Master 的可编辑 PPTX 模式**

输出模式已由用户目标明确为可编辑 PPTX，不再询问 HTML/PPTX 二选一。内容来源为对话中的已确认规格和两个公开仓库，无需导入私有源文件。先初始化项目：

Run: `cd /Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill && /Users/kdnsna/.local/share/uv/python/cpython-3.13-macos-aarch64-none/bin/python3.13 /Users/kdnsna/.codex/skills/ultimate-ppt-master/scripts/project_manager.py init decklint_proof_loop_v02 --format ppt169`
Expected: 创建 `/Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711`。

随后读取该 Skill 要求的 `/Users/kdnsna/.codex/skills/ultimate-ppt-master/references/strategist.md` 和 `/Users/kdnsna/.codex/skills/ultimate-ppt-master/templates/design_spec_reference.md`。

- [ ] **Step 2: 向用户提交以下八项正式建议并等待一次确认**

```text
1. 画布：16:9 可编辑 PowerPoint。
2. 页数：9 页。
3. 受众：AI 开发者、演示文稿创作者、GitHub 用户。
4. 风格：正式技术产品报告，强调证据链与本地隐私。
5. 色彩：深海军蓝、纸白、低饱和灰，信号橙仅用于 findings 与变化标记。
6. 图标：少量单色线性图标，不使用装饰性图标墙。
7. 字体：中文使用思源黑体/系统兼容黑体，英文与数字使用 Inter/Aptos；封面≥50pt，页标题≥35pt，正文≥18pt。
8. 图片：只使用公开产品报告截图和必要的页面前后证据，不生成装饰性 AI 图片。
```

Expected: 用户明确确认或修改后再继续。

- [ ] **Step 3: 生成 design spec 和 spec lock**

按 Ultimate PPT Master 的 I–XI 结构写入设计规格，并在 spec lock 中固定九页 rhythm、布局、色彩、字体、标题与图像资源。设计参数输出后再进入 Executor。

- [ ] **Step 4: 启动 live preview 并逐页生成 SVG**

先启动 `/Users/kdnsna/.codex/skills/ultimate-ppt-master/scripts/svg_editor/server.py /Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711 --live`，再由主 Agent 逐页生成 9 个 SVG；每页生成前重新读取 `spec_lock.md`。不得使用子 Agent 或批量脚本生成 SVG。

- [ ] **Step 5: 运行 SVG 质量门禁**

Run: `/Users/kdnsna/.local/share/uv/python/cpython-3.13-macos-aarch64-none/bin/python3.13 /Users/kdnsna/.codex/skills/ultimate-ppt-master/scripts/svg_quality_checker.py /Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711`
Expected: 0 errors；逐页修复所有错误。

- [ ] **Step 6: 逐条执行导出三命令**

Run 1: `/Users/kdnsna/.local/share/uv/python/cpython-3.13-macos-aarch64-none/bin/python3.13 /Users/kdnsna/.codex/skills/ultimate-ppt-master/scripts/total_md_split.py /Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711`
Expected: notes 拆分成功。

Run 2: `/Users/kdnsna/.local/share/uv/python/cpython-3.13-macos-aarch64-none/bin/python3.13 /Users/kdnsna/.codex/skills/ultimate-ppt-master/scripts/finalize_svg.py /Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711`
Expected: `svg_final/` 完整。

Run 3: `/Users/kdnsna/.local/share/uv/python/cpython-3.13-macos-aarch64-none/bin/python3.13 /Users/kdnsna/.codex/skills/ultimate-ppt-master/scripts/svg_to_pptx.py /Users/kdnsna/Documents/06-项目代码/ai-tools/ultimate-ppt-master-skill/projects/decklint_proof_loop_v02_ppt169_20260711`
Expected: `exports/*.pptx` 生成。

- [ ] **Step 7: 渲染并逐页检查 before deck**

使用 Presentations 容器工具 `render_slides.py`、`create_montage.py` 和 `slides_test.py`。逐页全尺寸检查标题换行、重叠、裁切、中文字体和页码；只修复导出错误，不提前根据 DeckLint 猜测 findings。

- [ ] **Step 8: 复制经过基础可打开性验证的初稿**

将文件保存为 `examples/ultimate-ppt-master-proof/before.pptx`，并记录源 commit、生成时间、Skill 版本和公开来源到 `examples/ultimate-ppt-master-proof/provenance.json`。

### Task 9: 使用 DeckLint 完成审计、局部重做和复验

**Files:**
- Create: `examples/ultimate-ppt-master-proof/before-report.{html,json}`
- Create: `examples/ultimate-ppt-master-proof/repair-ledger.json`
- Create: `examples/ultimate-ppt-master-proof/after.pptx`
- Create: `examples/ultimate-ppt-master-proof/after-report.{html,json}`
- Create: `examples/ultimate-ppt-master-proof/comparison.{html,json}`
- Create: `examples/ultimate-ppt-master-proof/README.md`
- Modify: `tests/test_public_contract.py`

- [ ] **Step 1: 审计 before deck**

Run: `PYTHONPATH=src .venv/bin/python -m decklint audit examples/ultimate-ppt-master-proof/before.pptx --profile ai-generated --renderer libreoffice --output examples/ultimate-ppt-master-proof/before-report --fail-on none`
Expected: HTML/JSON 生成，页数与 PPTX 一致。

- [ ] **Step 2: 从真实 findings 建立修复台账**

`repair-ledger.json` 每项包含 `findingId`、`slideIndex`、`ruleId`、`evidence`、`plannedChange`、`status`。只录入真实 finding；人工视觉润色使用独立 `visualQaId`。

- [ ] **Step 3: 只重做受影响页面**

按 Ultimate PPT Master live-preview/Executor 规则重新读取 spec lock，在主 Agent 上逐页修改对应 SVG。未被 finding 或视觉 QA 命中的页面保持不变。重新执行 quality checker、notes split、finalize 和 export，保存为 `after.pptx`。

- [ ] **Step 4: 审计 after 并比较**

Run: `PYTHONPATH=src .venv/bin/python -m decklint audit examples/ultimate-ppt-master-proof/after.pptx --profile ai-generated --renderer libreoffice --output examples/ultimate-ppt-master-proof/after-report --fail-on high`
Expected: 退出 0。

Run: `PYTHONPATH=src .venv/bin/python -m decklint compare examples/ultimate-ppt-master-proof/before-report.json examples/ultimate-ppt-master-proof/after-report.json --output examples/ultimate-ppt-master-proof/comparison --fail-on-regression high`
Expected: 退出 0，总分提高，无维度下降，至少一个 resolved finding。

- [ ] **Step 5: 写案例合同测试**

```python
def test_ultimate_ppt_master_proof_is_real_and_improves() -> None:
    root = ROOT / "examples/ultimate-ppt-master-proof"
    comparison = json.loads((root / "comparison.json").read_text(encoding="utf-8"))
    assert (root / "before.pptx").is_file() and (root / "after.pptx").is_file()
    assert comparison["gate"]["passed"] is True
    assert comparison["scores"]["overall"]["delta"] > 0
    assert all(item["delta"] >= 0 for item in comparison["scores"]["categories"].values())
    assert comparison["resolved"]
```

- [ ] **Step 6: 运行案例测试并提交**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_public_contract.py -k ultimate -q`
Expected: PASS。

```bash
git add examples/ultimate-ppt-master-proof tests/test_public_contract.py
git commit -m "docs: add Ultimate PPT Master proof loop"
```

### Task 10: 部署中文案例站和出圈素材

**Files:**
- Create: `site/index.html`
- Create: `site/assets/*`
- Create: `.github/workflows/pages.yml`
- Create: `assets/social-preview.png`
- Create: `assets/proof-loop-demo.gif`
- Create: `docs/launch-kit.md`
- Modify: `README.md`
- Modify: `tests/test_public_contract.py`

- [ ] **Step 1: 写 Pages/README 公共表面失败测试**

```python
def test_pages_site_is_chinese_local_and_links_proof() -> None:
    site = (ROOT / "site/index.html").read_text(encoding="utf-8")
    assert '<html lang="zh-CN">' in site
    assert "从生成到可交付" in site
    assert "comparison.html" in site
    assert "https://" not in site.replace("https://github.com/kdnsna/decklint", "")
    workflow = yaml.safe_load((ROOT / ".github/workflows/pages.yml").read_text(encoding="utf-8"))
    assert "actions/deploy-pages" in json.dumps(workflow)


def test_readme_hero_uses_real_proof_assets() -> None:
    readme = (ROOT / "README.md").read_text(encoding="utf-8")
    assert "assets/proof-loop-demo.gif" in readme
    assert "ultimate-ppt-master-proof/comparison.html" in readme
```

- [ ] **Step 2: 运行测试并确认 RED**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_public_contract.py -k 'pages_site or readme_hero' -q`
Expected: FAIL，站点与素材不存在。

- [ ] **Step 3: 构建中文单页案例站**

页面顺序固定为：中文一句承诺、before/after 总分、五维变化、三类 findings、逐页证据、隐私/只读边界、复现命令、GitHub/Release 链接。CSS/JS 内联；报告和 PPT 使用相对路径；无遥测、CDN 或远程字体。

- [ ] **Step 4: 生成真实 GIF 和 social preview**

从 before/after 全尺寸渲染和 comparison HTML 截图制作 10 秒 GIF；social preview 使用 1200×630，内容为产品名、中文承诺和真实 score delta。不得伪造 UI 或使用与成品不一致的数字。

- [ ] **Step 5: 创建 Pages workflow**

工作流使用 `actions/configure-pages`、`actions/upload-pages-artifact`、`actions/deploy-pages`；先组装 `site-dist/`，将案例报告和本地 assets 复制进去，再部署。设置 `pages: write`、`id-token: write`，environment 为 `github-pages`。

- [ ] **Step 6: 更新 README 和发布工具包**

README 中文首屏嵌入真实 GIF、score delta、audit/compare 三条命令和案例链接。`docs/launch-kit.md` 提供正式中文 100 字、300 字和长文版发布文案，以及一个简短英文摘要。

- [ ] **Step 7: 运行公共表面测试并提交**

Run: `PYTHONPATH=src .venv/bin/python -m pytest tests/test_public_contract.py -q`
Expected: PASS。

```bash
git add site .github/workflows/pages.yml assets README.md docs/launch-kit.md tests/test_public_contract.py
git commit -m "docs: publish Chinese proof loop showcase"
```

### Task 11: 完成本地、跨版本和发布前验证

**Files:**
- Modify only if a gate finds a defect.

- [ ] **Step 1: 运行完整本地测试与 lint**

Run: `PYTHONPATH=src PATH="/Users/kdnsna/.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override:$PATH" .venv/bin/python -m pytest -q -rs`
Expected: 全部 PASS，真实 LibreOffice 测试不跳过。

Run: `.venv/bin/ruff check src tests tools`
Expected: `All checks passed!`。

Run: `git diff --check`
Expected: 无输出。

- [ ] **Step 2: 在 Python 3.10–3.13 隔离环境验证**

Run: `for v in 3.10 3.11 3.12 3.13; do uv run --python "$v" --isolated --extra dev pytest -q || exit 1; done`
Expected: 四个版本全部 PASS。

- [ ] **Step 3: 构建和检查发行包**

Run: `.venv/bin/python -m build`
Expected: `decklint-0.2.0` wheel/sdist。

Run: `uvx twine check dist/* && uvx check-wheel-contents dist/*.whl && uvx pip-audit --strict`
Expected: 全部通过，无已知漏洞。

- [ ] **Step 4: 全新环境验证四条入口**

验证 console script、`python -m decklint`、Release wheel 的 `uvx --from`、GitHub Action。每条都执行 audit 和 compare，并检查两个输出文件。

- [ ] **Step 5: 全尺寸视觉检查**

逐页检查 before/after；桌面和窄屏检查 audit HTML、comparison HTML 与 site。记录结果到 `examples/ultimate-ppt-master-proof/qa-ledger.json`，不得提交临时截图或绝对路径。

- [ ] **Step 6: 请求代码复审并修复所有 Important/Critical**

使用 `superpowers:requesting-code-review`。每个 actionable finding 先写失败测试，再最小修复，重新运行完整门禁。

- [ ] **Step 7: 提交验证修复**

```bash
git add -A
git commit -m "fix: close v0.2 release gates"
```

### Task 12: 合并、发布 v0.2、Pages、PyPI 和双仓互链

**Files:**
- DeckLint repository state and GitHub metadata.
- Modify in Ultimate PPT Master canonical repo: `README.md`、`README.zh-CN.md` 或现有质量门禁文档中的最小文档入口。

- [ ] **Step 1: 使用 finishing-a-development-branch 完成分支**

确认工作树干净、测试是当前提交的结果，fast-forward 合并 `codex/v02-proof-loop` 到 `main`，推送 `main`。

- [ ] **Step 2: 等待 main CI 全绿**

Run: `run_id=$(gh run list --repo kdnsna/decklint --branch main --limit 1 --json databaseId --jq '.[0].databaseId'); gh run watch "$run_id" --repo kdnsna/decklint --exit-status`
Expected: package、Action contract、三系统×四 Python 全部 success。

- [ ] **Step 3: 发布标签和 Release**

创建 annotated `v0.2.0` 和浮动 `v0`，推送后等待两轮 tag CI。Release 附带 wheel、sdist、before.pptx、after.pptx、comparison.html，并验证 SHA-256 digest。

- [ ] **Step 4: 启用并验证 GitHub Pages**

触发 Pages workflow，等待 `github-pages` deployment success；用 HTTP 检查首页、comparison、GIF 和 PPT 链接均为 200；将仓库 homepage 更新为 Pages URL，并补齐正式 topics。

- [ ] **Step 5: 完成 PyPI Trusted Publisher**

PyPI 配置固定为 owner `kdnsna`、repository `decklint`、workflow `publish-pypi.yml`、environment `pypi`。授权后运行 workflow，输入 `0.2.0`；随后在全新目录执行：

Run: `uvx decklint==0.2.0 audit examples/ultimate-ppt-master-proof/after.pptx --renderer wireframe`
Expected: 成功生成报告。

若 PyPI 账号需要用户进行登录或二次验证，停止在该登录门禁，其他 GitHub 发布工作继续保持已验证状态，不伪称 PyPI 已发布。

- [ ] **Step 6: 增加 Ultimate PPT Master 文档互链**

在 canonical repo 中只增加固定版本、可选的 DeckLint audit/compare 流程和案例链接；不增加运行时依赖，不删除旧审计入口。运行其 release-integrity 测试，提交、推送并等待该仓 CI。

- [ ] **Step 7: 最终完成审计**

逐项验证设计规格第十四节：README 中文首屏、准确安装、audit、compare、真实案例、Action `@v0`、Pages、Release、PyPI 和双仓互链。只有每项有当前证据且无剩余工作时，才调用 `update_goal(status="complete")`。
