# INFRA-F110 consumer skill 目录级 symlink 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:** 把 consumer 的 `.claude/skills` 从「11 个逐个 symlink」改成「整个目录一个 symlink 指向 `$TVU/skills`」，消灭清单漂移。

**Architecture:** `scripts/setup-consumer.sh` 的 for 循环塌缩成一次 `ln -sfn`，外加一个三分支的迁移前置判断（已是 symlink / 真目录且全 symlink / 真目录含真文件 → 拒绝）。行为由新增的 vitest 用例锁住，自动挂进既有 pre-commit `pnpm test`。然后把本机 6 个 consumer 就地迁过去。

**Tech Stack:** bash (POSIX-ish, macOS `/bin/bash` 3.2 兼容)、vitest（`execFileSync` + `mkdtempSync` 惯例见 `tests/DesignKickoff.test.ts`）

**判据真源：** [2026-08-11-infra-f110-consumer-skill-directory-symlink-design.md](../specs/2026-08-11-infra-f110-consumer-skill-directory-symlink-design.md)。本计划只负责落地，判据有歧义时以 spec 为准。

## Global Constraints

- **不碰任何全局设置**：`~/.claude/settings.json`、`~/.claude/skills/`、全局 hook 一律不动（spec §5，owner 明确要求）。
- **生效范围仍是项目级 opt-in**：只有跑过 setup 的 cwd 内唤醒词才 work。
- **绝不盲删**：`.claude/skills` 是真目录且含**任何非 symlink 条目**时，脚本必须 `exit 1` 且不删任何文件（spec §7.1 第三分支 / 判据 S7）。
- **`vocabulary.md` symlink 保持不变**：仍指向 `$TVU/skills/shared-vocab-rules/vocabulary.md`（spec §7.4）。
- **macOS bash 3.2**：不用 `mapfile`、不用 `declare -A`、不用 `${var,,}`。
- **6 个 consumer 逐个实跑验收，不得由一个推断另一个**（spec §8 S1/S2）。
- Commit message 用中文正文 + 英文 type 前缀，跟 DS 现有 `git log` 一致。

---

### Task 1: `setup-consumer.sh` 改目录级 symlink + 三分支迁移

**Files:**
- Modify: `scripts/setup-consumer.sh:37-61`（`mkdir -p` / for 循环 / broken 检测三段）
- Modify: `scripts/setup-consumer.sh:1-6`（头注释加回指 spec）
- Test: `tests/SetupConsumer.test.ts`（新建）

**Interfaces:**
- Consumes: 无（本任务是链条起点）
- Produces: 迁移后的 consumer 形态 = `.claude/skills` 为 symlink → `$TVU/skills`；`.claude/vocabulary.md` 为 symlink → `$TVU/skills/shared-vocab-rules/vocabulary.md`。Task 3 依赖这个形态做验收。

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

新建 `tests/SetupConsumer.test.ts`：

