# Sync B 退役 · 收敛到单一 Claude Design 系统 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.
> **⚠️ 但 DesignSync 远端读写（`list_files` / `get_file` / `finalize_plan` / `write_files` / `delete_files`）只能主 session 亲做，绝不外包子代理**（子代理无此工具，memory `designsync-tool-main-session-only`）。子代理只能干纯本地活（脚本/测试/文档）。

**Goal:** 把 owner 的两个 claude.ai/design 项目收敛成一个 —— 保留 **Sync A（`bfca04be-9783-4eb5-b754-37b04a6c35ef`，"TVU Networks Design System"）**，把 **Sync B（`406a39da-1681-4edf-ae26-826cf3c33c9c`，"TVU UX Design System"）** 冷归档并拆掉仓库侧一切「为 B 而存在」的机制，且**不丢任何只存在于 B 的能力或内容**（丢了也要显式记为已知损失，而不是静默消失）。

**Architecture:** 三段。**先分类后动手**：逐条枚举 B 的远端文件，把每一条落进「A 已有 / 可由仓库再生 / owner 已定放弃 / B 独有待决」四个桶，桶总和必须等于文件总数（防遗漏的机械口径，不靠"看一遍觉得没了"）。**再按证据决定独有项的去向**：B 的 per-component `.prompt.md`/`.d.ts` 是否搬到 A，取决于 A 那个项目形态**是否真有消费者**——先读 A 的 `_ds_manifest.json` 与卡片实况来判，判不出消费者就**记为能力损失**、不做无消费者的搬运（cargo cult）。**最后拆机制并冷归档**：仓库侧删 emit 脚本/测试/npm script、改 reference 编号 registry（否则 INFRA-F78 闸会红）、driver 上传目标切到 A 并给「不得覆盖 A 策展路径」加一个上传前机械检查；远端只写归档标记、**不删 B 的文件**（可逆），改名/删项目是 owner 在 UI 的事（DesignSync API 无 rename）。

**Tech Stack:** Node 22 + ESM stdlib-only 脚本（`audit:scripts-stdlib` 闸禁用非 stdlib API）· vitest（`tests/*.test.mts`，repo 默认 config）· DesignSync MCP 工具（仅主 session）· 现有 `.ds-sync` gitignored driver + `.design-sync/reapply-driver-patches.mjs`

---

## Global Constraints

- **起手 onboarding 不可跳**：`docs/STATUS.md` §起手必读链路 L-core 四份全文 + 命中的 L-ref（本计划命中：`AGENTS.md` §硬规则表、`backlog.md` 的 INFRA-F63/F78/F79 entry 全文、`.design-sync/NOTES.md` 全文）。
- **硬规则 #10（活源优先）**：判断远端当前状态前必须 `list_files` / `get_file` 实读。**本计划里所有"当前实况"数字都标了观测时间（2026-07-30），执行时必须重新观测**——期间可能有并行 session 或 owner 手动改动。
- **硬规则 #9（禁编造）**：文件数、HTTP 码、`ts`、sha、exit code 一律从工具原始输出逐字复制。
- **DesignSync 写操作纪律**：① 每次 upload/delete 前 `finalize_plan` 锁定**精确路径清单** + `localDir`，**贴给 owner 确认后**才 `write_files`；② `delete_files` **不支持 glob**，glob 会静默删 0 条 —— 必须先 `list_files` 拿真实路径逐条字面列出，删后再 `list_files` 亲验剩余（memory `designsync-delete-no-glob`）；③ 写完逐文件 `get_file` 亲验字节，**面板 `updatedAt` / 卡片对非视觉写入不刷新，不得当同步凭据**（memory `designsync-nonvisual-writes-no-ui-bump`）；④ 若动了卡片集合，写 `_ds_needs_recompile` 哨兵 + 让 owner 重开 SPA 触发重编译，再 `get_file _ds_manifest.json` 亲验（memory `designsync-card-index-recompile`）。
- **第三个项目 `019dfb04-d524-7ae9-b05e-4e14da423643`（同名 "TVU Networks Design System"，`isOwned` 缺失 = 不属于本 owner，只读）**：**完全不在本计划范围内，不读不写不提建议**。它 2026-07-29 有过更新，属他人资产。
- **不改历史文档**：`docs/internal/STATUS-CHANGELOG.md`、`docs/_archive/**`、`docs/internal/_handoffs/**`、`docs/internal/_plans/**`、`docs/internal/_prompts/**`、`docs/internal/_metrics/**`、`docs/superpowers/plans/2026-07-23-ds-merge-two-systems.md`、`docs/superpowers/specs/2026-07-22-ds-rule-consumption-design.md` 里对 Sync B 的引用是**史实**，一律保留原文，禁止追溯改写。只改**当前态**文档（`STATUS.md` / `backlog.md` / `.design-sync/NOTES.md` / registry / 脚本）。
- **gate 平权（INFRA-F61）**：本计划若新增任何机械检查，pre-commit 与 `.gitea/workflows/pr-checks.yml`（PR + `push:master` 都触发）**两处都要挂**。
- **Owner 直 commit master**（AGENTS.md 硬规则 #7 broader override）：不建 feature 分支；每个 Task 末尾 commit 并 push 双 remote（`origin` = Gitea、`github`），push 落地以 `git ls-remote` 亲验（GitHub 偶发 `remote rejected` 是本机已知假报，memory `local-env-tool-quirks`）。
- **`git add` 用显式路径，禁止 `git add -A`**：本仓库常有并行 session 的 in-flight 文件（2026-07-30 实证：`-A` 把另一 session 正在写的 97KB 计划扫进了 commit）。
- **无 changeset**：本计划全部改动不进 npm 包（`package.json` `files[]` = `dist / llms.txt / eslint-plugin / scripts / templates / 3 份 docs`——注意 `scripts` **在**包里，所以删 `scripts/emit-reference-to-sync-b.mjs` 会改变 tarball 内容，但不改变任何 consumer 行为，按 owner 2026-07-29「版本号该表达代码变化、不该表达基建变化」的拍板不写 changeset）。
- **不删 B 的远端文件**：归档是**加标记**，不是清空。删项目/改名只有 owner 能在 claude.ai UI 做（DesignSync API 无 rename 方法，`method` 枚举里没有）。冷却期结束前不提删除。

