# 67 条 unclassified 逐条修法 · 实施计划

> **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:** 让 P6 (`b3728d38`) 上那 **67 条「没人管」的 Acceptance 段**每一条都拿到一个**具体、可执行、
带前置依赖的修法方向**，且分类**由可测属性推出**（⛔ 不是手写 67 段散文）。

**Architecture:** 一支新量具 `metrics/unclassified-fix-triage.mjs` 读 pin 住的 DS 真源，抽出每条 Acceptance 段的
**核查项正文**，按词法判据给出 `fixClass`（做什么）与 `blockers`（先解什么）两个**正交**维度；
判据的成立与否用 DS 自己已标注的两组段做**金标对照**（已豁免 32 条 = F3 的正样本；已 gated 27 条 = F3 的负样本）。
量具产出逐条归类表，处方 `proposals/2026-08-31-unclassified-67-fix-directions.md` 给每一类的具体修法动作。

**Tech Stack:** Node ≥18 内置模块。零第三方依赖。

**Spec:** 无独立 spec。需求来自 owner 2026-08-31 拍板（下方「owner 决策留痕」逐字记录）。
被处理对象是 [`reports/acceptance-coverage-b3728d38.md`](../reports/acceptance-coverage-b3728d38.md)
「🟠 没人管的 67 段（逐条）」那张表，逐条数据在
[`subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json`](../subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json)。

---

## 🔴 owner 决策留痕（⛔ 不删 —— 本计划推翻了 lab 自己的一条现行推荐）

**2026-08-31，owner 拍定：推翻 §30.4 冻结，全 67 条都要修法。**

⚠️ **顺带订正交接措辞**：此前把「现行推荐」记成 §30.2 **第 2 条**。核对后 —— 第 2 条讲的是
「**14 件 owner 待拍**打包归档为接受现状」，与这 67 条无关。逐条修法真正推翻的是：

- **§30.2 第 3 条**：「立刻只做 1 件（补 WC 类型声明）；其余 7 步建议**进 backlog 不排期**」
  —— 67 条属其中第 2–4 步。
- **§30.4**：「候选清单封顶，新发现**只登记不开格**」。

⇒ **解冻范围严格限定在「已登记的这 67 条」**。⛔ 本计划不新开评审格、不扩大扫描面、不查新问题。
新撞到的东西一律**只登记**（沿用 §30.4 后半段）。

---

## 🔴 lab 给的是「可选修法方向」，⛔ 不是裁定（AGENTS §8）

`AGENTS.md` §8 单向纪律：**lab 只审查、不裁定被验方的技术口径。**
且 `AGENTS.md:301` 逐字：「报告要给**可选修法方向**，⛔ 但不替对方选」。

⇒ 本计划的产出对每一条给的是「**这条看起来属哪一类 / 该类的修法动作是什么 / 前置是什么**」，
**DS owner 保留否决与改判的权力**。三条硬性措辞纪律：

1. 处方里每条修法写「**建议**」/「**可选方向**」，⛔ 不写「应当」「必须」。
2. `fixClass` 是**量具的判读**，⛔ 不是 DS 的口径。JSON 里每条必须带 `criterion`（命中了哪条词法判据）
   与 `confidence`，让 DS 能逐条复核**为什么**被这么分。
3. 🔴 **F3（建议走具名豁免）尤其敏感** —— 它等于说「这条别自动检查了」。
   ⇒ 该类每条必须额外带 `exemptionRationale` 草稿文本，让 owner 能直接判「这理由站不站得住」。

---

## Global Constraints

1. **只读 pin，⛔ 不读 DS 工作区**（§1）。本计划全程用 **P6 = `b3728d38`**（与 67 这个数同源）。
   ⚠️ DS 侧有并行 session 在写真源，HEAD 漂得很快（实测 `cb2e6576` → `80f56a17` → `3db72145`）。
   worktree dirty ⇒ **fail closed**。
2. **先去注释再切**（§3.3）。抽核查项正文前必须剥**代码围栏 + HTML 注释**，且**行号守恒**。
   ⚠️ 同一 session 已栽过两次（N76 漏目录面 · 风格轴「17 个」实为 2 个）。
3. **取退出码 ⛔ 别接管道**（§3.9）。zsh 下 `${PIPESTATUS[0]}` 是空的。用 `$pipestatus[1]` 或不接管道。
4. **凡输出「空 / 零 / 无变化」先证判别力**（§30.5.2）。本计划命中处：Task 2 的金标对照
   若召回率/误报率**恰好等于预注册阈值边界**，必须再补一次扰动验证。