```typescript
import { describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import {
  existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync,
  readlinkSync, symlinkSync, writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'

const script = resolve('scripts/setup-consumer.sh')
const TVU = resolve('.')
const DS_SKILL_COUNT = readdirSync(join(TVU, 'skills')).length

function newConsumer(): string {
  return mkdtempSync(join(tmpdir(), 'tvu-consumer-'))
}

function run(cwd: string) {
  return execFileSync('bash', [script], {
    cwd,
    encoding: 'utf8',
    stdio: ['ignore', 'pipe', 'pipe'],
  })
}

function runExpectingFailure(cwd: string): { status: number; stderr: string } {
  try {
    run(cwd)
    return { status: 0, stderr: '' }
  } catch (e) {
    const err = e as { status: number; stderr: string }
    return { status: err.status, stderr: err.stderr }
  }
}

describe('setup-consumer.sh — 目录级 symlink (INFRA-F110)', () => {
  it('S-a: 全新 consumer → .claude/skills 是 symlink，解析出 DS 全部 skill', () => {
    const c = newConsumer()
    run(c)
    const link = join(c, '.claude/skills')
    expect(lstatSync(link).isSymbolicLink()).toBe(true)
    expect(readlinkSync(link)).toBe(join(TVU, 'skills'))
    expect(readdirSync(link).length).toBe(DS_SKILL_COUNT)
  })

  it('S-b: 旧式真目录（内含全 symlink）→ 迁移成 symlink，无嵌套残留', () => {
    const c = newConsumer()
    mkdirSync(join(c, '.claude/skills'), { recursive: true })
    symlinkSync(join(TVU, 'skills/role-ux'), join(c, '.claude/skills/role-ux'))
    symlinkSync(join(TVU, 'skills/tvu-design-code'), join(c, '.claude/skills/tvu-design-code'))

    run(c)

    const link = join(c, '.claude/skills')
    expect(lstatSync(link).isSymbolicLink()).toBe(true)
    expect(readdirSync(link)).not.toContain('skills')
    expect(readdirSync(link).length).toBe(DS_SKILL_COUNT)
  })

  it('S-c: 旧式真目录且链接已断 → 仍可安全迁移（断链也是 symlink）', () => {
    const c = newConsumer()
    mkdirSync(join(c, '.claude/skills'), { recursive: true })
    symlinkSync(join(TVU, '.claude/skills/design-discovery'), join(c, '.claude/skills/design-discovery'))
    expect(existsSync(join(c, '.claude/skills/design-discovery'))).toBe(false)

    run(c)

    expect(lstatSync(join(c, '.claude/skills')).isSymbolicLink()).toBe(true)
  })

  it('S7: 真目录含非 symlink 条目 → 拒绝执行，且一个文件都不删', () => {
    const c = newConsumer()
    mkdirSync(join(c, '.claude/skills/my-local-skill'), { recursive: true })
    const asset = join(c, '.claude/skills/my-local-skill/SKILL.md')
    writeFileSync(asset, '# 项目专属 skill\n')

    const { status, stderr } = runExpectingFailure(c)

    expect(status).not.toBe(0)
    expect(existsSync(asset)).toBe(true)
    expect(lstatSync(join(c, '.claude/skills')).isSymbolicLink()).toBe(false)
    expect(stderr).toContain('my-local-skill')
  })

  it('S4: 已是 symlink 时重复执行幂等', () => {
    const c = newConsumer()
    run(c)
    run(c)
    const link = join(c, '.claude/skills')
    expect(lstatSync(link).isSymbolicLink()).toBe(true)
    expect(readdirSync(link).length).toBe(DS_SKILL_COUNT)
    expect(readdirSync(link)).not.toContain('skills')
  })

  it('S3: vocabulary.md symlink 仍解析得到', () => {
    const c = newConsumer()
    run(c)
    const vocab = join(c, '.claude/vocabulary.md')
    expect(lstatSync(vocab).isSymbolicLink()).toBe(true)
    expect(existsSync(vocab)).toBe(true)
  })

  it('S5: DS 新增 skill 后 consumer 无需任何动作即可见', () => {
    const c = newConsumer()
    run(c)
    const probe = join(TVU, 'skills/__f110-probe__')
    try {
      mkdirSync(probe)
      writeFileSync(join(probe, 'SKILL.md'), '---\nname: __f110-probe__\n---\n')
      expect(readdirSync(join(c, '.claude/skills'))).toContain('__f110-probe__')
    } finally {
      execFileSync('rm', ['-rf', probe])
    }
  })
})
```

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

```bash
cd /Users/nancy/Documents/AICoding/VS_Code/tvu-design-system
pnpm exec vitest run tests/SetupConsumer.test.ts
```

预期：**FAIL**。当前脚本建的是逐个 symlink，`lstatSync('.claude/skills').isSymbolicLink()` 为 `false` ⇒ S-a / S-b / S-c / S4 / S5 全红；S7 也红（现脚本根本不检查真文件，会直接在目录内建链接后继续跑）。S3 应该已经绿（vocabulary 那行没变）。

- [ ] **Step 3: 改脚本**

把 `scripts/setup-consumer.sh` 第 37–61 行（`mkdir -p .claude/skills` 到 broken 检测结束）整段替换为：