---

## 观测基线（2026-07-30 13:0x，执行时必须重测并对比）

**Sync A（保留）** `list_files` 实况要点：36 个 `components/<Name>/<Name>.html` · `preview/` 6 张基础卡 + `_card.css` · `reference/` 编号 01–05·07–13·16 + 策展 14/15 + `composition.json` + `icon-keys-*.md` 18 类 + `icon-keys-index.md` + `upstream-gate.schema.json` · `SKILL.md` · `MANIFEST.json` · `_ds_bundle.{css,js}` + `_ds_manifest.json` + `_vendor/react*.js` · `tokens/`（`variables.css` / `figma-variables.json` / `tokens.dtcg.json` / `fonts/`）· `assets/icons/sprite.svg` + `assets/email-hero-v3.png` · **策展且不可再生**：`mockups/` 9 份（邮件族 5 + Command Center v2 + TVU One Local Management + TopBar UserMenu Demo + unsubscribe）· `share/` 3 · `screenshots/` 21 · `versions/v2/**` 归档 · `uploads/**` · 根 `styles.css`（含邮件 CTA 依赖的手工 legacy `.btn` 段，INFRA-F78 已把「别覆盖」写进 bundle MANIFEST 的 `syncPolicy.neverOverwriteExisting`）。

**Sync B（退役）** `list_files` 实况要点：`components/general/<Name>/` × 36，每个含 `.d.ts` / `.html` / `.jsx` / `.prompt.md` · `_preview/<Name>.js` × 36 · `_vendor/react.js` + `react-dom.js` · `_ds_bundle.{css,js}` + `_ds_manifest.json` + `_ds_sync.json` + `_adherence.oxlintrc.json` · `README.md` · `styles.css` · `fonts/` 9 · `preview/` 6 张 + `_card.css` · **`reference/` 只有 01/03/04/05** · `templates/device-monitor/{.thumbnail,DeviceMonitor.dc.html,ds-base.js,support.js}`。

**已知裁定（不要在执行时重新讨论）**：
- `templates/device-monitor` = `live-device-monitor`，**源已丢**，**owner 2026-07-24 已定「放弃、不找回、不重建」**（`docs/internal/_handoffs/2026-07-24-ds-merge-followup.md:25` + `docs/internal/_demos/README.md:20`）。它在 B 上是弃件，**不搬、不救**，Task 1 分类进「owner 已定放弃」桶即可。
- 2026-07-23 合并计划的原始目标是「两个项目都完整同源」，**不是**定主次；owner 2026-07-30 改需求为「只保留一个」并同意保留 A。本计划是那次需求变更的落地，**不算返工那份计划**。

---

## File Structure

| 文件 | 责任 | 动作 |
|---|---|---|
| `docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md` | B 每条远端路径的四桶分类表 + 计数等式，作为「没遗漏」的可复核证据 | **Create**（Task 1） |
| `scripts/check-designsync-upload-safety.mjs` | 上传前机械检查：给定待写路径清单，命中「A 的受保护策展路径」即 FAIL | **Create**（Task 4） |
| `tests/check-designsync-upload-safety.test.mts` | 上述脚本的单测（含每条保护规则的正反例） | **Create**（Task 4） |
| `scripts/emit-reference-to-sync-b.mjs` | 只为 B emit 4 份 reference 文档 | **Delete**（Task 3） |
| `tests/emit-reference-to-sync-b.test.mts` | 上述脚本的单测 | **Delete**（Task 3） |
| `package.json` | `emit:reference-to-sync-b` script（删）· `check:upload-safety` script（加） | **Modify**（Task 3、4） |
| `docs/internal/claude-design-reference-registry.json` | reference 编号占用真源：删 producer `emit-reference-to-sync-b`、从 01/03/04/05 的 `producers[]` 摘掉它、删 project `406a39da` | **Modify**（Task 3） |
| `.design-sync/config.json` | driver 的上传目标 `projectId` → A | **Modify**（Task 4） |
| `.design-sync/NOTES.md` | 新增 §Sync B retired + §Upload safety（受保护路径清单 + 上传前跑 check 脚本）；既有 Sync B 章节加归档批注 | **Modify**（Task 3、4） |
| `.husky/pre-commit` + `.gitea/workflows/pr-checks.yml` | 新脚本的单测已被 `pnpm test` 覆盖，不新增 gate；仅当 Task 4 决定把 check 脚本变成 commit 期检查时才动 | **Modify（条件性）**（Task 4） |
| `docs/STATUS.md` | 顶部摘要 ⑨ + §Active 行 + Active 计数 | **Modify**（Task 6） |
| `docs/internal/backlog.md` | 新 entry **INFRA-F81**（退役残余：owner UI 改名 / 冷却期后删项目 / 能力损失登记） | **Modify**（Task 6） |

