# §M33 产品帧正向识别改为 `SCREEN · ` 前缀 —— 实施计划

> **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:** 把 `audit:mockup-language-isolation` 的 product 区判法从「非 reference、非 annotation ⇒ product」的**反向兜底**，改成「帧名以 `SCREEN · ` 开头（大小写敏感）⇒ product」的**正向识别**，并让「没有任何标记」如实报成 `unverified` 而不是绿。

**Architecture:** 区域判定从三分（annotation / reference / product）收敛为**二分**：`SCREEN · ` 子树内 = product（全判，⛔ 内部不再豁免），子树外 = `unmarked`（**一律不判**）。引擎层用已有的 `unverified` 档承接「本次没有任何 `SCREEN · ` 帧」，⇒ 结构上不可能出现「什么都没判却报绿」。

**Tech Stack:** Node ESM（`scripts/*.mjs`）· vitest（`tests/*.test.ts`）· Figma REST。⛔ 无新依赖。

**Spec:**
- 取证读数 = [`docs/internal/_spikes/2026-09-15-f149-product-frame-evidence-stage1.md`](../../internal/_spikes/2026-09-15-f149-product-frame-evidence-stage1.md)
- 预注册 = [`docs/internal/_spikes/2026-09-15-f149-product-frame-evidence-preregistration.md`](../../internal/_spikes/2026-09-15-f149-product-frame-evidence-preregistration.md)
- 条目 = `docs/internal/backlog.md` §INFRA-F149

## Global Constraints

- **标记形态逐字 = `SCREEN · `**（大写 `SCREEN` + 空格 + `·` + 空格），**大小写敏感**。owner 2026-09-15 拍定。
  - 判据 `PRODUCT_FRAME_RE = /^SCREEN\s*·\s/`，⛔ **不加 `/i`**。理由与闸内 `REFERENCE_SECTION_RE` 同源且已实测：
    活树里 `screen` 作为普通产品词出现 **58** 次（`full screen-2ch` / `Multi full screen/Audio lock to Input 1`），带 `/i` 会静默误豁免真产品帧。
  - 取数实测：`^SCREEN ·` 在 17 个真实产品文件的 7427 个 topFrame 上**零撞车**（对照组 `REFERENCE` 命中 3 ⇒ 探针是活的）。
- **射程 = 只对新交付生效。⛔ 存量零改动、⛔ 不做批量改名。** 老帧不带标记 ⇒ 永远不判 ⇒ 不产生违例。
- 🔴 **fail-open 必须如实登记，⛔ 不许让它长得像"覆盖了"**：忘了标 ⇒ 静默不判。
  与 2026-09-15 那次 `\bbaseline\b` 静默豁免 281 个真实产品 TEXT **同形态**，区别只在这次是有意为之。
  ⇒ 承接手段 = `markedFrames === 0` 时走 `unverified`（Task 2），⛔ 不是写一句注释了事。
- ⛔ 不动 `REFERENCE_SECTION_RE` / `REFERENCE_NODE_RE` / `ANNOTATION_RE` 的**定义**（`ANNOTATION_RE` 是三份刻意同步的副本之一，动它会漂）。
- ⛔ 不改设计稿、⛔ 不写 Figma（只 REST 读）。

## File Structure

| 文件 | 责任 | 动作 |
|---|---|---|
| `scripts/audit-mockup-language-isolation.mjs` | 判据真源：正向识别 + 遍历 + 两条 probe | 改 |
| `scripts/mockup-rules/language-isolation.mjs` | 引擎规则模块：把判据结果翻成 `unverified` / findings | 改 |
| `scripts/mockup-rules/index.mjs` | `QUARANTINE` 名单与出场条件 | 改 |
| `docs/internal/mockup-conventions.md` §M33 | 判据真源（规则文字） | 改 |
| `skills/tvu-design-mockup/SKILL.md` | 起手协议：画设备屏幕帧时命名 `SCREEN · ` | 改 |
| `tests/mockup-language-isolation.test.ts` | 判据单测（18 项 → 扩） | 改 |
| `tests/audit-mockup-language-isolation-cli.test.ts` | CLI 自印面（**5** 项 —— 初版本表误写 7，2026-09-15 评审现取 `git show e3583700:` 核实为 5，已订正） | 改 |

---

### Task 1: 判据真源改成正向识别

**Files:**
- Modify: `scripts/audit-mockup-language-isolation.mjs`（`classifyRegion` :148-156 · `findLanguageIsolationViolations` :180-232 · 头注释 :18-73）
- Test: `tests/mockup-language-isolation.test.ts`