```bash
# Ensure .claude/ exists（注意：不再预建 skills/，它本身要变成 symlink）
mkdir -p .claude

SKILLS_LINK=".claude/skills"
TARGET="$TVU/skills"

# 三分支迁移 —— 判据见 docs/superpowers/specs/2026-08-11-infra-f110-consumer-skill-directory-symlink-design.md §7.1
if [ -L "$SKILLS_LINK" ]; then
  # 已是 symlink：直接覆盖（幂等，spec §2d 实证）
  ln -sfn "$TARGET" "$SKILLS_LINK"
  echo "▶ .claude/skills 已是目录级 symlink，已刷新指向"
elif [ -d "$SKILLS_LINK" ]; then
  # 真目录：只有「内容全是 symlink」才可安全移除
  REAL_ENTRIES=$(find "$SKILLS_LINK" -maxdepth 1 -mindepth 1 ! -type l)
  if [ -n "$REAL_ENTRIES" ]; then
    echo "" >&2
    echo "❌ $SKILLS_LINK 是真目录，且含非 symlink 条目——可能是本项目专属 skill：" >&2
    echo "$REAL_ENTRIES" | sed 's/^/     /' >&2
    echo "" >&2
    echo "   脚本不会删除它们。请先手动迁走或备份，再重跑本脚本。" >&2
    echo "   （方案 A 之下 .claude/skills 整个目录归 DS 所有，放不了项目专属 skill；" >&2
    echo "     若确需保留本地 skill，该项目应退回逐个 symlink 或改用 plugin。）" >&2
    exit 1
  fi
  rm -rf "$SKILLS_LINK"
  ln -sfn "$TARGET" "$SKILLS_LINK"
  echo "▶ 已从旧式逐个 symlink 迁移为目录级 symlink"
else
  ln -sfn "$TARGET" "$SKILLS_LINK"
  echo "▶ 已建立目录级 symlink"
fi

# Symlink vocabulary（保持不变）
ln -sfn "$TVU/skills/shared-vocab-rules/vocabulary.md" ".claude/vocabulary.md"
echo "  ✓ vocabulary.md"

# Verify：目录级 symlink 必须解析得到，且能列出 skill
if [ ! -e "$SKILLS_LINK" ]; then
  echo "" >&2
  echo "⚠️  $SKILLS_LINK 建好了但解析不到（TVU clone 路径是否正确？）：" >&2
  echo "     -> $(readlink "$SKILLS_LINK")" >&2
  exit 1
fi
SKILL_COUNT=$(ls "$SKILLS_LINK" | wc -l | tr -d ' ')
echo "  ✓ skills/ → $TARGET（$SKILL_COUNT 个）"
```

同时把第 41 行原来的 `echo "▶ Symlinking skills (11)..."` 一并删掉（该行在被替换的区间内），并把文件头注释（第 2 行后）加一行：

```bash
# 判据真源：docs/superpowers/specs/2026-08-11-infra-f110-consumer-skill-directory-symlink-design.md
#   （INFRA-F110：.claude/skills 是**目录级** symlink，不是逐个 skill 的清单——
#     清单会腐坏，目录不会。改动前先读该 spec §7.1 的三分支迁移判据。）
```

最后把脚本结尾 heredoc 里的 `+ 其它 TVU 团队 skill（discovery / walkthrough / qa-loop / persona / consumer-conventions）` 改为 `+ DS skills/ 下的全部 skill（自动跟随，无需重跑本脚本）`。

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

```bash
pnpm exec vitest run tests/SetupConsumer.test.ts
```

预期：**7 个用例全 PASS**。

若 S-c 红：检查 `find ! -type l` 对断链 symlink 的判定——断链仍是 symlink，`! -type l` 不该命中它。
若 S7 红且文件被删：说明 `rm -rf` 跑在了检查之前，核对分支顺序。

- [ ] **Step 5: 跑全量测试确认没打断别的**

```bash
pnpm test
```

预期：PASS。这一步不能跳——`pnpm test` 是 pre-commit 的一环，本地先跑一遍避免 commit 时才发现。

- [ ] **Step 6: Commit**

```bash
git add scripts/setup-consumer.sh tests/SetupConsumer.test.ts
git commit -m "feat(f110): setup-consumer 改目录级 symlink + 三分支迁移

.claude/skills 整个目录做成一个 symlink 指向 \$TVU/skills，取代原来硬编码
的 11 个逐个 symlink。DS 加/删/改名 skill 后 consumer 零动作自动跟上。

迁移按 spec §2d 实证分三支：已是 symlink 直接覆盖；真目录且内容全是
symlink 则移除后重建；真目录含任何非 symlink 条目则拒绝执行且不删任何
文件——盲删会静默吞掉 consumer 的项目专属 skill。

tests/SetupConsumer.test.ts 锁住全部七条行为，随 pre-commit pnpm test 跑。"
```