> **ID 核对**：`INFRA-F80` 已于 2026-07-30 被「主机心跳只跑在会睡的笔记本」占用，**下一个可用 = INFRA-F81**。新增 entry 前仍按 memory `backlog-id-collision` 机械核一次：`grep -roE 'INFRA-F[0-9]+' docs/ AGENTS.md | grep -oE '[0-9]+' | sort -n -u | tail -3`。

---

### Task 1: 逐条分类 B 的全部远端文件（只读，防遗漏的机械口径）

**Files:**
- Create: `docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md`

**Interfaces:**
- Produces: 分类表 + 计数等式，后续 Task 2/5 只处理「B 独有待决」桶里的条目。

- [ ] **Step 1: 重测两个项目的实况（活源，不用本计划的基线数字）**

主 session 亲跑（子代理无 DesignSync 工具）：

```
DesignSync list_files projectId=406a39da-1681-4edf-ae26-826cf3c33c9c
DesignSync list_files projectId=bfca04be-9783-4eb5-b754-37b04a6c35ef
```

把两份 `paths[]` 原样存进 scratchpad 两个文件（例如 `/tmp/…/b.json`、`/tmp/…/a.json`），后续步骤对文件算，不靠肉眼。

- [ ] **Step 2: 算出 B 的文件总数与四桶归属**

只统计**文件**（`paths[]` 里同时含目录条目，目录名没有扩展名且会作为其它路径的前缀）。用一次性脚本算，别手数：

```bash
python3 - <<'PY'
import json
b=json.load(open('/tmp/b.json'))['paths']; a=set(json.load(open('/tmp/a.json'))['paths'])
files=[p for p in b if '.' in p.rsplit('/',1)[-1]]          # 有扩展名 = 文件
print('B 文件总数 =', len(files))
same=[p for p in files if p in a]                            # 同路径已在 A
print('同路径已在 A =', len(same))
# 形态差异：B 是 components/general/<N>/…，A 是 components/<N>/…
import re
norm=lambda p: re.sub(r'^components/general/','components/',p)
same2=[p for p in files if p not in a and norm(p) in a]
print('归一化后已在 A =', len(same2), same2[:5])
rest=[p for p in files if p not in a and norm(p) not in a]
print('剩余待归桶 =', len(rest))
for p in rest: print('   ', p)
PY
```

- [ ] **Step 3: 写分类表**

在 `docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md` 里为 Step 2 的**每一条** `rest` 给出桶归属与依据，四个桶只能是：

| 桶 | 判据 | 处置 |
|---|---|---|
| `already-in-A` | 同路径或归一化后同名已在 A | 无动作 |
| `regenerable` | 由仓库脚本/driver 可再生（`.ds-sync` driver 产 `_ds_bundle.*`/`_preview/*`/`_vendor/*`/`components/general/**`；`emit-reference-to-sync-b.mjs` 产 `reference/0{1,3,4,5}`；`preview/*.html` 由 `gen:foundation-cards` 产） | 无动作（A 侧已有等价物或可随时再生） |
| `abandoned-by-owner` | owner 已明确放弃 | 无动作，引用裁定出处 |
| `unique-undecided` | 以上都不是 | **进 Task 2 决策** |

硬性要求：`len(already-in-A) + len(regenerable) + len(abandoned-by-owner) + len(unique-undecided) == B 文件总数`，等式写进文档。**桶和不等于总数 = 分类没做完，不许进 Task 2。**

- [ ] **Step 4: 提交**

```bash
git add docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md
git commit -m "docs(handoff): Sync B 退役前逐条分类（四桶 + 计数等式，防遗漏）"
git push origin master && git push github master
git ls-remote origin master | cut -f1 && git ls-remote github master | cut -f1   # 与 git rev-parse HEAD 三处一致
```

---

### Task 2: 按证据决定 B 独有产物的去向（不做无消费者的搬运）

**Files:**
- Modify: `docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md`（追加 §决策与证据）

**Interfaces:**
- Consumes: Task 1 的 `unique-undecided` 桶。
- Produces: 每条独有产物的处置裁定（搬 / 记为损失），Task 5 归档前必须全部有裁定。

**背景（写清楚免得执行者凭直觉搬）**：B 的 per-component `.prompt.md` 与 `.d.ts` 是 `/design-sync` **package 形态**项目的约定产物（`.prompt.md` 里有 `reapply-driver-patches.mjs` 注入的 `CORE_POINTER`；`.d.ts` 由 `react-pilot/tsconfig.dts.json` → `tsc -p` 产出，缺了 props 会退化成 `{[key:string]: unknown}`，见 `.design-sync/NOTES.md`）。**A 不是 package 形态**：2026-07-30 观测 A 的每个组件目录下只有 `<Name>.html`，没有 `.prompt.md`/`.d.ts`/`.jsx`。所以「搬过去」可能根本没有消费者。

