# INFRA-F110 — consumer skill 链接改目录级 symlink（消灭清单漂移，而非监控它）

> **状态**：**已实施**（2026-08-11 当日落地。`51475cf0` = Task 1 脚本 + 7 条 vitest；`323e716a` = Task 2 文案；6 个 consumer 同日完成迁移，**S1–S7 全绿**）。owner 2026-08-11 逐节确认后开工：方案 A · 零动作自动生效 · 约束只落 DS 仓库 + 各 consumer 项目目录，**不碰任何全局设置**。
>
> **落地实证**：6 个 consumer 各 13/13、无嵌套残留、`vocabulary.md` 全部解析得到；逐个 `claude -p` 探针 6/6 答「有 / 有」（未互推）；`~/.claude/` 操作面（skills / hooks / settings 校验和 / plugin 清单）零变化。`tvu-saas-dashboard` 由 §2e 的「6 条全断、一个 skill 都用不了」变为 13 个全可用。
> **判据真源**：本文件。`scripts/setup-consumer.sh` 与 `skills/setup-tvu-consumer/SKILL.md` 的头注释回指这里（F96 零引用 spec 规则）。
> **owner 已拍（别重开）**：方案 A 选定 · 方案 B（逐个 symlink + SessionStart hook）已否 · 方案 C（自动枚举 + audit gate）已否 · plugin 路线维持既有否决（项目级 opt-in 不变）。
> **实施计划**：[2026-08-11-infra-f110-consumer-skill-directory-symlink.md](../plans/2026-08-11-infra-f110-consumer-skill-directory-symlink.md)（3 个 Task，判据 S1–S7 逐条落到具体步骤）。

---

## 1. 一句话问题

`scripts/setup-consumer.sh` 第 42–45 行**硬编码了 11 个 skill 名**，而 DS 今天有 **13 个**。新增的 skill 从来不会自己进到任何 consumer 里——5 个 consumer 各漏 2 个（其中 `tvu-design-pipeline` 已漏 2 个多月），第 6 个 consumer **6 条链接全断、一个 skill 都用不了**，而 **没有任何机制会喊一声**。

---

## 2. 本轮（2026-08-11）实证

全部自跑，非引用。

### 2a. 本机 consumer 普查 —— 是 6 个，不是 3 个

不硬编码目录名，扫 `VS_Code/` 下所有含 `.claude/skills` 的目录：

| consumer | 条目数 | symlink 指向根 | 状态 |
|---|---|---|---|
| Email Template | 11 | `$TVU/skills` | 有效，缺 2 |
| MicroApps | 11 | `$TVU/skills` | 有效，缺 2 |
| NOC | 11 | `$TVU/skills` | 有效，缺 2 |
| RPS | 11 | `$TVU/skills` | 有效，缺 2 |
| TVU Pack | 11 | `$TVU/skills` | 有效，缺 2 |
| **tvu-saas-dashboard** | **6** | **`$TVU/.claude/skills`** | **6 条全断链，见 §2e** |
| **DS `skills/` 实际** | **13** | — | — |

`Claude/` 目录含 12 个**真文件**（非 symlink、不指向 DS），是 `claude-roles-config` 仓库自己的 skills，**不是 consumer**——列在这里是为了下次普查不再误判。

缺的两个：

| skill | 首次加入 DS | 性质 |
|---|---|---|
| `upstream-gate` | **2026-07-09** (`25152cad`) | 任何「做产品设计 / 画 mockup」任务的**最前置强制 gate** |
| `tvu-design-pipeline` | **2026-06-05** (`dec3b5c1`) | `@TVU 设计全流程` 编排器 |

> 日期取 `git log --diff-filter=A`（首次加入）。初稿误用 `git log -1`，拿到的是**最后触碰**该路径的 commit（07-29 / 08-04），把 `tvu-design-pipeline` 的漏链时长少算了两个月。

`scripts/setup-consumer.sh` 最后修改：**2026-05-22** (`5999bb22`)。两个 skill 都晚于它诞生 ⇒ **从未被链进任何 consumer**。`tvu-design-pipeline` 已经漏了 **2 个多月**。

**⇒ 漏掉的恰好是「动手前必跑的 gate」和「全流程编排器」——漏链的代价不是少个便利功能，是绕过了前置纪律。**

### 2b. 文案侧同一处债

`skills/setup-tvu-consumer/SKILL.md` 第 62 行仍写「11 个 skill + vocabulary.md」。清单在两处各存一份，两处都停在 5 月。

### 2c. 目录级 symlink 可行 —— 阴阳对照实测

此前无实证，不能假设 Claude Code 会遍历一个**本身是 symlink** 的 `.claude/skills` 目录。实测（`claude 2.1.196`，`-p` 非交互，`--model haiku`，空白探针目录，不使用任何工具）：