---

### Task 2: 同步 `setup-tvu-consumer` skill 文案

**Files:**
- Modify: `skills/setup-tvu-consumer/SKILL.md:62`（「11 个 skill + vocabulary.md」）
- Modify: `skills/setup-tvu-consumer/SKILL.md:66-68`（clone-based / plugin 用户说明）

**Interfaces:**
- Consumes: Task 1 落地后的脚本行为
- Produces: 无下游依赖（纯文案）

- [ ] **Step 1: 改第 62 行**

原文：

```markdown
该脚本会在当前 cwd 建 `.claude/skills/` 项目级 symlink（11 个 skill + vocabulary.md），让用户在该 cwd 内 `@TVU mockup` / `@UX` 等唤醒词 work。
```

改为：

```markdown
该脚本会在当前 cwd 把 `.claude/skills` 建成**指向 `$TVU/skills` 的目录级 symlink**（外加 `vocabulary.md` 单独一条），让用户在该 cwd 内 `@TVU mockup` / `@UX` 等唤醒词 work。

**不再有 skill 清单**——DS 里加 / 删 / 改名 skill，所有 consumer 下次开 session 自动跟上，无需重跑本脚本（INFRA-F110，判据见 [design spec](../../docs/superpowers/specs/2026-08-11-infra-f110-consumer-skill-directory-symlink-design.md)）。若目标 `.claude/skills` 已是含**真文件**的目录（= 该项目有自己的本地 skill），脚本会**拒绝执行并保留文件**，需人工决定：迁走本地 skill，或该项目退回逐个 symlink / 改用 plugin。
```

- [ ] **Step 2: 跑 skills lint**

```bash
node scripts/lint-skills.mjs
```

预期：PASS。该 lint 会验证 SKILL.md 里的 markdown 链接目标真实存在——上面新增的 design spec 相对路径必须解析得到（`skills/setup-tvu-consumer/` → `../../docs/superpowers/specs/…`）。若报缺失，先核对相对层级再改。

- [ ] **Step 3: Commit**

```bash
git add skills/setup-tvu-consumer/SKILL.md
git commit -m "docs(f110): setup-tvu-consumer 文案跟上目录级 symlink

第 62 行「11 个 skill」是清单的第二份副本，与 setup-consumer.sh 里那份
一起停在 2026-05-22。目录级 symlink 之后不再有清单，改为说明自动跟随
行为 + 含本地 skill 时脚本会拒绝执行的兜底。"
```

---

### Task 3: 6 个 consumer 就地迁移 + 全量验收

**Files:**
- Modify（本机、不入 git）：6 个 consumer 的 `.claude/skills`
  - `/Users/nancy/Documents/AICoding/VS_Code/TVU Pack`
  - `/Users/nancy/Documents/AICoding/VS_Code/MicroApps`
  - `/Users/nancy/Documents/AICoding/VS_Code/Email Template`
  - `/Users/nancy/Documents/AICoding/VS_Code/NOC`
  - `/Users/nancy/Documents/AICoding/VS_Code/RPS`
  - `/Users/nancy/Documents/AICoding/VS_Code/tvu-saas-dashboard`

**Interfaces:**
- Consumes: Task 1 的脚本
- Produces: 本任务是终点

- [ ] **Step 1: 迁移前快照（S6 的对照基线）**

```bash
ls -la ~/.claude/ > /tmp/f110-global-before.txt
ls ~/.claude/skills/ >> /tmp/f110-global-before.txt
cat /tmp/f110-global-before.txt
```

- [ ] **Step 2: 逐个跑迁移**

```bash
TVU=/Users/nancy/Documents/AICoding/VS_Code/tvu-design-system
for c in "TVU Pack" MicroApps "Email Template" NOC RPS tvu-saas-dashboard; do
  echo "════════ $c ════════"
  ( cd "/Users/nancy/Documents/AICoding/VS_Code/$c" && bash "$TVU/scripts/setup-consumer.sh" ) || echo "❌ $c 失败"
done
```

预期：6 个全部成功。若任何一个报「含非 symlink 条目」，**停下来人工看**——说明该项目有本地 skill，spec §6 第 1 行的代价在它身上兑现了，需要 owner 决定去留，不要绕过。

