# 复审基础设施实施计划（三件套）

> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development` 或
> `superpowers:executing-plans` 逐 Task 执行。步骤用 `- [ ]` 复选框跟踪。

**Goal:** 让「DS 优化后再审一次」变成三条可复现的命令，产出逐条清单 + 时点对比 + 可点击的报告索引。

**Architecture:** 三个纯 Node 零依赖脚本落在 `metrics/`，读 pin 住的只读 worktree，
产物落 `subjects/tvu-ds/inventory/`（JSON，机器口径）与 `reports/`（Markdown/HTML，人类口径）。
`dump` 取单时点逐条清单，`diff` 吃两份 dump 出变化，`build-report-index` 独立扫 `reports/` 生成索引页。

**Tech Stack:** Node ≥18 内置模块（`node:fs` / `node:path` / `node:child_process`）。
⛔ 零依赖、无 `package.json`、无测试框架 —— 与 `metrics/` 现有 13 个脚本同形。

**Spec:** 无独立 spec 文档。需求来自 2026-08-28 与 owner 的对话，逐条记在下方「需求来源」。
判据纪律的真源是 [`AGENTS.md`](../AGENTS.md) §1 / §2 / §3 / §4。

## 需求来源（owner 2026-08-28 逐字）

| # | owner 的问题 | 现状（已实测） | 本计划交付 |
|:-:|---|---|---|
| 1 | 「哪些没人管的规则没有提出建议如何修改吗？」 | 🔴 71 条**只有总数，无逐条清单落盘**；`inventory/` 29 个文件无一是 acceptance 覆盖面 | Task 2 |
| 2 | 「后续 DS 优化了再来审查，我能看到改进了哪些吗？有无对比？」 | 🟡 机制有（§29.2 四点曲线），但**人工写进表格**，且体检报告基线 `19e55102` **无 pin worktree** | Task 1 + Task 3 |
| 3 | 「有无路径可以点击查看不同时间审查的报告？」 | 🔴 `reports/` 是 34 份扁平 `.md` + 1 份 `.html`，**无索引页** | Task 4 |

## Global Constraints

逐条抄自 `AGENTS.md`，**每个 Task 的验收都隐含这一节**：

1. **只读边界（§1）**：DS 真源 `master` **永不直接写**。所有测量在 pin 住的 worktree 里做，
   ⛔ **不读 DS 工作区** —— 工作区可能是脏的、可能跑在 pin 之后。
2. **fail closed（§3.1）**：输入面塌了要抛，⛔ 不静默降级成「0 命中 / 干净」。
   尤其禁止 `try/catch` 把异常吞成 `false`。
3. **内建控制（§3.2）**：每条判据配 **must-hit + must-not-hit**，签名一变当场抛。
   控制必须钉「**这条闸应该是什么形态**」这种具名事实，⛔ 不钉「解析器有没有抛异常」（形态换代时恒真）。
   控制必须走**真代码路径**，⛔ 不测副本。
4. **判据钉形态不钉字面（§3.3）**：凡文本切片判据，**先去注释再切**。
5. **登记覆盖边界（§3.4）**：写在头注释里，逐字标明「**这是边界，不是 TODO**」。
6. **计数与归因分开（§3.5）**：计数可靠 ≠ 归因可靠。
7. **双向夹逼（§3.7）**：一个刻意从宽（上界）、一个刻意从严（下界），**差集必须逐条说清**。
   两侧收敛才是「形态枚举穷尽」的证据。
8. **exit code 用 `spawnSync(...).status`，⛔ 不经 shell**（§3.9）——
   zsh 下 `${PIPESTATUS[0]}` 是空的，接管道会读到 `tail` 的退出码。
9. **不接受文字总结（§4）**：只输出结构化 JSON（含证据定位）。
   ⛔ 禁止「通过 / 完成 / N 项 pass」式断言，**包括写给用户的结论**。
10. **一次性 grep 只能找线索，⛔ 不能下结论**（§3.9 N42）：凡写进报告的数，必须出自带控制的量具。

---

## 已完成的前提验证（本计划据此成立，⛔ 不必重做）

| 验证项 | 命令 / 方法 | 结果 |
|---|---|---|
| `19e55102` 在 DS 仓存活 | `git cat-file -t 19e55102` | 🟢 存在，距 HEAD **32** commit |
| DS 工作区干净 | `git status --porcelain \| wc -l` | 🟢 `0` |
| 闸零依赖可跑 | `node scripts/audit-acceptance-gate-coverage.mjs` | 🟢 exit 0，无需 `node_modules` |
| 闸导出可复用 | 读 `scripts/audit-acceptance-gate-coverage.mjs` | 🟢 导出 `SCAN_FILES` `GATE_DIR` `ALLOWLIST` `BASELINE_UNCLASSIFIED` `collectSections` `collectCovers` `loadAllowlist` `classify` `summarize` `parseAcceptanceSections` |
| `--json` 输出结构 | `--json \| keys` | 🟢 顶层 `scanFiles·sections·covers·allow·counts·baseline·failures·rows`；`rows[i]` = `id·line·state·items` |
| **宽尺能重现 126** | scratchpad 脚本，`git show 19e55102:<file>` 只读取内容 | 🟢 窄 **118** / 宽 **126** / **差额 8**，与 §29.2 表格 `19e55102` 行逐字吻合 |
| 8 条盲区的具体位置 | 同上 | 🟢 全部在 `docs/internal/mockup-conventions.md`，行号 693 / 917 / 1247 / 1256 / 1608 / 2730 / 3079 / 3107 |

**⚠️ 一处必须重取的读数**：能力探测时在 **DS 工作区**上跑过一次闸，得 `gated 27 · nmc 32 · unclassified 59`。
按 §1 那**不是合格读数**（工作区非 pin），只能当「DS 又推进了」的**信号**。
正式数在 Task 1 pin 完后于 worktree 上重取。⛔ 不得引用 59 这个数写进任何报告。

---

## File Structure

| 文件 | 责任 | 新建/修改 |
|---|---|---|
| `metrics/acceptance-coverage-dump.mjs` | 单时点逐条清单：窄尺/宽尺双向夹逼 + 三态分类 + 盲区标记 | 新建 |
| `metrics/acceptance-coverage-diff.mjs` | 两份 dump 的逐条 diff：状态迁移 / 新增 / 消失 / 盲区变化 | 新建 |
| `metrics/build-report-index.mjs` | 扫 `reports/` 生成可点击索引页 | 新建 |
| `reports/index.html` | 索引页产物（由上面脚本生成，⛔ 不手改） | 新建（生成物） |
| `subjects/tvu-ds/subject.json` | `pinHistory` 增补两个 pin（P4 `19e55102` / P5 当前 HEAD） | 修改 |
| `subjects/tvu-ds/inventory/acceptance-coverage-<sha>.json` | dump 产物 | 新建（生成物 ×2） |
| `reports/2026-08-28-acceptance-coverage-diff.md` | diff 产物（人类口径） | 新建（生成物） |

**依赖顺序**：Task 1 → Task 2 → Task 3。Task 4 **独立**，可并行。

---

### Task 1: re-pin 两个基线 worktree

**为什么**：体检报告的读数取自 `19e55102`，而它**没有 pin worktree** ⇒ 报告目前**不可复现**。
复审对比还需要一个「新时点」pin。`repinProtocol.pinTarget` 规定 pin 到**处方线最后一个 commit**，
⛔ 不是「对方当前 HEAD」—— 但本次两个 pin 的角色不同，见下方 Step 2 的判定。

**Files:**
- Modify: `subjects/tvu-ds/subject.json`（`pinHistory` 数组追加两项）
- Create: `~/.ai-ds-lab/pins/tvu-ds-19e55102/`（`git worktree --detach`）
- Create: `~/.ai-ds-lab/pins/tvu-ds-<新sha>/`（同上）

**Interfaces:**
- Produces: 两个只读 worktree 的绝对路径，Task 2 用 `--pin <路径>` 消费。

- [x] **Step 1: 先证故障态 —— 确认 19e55102 当前确实没有 pin**

```bash
ls -d ~/.ai-ds-lab/pins/*19e55102* 2>/dev/null || echo "✓ 故障态成立：19e55102 无 pin"
ls ~/.ai-ds-lab/pins/
```

Expected: 打印「✓ 故障态成立」，且现有 pin 只有 `339a72bf` / `4a68e02d` / `71ac2711` / `c1db57b8` 四个。

- [x] **Step 2: 判定新时点 pin 的目标 sha**

⚠️ **不要直接 pin HEAD**。按 `repinProtocol.pinTarget`，先看这 32 个 commit 里哪个是「acceptance 对位登记线」的收尾：

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
git log --oneline 19e55102..HEAD -- scripts/audit-acceptance-gate-coverage.mjs \
  figma-data/audit-allowlist/acceptance-not-machine-checkable.json
```

判定规则：取**最后一个改动了闸或豁免表的 commit** 作为新 pin 目标（记为 `<新sha>`）。
若该 commit 就是 HEAD，则 pin HEAD；若不是，pin 那个 commit 并在 `subject.json` 里写明
「其后 N 个 commit 与 acceptance 面无关」——这是 `pinHistory[3].whyKeep` 那条 93% 污染实证的同一纪律。

- [x] **Step 3: 建两个 detach worktree**

⚠️ 路径必须在 **DS 工作树之外**（`subject.json.note`：仓内 worktree 会被 DS 自己的 glob 算成重影）。

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
git worktree add --detach ~/.ai-ds-lab/pins/tvu-ds-19e55102 19e55102
git worktree add --detach ~/.ai-ds-lab/pins/tvu-ds-<新sha> <新sha>
```

- [x] **Step 4: 核验两个 worktree 的 HEAD 与洁净度**

```bash
for p in ~/.ai-ds-lab/pins/tvu-ds-19e55102 ~/.ai-ds-lab/pins/tvu-ds-<新sha>; do
  echo "== $p"
  git -C "$p" rev-parse --short HEAD
  git -C "$p" status --porcelain | wc -l
done
```

Expected: 两行 sha 分别为 `19e55102` / `<新sha>`；两个 `status` 计数都是 `0`。
⛔ 非 0 就停下 —— 脏 worktree 上取的数不可复现。

- [x] **Step 5: 核验 DS 真源未被写**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
git status --porcelain | wc -l && git rev-parse --short HEAD
```

Expected: `0`，且 HEAD 与 Step 2 之前一致（§1 只读边界）。

- [x] **Step 6: 更新 subject.json 的 pinHistory**

在 `pinHistory` 数组末尾追加两项，`keep: true`，`whyKeep` 必须写明**为什么不能拆**：

```json
{
  "sha": "19e55102...(补全40位)",
  "short": "19e55102",
  "role": "P4 — 2026-08-28 设计系统体检报告的读数基线",
  "worktree": "~/.ai-ds-lab/pins/tvu-ds-19e55102",
  "pinnedAt": "2026-08-28",
  "keep": true,
  "whyKeep": "⛔ 别拆。reports/2026-08-28-design-system-review.{md,html} 全部读数以它为主键：126 段 / 71 unclassified / 25 gated / 8 条闸盲区、四点曲线第 4 行。⚠️ 它是【补 pin】—— 报告写作当时没有 pin，读数取自当时的工作区快照；本 pin 是事后固定，acceptance 面已逐条复现（窄 118 / 宽 126 / 差 8）。"
},
{
  "sha": "<新sha 40位>",
  "short": "<新sha>",
  "role": "P5 — 复审新时点（acceptance 对位登记线收尾）",
  "worktree": "~/.ai-ds-lab/pins/tvu-ds-<新sha>",
  "pinnedAt": "2026-08-28",
  "keep": true,
  "whyKeep": "第一次真实复审对比（P4→P5）的终点。delta 对 P4 算，⛔ 不对 c1db57b8（那是处方线的 pin，口径不同）。"
}
```

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

```bash
cd /Users/nancy/Documents/AICoding/VS_Code/ai-ds-lab
git add subjects/tvu-ds/subject.json
git commit -m "chore(pin): 补 P4=19e55102（体检报告基线）+ P5=<新sha>（复审新时点）

体检报告的读数此前取自未 pin 的 sha ⇒ 不可复现。本次补 pin 并固定，
acceptance 面已逐条复现：窄尺 118 / 宽尺 126 / 差额 8。

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

---

### Task 2: `acceptance-coverage-dump.mjs` —— 逐条清单落盘

**为什么**：owner 问题 1。71 条目前**只有总数**，逐条清单从未落盘 ⇒ 无法逐条提建议、无法逐条 diff。

**Files:**
- Create: `metrics/acceptance-coverage-dump.mjs`
- Output: `subjects/tvu-ds/inventory/acceptance-coverage-<shortSha>.json`
- Output: `reports/acceptance-coverage-<shortSha>.md`（人类口径逐条清单）

**Interfaces:**
- Consumes: Task 1 的 worktree 路径（`--pin <path>`）
- Produces: JSON schema（Task 3 消费）：

```js
{
  auditId: 'acceptance-coverage-dump',
  subjectSha: '19e55102',            // 取自 git -C <pin> rev-parse --short HEAD
  checkedAt: '<ISO>',
  scanFiles: [...],                   // 7 份，抄自闸的 SCAN_FILES
  gauge: { narrow: 118, wide: 126, blind: 8 },   // 双向夹逼三个数
  counts: { gated: n, 'not-machine-checkable': n, unclassified: n },  // 宽尺口径
  countsNarrow: { ... },              // 窄尺口径（= 闸自己看到的）
  baseline: 63,                       // 闸的 BASELINE_UNCLASSIFIED
  rows: [{
    id: 'mockup-conventions#C4',      // 主键：<file basename 去扩展名>#<anchor>
    file: 'docs/internal/mockup-conventions.md',
    line: 917,
    state: 'unclassified',            // gated | not-machine-checkable | unclassified
    blindSpot: true,                  // 🔴 窄尺看不见 ⇒ 那 8 条
    form: 'list-bold',                // heading | bold | list-bold | indented-bold | zh-heading
    checkItems: 3,
    heading: '§M48 self-check',       // 所属最近标题，给人看的定位
    claims: [{ gate: 'audit-xxx.mjs', claimedCheckItems: 3 }],
    allow: { reason: '...', since: '2026-08-13' } | null
  }]
}
```

- [x] **Step 1: 写内建控制（must-hit / must-not-hit），先证它会失败**

控制**钉具名事实**（§3.2 推论二），⛔ 不钉「解析器跑通了没有」。先只写控制、不写实现：

```js
// ---------- 内建控制（§3.2）----------
// ⛔ 钉的是「这条闸/这份文档应该是什么形态」这种具名事实，不是「解析器有没有抛异常」。
const CONTROLS = {
  // must-hit ①：窄尺必须逐字复现闸自己的读数。签名一变当场抛。
  narrowMatchesGate: (narrow, gateJson) => narrow === gateJson.sections,
  // must-hit ②：宽尺 ⊋ 窄尺，且差额就是那 8 条盲区（19e55102 时点的具名事实）
  wideStrictlyWider: (narrow, wide) => wide > narrow,
  // must-not-hit ①：宽尺 ⛔ 不得收进代码围栏里的 Acceptance（那是示例，不是规则）
  //   具名事实：mockup-conventions.md 里有 ```…``` 围栏内出现 `**Acceptance**` 的样例
  noFencedMatches: rows => rows.every(r => !r.inFence),
  // must-not-hit ②：⛔ 不得把注释/散文里「提到 Acceptance」算成段（§3.3）
  //   具名事实：`<!-- **Acceptance** -->` 形态必须为 0 命中
  noCommentMatches: rows => rows.every(r => !r.inComment),
}
```

- [x] **Step 2: 跑控制验证它当前失败**

```bash
node metrics/acceptance-coverage-dump.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check
```

Expected: **FAIL**，报「实现缺失」。⛔ 若此步就 PASS，说明控制钉在恒真的事上，回 Step 1 重写。

- [x] **Step 3: 实现宽尺 parser（先剥注释与代码围栏）**

⚠️ §3.3 铁律：**先去注释再切**。`parseAcceptanceSections` 的闸版本**不剥围栏**，
lab 的宽尺收得更多 ⇒ 必须自己剥，否则围栏里的样例会污染读数（与 2026-08-12 那次
`max-width: 1100px` 注释误读**同型**，体检报告 §二已登记这是**反复出现的形态**）。

```js
// 剥 HTML 注释与 ``` 代码围栏 —— 保留行号（用等长空行替换，⛔ 不能删行）
function stripNoise(text) {
  const lines = text.split('\n')
  const out = new Array(lines.length)
  let inFence = false
  for (let i = 0; i < lines.length; i++) {
    const L = lines[i]
    if (/^\s*```/.test(L)) { inFence = !inFence; out[i] = ''; continue }
    out[i] = inFence ? '' : L
  }
  // HTML 注释可跨行 —— 整体替换后按行数校验
  const joined = out.join('\n').replace(/<!--[\s\S]*?-->/g, m => m.replace(/[^\n]/g, ''))
  const res = joined.split('\n')
  if (res.length !== lines.length) throw new Error(`[fail-closed] stripNoise 改变了行数 ${lines.length}→${res.length}`)
  return res
}

// 宽尺：收闸的窄尺 + 报告点名的三种形态
const NARROW = /^(?:\*\*Acceptance|#{2,6}\s+.*Acceptance)/          // 逐字抄自闸:145
const WIDE = /^(?:\s*[-*]\s+)?\*\*Acceptance|^\s+\*\*Acceptance|^#{2,6}\s+.*(?:Acceptance|验收标准)/

function formOf(line) {
  if (/^#{2,6}\s+.*验收标准/.test(line)) return 'zh-heading'
  if (/^#{2,6}\s/.test(line)) return 'heading'
  if (/^\s*[-*]\s+\*\*Acceptance/.test(line)) return 'list-bold'
  if (/^\s+\*\*Acceptance/.test(line)) return 'indented-bold'
  return 'bold'
}
```

- [x] **Step 4: 实现三态分类 —— 复用闸的真代码路径**

§3.2 推论三：控制必须走**真代码路径**，⛔ 不测副本。所以 `classify` / `collectCovers` /
`loadAllowlist` 一律 `import` 闸本体，只有 section 枚举用 lab 的宽尺：

```js
import { pathToFileURL } from 'node:url'
const gatePath = join(PIN, 'scripts/audit-acceptance-gate-coverage.mjs')
if (!existsSync(gatePath)) throw new Error(`[fail-closed] 闸不存在于 pin：${gatePath}`)
const gate = await import(pathToFileURL(gatePath).href)

// 复用闸的：自陈采集 / 豁免表 / 三态分类 / BASELINE
const covers = gate.collectCovers(join(PIN, gate.GATE_DIR), ...)
const { entries: allowEntries } = gate.loadAllowlist(join(PIN, gate.ALLOWLIST), ...)
const { rows } = gate.classify({ sections: wideSections, covers, allowEntries })
```

⚠️ **fail closed**：`gate.collectCovers` 等签名一变（DS 改了闸）就会抛 —— 这是**想要的**，
⛔ 不许 `try/catch` 吞掉降级成「0 条自陈」。

- [x] **Step 5: 跑闸拿窄尺读数做 must-hit ①**

⛔ 用 `spawnSync(...).status`，不经 shell（§3.9）：

```js
import { spawnSync } from 'node:child_process'
const r = spawnSync('node', ['scripts/audit-acceptance-gate-coverage.mjs', '--json'],
  { cwd: PIN, encoding: 'utf8', maxBuffer: 64 << 20 })
if (r.status !== 0 && r.status !== 1) throw new Error(`[fail-closed] 闸异常退出 status=${r.status}`)
const gateJson = JSON.parse(r.stdout)
if (!CONTROLS.narrowMatchesGate(narrowCount, gateJson))
  throw new Error(`[must-hit] 窄尺 ${narrowCount} ≠ 闸自报 ${gateJson.sections} —— 量具与被测对象口径已分叉`)
```

- [x] **Step 6: 跑 self-check，验证控制全绿**

```bash
node metrics/acceptance-coverage-dump.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check
```

Expected: 四条控制全 PASS，且打印 `narrow=118 wide=126 blind=8`。
⛔ `blind` 不是 8 就停 —— 与已验证的前提矛盾，先查量具再改结论（§3.9）。

- [x] **Step 7: 造故障验证它会拦（§3.8）**

⚠️ 造「格式合法但内容有毒」那一种。在 **worktree 的副本**上注入（用完 `git checkout` 还原）：

```bash
P=~/.ai-ds-lab/pins/tvu-ds-19e55102
# 注入：在代码围栏里塞一条假 Acceptance —— 宽尺若不剥围栏就会多收 1 段
python3 - "$P" <<'PY'
import sys,pathlib
f=pathlib.Path(sys.argv[1])/'docs/internal/mockup-conventions.md'
t=f.read_text(encoding='utf8')
t=t.replace('```markdown\n','```markdown\n- **Acceptance**：这是围栏里的假段，量具不该收它\n',1)
f.write_text(t,encoding='utf8')
PY
grep -c 'Acceptance' "$P/docs/internal/mockup-conventions.md"   # marker 计数必须涨，否则注入没生效
node metrics/acceptance-coverage-dump.mjs --pin "$P" --self-check
```

Expected: `wide` **仍是 126**（围栏被剥掉），`noFencedMatches` 控制 PASS。
⛔ 若 wide 变成 127，说明剥围栏失效 —— 这正是这条控制存在的理由，回 Step 3 修。

- [x] **Step 8: 还原并核验**

```bash
git -C ~/.ai-ds-lab/pins/tvu-ds-19e55102 checkout -- docs/internal/mockup-conventions.md
git -C ~/.ai-ds-lab/pins/tvu-ds-19e55102 status --porcelain | wc -l
```

Expected: `0`。⛔ 非 0 不许进下一步。

- [x] **Step 9: 出两个时点的 dump**

```bash
node metrics/acceptance-coverage-dump.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 \
  --out subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json \
  --md reports/acceptance-coverage-19e55102.md
node metrics/acceptance-coverage-dump.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-<新sha> \
  --out subjects/tvu-ds/inventory/acceptance-coverage-<新sha>.json \
  --md reports/acceptance-coverage-<新sha>.md
```

- [x] **Step 10: 亲验产物（⛔ 不接受「已生成」式断言，§4）**

```bash
node -e '
const j=require("./subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json");
console.log("gauge:",JSON.stringify(j.gauge));
console.log("counts:",JSON.stringify(j.counts));
console.log("rows:",j.rows.length,"blind:",j.rows.filter(r=>r.blindSpot).length);
console.log("unclassified:",j.rows.filter(r=>r.state==="unclassified").length);
'
```

Expected: `gauge {narrow:118,wide:126,blind:8}` · `rows 126` · `blind 8` · `unclassified 71`
—— **71 这个数必须与体检报告逐字一致**，不一致就是量具或报告有一个错了，停下来查。

- [x] **Step 11: 提交**

```bash
git add metrics/acceptance-coverage-dump.mjs \
  subjects/tvu-ds/inventory/acceptance-coverage-*.json reports/acceptance-coverage-*.md
git commit -m "feat(metrics): acceptance 覆盖面逐条清单落盘 —— 71 条第一次可逐条查

双向夹逼：窄尺(闸自己 ACC_RE)118 / 宽尺(收三种漏形态)126 / 盲区 8。
71 条 unclassified 逐条带 id·file·line·form·checkItems·所属标题。

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

---

### Task 3: `acceptance-coverage-diff.mjs` —— 两时点对比

**为什么**：owner 问题 2。现在的对比是**人工写进 status.md 表格**的，且只有总数。

**Files:**
- Create: `metrics/acceptance-coverage-diff.mjs`
- Output: `reports/2026-08-28-acceptance-coverage-diff.md`

**Interfaces:**
- Consumes: 两份 Task 2 的 dump JSON（`--from <json> --to <json>`）
- Produces: Markdown 报告 + stdout JSON（`--json`）

- [x] **Step 1: 写控制，先证失败**

```js
const CONTROLS = {
  // must-hit：同一条 id 在两份 dump 里若 state 未变，⛔ 不得出现在 transitions 里
  noPhantomTransitions: d => d.transitions.every(t => t.from !== t.to),
  // must-not-hit：⛔ 不得把「新增段」误报成「状态迁移」（id 不在 from 里就不是迁移）
  addedNotTransition: (d, fromIds) => d.transitions.every(t => fromIds.has(t.id)),
  // must-hit：三类计数守恒 —— from 的 rows 数 = 留存 + 消失
  conservation: (d, from) => d.kept + d.removed === from.rows.length,
}
```

```bash
node metrics/acceptance-coverage-diff.mjs --from … --to … --self-check   # Expected: FAIL
```

- [x] **Step 2: 实现 diff —— 按 id 主键对齐**

```js
const byId = rows => new Map(rows.map(r => [r.id, r]))
const A = byId(from.rows), B = byId(to.rows)
const transitions = [], added = [], removed = []
for (const [id, a] of A) {
  const b = B.get(id)
  if (!b) { removed.push(a); continue }
  if (a.state !== b.state) transitions.push({ id, from: a.state, to: b.state, file: b.file, line: b.line, heading: b.heading })
}
for (const [id, b] of B) if (!A.has(id)) added.push(b)
// 盲区面单独算 —— 它不随兑现改善，是结构性的（§29.2）
const blindFrom = from.rows.filter(r => r.blindSpot).map(r => r.id)
const blindTo = to.rows.filter(r => r.blindSpot).map(r => r.id)
const blindFixed = blindFrom.filter(id => !blindTo.includes(id))
```

⚠️ **计数与归因分开（§3.5）**：diff 只报「谁的 state 变了」，
⛔ **不推断「因为 DS 做了 X 所以变了」** —— 归因要人去看 commit。这条写进头注释的覆盖边界。

- [x] **Step 3: 跑 self-check 验证控制全绿**

```bash
node metrics/acceptance-coverage-diff.mjs \
  --from subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json \
  --to   subjects/tvu-ds/inventory/acceptance-coverage-<新sha>.json --self-check
```

Expected: 三条控制全 PASS。

- [x] **Step 4: 造故障 —— 拿同一份 dump 自己跟自己 diff**

```bash
node metrics/acceptance-coverage-diff.mjs \
  --from subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json \
  --to   subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json --json
```

Expected: `transitions: []` · `added: []` · `removed: []` · `blindFixed: []`。
⛔ 任何一项非空 ⇒ 主键对齐逻辑坏了。

- [x] **Step 5: 出正式对比报告**

```bash
node metrics/acceptance-coverage-diff.mjs \
  --from subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json \
  --to   subjects/tvu-ds/inventory/acceptance-coverage-<新sha>.json \
  --md reports/2026-08-28-acceptance-coverage-diff.md
```

- [x] **Step 6: 亲验报告内容与 dump 一致**

```bash
node -e '
const a=require("./subjects/tvu-ds/inventory/acceptance-coverage-19e55102.json");
const b=require("./subjects/tvu-ds/inventory/acceptance-coverage-<新sha>.json");
const u=x=>x.rows.filter(r=>r.state==="unclassified").length;
console.log("unclassified:",u(a),"→",u(b),"  delta:",u(b)-u(a));
console.log("blind:",a.rows.filter(r=>r.blindSpot).length,"→",b.rows.filter(r=>r.blindSpot).length);
'
```

⚠️ 把这两行数字与 `reports/2026-08-28-acceptance-coverage-diff.md` 正文**逐字比对**。
不一致 ⇒ 报告生成器错了（§4：⛔ 不接受「已生成」式断言）。

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

```bash
git add metrics/acceptance-coverage-diff.mjs reports/2026-08-28-acceptance-coverage-diff.md
git commit -m "feat(metrics): 两时点 acceptance 覆盖面逐条 diff —— 复审对比不再靠手写表格

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

---

### Task 4: `build-report-index.mjs` + `reports/index.html`

**为什么**：owner 问题 3。34 份报告扁平堆在 `reports/`，无索引、无法点击浏览历史。
⚠️ **本 Task 与 Task 1–3 无依赖**，可并行执行。

**Files:**
- Create: `metrics/build-report-index.mjs`
- Output: `reports/index.html`（⛔ 生成物，不手改）

**Interfaces:**
- Consumes: `reports/*.md` 与 `reports/*.html` 的文件名与首部内容
- Produces: 无（终端产物）

- [x] **Step 1: 写控制，先证失败**

```js
const CONTROLS = {
  // must-hit：reports/ 下每一份 .md/.html（除 index.html 自己）都必须出现在索引里
  //   ⛔ 具名事实：漏一份就是索引撒谎，比没有索引更糟
  allFilesIndexed: (files, entries) => entries.length === files.length,
  // must-hit：每条都要解析出日期（文件名前缀 YYYY-MM-DD）
  allDated: entries => entries.every(e => /^\d{4}-\d{2}-\d{2}$/.test(e.date)),
  // must-not-hit：⛔ 标题不得是空串或文件名本身（说明标题提取失败，静默降级）
  noFallbackTitles: entries => entries.every(e => e.title && e.title !== e.file),
}
```

- [x] **Step 2: 跑控制验证失败**

```bash
node metrics/build-report-index.mjs --self-check
```

Expected: **FAIL**（实现缺失）。

- [x] **Step 3: 实现扫描与标题提取**

```js
// 标题：.md 取第一个 `# `；.html 取 <title>。⛔ 取不到就抛，不降级成文件名（fail closed §3.1）
function titleOf(path, text) {
  if (path.endsWith('.html')) {
    const m = text.match(/<title>([^<]+)<\/title>/)
    if (!m) throw new Error(`[fail-closed] ${path} 无 <title>`)
    return m[1].trim()
  }
  const m = text.match(/^#\s+(.+)$/m)
  if (!m) throw new Error(`[fail-closed] ${path} 无一级标题`)
  return m[1].trim()
}
// 一句话结论：.md 取「## 一句话结论」下第一个非空行，取不到则取首个非标题非引用段落
// ⚠️ 覆盖边界：34 份报告里只有体检报告有「一句话结论」节 ⇒ 其余走兜底路径。
//    这是边界，不是 TODO。
```

- [x] **Step 4: 生成 HTML —— 复用体检报告的视觉语言**

⚠️ 视觉与 `reports/2026-08-28-design-system-review.html` 同源（蓝图网格版头、
Archivo + IBM Plex Mono + Noto Sans SC、三态主题 token）。**三态主题必须齐全**：
`:root` 定义完整浅色 → `@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]) }`
→ `:root[data-theme="dark"]`。⛔ 任何颜色不得只定义在 media/`[data-theme]` 块里。

索引按**时间倒序**分组（按日期），每条一行：日期 · 标题 · 格位标记 · 一句话结论 · 文件类型徽章。
体检报告置顶并突出（它是汇总层，其余是分格产出）。

- [x] **Step 5: 跑 self-check 验证控制全绿**

```bash
node metrics/build-report-index.mjs --self-check
```

Expected: 三条控制全 PASS，打印 `indexed=35`（34 份 .md + 1 份 .html；⛔ index.html 自己不计）。

- [x] **Step 6: 造故障 —— 临时加一份无标题的 md，验证 fail closed**

```bash
printf 'no heading here\n' > reports/zz-fault-injection.md
node metrics/build-report-index.mjs --self-check; echo "exit=$?"
rm reports/zz-fault-injection.md
```

Expected: **非 0 退出**，报 `[fail-closed] reports/zz-fault-injection.md 无一级标题`。
⛔ 若它静默跳过该文件并 exit 0，说明降级了 —— 回 Step 3 修。

- [x] **Step 7: 生成索引页并亲验**

```bash
node metrics/build-report-index.mjs --out reports/index.html
node -e '
const t=require("fs").readFileSync("reports/index.html","utf8");
const n=(t.match(/class="row-report"/g)||[]).length;
console.log("索引条目数:",n);
console.log("有 title:", /<title>/.test(t));
console.log("三态主题齐全:", /:root\{/.test(t) && /prefers-color-scheme: dark/.test(t) && /\[data-theme="dark"\]/.test(t));
'
open reports/index.html
```

Expected: 条目数 **35**；三项都是 `true`；浏览器里逐条链接可点击跳转。

- [x] **Step 8: 提交**

```bash
git add metrics/build-report-index.mjs reports/index.html
git commit -m "feat(reports): 本地报告索引页 —— 35 份报告按时间倒序可点击

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

---

## 交付后的入口（给 owner）

| 想做什么 | 命令 / 路径 |
|---|---|
| 看所有历史报告 | `open reports/index.html` |
| 看 71 条逐条清单 | `reports/acceptance-coverage-19e55102.md` |
| DS 又改进后再审 | ① `git worktree add --detach ~/.ai-ds-lab/pins/tvu-ds-<新> <新sha>` ② `dump --pin <新>` ③ `diff --from <旧json> --to <新json>` |
| 看两次审查差在哪 | `reports/2026-08-28-acceptance-coverage-diff.md` |

## Self-Review 记录

- **需求覆盖**：owner 三个问题 → Task 2 / Task 1+3 / Task 4，逐条有归属 ✓
- **占位符扫描**：无 TBD/TODO；`<新sha>` 是 Task 1 Step 2 判定后的具体值，已给判定规则 ✓
- **类型一致性**：`dump` 产出的 `rows[].id/state/blindSpot` 与 `diff` 消费的字段名逐条对齐 ✓
- **⚠️ 已知风险**：Task 2 Step 4 `import` 闸本体 ⇒ DS 改闸签名会导致 dump 抛错。
  这是**故意的**（fail closed），但意味着**旧 pin 的 dump 不能用新闸重跑** ——
  已在 Task 2 头注释登记为覆盖边界。


---

## ✅ 执行结果与预注册对账（2026-08-28 当日执行完毕）

**全部 4 个 Task 已执行完毕**，产物与 commit：

| Task | 产物 | commit |
|:-:|---|---|
| 1 | pin P4 `19e55102` + P5 `dcc1c29b`，`subject.json` pinHistory 6 条 | `1738971` |
| 2 | `metrics/acceptance-coverage-dump.mjs` + 2 份 JSON + 2 份 md 清单 | `7169d3a` |
| 3 | `metrics/acceptance-coverage-diff.mjs` + 对比报告 | `f8e9b9e` |
| 4 | `metrics/build-report-index.mjs` + `reports/index.html` | `c724f0a` |

### 🔴 与预注册的四处偏差（⛔ 不删，留痕）

| # | 计划怎么写的 | 实际怎么做的 | 为什么 |
|:-:|---|---|---|
| **1** | Task 1 Step 2 判定规则只看**分子面**（闸 / 豁免表） | 补了**分母面**实测：`fbfc3e4f` 改过扫描面里的 `meta-rules.md`（+22/−5） | 分母变了同样会改读数，而规则没覆盖这种情况。实测三 sha 段数恒为窄 118 / 宽 126 / 差 8 ⇒ **未动 Acceptance 段**，判定 `dcc1c29b` 成立 |
| **2** | Task 2 未预见 `--allow-dirty` | 新增该参数 | §1「pin 必须干净」的 fail-closed **挡住了 §3.8 要求的故障注入**（注入必然弄脏 worktree）。⇒ 加逃生口，并让产物带 `dirty` 标记 + 拒绝落盘 |
| **3** | Task 2 完全没预见 **id 位移** | 新增 id 冻结 + `must-not-hit-3` | 见 `round2-status.md` §30.5 **N74**。是清单产出后逐条核对时撞出来的 —— **写计划时不知道有这个缺陷** |
| **4** | Task 4 只预见「渲染器够用即可」 | 新增孤立表格行分支 | `must-not-hit-2` 抓出 `task-discrimination.md:193` 是从别处摘出的**无表头单行表格**，原会塌成一串竖线 |

### ⚠️ 执行期间的两次自我订正

1. **能力探测时在 DS 工作区上跑过闸**（得 `unclassified 59`），违反 §1「不读 DS 工作区」。
   ⇒ 该数只当信号，**正式读数全部在 pin 上重取**（`dcc1c29b` 的 pin 读数同为 59，事后印证信号没错，⛔ 但这不改变当时那次取数不合规）。
2. **读退出码时用了 `${PIPESTATUS[0]}`** —— §3.9 逐字警告过 **zsh 下它是空的**。
   ⇒ 改为不接管道读 `$?`，两档故障注入才拿到真实的 `exit=1`。

### 未做的事（⛔ 明确登记，不是遗漏）

- **没有给 71 条逐条写「怎么修」**。本次交付的是**逐条现状清单**（哪条、在哪、什么形态、几项核查、谁在管）——
  有了它才谈得上逐条提建议，但**修法本身需要人逐条判断**，且 §30.2 第 2 条的现行推荐是「14 件打包归档为接受现状」。
  ⇒ 若要逐条修法，那是一次**新的决策**，⛔ 不在本计划射程。
- **没有复验体检报告的其它三个面**（组件数 / 入口类型声明 / 变量占比）。本次只在 P4 上复现了 **acceptance 面**。