5. **计数 ≠ 归因**（第 27 条 / N74）。67 这个**总数**对，⛔ 不代表逐条归属对。
   ⇒ Task 4 必须验「67 条各归一类、无重复、无遗漏」（`sum(byClass) === 67` 且 id 集合逐字相等）。
6. **两个正交维度 ⛔ 不许焊接**（第 22 条推论三）。`fixClass`（做什么）与 `blockers`（先解什么）
   **必须分开存、分开报**。一条可以既是 F1 又被 `N-D1-anchor` 阻塞。
7. **id 冻结**（N74）。逐条归属靠 `id`，⛔ 不靠表格行号。id 与 P6 的 dump JSON 必须**逐字相同**。
8. **纯 Node 零依赖**（D4）。

---

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

起手在 P6 dump JSON 上实测（读 JSON，⛔ 未 grep 正文）：

| 维度 | 分布 |
|---|---|
| `unclassified` 总数 | **67**（⚠️ 71 是 P4 时点的数，P5 起降到 67，⛔ 别抄旧报告） |
| 按文件 | `mockup-conventions.md` **48** · `design-process.md` **14** · `domain-tvu.md` **5** |
| 按书写形态 | `bold` 33 · `heading` 26 · `list-bold` 5 · `indented-bold` 2 · `zh-heading` 1 |
| 标题具名性 | 具名规则段 **34** · 匿名段（标题逐字就叫 `Acceptance`）**33** |
| 核查项数 | `itemCount=0` **10 条** · 其余 1–8 项 |
| 闸盲区 | `blindSpot=true` **8 条** |

**另两组是本计划的金标（DS 自己标注的，⛔ 不是 lab 造的）**：
- `state==='not-machine-checkable'` **32 条** —— 带豁免理由，逐条读过是「要 AI 显式声明一句话 / 主观判断 / 问不问用户」形态
- `state==='gated'` **27 条** —— 有闸自陈在管，即 DS 认为**可机检**

⇒ 这两组给了 F3 判据一个**现成的带标签验证集**。⛔ 不必自己造样本。

---

## File Structure

| 文件 | 责任 | Task |
|---|---|:-:|
| `metrics/unclassified-fix-triage.mjs` | 抽核查项正文 → 词法判据 → `fixClass` + `blockers` + 金标对照 | 1–3 |
| `subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json` | 逐条归类落盘 | 3 |
| `reports/unclassified-fix-triage-b3728d38.md` | 逐条归类表（67 行）+ 金标对照读数 | 3 |
| `proposals/2026-08-31-unclassified-67-fix-directions.md` | 处方：每类的具体修法动作 + 前置依赖 + 逐条索引 | 4 |
| `docs/round2-status.md` | 登记 **N80**（含「本条推翻了 §30.2 第 3 条与 §30.4」的留痕） | 4 |
| `reports/index.html` | 重建 | 4 |

**依赖顺序**：Task 1 → Task 2 → Task 3 → Task 4（严格串行，Task 2 是判据成立与否的闸）。

---

## Task 1: 抽出 67 条的**核查项正文**

**为什么**：现有 dump JSON 里 `checkItems` **只有一个计数**（`3` / `5` / `0`），没有正文。
⇒ 光凭计数**无法**判断一条规则可不可机检。本 Task 是后面所有分类的**输入面**。

**Files:**
- Create: `metrics/unclassified-fix-triage.mjs`

**Interfaces:**
- Consumes: `subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json`（取 `rows` 与 `id`，**id 冻结**）；
  pin `~/.ai-ds-lab/pins/tvu-ds-b3728d38` 下的 7 份规则真源。
- Produces:
  - CLI：`node metrics/unclassified-fix-triage.mjs --pin <worktree> --dump <dump.json> [--self-check] [--out <json>] [--md <md>]`
  - 内部结构（Task 2/3 逐字消费）：
    `Array<{ id, file, line, heading, form, blindSpot, state, items: Array<{ line, text }> }>`

- [ ] **Step 1: 写「剥围栏与 HTML 注释、行号守恒」的预处理**

⚠️ 与 `acceptance-coverage-dump.mjs` **同一口径**（那支已实测有效），⛔ 但复制而非 import（各量具自包含）：