**Interfaces:**
- Consumes: 无（本任务是链条起点）
- Produces:
  - `export const PRODUCT_FRAME_RE = /^SCREEN\s*·\s/`
  - `export function isProductFrameName(name: string): boolean`
  - `export function classifyRegion(name: string, parentRegion: 'unmarked'|'product'): 'unmarked'|'product'`
  - `findLanguageIsolationViolations(rootDoc, {allowNodeIds}) → { violations, scannedNodes, allowedSkipped, markedFrames, unmarkedSkipped }`
    ⚠️ 返回值**删掉** `referenceSkipped` / `annotationSkipped`，**新增** `markedFrames` / `unmarkedSkipped`。Task 2 依赖这四个名字。

- [ ] **Step 1: 写失败的测试**

在 `tests/mockup-language-isolation.test.ts` 顶部的 import 里加 `isProductFrameName, PRODUCT_FRAME_RE`，
并把 `describe('classifyRegion — 区域三分', …)` 整块替换为：

```ts
describe('isProductFrameName — 产品帧正向识别（§M33，owner 2026-09-15 拍定 SCREEN · 前缀）', () => {
  it('命中标记形态', () => {
    expect(isProductFrameName('SCREEN · Config-T Network')).toBe(true)
    expect(isProductFrameName('SCREEN · LCD Home')).toBe(true)
  })

  // 🔴 大小写敏感是硬要求：活树实测 `screen` 作普通产品词出现 58 次。
  // 带 /i 会把 `full screen-2ch` 这类真产品帧整棵误判 —— 方向与 REFERENCE 那条同源。
  it('⛔ 大小写敏感：小写 screen 不命中', () => {
    expect(isProductFrameName('screen · Config-T')).toBe(false)
    expect(isProductFrameName('Screen · Config-T')).toBe(false)
  })

  it('⛔ 不误吃活树里真实存在的普通产品命名', () => {
    expect(isProductFrameName('full screen-2ch')).toBe(false)
    expect(isProductFrameName('Multi full screen/Audio lock to Input 1')).toBe(false)
    expect(isProductFrameName('SCREENSHOT reference')).toBe(false)  // 无 · 分隔符
  })
})

describe('classifyRegion — 二分：标记内 product / 标记外 unmarked', () => {
  it('根与未标记帧都是 unmarked', () => {
    expect(classifyRegion('Frame 29', 'unmarked')).toBe('unmarked')
    expect(classifyRegion('PRD · V4-1864', 'unmarked')).toBe('unmarked')
  })

  it('SCREEN · 帧切 product，并向下继承', () => {
    expect(classifyRegion('SCREEN · Config-T Network', 'unmarked')).toBe('product')
    expect(classifyRegion('body', 'product')).toBe('product')
  })

  // 🔴 刻意：product 子树内**不再**放 annotation / reference 逃逸。
  // §M33 逐字「在产品 frame 里叠加的 ZH 翻译 sticky note = ❌ 不合规」
  // ⇒ 让它逃逸等于把该抓的那一类静默豁免掉。真有例外走 --allow-node。
  it('⛔ product 子树内 annotation / reference 命名不逃逸', () => {
    expect(classifyRegion('UX · Delivery Card', 'product')).toBe('product')
    expect(classifyRegion('ZH ref caption', 'product')).toBe('product')
    expect(classifyRegion('REFERENCE — old page', 'product')).toBe('product')
  })
})

describe('findLanguageIsolationViolations — 未标记一律不判', () => {
  it('没有任何 SCREEN · 帧 ⇒ markedFrames=0 且 scannedNodes=0、零违例', () => {
    const tree = frame('0:1', 'Page', [
      frame('0:2', 'PRD · V4-1864', [text('0:3', 'body', '中文注释')]),
      frame('0:4', 'Frame 29', [text('0:5', 'label', '中文也不判')]),
    ])
    const r = findLanguageIsolationViolations(tree)
    expect(r.markedFrames).toBe(0)
    expect(r.scannedNodes).toBe(0)
    expect(r.violations).toHaveLength(0)
    expect(r.unmarkedSkipped).toBe(2)
  })

  it('SCREEN · 帧内的中文 ⇒ 报 product-ui-han', () => {
    const tree = frame('0:1', 'Page', [
      frame('0:2', 'SCREEN · Config-T Network', [
        text('0:3', 'title', '2 路通道未选择 Receiver'),
        text('0:4', 'ok', 'Confirm'),
      ]),
      frame('0:5', 'PRD · card', [text('0:6', 'zh', '这一段不该被判')]),
    ])
    const r = findLanguageIsolationViolations(tree)
    expect(r.markedFrames).toBe(1)
    expect(r.scannedNodes).toBe(2)
    expect(r.violations.map((v: any) => v.nodeId)).toEqual(['0:3'])
    expect(r.violations[0].probe).toBe('product-ui-han')
  })

  it('SCREEN · 帧内用注释字体但无中文 ⇒ 报 product-ui-zh-font', () => {
    const tree = frame('0:1', 'Page', [
      frame('0:2', 'SCREEN · LCD', [text('0:3', 'label', 'Confirm', ZH_FONT)]),
    ])
    const r = findLanguageIsolationViolations(tree)
    expect(r.violations).toHaveLength(1)
    expect(r.violations[0].probe).toBe('product-ui-zh-font')
  })

  it('--allow-node 仍能豁免 product 区个别节点', () => {
    const tree = frame('0:1', 'Page', [
      frame('0:2', 'SCREEN · LCD', [text('0:3', 'title', '中文')]),
    ])
    const r = findLanguageIsolationViolations(tree, { allowNodeIds: new Set(['0:3']) })
    expect(r.violations).toHaveLength(0)
    expect(r.allowedSkipped).toBe(1)
  })
})
```