- [ ] **Step 1: 取 A 的形态证据**

```
DesignSync get_file projectId=bfca04be-9783-4eb5-b754-37b04a6c35ef path=_ds_manifest.json
```

读 `cards[]`（渲染的是哪些 path）、`source` 字段（`spa` / `design-sync-cli`）、有无 `namespace`/`templates[]`。再取一张组件卡看它怎么渲染：

```
DesignSync get_file projectId=bfca04be-9783-4eb5-b754-37b04a6c35ef path=components/Button/Button.html
```

- [ ] **Step 2: 按证据判定，三种结论各有明确动作**

| 证据 | 结论 | 动作 |
|---|---|---|
| manifest / 卡片里有任何机制引用 `*.prompt.md` 或 `*.d.ts` | **有消费者** | 搬：把 `ds-bundle/components/general/<N>/<N>.prompt.md` 与 `.d.ts` 上传到 A 的 `components/<N>/`（36 组件 × 2 = 72 文件），走 Global Constraints 的写纪律，逐文件 `get_file` 亲验；并在 NOTES 记「A 现在也带 prompt/dts，driver 重建后需重传」 |
| 无任何引用（**2026-07-30 的预期结论**） | **无消费者** | **不搬**。在分类文档 §决策 写下「B 退役 = 失去 per-component 类型化 props + per-component prompt 这一层」，并写明替代面：A 的 `SKILL.md` + `reference/02-component-affordances.md` + `reference/composition.json`（组合契约）承担组合指导；类型化 props 的真源是 npm 包的 `.d.ts`（`./tokens/js`、组件 `types`），设计工具侧不再有 |
| 证据不足以判断 | **STOP，问 owner** | 不猜。把两份 `get_file` 的相关片段贴给 owner，附「搬 72 个文件的代价 vs 可能无消费者」的判断，等裁定 |

- [ ] **Step 3: 其余 `unique-undecided` 条目逐条裁定**

对 Task 1 桶里剩下的每一条（预期只剩 `_ds_sync.json` / `_adherence.oxlintrc.json` / B 自己的 `README.md` / `templates/device-monitor/**` 这类）写一行裁定 + 依据。`templates/device-monitor/**` 直接引用 owner 2026-07-24 的放弃裁定。

- [ ] **Step 4: 提交**

```bash
git add docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md
git commit -m "docs(handoff): B 独有产物去向按 A 的项目形态证据裁定（含无消费者则不搬的理由）"
git push origin master && git push github master
```

---

### Task 3: 拆掉仓库侧「为 B 而存在」的机制（含 INFRA-F78 registry，否则闸会红）

**Files:**
- Delete: `scripts/emit-reference-to-sync-b.mjs`
- Delete: `tests/emit-reference-to-sync-b.test.mts`
- Modify: `package.json`（删 `emit:reference-to-sync-b`）
- Modify: `docs/internal/claude-design-reference-registry.json`
- Modify: `.design-sync/NOTES.md`

**Interfaces:**
- Consumes: 无（纯本地，**可外包子代理**）。
- Produces: `pnpm audit:reference-numbering` 与 `pnpm test` 均绿的仓库状态。

- [ ] **Step 1: 先看闸现在为什么会因为「只删脚本」而红**

```bash
node scripts/audit-claude-design-reference-numbers.mjs   # 期望：PASS（16 号 / producer 出 17 项 / 2 项目）
```

registry 里 `producers['emit-reference-to-sync-b']` 指向那个脚本，且编号 01/03/04/05 的 `producers[]` 含它，项目列表里有 `406a39da`。**只删脚本会让 R2（producer script 不存在）+ R5（登记的 producer 没产出）同时红** —— 这是设计好的，删机制必须同批改数据。

- [ ] **Step 2: 写下失败态先成立（不接受空过型 PASS）**

```bash
mv scripts/emit-reference-to-sync-b.mjs /tmp/emit.bak.mjs
node scripts/audit-claude-design-reference-numbers.mjs; echo "exit=$?"   # 期望 exit=1，且报 [R2] producer script 不存在
mv /tmp/emit.bak.mjs scripts/emit-reference-to-sync-b.mjs
```

- [ ] **Step 3: 同批改 registry + 删脚本与测试与 npm script**

```bash
python3 - <<'PY'
import json
p='docs/internal/claude-design-reference-registry.json'
d=json.load(open(p))
d['producers'].pop('emit-reference-to-sync-b')
for n in ('01','03','04','05'):
    e=d['numbers'][n]
    e['producers']=[x for x in e['producers'] if x!='emit-reference-to-sync-b']
    assert e['producers'], f'编号 {n} 不能没有 producer'
d['projects']=[x for x in d['projects'] if x['id']!='406a39da-1681-4edf-ae26-826cf3c33c9c']
d['_meta']['scopeNote']=d['_meta']['scopeNote']+' 2026-07-30 起只剩 Sync A 一个项目（Sync B 已退役，见 docs/superpowers/plans/2026-07-30-sync-b-retirement-consolidate-into-sync-a.md）；编号命名空间仍是共享的——bundle 出的号与 A 上的策展文档同处一个 reference/ 目录。'
json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)
open(p,'a').write('')
print('registry 已改')
PY
git rm scripts/emit-reference-to-sync-b.mjs tests/emit-reference-to-sync-b.test.mts
node -e "const fs=require('fs');const p='package.json';const j=JSON.parse(fs.readFileSync(p));delete j.scripts['emit:reference-to-sync-b'];fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n')"
```