```js
// AGENTS §3.3：先去注释再切。⛔ 用空串替换，不删行（行号必须守恒，否则 id/line 对不上 dump）
function stripFencesAndComments(text) {
  const lines = text.split('\n')
  let inFence = false, inHtml = false
  return lines.map((s) => {
    if (/^\s*```/.test(s)) { inFence = !inFence; return '' }
    if (inFence) return ''
    let out = s
    if (inHtml) {
      const e = out.indexOf('-->')
      if (e === -1) return ''
      out = out.slice(e + 3); inHtml = false
    }
    for (;;) {
      const b = out.indexOf('<!--')
      if (b === -1) break
      const e = out.indexOf('-->', b + 4)
      if (e === -1) { out = out.slice(0, b); inHtml = true; break }
      out = out.slice(0, b) + out.slice(e + 3)
    }
    return out
  }).join('\n')
}
```

- [ ] **Step 2: 写核查项抽取（从段起始行往下收到下一个段边界）**

```js
// 核查项形态：段标题行之后，到「下一个同级或更高级标题 / 下一个 Acceptance 段起始行」之前的列表项
// ⚠️ 只收列表项（- / * / 数字.），⛔ 不收散文行 —— dump 的 checkItems 计数就是这么数的，
//    两边必须一致，否则 must-hit-0 会红（那正是它存在的目的）
const RE_LIST_ITEM = /^\s*(?:[-*]\s+|\d+[.)]\s+)(.+)$/

function extractItems(strippedLines, startLine, endLine) {
  const items = []
  for (let i = startLine; i < endLine && i < strippedLines.length; i++) {
    const m = RE_LIST_ITEM.exec(strippedLines[i])
    if (m) items.push({ line: i + 1, text: m[1].trim() })
  }
  return items
}
```

- [ ] **Step 3: 写控制 —— 抽取面必须与 dump 对齐**

```js
const CONTROLS_T1 = [
  {
    id: 'must-hit-0/itemcount-matches-dump',
    // 🔴 本 Task 唯一真正的判据：抽出的核查项条数必须与 dump 的 checkItems 逐条相同。
    // 不同 ⇒ 抽取面没对齐（第 27 条推论三第 ④ 种 0），⛔ 后面所有分类都不可信
    run: (ctx) => {
      const bad = ctx.sections.filter((s) => s.items.length !== s.dumpCheckItems)
      return { pass: bad.length === 0, detail: `mismatched sections = ${bad.length}: ${bad.slice(0, 5).map((b) => `${b.id}(${b.items.length}≠${b.dumpCheckItems})`).join(', ')}` }
    },
  },
  {
    id: 'must-hit-1/ids-frozen',
    // N74 那条：id 必须与 dump 逐字相同、且集合大小 = 67
    run: (ctx) => {
      const mine = ctx.sections.map((s) => s.id).sort()
      const theirs = ctx.dumpUnclassifiedIds.slice().sort()
      return { pass: mine.length === 67 && JSON.stringify(mine) === JSON.stringify(theirs), detail: `mine=${mine.length} theirs=${theirs.length} equal=${JSON.stringify(mine) === JSON.stringify(theirs)}` }
    },
  },
  {
    id: 'must-not-hit-0/fenced-lines-never-collected',
    // §3.3 那两次栽的形态：代码围栏里的列表项 ⛔ 不许被当成核查项
    // 判据：任一核查项正文若原样出现在某个 ``` 围栏内 ⇒ 红
    run: (ctx) => ({ pass: ctx.fencedCollisions === 0, detail: `items collected from inside fences = ${ctx.fencedCollisions}` }),
  },
  {
    id: 'must-hit-2/zero-item-sections-are-really-empty',
    // 🔴 §30.5.2：10 条 itemCount=0 是「零输出」⇒ 先证不是抽取器坏了。
    // 判据：这 10 条的段区间里【任何形态的非空行】数必须 > 0（段本身有内容，只是没有列表项）
    run: (ctx) => {
      const zeros = ctx.sections.filter((s) => s.items.length === 0)
      const suspicious = zeros.filter((s) => s.nonEmptyLinesInRange === 0)
      return { pass: zeros.length === 10 && suspicious.length === 0, detail: `zero-item sections = ${zeros.length} (expect 10), of which structurally empty = ${suspicious.length}` }
    },
  },
]
```

- [ ] **Step 4: 跑控制，确认实现前是红的**

Run: `node metrics/unclassified-fix-triage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-b3728d38 --dump subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json --self-check; echo "exit=$?"`
Expected: `exit=1`。⛔ 不接管道。

- [ ] **Step 5: 实现主流程，跑到 4 条控制全绿**

Run: 同上
Expected: `exit=0`，且 `must-hit-0` detail 逐字打印 `mismatched sections = 0`。

🔴 **`must-hit-0` 红了怎么办**：说明抽取口径与闸不同。⛔ **不许调 67 那个数去迁就** ——
去对齐**抽取器**（多半是列表项正则或段边界）。若三次调整仍对不齐 ⇒ **停下，把差额逐条登记**，
本计划降级（见「降级路径」）。

- [ ] **Step 6: Commit**

```bash
git add metrics/unclassified-fix-triage.mjs
git commit -m "feat(metrics): 67 条 unclassified 的核查项正文抽取 —— 与 dump 逐条对齐 + 零项段判别力控制"
```

---

## Task 2: F3 判据 + **金标对照**（🔴 本计划的成立与否闸门）

**为什么**：分类的难点只有一个 —— 「这条核查项**可不可能**被程序检查」。
这是一个**判据**，⛔ 不是一句总结（AGENTS §4）。而 DS 已经替我们标注好了两组样本：
**32 条已具名豁免**（DS 判定不可机检）与 **27 条已 gated**（DS 判定可机检）。
⇒ 判据先在这 59 条上验，验不过就**不许**拿去分那 67 条。

**Files:**
- Modify: `metrics/unclassified-fix-triage.mjs`

**Interfaces:**
- Consumes: Task 1 的 `sections` 结构；另需对 `state==='not-machine-checkable'` 与 `state==='gated'` 的段
  也跑一遍 Task 1 的抽取（⇒ Task 1 的抽取**不能只对 unclassified 跑**，要对全 126 段跑）。
- Produces: `goldStandard: { recall, recallN, recallD, falsePositive, fpN, fpD, threshold, verdict }`

- [ ] **Step 1: 🔴 预注册阈值（⛔ 先写死在代码里，再跑；不许跑完再定）**

```js
// 预注册（AGENTS §2 度量纪律）：阈值写死在源码里，先提交，再跑。
// ⛔ 跑完再调阈值 = 把判据调成必过 = 恒绿 = 零信息（第 25 条）
const GOLD = {
  // F3 判据在「DS 已判不可机检」的 32 条上的召回率下界
  MIN_RECALL: 0.75,      // ≥ 24/32
  // F3 判据在「DS 已判可机检」的 27 条上的误报率上界
  MAX_FALSE_POSITIVE: 0.26, // ≤ 7/27
}
```

- [ ] **Step 2: 写 F3 词法判据（「要人/AI 自报一句话」= 不可机检的核心形态）**

判据来自逐字读那 32 条豁免理由后归纳的形态，**每条模式都注明它来自哪条豁免理由**：

```js
// F3 = 结构上不可机检。四种形态（⛔ 判的是「被测物是什么」，不是「措辞像不像」）
const F3_PATTERNS = [
  // ① 被测物是「对话里的一句显式声明」——来自 code-conventions#Acceptance~2/~3/~5 的豁免理由逐字
  { id: 'self-declaration', re: /(显式声明|必含一行|必含一次|response 顶部|conversation 内可见|写第一行.*之前|开始前.*声明)/ },
  // ② 被测物是「答得出的问题」——来自 code-conventions#Acceptance 的豁免理由逐字「要作者答得出的问题」
  { id: 'author-answerable', re: /(答得出|说得出|能说明|理由是什么|为什么这么)/ },
  // ③ 被测物是主观判断/审美——无客观取值面
  { id: 'subjective', re: /(合理|恰当|美观|清晰易读|符合直觉|看起来|视觉上舒适)/ },
  // ④ 被测物是「问不问用户」——来自 code-conventions#Acceptance~5 的豁免理由逐字
  { id: 'ask-user', re: /(先问用户|向用户确认|ambiguous.*ask|确认后再)/ },
]