- [ ] **Step 2: 跑测试确认它失败**

Run: `npx vitest run tests/mockup-language-isolation.test.ts`
Expected: FAIL —— `isProductFrameName is not a function`（以及 classifyRegion 断言不符）。
⛔ 别 pipe 到 `head` 再取 `$?`。

- [ ] **Step 3: 改判据真源**

把 `scripts/audit-mockup-language-isolation.mjs` 里 `classifyRegion`（:148-156）替换为：

```js
/**
 * 产品帧正向识别（§M33，owner 2026-09-15 拍定）。
 *
 * ⛔ **不加 `/i`** —— 与 REFERENCE_SECTION_RE 同源的理由，且已实测：
 *   活树 17 个真实产品文件的 7427 个 topFrame 上，`screen` 作普通产品词出现 58 次
 *   （`full screen-2ch` / `Multi full screen/Audio lock to Input 1`）。
 *   带 /i ⇒ 这些真产品帧整棵被误认成"已标记"，而真正的漏标帧仍然漏 —— 两头都错。
 * 取数实测：`^SCREEN ·` 在同一分母上**零撞车**（对照组 REFERENCE 命中 3 ⇒ 探针是活的）。
 */
export const PRODUCT_FRAME_RE = /^SCREEN\s*·\s/

/** 产品帧命名判定。导出供测试逐形态核。 */
export function isProductFrameName(name) {
  return PRODUCT_FRAME_RE.test(name || '')
}

/**
 * 区域二分（⛔ 2026-09-15 起不再是三分）。
 *
 * 🔴 **默认档是 `unmarked`，⛔ 不是 product** —— 这是本次改动的全部要点。
 *   旧实现「非 reference、非 annotation ⇒ product」在 7 个真实交付页上假阳性 66/66 = 100%，
 *   根因是它把「没认出来」当成了「是产品」。现在没认出来就是没认出来，不判。
 *
 * ⛔ **product 子树内不再放 annotation / reference 逃逸**：§M33 逐字
 *   「在产品 frame 里叠加的 ZH 翻译 sticky note」本身就是违规 ⇒ 放它逃逸 = 把该抓的静默豁免掉。
 *   真有例外走 `--allow-node`（显式、可复核），⛔ 不靠命名猜。
 */
export function classifyRegion(name, parentRegion) {
  if (parentRegion === 'product') return 'product'
  return isProductFrameName(name) ? 'product' : 'unmarked'
}
```

再把 `findLanguageIsolationViolations`（:180-232）的计数与遍历替换为：