| 条件 | `upstream-gate` | `tvu-design-pipeline` | `tvu-design-mockup` |
|---|---|---|---|
| **阳**：`.claude/skills` → symlink 指向 `$TVU/skills` | 有 | 有 | 有 |
| **阴**：移除该 symlink，其余不变 | 无 | 无 | 无 |

阴性对照排除了「其实是从全局 `~/.claude/skills/` 读到的」这一混淆——该目录只有 7 个 skill，三者均不在其中。

**⇒ 目录级 symlink 成立，且是本方案唯一的技术前提。**

### 2d. `ln -sfn` 对「已存在的真目录」不会替换它 —— 实测

6 个 consumer 今天的 `.claude/skills` **全是真目录**（普查实测），这是迁移的起始状态。实测两种起始状态：

| 起始状态 | 执行 `ln -sfn $SRC .claude/skills` 后 | `.claude/skills` 本身是 symlink？ |
|---|---|---|
| 真目录（含既有内容） | 链接被建到**目录内部**：`.claude/skills/src -> $SRC` | ❌ **否** |
| 已是 symlink | 正确替换，无嵌套 | ✅ 是 |

**⇒ 脚本必须先移除旧真目录再建链接，否则迁移静默失败**（旧的 11 个 symlink 还在，外加一个多余的嵌套链接，表面看「跑成功了」）。第二行同时证明了 §8 S4 的幂等性：目标已是 symlink 时重复跑无副作用。

### 2e. tvu-saas-dashboard 的 6 条链接**全部断链**，已断了不知多久

该 consumer 建于 **2026-05-20**（早于 `setup-consumer.sh` 诞生的 05-22），用的是更早的手工链接方式，指向 **`$TVU/.claude/skills/`** —— 而 DS 的 `.claude/` 下今天只有 `agents/ hooks/ settings.json settings.local.json worktrees/`，**`skills/` 早已不存在**。

逐条实测：6 条 `test -e` **全部失败**。

| 事实 | 含义 |
|---|---|
| 该项目今天**一个 TVU skill 都用不了** | 不是「缺 2 个」，是全军覆没 |
| 没有任何人发现 | 断链只在**跑 setup 脚本时**才检测（脚本第 55 行），而它从 05-20 起就没再跑过 |

**⇒ 这条独立证明了「只在 setup 时检测」不足以维持健康。** 方案 A 之下，目录级 symlink 一旦断是**整个失效**——下次开 session 唤醒词全不 work，立刻暴露；不像现在逐条断，可以静悄悄烂两个月。

### 2f. 自证：初稿犯的正是同一个错

本 spec 初稿写「三个 consumer 全中」，因为普查时**硬编码了 `MicroApps / tvu-saas-dashboard / Email Template` 三个目录名**去扫，于是漏掉了 NOC、RPS，也没发现 saas-dashboard 的断链。

**与 `setup-consumer.sh` 硬编码 11 个 skill 名而漏掉 2 个，是同一个失效模式的两次发生**——一次在工具里，一次在写这份分析的过程里。这不是巧合，是「手写清单必然腐坏」的直接证据，也是本方案不选「维护一份自动枚举 + exclude 清单」（方案 B）的理由。

---

## 3. 根因

问题不在「清单写错了」，在于**存在两份需要保持一致的状态**：DS 有哪些 skill / consumer 链了哪些 skill。只要这两份分开存放，就永远需要一个同步动作，也就永远可能漏做。

之前的设计把力气花在**维护这份一致性**上（写清单、跑脚本），而没有质疑**为什么需要两份状态**。

---

## 4. 方案与取舍

### 选定：A — `.claude/skills` 整个目录做成单个 symlink → `$TVU/skills`

```bash
ln -sfn "$TVU/skills" ".claude/skills"
```

不再有清单，因此不存在清单漂移。DS 里加 / 删 / 改名 skill，所有 consumer 下次开 session 自动跟上，零动作。

### 已否：B — 逐个 symlink + 自动枚举 + SessionStart hook

同样零动作，但保留了「自动枚举」与「手动 exclude 清单」两个机制并存 ⇒ **漏链风险只是换成 exclude 清单腐坏的风险**，问题被挪位而非消灭。且 hook 要么进 `~/.claude/settings.json`（污染全局，与项目级 opt-in 决策冲突），要么每个项目各存一份（N 份配置漂移）。

### 已否：C — 自动枚举 + audit gate 检测漂移

需要显式重跑同步动作。owner 明确要零动作。

### 判据

**消灭问题优于监控问题。** B 和 C 都是在两份状态之间维持一致性，A 让它们变成同一份——没有清单，就没有需要 gate 去检测的东西。

---

## 5. 约束落点（owner 明确要求：不进全局设置）