function isF3(items) {
  const hits = []
  for (const it of items) for (const p of F3_PATTERNS) if (p.re.test(it.text)) hits.push({ line: it.line, criterion: p.id })
  // 口径：段内**过半**核查项命中才判 F3（⛔ 不是「命中一条就算」——那会把混合段整段误判）
  const distinct = new Set(hits.map((h) => h.line)).size
  return { f3: items.length > 0 && distinct * 2 > items.length, hits, distinct, total: items.length }
}
```

- [ ] **Step 3: 写金标对照控制**

```js
const CONTROLS_T2 = [
  {
    id: 'must-hit-3/gold-recall-on-exempted',
    // 32 条 DS 已判「不可机检」的段，F3 判据应当认出至少 75%
    run: (ctx) => ({ pass: ctx.gold.recall >= GOLD.MIN_RECALL, detail: `recall = ${ctx.gold.recallN}/${ctx.gold.recallD} = ${ctx.gold.recall.toFixed(3)} (min ${GOLD.MIN_RECALL})` }),
  },
  {
    id: 'must-not-hit-2/gold-fp-on-gated',
    // 27 条 DS 已判「可机检」的段，F3 判据误报应 ≤ 26%
    run: (ctx) => ({ pass: ctx.gold.falsePositive <= GOLD.MAX_FALSE_POSITIVE, detail: `FP = ${ctx.gold.fpN}/${ctx.gold.fpD} = ${ctx.gold.falsePositive.toFixed(3)} (max ${GOLD.MAX_FALSE_POSITIVE})` }),
  },
  {
    id: 'must-hit-4/criterion-has-discriminating-power',
    // 🔴 §30.5.2：判据必须真的在区分两组，⛔ 不许「两组都判 F3」或「两组都不判」。
    // 恒真/恒假的判据在两组上读数相同 ⇒ 零信息（第 25 条「恒红=零信息」的同轴）
    run: (ctx) => ({ pass: ctx.gold.recall - ctx.gold.falsePositive >= 0.5, detail: `recall - FP = ${(ctx.gold.recall - ctx.gold.falsePositive).toFixed(3)} (min 0.5)` }),
  },
]
```

- [ ] **Step 4: 跑金标对照**

Run:
```bash
node metrics/unclassified-fix-triage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-b3728d38 \
  --dump subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json --self-check