```js
export function findLanguageIsolationViolations(rootDoc, { allowNodeIds = new Set() } = {}) {
  const violations = []
  let scannedNodes = 0
  let allowedSkipped = 0
  let markedFrames = 0
  let unmarkedSkipped = 0

  function visit(node, parentRegion) {
    if (!node) return
    const region = classifyRegion(node.name, parentRegion)

    // 本节点自己就是标记帧（父不是 product ⇒ 这是子树根）⇒ 计一个已标记帧
    if (region === 'product' && parentRegion !== 'product') markedFrames++
    if (region === 'unmarked' && node.type === 'FRAME') unmarkedSkipped++

    if (node.type === 'TEXT' && region === 'product') {
      if (allowNodeIds.has(node.id)) {
        allowedSkipped++
      } else {
        scannedNodes++
        const chars = node.characters || ''
        const hanChars = Array.from(chars).filter((c) => HAN_RE.test(c))
        const family = fontFamilyOf(node)
        const sample = `${chars.trim().slice(0, 60)}${chars.trim().length > 60 ? '…' : ''}`

        if (hanChars.length) {
          violations.push({ probe: 'product-ui-han', nodeId: node.id, name: node.name || '(text)', han: hanChars.length, font: family || '(unknown)', sample })
        } else if (family === ZH_FONT) {
          violations.push({ probe: 'product-ui-zh-font', nodeId: node.id, name: node.name || '(text)', han: 0, font: family, sample })
        }
      }
    }

    for (const c of node.children || []) visit(c, region)
  }

  visit(rootDoc, 'unmarked')
  return { violations, scannedNodes, allowedSkipped, markedFrames, unmarkedSkipped }
}
```

⚠️ `isReferenceName` / `REFERENCE_SECTION_RE` / `REFERENCE_NODE_RE` 的**定义保留不删**（`bilingual-spacing` 仍在用同族语义，且删了 Task 1 的回归面会丢）；但本闸不再调用 `isReferenceName`。在其 JSDoc 上加一行：
`// ⚠️ 2026-09-15 起本闸不再调用它（区域改二分）；保留是因为定义被测试与 bilingual-spacing 的语义共享。`

- [ ] **Step 4: 跑测试确认通过**

Run: `npx vitest run tests/mockup-language-isolation.test.ts`
Expected: PASS，全部项通过。

- [ ] **Step 5: 更新 CLI 自印面并跑第二个测试文件**

`main()` 里的三分自印行（:277）替换为：