- [ ] **Step 4: 两条闸 + 单测都要绿**

```bash
node scripts/audit-claude-design-reference-numbers.mjs   # 期望 PASS，且行尾计数变为「1 个项目实况已核」
pnpm test 2>&1 | tail -5                                 # 期望：删掉的 emit 测试不再出现，其余全 pass
pnpm run audit:scripts-stdlib && pnpm run audit:stale-anchors
```

⚠️ `audit:stale-anchors` 会抓「文档里引用了已删文件」——若它报 `scripts/emit-reference-to-sync-b.mjs` 孤儿，说明还有**当前态**文档引用它（`.design-sync/NOTES.md` 的 step 6b 就是），去 Step 5 一并处理；**史料文档不改**，若 stale-anchors 把史料也算孤儿，按其既有 allowlist 机制处理，不要为了让闸绿去改史实。

- [ ] **Step 5: NOTES.md 改写为归档口径**

在 `.design-sync/NOTES.md`：
- 新增顶部小节 `## Sync B retired (2026-07-30)`：一句话结论（owner 定只维护一个系统 → 留 A、B 冷归档）+ 指向本计划 + 指向 Task 1 的分类文档 + 一句「B 上的文件没有删，只加了归档标记，可逆」。
- 既有 **step 6b（emit reference 到 Sync B）** 与 **Sync B 上传流程/CRITICAL 三项** 段落：保留原文并在段首加 `> ⚠️ ARCHIVED 2026-07-30 — Sync B 已退役，本段仅作史料；driver 现在只服务 Sync A（见 §Upload safety）。`
- **不要删这些段落**：它们记录了 B 空-bundle 三项自检等经验，driver 换目标后仍适用。

- [ ] **Step 6: 提交**

```bash
git add scripts tests package.json docs/internal/claude-design-reference-registry.json .design-sync/NOTES.md
git commit -m "chore(design-sync): 退役 Sync B 的仓库侧机制（emit 脚本+测试+npm script+编号 registry 登记）"
git push origin master && git push github master
```

---

### Task 4: driver 上传目标切到 A + 给「不得覆盖 A 策展路径」加机械检查

**Files:**
- Modify: `.design-sync/config.json`（`projectId` → `bfca04be-9783-4eb5-b754-37b04a6c35ef`）
- Create: `scripts/check-designsync-upload-safety.mjs`
- Create: `tests/check-designsync-upload-safety.test.mts`
- Modify: `package.json`（加 `check:upload-safety`）
- Modify: `.design-sync/NOTES.md`（§Upload safety）

**Interfaces:**
- Produces: `node scripts/check-designsync-upload-safety.mjs <path>...` → 命中受保护路径即 exit 1 并列出命中项；全清则 exit 0。上传前在 `finalize_plan` 之前跑。

**为什么需要这个**：`/design-sync` 的 driver 是按 package 形态**整目录**产出的，而 A 是策展项目。把 `projectId` 指到 A 之后，一次不设防的全量上传就会覆盖 A 根上的 `styles.css`（邮件族 CTA 依赖手工 legacy `.btn` 段）、`mockups/**`、`versions/**`、`screenshots/**`、`README.md`、以及 app 编译产物 `_ds_manifest.json`。这与 INFRA-F78 抓到的「同名 `styles.css` 两个 producer」是同一类危险，那次的教训是**把「别覆盖」变成数据、不靠人记**。

- [ ] **Step 1: 写失败测试（先测保护规则会拦）**

`tests/check-designsync-upload-safety.test.mts`：

```ts
import { describe, expect, it } from 'vitest'
import { checkPaths, PROTECTED } from '../scripts/check-designsync-upload-safety.mjs'

describe('designsync upload safety', () => {
  it('拦根 styles.css（邮件 CTA 依赖手工 .btn 段）', () => {
    expect(checkPaths(['styles.css']).map((h) => h.path)).toEqual(['styles.css'])
  })

  it('拦策展目录整片', () => {
    const hits = checkPaths([
      'mockups/MediaHub Welcome Emails.html',
      'versions/v2/SKILL.md',
      'screenshots/alert-fit.png',
      'share/unsubscribe.html',
      'uploads/README.md',
    ])
    expect(hits).toHaveLength(5)
  })

  it('拦 app 编译产物与策展 reference 编号', () => {
    expect(checkPaths(['_ds_manifest.json']).length).toBe(1)
    expect(checkPaths(['reference/14-email-templates.md']).length).toBe(1)
    expect(checkPaths(['reference/15-design-retrospective.md']).length).toBe(1)
  })

  it('放行 driver 正常产物与组件卡', () => {
    expect(
      checkPaths([
        '_ds_bundle.js',
        '_ds_bundle.css',
        '_vendor/react.js',
        'components/Button/Button.html',
        'reference/16-upstream-gate.md',
        'tokens/variables.css',
      ]),
    ).toEqual([])
  })

  it('每条保护规则都带 why（消费者要知道为什么被拦）', () => {
    for (const rule of PROTECTED) expect(rule.why.length).toBeGreaterThan(10)
  })
})
```

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