echo "exit=$?"
```
Expected: `exit=0`，三条控制全绿，且逐字打印 `recall = N/32` 与 `FP = N/27`。

🔴 **若 `must-hit-3` 或 `must-not-hit-2` 红了** —— 这是**真实结论，⛔ 不是失败**：
说明「可不可机检」这件事**没有词法判据**。此时按「降级路径」走，⛔ 不许放宽阈值重跑。

⚠️ **若读数恰好落在阈值边界上**（`recall === 0.75` 或 `FP === 0.26`），按 Global Constraint 4
补一次扰动验证：随机去掉一条 `F3_PATTERNS` 再跑，读数必须**下降**；不降 ⇒ 该模式是死码，登记后删除。

- [ ] **Step 5: Commit（⚠️ 无论绿红都提交 —— 红也是结论）**

```bash
git add metrics/unclassified-fix-triage.mjs
git commit -m "feat(metrics): F3 可机检性判据 + 金标对照（32 已豁免召回 / 27 已 gated 误报）"
```

---

## Task 3: 全分类 —— `fixClass` × `blockers` 两个正交维度

**为什么**：Task 2 只解决了 F3（不可机检）。剩下的还要分「可机检但要不要新写闸」「段里根本没核查项」，
并且**独立地**标出每条的前置阻塞。⚠️ 第 22 条推论三：两个正交维度**必须分开存**，焊接就会读错。

**Files:**
- Modify: `metrics/unclassified-fix-triage.mjs`
- Create: `subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json`（脚本产出）
- Create: `reports/unclassified-fix-triage-b3728d38.md`（脚本产出）

**Interfaces:**
- Consumes: Task 1 的 `sections`、Task 2 的 `isF3()`。
- Produces:
  - JSON 顶层：`auditId: 'unclassified-fix-triage'` · `subjectSha: 'b3728d38'` · `pin` · `dirty` · `checkedAt` ·
    `gold` · `counts: { F1, F2, F3, F4 }` · `blockerCounts: { none, anchor, blind, both }` ·
    `rows: Array<{ id, file, line, heading, form, fixClass, criterion, confidence, blockers, exemptionRationale }>` · `controls`
  - `fixClass` 闭集：`'F1'` · `'F2'` · `'F3'` · `'F4'`
  - `blockers` 元素闭集：`'N-D1-anchor'` · `'ACC_RE-blind'`

- [ ] **Step 1: 写四类的判据（⛔ 判据顺序即优先级，写死）**

```js
// 四类修法（⛔ 每条恰好归一类 —— 由 must-hit-5 钉住）
//   F4 · 段里没有核查项（itemCount === 0）⇒ 先补写核查项，否则这条根本无从检查
//        ⚠️ 优先级最高：没有核查项就谈不上「可不可机检」
//   F3 · 结构上不可机检（Task 2 判据）⇒ 建议走具名豁免，与已有 32 条同轨
//   F1 · 可机检，且**同文件已有闸在扫**（同 file 有 gated 段）⇒ 建议扩已有闸 + 补 covers-acceptance 自陈
//   F2 · 可机检，但同文件**无闸在扫** ⇒ 需要新写一支闸（代价高，建议排期）
function classify(sec, gatedFilesSet) {
  if (sec.items.length === 0) return { fixClass: 'F4', criterion: 'no-check-items', confidence: 'high' }
  const f3 = isF3(sec.items)
  if (f3.f3) return { fixClass: 'F3', criterion: f3.hits.map((h) => h.criterion).join('+'), confidence: f3.distinct === f3.total ? 'high' : 'medium' }
  if (gatedFilesSet.has(sec.file)) return { fixClass: 'F1', criterion: 'machine-checkable + existing gate on same file', confidence: 'medium' }
  return { fixClass: 'F2', criterion: 'machine-checkable + no gate on same file', confidence: 'medium' }
}