```js
console.log(`  已标记产品帧 ${r.markedFrames} 个 · 判过的 TEXT ${r.scannedNodes} 个 · 未标记帧 ${r.unmarkedSkipped} 个（一律不判）`)
if (!r.markedFrames) {
  console.log(`  ⚠️ 本节点下 0 个 \`SCREEN · \` 帧 ⇒ 本闸未判任何东西。⛔ 此处的"无违例"不携带信息。`)
}
```

Run: `npx vitest run tests/audit-mockup-language-isolation-cli.test.ts`
按报错逐项把断言里的「区域三分：annotation … reference … product …」改成新自印文案。

- [ ] **Step 6: Commit**

```bash
git add scripts/audit-mockup-language-isolation.mjs tests/mockup-language-isolation.test.ts tests/audit-mockup-language-isolation-cli.test.ts
git commit -m "feat(gate): §M33 产品帧改正向识别 —— SCREEN · 前缀，未标记一律不判"
```

---

### Task 2: `markedFrames === 0` 走 `unverified`，⛔ 不报绿

**Files:**
- Modify: `scripts/mockup-rules/language-isolation.mjs`
- Test: `tests/mockup-conformance-report.test.ts`（既有回归面）

**Interfaces:**
- Consumes: Task 1 的 `{ violations, scannedNodes, allowedSkipped, markedFrames, unmarkedSkipped }`
- Produces: 规则结果里 `unverified = { count: 1, reasons: [...] }`（当 `markedFrames === 0`）

🔴 **这一步是 fail-open 的承接手段，⛔ 不可省**。没有它，一个「一个 `SCREEN · ` 都没标」的交付会拿到
`exitCode 0` + `0 violations` = **一个不携带信息的绿**，而那正是本仓 `variant-axis` 已经付过学费的形态
（`unknown` 臂 exit 0，头注释逐字「⛔ 别把 exit 0 读成族与主题匹配」）。

- [ ] **Step 1: 写失败的测试**

在 `tests/mockup-conformance-report.test.ts` 末尾加：

```ts
it('language-isolation：0 个 SCREEN · 帧 ⇒ 落 unverified，⛔ 不是 pass', async () => {
  const mod = (await import('../scripts/mockup-rules/language-isolation.mjs')).default
  const ctx = {
    payload: async () => ({ document: { id: '0:1', type: 'FRAME', name: 'Frame 29', children: [] } }),
    flag: () => undefined,
    nodeUrl: 'https://figma.com/x',
  }
  const r = await mod.run(ctx as any)
  expect(r.unverified).not.toBeNull()
  expect(r.unverified.count).toBe(1)
  expect(r.checkedUnits).toBe(0)
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `npx vitest run tests/mockup-conformance-report.test.ts -t 'SCREEN'`
Expected: FAIL —— `r.unverified` 是 `null`。

- [ ] **Step 3: 改规则模块**

把 `return ruleResult({...})` 里的 `unverified: null` 换成：

```js
      // 🔴 fail-open 的承接：一个 `SCREEN · ` 都没有 ⇒ 本规则**什么都没判过**。
      //   报绿会让「没标记」和「标了且干净」长得一模一样 —— 而这两件事天差地别。
      //   复用引擎已有的 unverified 档（同 library-binding 无缓存 / variant-axis unknown）。
      unverified: r.markedFrames
        ? null
        : { count: 1, reasons: ['本节点下 0 个 `SCREEN · ` 产品帧 ⇒ §M33 未被判定（⛔ 不是通过）'] },
```

并把 notes 那段（三分自印）换成：

```js
    notes.push(
      `已标记产品帧 ${r.markedFrames} 个 · 判过的 TEXT ${r.scannedNodes} 个 · 未标记帧 ${r.unmarkedSkipped} 个（一律不判）`,
    )
    if (!r.markedFrames) {
      notes.push('⚠️ 0 个 `SCREEN · ` 帧 ⇒ 本规则未判任何东西，⛔ 别把"无违例"读成合规。')
    }
```

- [ ] **Step 4: 跑测试确认通过 + 跑既有回归面**

Run: `npx vitest run tests/mockup-conformance-report.test.ts tests/audit-mockup-conformance-cli.test.ts`
Expected: 全部 PASS。

- [ ] **Step 5: Commit**

```bash
git add scripts/mockup-rules/language-isolation.mjs tests/mockup-conformance-report.test.ts
git commit -m "feat(gate): language-isolation 无标记帧时落 unverified —— 堵掉不携带信息的绿"
```

---

### Task 3: 改 §M33 规则真源 + 同步闸的 `[N]` 锚

**Files:**
- Modify: `docs/internal/mockup-conventions.md` §M33 的「Acceptance」段
- Modify: `scripts/audit-mockup-language-isolation.mjs` 的 `covers-acceptance:` 行（:9）

⚠️ **这是判据真源，owner 已于 2026-09-15 拍定形态（`SCREEN · ` 前缀、大小写敏感、只对新交付生效）** ⇒ 照此落，⛔ 不再自拍。

- [ ] **Step 1: 改 Acceptance 第 1 条**

把逐字的

```
- 任何 product frame（识别：name 命中设备 mockup 模式如 `S<N>` / `M<N>` / 设备屏幕 frame） → probe 所有 TEXT 节点 `fontName.family`，**不应**出现 `Noto Sans SC`
```

替换为（**⚠️ 注意 `[N]` 不等于条目数，见下方 Step 2 的订正**）：

```
- **产品帧必须显式标记**：设备屏幕 frame 的 name 以 `SCREEN · ` 开头（**大小写敏感**，如 `SCREEN · Config-T Network`）。⛔ 只对**新交付**生效，存量零改动。
- 被标记的 product frame → probe 其子树所有 TEXT：**不应**含中文（Unicode Han），**不应**出现 `Noto Sans SC`。⛔ 子树内不再按命名豁免 annotation / reference —— §M33 反例已定「注释叠进产品 frame」本身即违规；真有例外走 `--allow-node`
- 注释 chip 内 ZH range opacity 仍走 0.45（M23 verbatim 不变）
- handoff doc 必含一行 "Product UI language: \<device locale, e.g. en-US\>"
- **机器闸**：`pnpm audit:mockup-language-isolation --file <key> [--node <id>]` —— 未标记的帧**一律不判**；若本次 0 个 `SCREEN · ` 帧，闸落 `unverified` 并显式出声，⛔ 不报绿
```

并在 Acceptance 段**之后**加一段 🔴 如实登记（⛔ 别放进 Acceptance 列表，否则核查项数又变）：

```
🔴 **本判据是 fail-open，如实登记**：忘了标 `SCREEN · ` ⇒ 该帧**静默不判**。
这与 2026-08-06 `annotation`→`\bannot`、2026-09-15 `\bbaseline\b` 静默豁免 281 个真实产品 TEXT
**是同一个形态**，区别只在这次是**有意为之** —— 代价是覆盖面从 0 起步、随新交付增长。
⛔ 别把闸的绿读成「产品 UI 是干净的」：它只说明「被标记的那些是干净的」。分母由闸自印。

⚠️ **为什么不回到反向兜底**：旧口径「非 reference、非 annotation ⇒ product」在 7 个真实交付页上
**假阳性 66/66 = 100%**（设计稿是对的、闸报错了）。读数与逐项核实见
[取证 Stage 1](_spikes/2026-09-15-f149-product-frame-evidence-stage1.md)。
⚠️ **`S<N>` / `M<N>` 已从识别口径删除** —— 实测 `S<N>` 存在但不区分产品与注释
（`state-label · S1` 同样带它），`M<N>` 命中 100% 是注释卡引用规则号（`PRD card · … (M23 canonical)`）
⇒ 照搬它会把注释卡整棵认成产品帧。
```

- [ ] **Step 2: 同步闸的 `[N]` 锚 —— 🔴 `[N]` ⛔ 不是条目数，⛔ 别按条目数去填**

`scripts/audit-mockup-language-isolation.mjs:9` 的 `[4]` 要改，但**改成几要现取、⛔ 不许算术推**。

🔴 **机制（本计划初版在这里写错过，2026-09-15 订正）**：`scripts/audit-acceptance-gate-coverage.mjs:318-321` 的
`countCheckItems()` 逐字是「`·` 分隔出的份数」= **数该条正文里 `·` 的个数 + 1**。
⇒ **正文里任何一个 `·` 都会让计数 +1，包括标记字面量 `SCREEN · ` 里那个中点。**

现值实测（改动前，`--json` 现取）：`#M33` → `state: "gated"`, `items: 4`（当前 4 条正文一个 `·` 都没有）。
新版 5 条正文里，第 1 条含 2 个 `·`、第 5 条含 1 个 ⇒ 预计 `items` 会变成 **8**，⛔ 不是 5。

⚠️ **这个 8 是「工具口径下的核查项数」，⛔ 不代表这段有 8 项要验的东西** ——
闸头注释自己逐字说过 `[N]` 是「**变更探测的锚**，不是覆盖度声明」。此处的膨胀来自字面量中点，属已知假象。

- [ ] **Step 3: 现取实际值再填 `[N]`，然后跑覆盖闸**

⛔ **别信上面那个预计的 8，也别信任何人的心算。** 先改完规则正文，再现取：

```
node scripts/audit-acceptance-gate-coverage.mjs --json > /tmp/agc.json
node -e "console.log(JSON.stringify(JSON.stringify(require('/tmp/agc.json')).match(/[^{]*M33[^}]*}/g)))"
```

读出 `#M33` 那条的 `items` 实际值，把 `[N]` 填成它。然后：

Run: `node scripts/audit-acceptance-gate-coverage.mjs`
Expected: `EXIT=0`，且自印仍为 `gated 49 · not-machine-checkable 54 · unclassified 32（BASELINE 32）`。
🔴 **若 `unclassified` 变成 33 ⇒ 立刻停手报告** —— 那意味着 `#M33` 掉出了 `gated`（自陈行被写坏），
棘轮会红，且这是「闸自陈」而非「条目数」的问题。⛔ 别靠改 BASELINE 去消红。

- [ ] **Step 4: 跑 doc-shape**

Run: `node scripts/audit-doc-shape.mjs`
Expected: `EXIT=0` PASS。⚠️ 本任务不动 `docs/STATUS.md`，S1 余量不受影响。

- [ ] **Step 5: Commit**

```bash
git add docs/internal/mockup-conventions.md scripts/audit-mockup-language-isolation.mjs
git commit -m "docs(rules): §M33 产品帧识别改 SCREEN · 前缀（owner 2026-09-15 拍）+ fail-open 如实登记"
```

---

### Task 4: 更新 `QUARANTINE` —— 旧出场条件已被路线改向作废

**Files:**
- Modify: `scripts/mockup-rules/index.mjs`（`QUARANTINE` :73-89）

🔴 **旧的 `exit` 条件现在不可能达成，⛔ 不能原样留着**：它逐字要求「新判据在 ≥ 7 页取证面上复跑：
假阳性 = 0 **且**两个 baseline 设备帧不再被整棵豁免」。而新判据下那 7 页**一个 `SCREEN · ` 都没有**
⇒ 假阳性平凡为 0、两个 baseline 帧**照样不被判**（它们没标记）⇒ 条件的后半永远不成立。

- [ ] **Step 1: 判断该出场还是留隔离**

⚠️ **这一拍是 C1/C4（放宽机检强度），⛔ agent 不自拍 —— 要 owner 拍。** 两个选项摆给 owner：
- **(a) 出场**：判据已换成正向识别 + `unverified` 兜底 ⇒ 不再会误导人去改对的东西 ⇒ 回默认规则集。
- **(b) 留隔离到有真实分母**：等某个新交付真的标了 `SCREEN · ` 帧、闸在活树上验过一次红一次绿，再出场。

⛔ 在 owner 拍板前，**本任务只改 `exit` 文字、不动 `QUARANTINE` 成员**。

- [ ] **Step 2: 改写 exit 条件**

```js
    // ⚠️ 2026-09-15 改写：原 exit 条件（「≥7 页取证面复跑假阳性=0 且两个 baseline 帧不再被整棵豁免」）
    //   已被路线改向作废 —— 新判据是正向识别，那 7 页一个 `SCREEN · ` 都没有
    //   ⇒ 假阳性平凡为 0、两个 baseline 帧照样不判（它们没标记）⇒ 后半永远不成立。
    exit: '① 判据已改正向识别（`SCREEN · ` 前缀）且 `markedFrames === 0` 落 unverified；'
        + '② 在一份**真实新交付**上验过两臂：标了 `SCREEN · ` 且含中文 ⇒ 红；同页未标记帧含中文 ⇒ 不判且自印说明；'
        + '③ owner 拍板是否回默认规则集（C1/C4，⛔ agent 不自拍）。',
```

- [ ] **Step 3: 跑引擎自印确认隔离档仍正常**

Run: `node scripts/audit-mockup-conformance.mjs --list-rules`
Expected: `language-isolation` 仍标 🚧，`RULES` 全集仍含它（⛔ 别从数组删掉 —— 删了 `--rule` 会落 unknown ⇒ exit 2）。

- [ ] **Step 4: Commit**

```bash
git add scripts/mockup-rules/index.mjs
git commit -m "docs(gate): QUARANTINE 出场条件改写 —— 旧条件已被 §M33 路线改向作废"
```

---

### Task 5: 起手协议写入 `SCREEN · ` 约定

**Files:**
- Modify: `skills/tvu-design-mockup/SKILL.md`

🔴 **这一步是 B 路线成立的前提，⛔ 不可省**：判据是 fail-open，唯一让它有分母的途径就是
「画设备屏幕帧时顺手标」。**修 §M33 至今唯一一次真实违规靠的就是改起手协议**
（2026-05-26，work-log 实证），⛔ 不是靠闸。

- [ ] **Step 1: 在 §M-LIFECYCLE 三时点的「操作前」加一行**

```markdown
- **画设备屏幕帧时命名 `SCREEN · <页面名>`**（大小写敏感，如 `SCREEN · Config-T Network`）。
  这是 §M33 产品 UI 单语闸**唯一**的识别入口 —— ⛔ 不标 = 该帧不被判（fail-open，⛔ 不是"通过"）。
  ⛔ 别给注释卡 / state-label / `BEFORE ·` 对照标签 / `demo ·` 容器加这个前缀。
```

- [ ] **Step 2: 在收尾自检清单里加一行核对**

```markdown
- [ ] 本轮新建/改动的设备屏幕帧都已标 `SCREEN · `；跑 `audit:mockup-conformance --rule language-isolation`
      后确认自印的「已标记产品帧 N 个」**N 与本轮实际的设备屏幕帧数一致**
      （⛔ N=0 时闸会落 unverified 并出声，那是"没判"不是"通过"）
```

- [ ] **Step 3: 复核 skill 与规则真源不打架**

Run: `grep -n "SCREEN ·" skills/tvu-design-mockup/SKILL.md docs/internal/mockup-conventions.md`
Expected: 两侧都命中，且措辞一致（都写「大小写敏感」）。

- [ ] **Step 4: Commit**

```bash
git add skills/tvu-design-mockup/SKILL.md
git commit -m "docs(skill): 起手协议写入 SCREEN · 产品帧标记 —— §M33 闸唯一的识别入口"
```

---

### Task 6: 活树两臂实测（⛔ 跑一遍绿不算验证）

**Files:** 无（只读验证 + 把读数补进 Stage 1 报告）

🔴 **判别力必须两臂都跑** —— 本仓 `variant-axis` 已付过学费：只跑一臂绿，分不出「判别器」与「恒绿」。

- [ ] **Step 1: 造故障臂（在 scratchpad 里构造 payload，⛔ 不写 Figma）**

写 `<scratchpad>/twoarm.mjs`：现取 `rJJjWWs51n2iFOlCIC7aYG` 的 `6907:2`，
把其中 `8589:1377`（`BEFORE · Config-T Modem settings (baseline · …)`，实测 161 个 TEXT）
的 name 在内存里改成 `SCREEN · Config-T Modem settings`，再跑 `findLanguageIsolationViolations`。

- [ ] **Step 2: 跑两臂并记读数**

| 臂 | 构造 | 期望 |
|---|---|---|
| **故障臂** | 上述改名 + 往子树注入一个含中文的 TEXT | `markedFrames=1` · `violations≥1` · `unverified=null` |
| **对照臂** | 同一棵树**不改名** | `markedFrames=0` · `violations=0` · `unverified.count=1` |

⛔ 两臂读数必须**不同**；若相同 ⇒ 判据不是判别器，回 Task 1 查。
⛔ 别 pipe 到 `head` 取 `$?`。

- [ ] **Step 3: 把两臂读数表补进 Stage 1 报告的新 §9**

⚠️ 同时**更新量具自证向量**：Task 1 改了 `findLanguageIsolationViolations` 的语义 ⇒
`docs/internal/_spikes/f149-evidence/selfcheck.mjs` 的 V3/V4 **必然全红**（那是对的，⛔ 别改期望值到能过）。
按 Stage 1 报告 §1.1 已登记的修法**拆两档**：结构档（V1 帧尺寸）保留，
内容档（V3/V4）标注「⚠️ 针对旧判据，2026-09-15 起不再适用」并停用。

- [ ] **Step 4: 跑全量回归**

Run: `npx vitest run`

🔴 **计划初版在这里写了一个会误导人的数，2026-09-15 订正**：原文写「基线 3384 passed / 14 skipped（2026-09-10 记录）」
—— 那是**九月十日的旧值**，本分支实测已是 **~3560 passed / 18 skipped**。照旧值去「逐项查差额」会查出一个不存在的问题。

⚠️ **本工作树有一个已知的环境产物红，⛔ 不是回归**：`tests/demo-renderer-react.test.ts` 报
`Failed to resolve import "@tvu/wc"`。**已现取核实：同一份测试在主工作树跑 14/14 通过** ——
worktree 的 `node_modules` 是指向主树的软链接，workspace 包 `@tvu/wc` 解析不到。
⇒ **把它当已知边界，⛔ 别去修它、⛔ 别为它改任何代码。**

**正确的判据**：除 `demo-renderer-react.test.ts` 外全绿；且 `audit-figma-env-single-source`
必须是绿的（它在 `cd14e2dd` 之前红过，那是真红、已修）。
⛔ 别把「全量有一个红」直接读成「有回归」——先问它在主工作树红不红。

- [ ] **Step 5: Commit**

```bash
git add docs/internal/_spikes/
git commit -m "test(gate): §M33 新判据两臂活树实测 + 自证向量拆结构档/内容档"
```

---

## Self-Review

**1. Spec coverage** —— 取证报告 §8 的两件待拍事项：射程（owner 已拍「只对新交付」⇒ Global Constraints）、
`M<N>` 应从 §M33 删除（⇒ Task 3 Step 1）。均有任务承接。

**2. Placeholder scan** —— 无 TBD / TODO；每个代码步都给了可直接粘贴的代码。
Task 4 Step 1 刻意**不给结论**，因为那是 owner 档（C1/C4），已显式标注并限制本任务只改文字。

**3. Type consistency** —— Task 1 Produces 的四个字段名
（`scannedNodes` / `allowedSkipped` / `markedFrames` / `unmarkedSkipped`）在 Task 2 的模块改写与
Task 6 的两臂表里逐字一致；`PRODUCT_FRAME_RE` / `isProductFrameName` / `classifyRegion` 签名在 Task 1 内自洽。

**4. 已知会红的东西（如实前置登记，⛔ 不是事后解释）** ——
① `tests/audit-mockup-language-isolation-cli.test.ts` 的三分自印断言（Task 1 Step 5 处理）；
② `selfcheck.mjs` 的 V3/V4（Task 6 Step 3 处理，⛔ 不改期望值到能过）。