- [ ] **Step 3: 验收 S1 + S3（逐个，不互推）**

```bash
DS_N=$(ls /Users/nancy/Documents/AICoding/VS_Code/tvu-design-system/skills | wc -l | tr -d ' ')
for c in "TVU Pack" MicroApps "Email Template" NOC RPS tvu-saas-dashboard; do
  d="/Users/nancy/Documents/AICoding/VS_Code/$c"
  s="$d/.claude/skills"
  printf '%-22s symlink=%s 数量=%s/%s vocab=%s\n' "$c" \
    "$([ -L "$s" ] && echo YES || echo NO)" \
    "$(ls "$s" 2>/dev/null | wc -l | tr -d ' ')" "$DS_N" \
    "$([ -e "$d/.claude/vocabulary.md" ] && echo OK || echo MISSING)"
done
```

预期：6 行全部 `symlink=YES`、数量与 DS 相等、`vocab=OK`。

`tvu-saas-dashboard` 的 vocab 若为 `MISSING`——它走的是 2026-05-20 的老路径，脚本这次会一并建好，所以应当是 `OK`；若仍 MISSING 则是脚本的 vocabulary 那行没跑到，回 Task 1 查。

- [ ] **Step 4: 验收 S2（逐个 consumer 实跑探针，不得互推）**

```bash
for c in "TVU Pack" MicroApps "Email Template" NOC RPS tvu-saas-dashboard; do
  echo "════════ $c ════════"
  ( cd "/Users/nancy/Documents/AICoding/VS_Code/$c" && claude -p "不要使用任何工具。只根据你的可用 skill 列表回答：是否存在 upstream-gate？是否存在 tvu-design-pipeline？各答 有 或 无，一行一个。" --model haiku )
done
```

预期：6 组回答全部「有 / 有」。

这一步是整个计划的落点——前面所有步骤都只是让 symlink 长得对，**只有这里证明 Claude Code 真的把新 skill 读进去了**。不要因为「S1 已经绿了」就跳过。

- [ ] **Step 5: 验收 S6（全局无污染）**

```bash
ls -la ~/.claude/ > /tmp/f110-global-after.txt
ls ~/.claude/skills/ >> /tmp/f110-global-after.txt
diff /tmp/f110-global-before.txt /tmp/f110-global-after.txt && echo "✅ S6：~/.claude/ 无变化"
```

预期：`diff` 无输出 + 打印 ✅。

- [ ] **Step 6: 更新 spec 状态行并 commit**

把 spec 第 3 行的 `**owner 已拍方向，待实施**` 改为 `**已实施**（commit `<Task1 sha>`…`<Task2 sha>`；6 个 consumer 于 2026-08-11 完成迁移，S1–S7 全绿）`。

```bash
git add docs/superpowers/specs/2026-08-11-infra-f110-consumer-skill-directory-symlink-design.md
git commit -m "docs(f110): spec 转已实施 —— 6 个 consumer 迁移完成，S1-S7 全绿"
```

---

## Self-Review

**Spec 覆盖对照：**

| spec 条目 | 落点 |
|---|---|
| §7.1 脚本三分支 | Task 1 Step 3 |
| §7.1 头注释回指 spec | Task 1 Step 3 末段 |
| §7.2 SKILL.md 文案 | Task 2 Step 1 |
| §7.3 6 个 consumer 迁移 | Task 3 Step 2 |
| §7.4 vocabulary 保持 | Task 1 Step 3（脚本）+ Task 3 Step 3（验收） |
| §8 S1 | Task 3 Step 3 |
| §8 S2 | Task 3 Step 4 |
| §8 S3 | Task 1 测试 S3 + Task 3 Step 3 |
| §8 S4 | Task 1 测试 S4 |
| §8 S5 | Task 1 测试 S5 |
| §8 S6 | Task 3 Step 1 + Step 5 |
| §8 S7 | Task 1 测试 S7 |

无遗漏。

**已知取舍：** S5 在 Task 1 里用「真的往 DS `skills/` 建一个临时探针目录再删」实现，而非 mock。理由是这条判据要证的正是「真实文件系统上的自动跟随」，mock 掉就什么也没证明。测试用 `try/finally` 保证探针必被清理；若测试进程被强杀留下 `skills/__f110-probe__`，手动 `rm -rf` 即可，不影响仓库其它部分。