```bash
pnpm vitest run tests/check-designsync-upload-safety.test.mts 2>&1 | tail -5
# 期望：FAIL —— Cannot find module '../scripts/check-designsync-upload-safety.mjs'
```

- [ ] **Step 3: 写最小实现**

`scripts/check-designsync-upload-safety.mjs`：

```js
#!/usr/bin/env node
/**
 * check-designsync-upload-safety.mjs
 *
 * 上传到 Sync A（bfca04be，唯一保留的 Claude Design 项目）之前跑一次：给定待写路径清单，
 * 命中「A 独有的策展内容 / app 编译产物」即 FAIL。
 *
 * 为什么存在：Sync B 退役后 driver 的目标改成了 A，而 driver 是按 package 形态整目录产出的，
 * A 却是策展项目。一次不设防的全量上传会静默废掉邮件族 mockup 的 CTA 样式（根 styles.css 里
 * 那段手工 legacy .btn）、覆盖策展 mockups/versions/screenshots、或用手写 JSON 顶掉 app 编译的
 * _ds_manifest.json。INFRA-F78 已经吃过一次同类的「同名 styles.css 两个 producer」，教训是把
 * 「别覆盖」变成数据、不靠人记。
 *
 * 用法：node scripts/check-designsync-upload-safety.mjs <path>...
 *      （或 `pnpm check:upload-safety -- <path>...`）
 */
export const PROTECTED = [
  { test: (p) => p === 'styles.css', why: 'A 根 styles.css 由 /design-sync 驱动产出后被策展过：手工加了邮件族 mockup 的 CTA 依赖的 legacy .btn 段。覆盖 = 静默废掉全部邮件 mockup 按钮样式（INFRA-F78）。' },
  { test: (p) => /^(mockups|share|screenshots|versions|uploads)\//.test(p), why: '策展内容，仓库里没有真源，覆盖即永久丢失。' },
  { test: (p) => p === '_ds_manifest.json', why: 'app 编译产物（cards[] 索引）。API 写不重建它，手写会让面板指向错路径；改卡片集要走哨兵 + SPA 重编译。' },
  { test: (p) => /^reference\/1[45]-/.test(p), why: 'reference 14/15 是 A 上的策展文档（email-templates / design-retrospective），仓库无真源；编号占用真源见 docs/internal/claude-design-reference-registry.json。' },
  { test: (p) => p === 'README.md', why: 'A 的 README 由 bundle 导出脚本生成后可能被策展过；确认无策展内容再显式放行。' },
]

/** @param {string[]} paths @returns {{path:string, why:string}[]} 命中项 */
export function checkPaths(paths) {
  const hits = []
  for (const p of paths) {
    const rule = PROTECTED.find((r) => r.test(p))
    if (rule) hits.push({ path: p, why: rule.why })
  }
  return hits
}

if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop())) {
  const paths = process.argv.slice(2)
  if (paths.length === 0) {
    console.error('用法：node scripts/check-designsync-upload-safety.mjs <path>...')
    process.exit(2)
  }
  const hits = checkPaths(paths)
  if (hits.length) {
    console.error(`✗ 上传安全检查 FAIL —— ${hits.length} 条命中受保护路径：\n`)
    for (const h of hits) console.error(`  ${h.path}\n    ↳ ${h.why}\n`)
    console.error('  若确实要覆盖：删掉该路径改为定向手工处理，或先把远端那份取回仓库存档。')
    process.exit(1)
  }
  console.log(`✓ 上传安全检查 PASS · ${paths.length} 条路径均不在受保护清单内`)
  console.log('  注意：本检查只拦「已知不可覆盖」的路径，不保证清单完整 —— finalize_plan 仍须 owner 过目。')
}
```

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

```bash
pnpm vitest run tests/check-designsync-upload-safety.test.mts 2>&1 | tail -5   # 期望 5 passed
node scripts/check-designsync-upload-safety.mjs styles.css components/Button/Button.html; echo "exit=$?"  # 期望 exit=1 且只列 styles.css
node scripts/check-designsync-upload-safety.mjs _ds_bundle.js; echo "exit=$?"   # 期望 exit=0
pnpm run audit:scripts-stdlib   # 期望 PASS（本脚本只用 stdlib）
```

- [ ] **Step 5: 接线 + config 切目标 + NOTES 写流程**

```bash
node -e "const fs=require('fs');const p='package.json';const j=JSON.parse(fs.readFileSync(p));j.scripts['check:upload-safety']='node scripts/check-designsync-upload-safety.mjs';fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n')"
python3 - <<'PY'
import json
p='.design-sync/config.json'
d=json.load(open(p))
assert d['projectId']=='406a39da-1681-4edf-ae26-826cf3c33c9c', f"projectId 已不是 Sync B：{d['projectId']}（有人改过，先查清）"
d['projectId']='bfca04be-9783-4eb5-b754-37b04a6c35ef'
json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)
open(p,'a').write('\n')
print('driver 目标已切到 Sync A')
PY
```