| 改动 | 载体 | 入 git |
|---|---|---|
| `scripts/setup-consumer.sh` | DS 仓库 | ✅ 跟 DS 走，新机器 / 新同事 clone 即有 |
| `skills/setup-tvu-consumer/SKILL.md` | DS 仓库 | ✅ 同上 |
| 各 consumer 的 `.claude/skills` symlink | 该 consumer 项目目录 | ❌ 已被 `.gitignore` 忽略，纯本机 |

**不碰**：`~/.claude/settings.json`、`~/.claude/skills/`、任何全局 hook。

**生效范围不变**：仍是项目级 opt-in——只有跑过 setup 的 cwd 内唤醒词才 work，离开目录立即失效。

---

## 6. 已知代价（全部摊开，owner 已确认接受）

| 代价 | 今天是否成立 | 退路 |
|---|---|---|
| consumer 放不了项目专属 skill（目录被 DS 整个占用） | **6 个 consumer 现均无本地 skill**（普查实测：真文件数全为 0）⇒ 今天不痛 | 该项目单独退回逐个 symlink，或改用 plugin；不影响其他 consumer |
| 无法排除任何 skill，DS 有什么 consumer 就看到什么 | 今天 13 个全是面向 consumer 的 | 若将来出现纯内部 skill，改为 `skills/` 下分 `consumer/` 子目录，symlink 指子目录 |
| DS 里写坏一个 skill 立刻影响所有 consumer，无缓冲 | 成立 | **但这不是 A 引入的**——逐个 symlink 同样无版本快照，风险面完全相同 |
| symlink 存本机绝对路径，换机器需重跑 setup | 成立 | **不是 A 引入的**，是既有性质。A 之下断链是整个目录一起断（一眼看出），优于现在的逐个断（可能只断几个而不自知） |

---

## 7. 落地范围

1. `scripts/setup-consumer.sh`：for 循环 → 单行 `ln -sfn`，并按 §2d 实证处理迁移：

   - `.claude/skills` 已是 symlink → 直接 `ln -sfn` 覆盖（幂等，已实证）
   - `.claude/skills` 是真目录，且**内容全是 symlink**（= 旧版脚本建的，无本地资产）→ 移除后建链接
   - `.claude/skills` 是真目录，且含**任何非 symlink 条目**（= consumer 自己的本地 skill）→ **报错停下，不删**，提示人工处理

   最后一条是硬要求：盲目 `rm -rf` 会静默吞掉 consumer 的项目专属 skill，而这恰好是方案 A 唯一有现实分量的代价面（§6 第 1 行）——不能让它以丢数据的形式兑现。头注释回指本 spec。
2. `skills/setup-tvu-consumer/SKILL.md`：第 62 行「11 个 skill + vocabulary.md」文案改掉；头注释回指本 spec。
3. **6 个** consumer 就地迁移：TVU Pack / MicroApps / Email Template / NOC / RPS / tvu-saas-dashboard。
   最后一个是**修复而非迁移**——它今天 6 条全断（§2e），迁完才第一次真正可用；同时它是唯一可能触发 §7.1 第三分支的（旧路径 `$TVU/.claude/skills` 下的链接虽断，仍是 symlink 类型，故按「全是 symlink」分支走，可安全移除）。
4. `vocabulary.md` symlink **保持不变**（指向 `skills/shared-vocab-rules/vocabulary.md`，A 之下依然解析得到）。逐个 consumer 确认该文件在不在——saas-dashboard 走的是老路径，很可能连这条也没有或已断。

---

## 8. 验收判据

| # | 判据 | 验法 |
|---|---|---|
| S1 | **6 个** consumer 的 `.claude/skills` 均为 symlink，各解析出 13 个 skill | `ls -la` + `ls` 计数，**逐个跑，含 NOC / RPS / saas-dashboard** |
| S2 | 各 consumer 内 `claude -p` 能看到 `upstream-gate` 与 `tvu-design-pipeline` | 照 §2c 的阳性探针跑，**逐个 consumer 实跑，不得由一个推断另一个** |
| S3 | `vocabulary.md` symlink 仍解析得到 | `test -e .claude/vocabulary.md`，逐个 consumer |
| S4 | 脚本对「已是 symlink」重复执行幂等；对「真目录且全 symlink」正确迁移；对「真目录含真文件」拒绝（见 S7） | 同一 consumer 连跑两次结果一致 + 三种起始状态各造探针 |
| S5 | 未来性：在 DS `skills/` 下新建一个临时 skill，consumer 侧无需任何动作即可见 | 建临时 skill → 探针确认可见 → 删除 |
| S6 | `~/.claude/` 下无任何新增 / 修改 | 落地前后对比 |
| S7 | `.claude/skills` 含非 symlink 条目时，脚本**拒绝执行且不删任何文件** | 造一个含真文件的探针目录，跑脚本，确认退出非零且文件仍在 |

S5 是本方案存在的理由，S7 是它唯一代价面的安全阀，**两条都不得跳过**。