// 🔴 blockers 独立算 —— ⛔ 不参与 fixClass 的判定（正交，第 22 条推论三）
function blockersOf(sec) {
  const b = []
  // 匿名锚点：标题逐字就是 Acceptance（id 带 ~N）⇒ 编号一漂，covers-acceptance 自陈就指错
  if (/^Acceptance\b/.test(sec.heading.trim()) || /~\d+$/.test(sec.id)) b.push('N-D1-anchor')
  // 闸结构上看不见 ⇒ 必须先让 ACC_RE 认得这三种形态（撞 N-D1 的顺序坑）
  if (sec.blindSpot) b.push('ACC_RE-blind')
  return b
}
```

- [ ] **Step 2: 给 F3 每条生成 `exemptionRationale` 草稿**

🔴 F3 等于建议「别自动检查了」，owner 必须能逐条判理由站不站得住（见上方 §8 纪律第 3 条）：

```js
// 沿用已有 32 条豁免的书写体例（「段级。<被测物是什么>。<为什么不可机检>」）
function draftRationale(sec, f3) {
  const sample = f3.hits.slice(0, 2).map((h) => sec.items.find((i) => i.line === h.line)?.text?.slice(0, 60)).filter(Boolean)
  return `段级。${sec.heading.slice(0, 40)} 的 ${sec.items.length} 条核查项中 ${f3.distinct} 条的被测物是` +
    `「${f3.hits.map((h) => h.criterion).join(' / ')}」形态，例如：${sample.join(' · ')}。` +
    `⇒ 无客观取值面可扫。⚠️ 本理由由量具草拟，待 DS owner 复核。`
}
```

- [ ] **Step 3: 写归属完整性控制（🔴 N74 那条教训的落地）**

```js
const CONTROLS_T3 = [
  {
    id: 'must-hit-5/every-row-exactly-one-class',
    // N74：三个 counts 碰巧全对但逐条归属全错。⇒ 只验总数不够，要验 id 集合逐字相等
    run: (ctx) => {
      const sum = ctx.counts.F1 + ctx.counts.F2 + ctx.counts.F3 + ctx.counts.F4
      const ids = new Set(ctx.rows.map((r) => r.id))
      return { pass: sum === 67 && ids.size === 67 && ctx.rows.every((r) => ['F1', 'F2', 'F3', 'F4'].includes(r.fixClass)), detail: `sum=${sum} distinctIds=${ids.size} (both expect 67)` }
    },
  },
  {
    id: 'must-hit-6/blocker-counts-match-known-facts',
    // 已实测的两个数：blindSpot 恰好 8 条；匿名段恰好 33 条。对不上 ⇒ blockers 判据漂了
    run: (ctx) => {
      const blind = ctx.rows.filter((r) => r.blockers.includes('ACC_RE-blind')).length
      const anchor = ctx.rows.filter((r) => r.blockers.includes('N-D1-anchor')).length
      return { pass: blind === 8 && anchor === 33, detail: `ACC_RE-blind = ${blind} (expect 8), N-D1-anchor = ${anchor} (expect 33)` }
    },
  },
  {
    id: 'must-not-hit-3/blockers-never-decide-class',
    // 🔴 正交性钉子：把所有 blockers 清空重跑一遍，fixClass 必须逐条不变。
    //    变了 ⇒ 两个维度焊接了（第 22 条推论三）
    run: (ctx) => ({ pass: ctx.orthogonality.identical === true, detail: JSON.stringify(ctx.orthogonality) }),
  },
  {
    id: 'must-not-hit-4/no-class-is-everything',
    // 恒态检测（第 25 条）：任何一类占满 67 或占 0，都说明判据没有判别力
    run: (ctx) => {
      const v = [ctx.counts.F1, ctx.counts.F2, ctx.counts.F3, ctx.counts.F4]
      return { pass: v.every((n) => n > 0 && n < 67), detail: `F1/F2/F3/F4 = ${v.join('/')}` }
    },
  },
]
```

- [ ] **Step 4: 跑全控制（Task 1–3 共 11 条）**

Run:
```bash
node metrics/unclassified-fix-triage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-b3728d38 \
  --dump subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json --self-check
echo "exit=$?"
```
Expected: `exit=0`，11 条全绿。

⚠️ **`must-not-hit-4` 红了是真结论**：说明四类里有一类是空的或吃满了 —— 分类维度选错了，
按「降级路径」办，⛔ 不许硬凑出四类都非空。

- [ ] **Step 5: 出报告 + 确认 pin 没被动**

```bash
node metrics/unclassified-fix-triage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-b3728d38 \
  --dump subjects/tvu-ds/inventory/acceptance-coverage-b3728d38.json \
  --out subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json \
  --md  reports/unclassified-fix-triage-b3728d38.md