`.design-sync/NOTES.md` 新增 `## Upload safety — driver 现在指向 Sync A（2026-07-30）`，内容至少含：① 为什么危险（driver 整目录 vs A 策展）② **上传前必跑** `pnpm check:upload-safety -- <所有待写路径>`，非 0 即停 ③ 受保护清单的真源在脚本的 `PROTECTED`（改清单去那里，不要在 NOTES 里维护第二份）④ 检查通过 ≠ 可以直接写：`finalize_plan` 仍要贴给 owner 过目 ⑤ 若动了卡片集合，走 `_ds_needs_recompile` 哨兵 + SPA 重开 + `get_file _ds_manifest.json` 亲验。

- [ ] **Step 6: 提交**

```bash
git add scripts/check-designsync-upload-safety.mjs tests/check-designsync-upload-safety.test.mts package.json .design-sync/config.json .design-sync/NOTES.md
git commit -m "feat(design-sync): driver 目标切到 Sync A + 上传前受保护路径机械检查"
git push origin master && git push github master
```

---

### Task 5: 远端冷归档 Sync B（只加标记，不删文件）

**Files:**
- 远端（Sync B `406a39da`）：`ARCHIVED.md`（新增）· `README.md`（加归档横幅）

**Interfaces:**
- Consumes: Task 2 的全部裁定（**每条 `unique-undecided` 都必须已有裁定，否则不许归档**）。

- [ ] **Step 1: 前置断言**

```bash
grep -c "裁定" docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md   # 应 ≥ unique-undecided 条数
```

若 Task 2 有「STOP 问 owner」的分支未闭合 → **停在这里**，别归档。

- [ ] **Step 2: 取 B 现有 README 原文（要在它上面加横幅，不是覆盖掉）**

```
DesignSync get_file projectId=406a39da-1681-4edf-ae26-826cf3c33c9c path=README.md
```

- [ ] **Step 3: 本地写好两份文件，贴给 owner 确认**

`ARCHIVED.md` 正文要素（英文或中文均可，与项目内既有语言一致）：这个项目 2026-07-30 起停止维护 · 唯一在维护的设计系统是 "TVU Networks Design System"（`bfca04be`）· 本项目的文件**未被删除**，是冷归档、可逆 · 内容分类与去向见仓库 `docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md` · 若 Task 2 判为「无消费者不搬」，在这里明写**失去的是哪一层**（per-component `.prompt.md` / `.d.ts`）· 不要在这里写 owner 的私人频道 id 之类内部标识。

`README.md` = 原文最前面插入 3–5 行横幅（`> ⚠️ ARCHIVED …` + 指向 `ARCHIVED.md`），**原文其余部分逐字保留**。

- [ ] **Step 4: 上传前跑安全检查（同样适用：别覆盖不该覆盖的）**

```bash
pnpm check:upload-safety -- ARCHIVED.md README.md
# 期望 exit=1 并命中 README.md —— 这是**预期**：本次是有意的定向覆盖，且 Step 2 已取回原文、Step 3 是「原文 + 横幅」。
# 记录这条判断到 handoff 文档（"检查报警但人工放行，理由 + 已取回原文"），不要静默无视。
```

- [ ] **Step 5: 归档写入（主 session，attended）**

```
DesignSync finalize_plan projectId=406a39da-… writes=["ARCHIVED.md","README.md"] localDir=<scratchpad 目录>
   → 贴 planId + 精确路径清单给 owner 确认
DesignSync write_files planId=… files=[{path:"ARCHIVED.md",localPath:"ARCHIVED.md"},{path:"README.md",localPath:"README.md"}]
DesignSync get_file projectId=406a39da-… path=ARCHIVED.md     # 亲验字节
DesignSync get_file projectId=406a39da-… path=README.md       # 亲验横幅在最前、原文未丢
```

- [ ] **Step 6: 列出 owner 待办（AI 做不到的部分）**

`DesignSync` 的 `method` 枚举里**没有 rename**，所以改项目名只能 owner 在 claude.ai UI 做。把这两条写进 Task 6 的 backlog entry：
1. 把 B 改名为 `ARCHIVED — TVU UX Design System (superseded by TVU Networks Design System)`；
2. 冷却期后（建议 **2026-09-30** 之后）再决定是否删除项目 —— 删除不可逆，**AI 不主动提删**。

- [ ] **Step 7: 提交（本地只有 handoff 文档变化）**

```bash
git add docs/internal/_handoffs/2026-07-30-sync-b-retirement-classification.md
git commit -m "docs(handoff): Sync B 已冷归档（ARCHIVED.md + README 横幅，逐文件 get_file 亲验）"
git push origin master && git push github master
```

---

### Task 6: 状态与残余登记（STATUS + backlog INFRA-F81）

**Files:**
- Modify: `docs/internal/backlog.md`（新增 INFRA-F81）
- Modify: `docs/STATUS.md`（顶部摘要 + §Active 行 + 计数）

- [ ] **Step 1: 核 ID 不撞车**

```bash
grep -roE 'INFRA-F[0-9]+' docs/ AGENTS.md | grep -oE '[0-9]+' | sort -n -u | tail -3   # 期望最大值 80 → 用 81
```

- [ ] **Step 2: 写 INFRA-F81 entry**

必含字段（对齐既有 entry 结构）：轨道（B 流程·支柱③ 适配器）· 优先级 · 发现/决策时间（owner 2026-07-30 同意留 A 退 B）· 问题（两套系统双份维护成本 + reference 编号共享命名空间 + driver 目标与策展项目形态冲突）· **已 shipped**（Task 1–5 各自产出 + commit hash 逐条列，hash 从 `git log --oneline` 逐字复制）· **残余**：① owner 在 UI 改名 ② 冷却期后（≥2026-09-30）决定是否删除项目 ③ 若 Task 2 判为不搬：per-component `.prompt.md`/`.d.ts` 这一层能力已失，替代面 = `SKILL.md` + `reference/02` + `composition.json` + npm `.d.ts` ④ `.design-sync/reapply-driver-patches.mjs` 的 L3 / F70-DTS 两个补丁**若** prompt/dts 不再被消费则成为死重，是否退役由下次 `.ds-sync` 重建时判 · 触发查看条件 · 阻塞关系（独立）。

- [ ] **Step 3: 更新 STATUS**

顶部加一段摘要（收敛结论 + 为什么留 A：A 独占不可再生策展物、能力已是超集；B 全部可再生）+ §Active 行加 F81 + `Backlog (active) (N)` 计数 +1。

- [ ] **Step 4: 闸复跑**

```bash
pnpm run audit:status-consistency   # C4 会核 STATUS 计数 vs backlog Active 条数
pnpm run audit:doc-sync && pnpm run audit:stale-anchors
pnpm run audit:reference-numbering && pnpm run audit:variables-freshness
pnpm test 2>&1 | tail -3
```

- [ ] **Step 5: 提交**

```bash
git add docs/STATUS.md docs/internal/backlog.md
git commit -m "docs(status): Claude Design 收敛为单一系统（留 Sync A）+ 登记 INFRA-F81 退役残余"
git push origin master && git push github master
```

---

### Task 7: 收尾核验（证明没有悬挂引用、没有静默丢失）

- [ ] **Step 1: 当前态文档里不该再有「活的」Sync B 引用**

```bash
grep -rn "406a39da\|emit-reference-to-sync-b" \
  docs/STATUS.md docs/internal/backlog.md .design-sync/NOTES.md .design-sync/config.json \
  docs/internal/claude-design-reference-registry.json package.json scripts/ tests/ 2>/dev/null
# 期望：只剩「ARCHIVED / 史料 / 退役叙述」性质的命中；registry / config / package.json / scripts / tests 里零命中
```

- [ ] **Step 2: 两个项目的实况快照对比**

```
DesignSync list_files projectId=bfca04be-…   # A：应与 Task 1 基线一致（+ 若 Task 2 判搬则多 72 个文件）
DesignSync list_files projectId=406a39da-…   # B：应与 Task 1 基线一致 + 多出 ARCHIVED.md（README.md 数量不变）
```

把两份计数写进 handoff 文档收尾段。**A 的策展路径（mockups/share/screenshots/versions/uploads/styles.css）条数必须与 Task 1 基线逐一相等** —— 不等就是被覆盖了，立即停下查。

- [ ] **Step 3: 全闸 + push 落地亲验**

```bash
for g in audit:status-consistency audit:doc-sync audit:stale-anchors audit:reference-numbering audit:variables-freshness audit:scripts-stdlib; do printf "%-32s " $g; pnpm run $g >/tmp/o 2>&1 && echo PASS || { echo FAIL; tail -5 /tmp/o; }; done
git rev-parse HEAD; git ls-remote origin master | cut -f1; git ls-remote github master | cut -f1   # 三处一致
```

- [ ] **Step 4: 写 work-log + 更新 STATUS "Last updated"**（`docs/WRAP-UP.md` 协议）

---

## Self-Review（本计划写完后的自查结果）

**1. 需求覆盖**：owner 的需求是「只保留一个、不要有遗漏和缺失」。遗漏防线 = Task 1 的四桶 + 计数等式（机械口径，桶和 ≠ 总数就不许往下走）+ Task 7 Step 2 的策展路径逐项等值断言。缺失防线 = Task 2 强制对「B 独有产物」逐条裁定，且**判为不搬时必须显式登记能力损失**（不允许"没提到就算没了"）。

**2. 占位扫描**：无 TBD/TODO；每个代码步骤都给了可直接跑的命令与预期输出；registry 改动给了完整脚本；上传安全检查给了完整实现 + 5 条单测。

**3. 类型/命名一致性**：`checkPaths` / `PROTECTED` 在测试与实现中同名同形；`projectId` 常量在 config 与各 DesignSync 调用中一致；registry 的键名（`producers` / `numbers[].producers[]` / `projects[].id`）与 `scripts/audit-claude-design-reference-numbers.mjs` 实际读取的字段一致。

**4. 已知的不确定项（刻意留成"按证据判"而非猜）**：A 是否消费 `.prompt.md`/`.d.ts` —— Task 2 用两次 `get_file` 判，三种结论各有明确动作，含「证据不足 → STOP 问 owner」。

**5. 刻意不做**：不删 B 的远端文件 · 不改项目名（API 无此方法，owner UI 事）· 不动第三个非本人项目 `019dfb04` · 不追溯改写史料文档 · 不为 dc-runtime 的 `live-device-monitor` 找回/重建（owner 2026-07-24 已定放弃）。