echo "exit=$?"
git -C ~/.ai-ds-lab/pins/tvu-ds-b3728d38 status --short
```
Expected: `exit=0`；`git status --short` **无输出**（pin 一个字节没动，§1）。

- [ ] **Step 6: Commit**

```bash
git add metrics/unclassified-fix-triage.mjs subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json reports/unclassified-fix-triage-b3728d38.md
git commit -m "feat(metrics): 67 条逐条归类 —— fixClass × blockers 两维正交 + 归属完整性钉子"
```

---

## Task 4: 处方 + status 登记

**为什么**：量具产出的是**归类**，处方才是**修法**。且 §28.9 那条纪律（判出即当场回改，⛔ 不只写报告）
要求把结论落进 owner 能直接执行的形态。

**Files:**
- Create: `proposals/2026-08-31-unclassified-67-fix-directions.md`
- Modify: `docs/round2-status.md`（追加 **N80**）
- Modify: `reports/index.html`（重建）

**Interfaces:**
- Consumes: `subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json` 的 `rows` / `counts` / `blockerCounts` / `gold`。
- Produces: 无下游 Task。

- [ ] **Step 1: 写处方骨架（沿用既有处方体例）**

对齐 `proposals/2026-08-28-variant-axis-rule-gap.md` 的结构，必含五段：

```markdown
## §0 本处方**不覆盖**什么（⛔ 边界，不是 TODO）
## §1 事实读数（量具 + 命令 + 金标对照读数）
## §2 四类修法 —— 每类：判据 / 修法动作 / 代价 / 前置依赖
## §3 逐条索引（67 行：id · 类 · criterion · confidence · blockers）
## §4 执行状态栏（🔴 §28.9 判出的结构性缺口：处方必须自带此栏）
```

🔴 **§4 执行状态栏不许省** —— §28.9 逐字判出「处方结构性没有『执行状态』栏」是回改存量缺口的**根因**。

- [ ] **Step 2: 写四类的修法动作（每类一段，含代价与前置）**

每类必须写满四栏，⛔ 不许只给方向不给代价：

| 栏 | 内容要求 |
|---|---|
| 判据 | 逐字给出量具用的判据（让 DS 能复核为什么这么分） |
| 修法动作 | 具体到「改哪个文件、加什么」 |
| 代价 | 给出条数 × 每条大致工作量，⛔ 不写「较小/较大」 |
| 前置依赖 | 引 `blockers`，逐字写明不解前置会发生什么 |

⚠️ F1 那类的前置必须逐字写明 **N-D1 的顺序坑**：先放宽 `ACC_RE` 会让 3 条现存自陈指到别的规则上
（体检报告第五节第 1 条 / N74 同源）。

- [ ] **Step 3: 生成 §3 逐条索引（⛔ 从 JSON 生成，不手写）**

```bash
node -e '
const rows=JSON.parse(require("fs").readFileSync("subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json","utf8")).rows;
console.log("| # | id | 类 | 判据 | 置信 | 前置 |");
console.log("|:-:|---|:-:|---|:-:|---|");
rows.forEach((r,i)=>console.log(`| ${i+1} | \`${r.id}\` | **${r.fixClass}** | ${r.criterion} | ${r.confidence} | ${r.blockers.join(" · ")||"—"} |`));
' > "$SCRATCH/idx.md"; echo "exit=$?"; wc -l "$SCRATCH/idx.md"
```

Expected: `exit=0`，`wc -l` = **69**（表头 2 行 + 67 条）。
⚠️ `$SCRATCH` 指 session scratchpad 目录，⛔ 别写 `/tmp`。

- [ ] **Step 4: 逐条核对处方索引与量具 JSON 一致（⛔ 不靠眼睛看）**

```bash
node -e '
const fs=require("fs");
const rows=JSON.parse(fs.readFileSync("subjects/tvu-ds/inventory/unclassified-fix-triage-b3728d38.json","utf8")).rows;
const md=fs.readFileSync("proposals/2026-08-31-unclassified-67-fix-directions.md","utf8");
const missing=rows.filter(r=>!md.includes(r.id));
console.log("rows:",rows.length,"missing from proposal:",missing.length, missing.slice(0,5).map(r=>r.id).join(", "));
process.exit(missing.length===0 && rows.length===67 ? 0 : 1)'
echo "exit=$?"
```
Expected: `exit=0`，`missing from proposal: 0`。
🔴 这一步钉的是「67 条**全都**进了处方」—— ⛔ 光看总数不够（N74 那条教训：总数对、逐条错）。

- [ ] **Step 5: 登记 N80（🔴 必须写明它推翻了什么）**

在 `docs/round2-status.md` §30.5 表格末尾追加一行，措辞必含三点：

1. 🔴 **本条推翻了 §30.2 第 3 条与 §30.4 冻结**，owner 2026-08-31 拍定；
2. ⚠️ 顺带订正：此前把「现行推荐」误记为 §30.2 **第 2 条**（那条讲的是 14 件待拍打包归档，与 67 条无关）；
3. 解冻范围**严格限定在已登记的 67 条**，⛔ 未新开评审格、未扩大扫描面。

同时把金标对照读数（`recall = N/32` · `FP = N/27`）逐字写进去 —— 它是「分类判据成不成立」的**唯一凭据**。

- [ ] **Step 6: 重建索引 + Commit**

```bash
node metrics/build-report-index.mjs; echo "exit=$?"
git add proposals/2026-08-31-unclassified-67-fix-directions.md docs/round2-status.md reports/index.html
git commit -m "docs(proposal): 67 条 unclassified 四类修法方向 + 逐条索引（N80，推翻 §30.4 冻结）"
```

---

## 🔴 降级路径（⛔ 不是「失败」，是预注册的另一个结局）

本计划有**两个**判据可能不成立。⚠️ 两者都**不许**靠放宽阈值绕过：

| 触发 | 含义 | 降级动作 |
|---|---|---|
| Task 2 `must-hit-3` / `must-not-hit-2` / `must-hit-4` 红 | 「可不可机检」**没有词法判据** —— 这本身是一条实质结论 | 处方降级为**逐条属性表**（id / 形态 / 核查项条数 / 核查项正文 / blockers），⛔ 不给 `fixClass`；并在 N80 里如实登记「自动分类判据不成立，逐条修法需人工判读，代价 67 × 人工」 |
| Task 3 `must-not-hit-4` 红 | 四类里有一类为空或吃满 67 ⇒ 分类维度选错 | 报告里给出**实际分布**，把空/满的那一类合并或拆开重定义，重跑一次；仍红则同上降级 |

🔴 **两种降级都算 Task 完成**，⛔ 不许为了让控制变绿去改 67 那个数、去调阈值、或去手工纠正个别条目的归类。

---

## Self-Review 记录

**1. Spec 覆盖**：owner 交接「给 67 条 unclassified 逐条写修法」—— Task 1（抽正文）→ Task 2（判据 + 金标）
→ Task 3（逐条归类 67/67）→ Task 4（处方给修法动作 + 逐条索引）。✅ 无遗漏。
owner 拍定的「推翻冻结」在「owner 决策留痕」与 Task 4 Step 5 各留一次痕。

**2. Placeholder 扫描**：无 TBD / TODO / 「适当处理」。所有阈值（0.75 / 0.26 / 0.5）、所有期望值
（67 / 8 / 33 / 10 / 32 / 27）均为具体数字。降级路径写明了触发条件与动作。

**3. 类型一致性**：`fixClass` / `blockers` / `confidence` / `criterion` / `exemptionRationale` 五个字段
在 Task 3 Interfaces 块里逐字定义，Task 4 Step 3/4 引用的正是这些名字。
`isF3()` 在 Task 2 定义、Task 3 Step 1 调用，返回结构 `{ f3, hits, distinct, total }` 两处一致。

**4. 与 Task 1 的口径耦合**：Task 2 的金标需要对**全 126 段**（不止 67 条）抽核查项 ——
已在 Task 2 Interfaces 里显式写明「Task 1 的抽取不能只对 unclassified 跑」。✅ 已消解。

---

## 覆盖边界（⛔ 这是边界，不是 TODO）

1. **只在 P6 = `b3728d38` 上做。** ⛔ 不做多时点 —— 67 这个数与 P6 同源，换时点要重跑。
2. **lab 给「可选修法方向」，⛔ 不裁定**（§8）。`fixClass` 是量具判读，DS owner 保留否决权。
3. **⛔ 不替 DS 改真源。** 本计划不碰 `mockup-conventions.md` / `design-process.md` / `domain-tvu.md`
   一个字节，也不改闸。产出是处方。
4. **⛔ 不判「这条规则本身该不该存在」。** 只判「它现在没人管，可以怎么让它有人管」。
5. **⛔ 不验「gated 那 27 条是不是真在管」。** 那是 §30.2 第 4 条记账的另一件事，本计划把它们**只当金标负样本**用
   —— ⚠️ 这意味着若那 27 条的自陈是假的，金标负样本就带噪。**已知边界，如实登记。**
6. **⛔ 不解 N-D1。** 编号漂移是 DS 侧的前置，本计划只把它标成 `blockers`。