# 工程改进清单

> **这个文件存的是计划**：待办、依赖图、验收标准，以及已经探明的结论。它不跟踪进度——运行时状态
> （pending / in_progress / completed，以及哪条分支在做哪一项）由 coordinator 在仓库之外的共享任务
> 列表里维护，不进 git。
>
> **业务 PR 不要改这个文件。** 理由是争用：若每完成一项都要回来打个勾，所有并行 PR 最后都在这一个
> 文件上相遇，而「总览」表的行是连续的，相邻两行同时改就是实打实的冲突。这个文件属于控制平面，
> 与业务改动分开走。结论有变化时（例如探针跑完发现问题不存在）改这里的正文并写明，不要直接删条目——
> 删掉就等于把判断过程一起丢了。
>
> **基线**：2026-09-01，v0.3.2（`2c789fa`）。文中行号引用以该提交为准，重构后可能漂移——这已经
> 发生了：`picker.rs` 在 `97a3e63` 上是 8400 行，不再是下文写的 7931 行。引用行号时请当成
> 「大致位置」而不是坐标。
>
> **2026-09-02 起追加「落地后的修正」小节**：批次 0-3 与 4.1-4.4 已经跑完，多条的**结论**因此变了，
> 逐条写在原文之后。这些小节记的是探明了什么、哪条预测没命中，不是进度勾——进度仍然不进 git
> （见上）。原文一字不删，预测错了也留着：「当初怎么想的」和「实际怎么发生的」并排放才有信息量，
> 只留后者的话，下次还会以同样的方式猜错。

本清单是一次代码库体检的产出，覆盖 10 项工程债。排序依据是「尾巴是否可控」而非代码量：
有些任务改动只有几行，但它打开的是一扇之前关着的门，门后有多少东西事前无法预估。
每项因此给两个成本——**落地成本**（写这个改动本身）和**尾巴**（它可能暴露出的后续工作）。

## 总览

| # | 任务 | 优先级 | 落地成本 | 尾巴 | 已探明 |
| --- | --- | --- | --- | --- | --- |
| 1 | panic 后终端卡在 raw mode | P0 | 1 小时 | 小 | 2026-09-02 验收标准不可执行，改用 PTY 探针 |
| 2 | fish / zsh payload 无任何检查 | P0 | 20 分钟 | 中（两边都已探明干净） | 2026-09-01 fish；2026-09-02 zsh 在 CI |
| 3 | macOS 二进制未经测试就发布 | P0 | 10 分钟 | 不可控 | 2026-09-02 咬人的是测试可移植性，非用户 bug |
| 4 | `picker.rs` 体量大且无帧级测试 | P1 | 1-2 周 | 分阶段可控 | 2026-09-02 「几乎无测试」是误判，见 §4 |
| 5 | MSRV 1.74 缺 CI 固化 | P1 | 15 分钟 | 无（已探明） | 2026-09-01 1.74.1 通过；2026-09-02 载体预测未命中 |
| 6 | 发布流程两个硬失败点 | P1 | 20 分钟 | 小 | — |
| 7 | 缺 ARM Linux / musl 产物 | P2 | 2-4 小时 | 中 | 2026-09-02 一半达成、一半明确未达成 |
| 8 | 无依赖审计与 dependabot | P2 | 1-2 小时 | 中 | 2026-09-02 豁免曾整类失效，见 §8 |
| 9 | `toml` 精确锁定原因失传 | P2 | 5 分钟 | 无 | 2026-09-02 已放宽并写明约束 |
| 10 | 缺 CHANGELOG / SECURITY / 模板 / 英文 README | P2 | 见分项 | 无 | — |

## 执行批次

批次内的任务建议一个 PR 打包完成——2、3、5、6 都在改 CI 配置的同一片区域，分四次提会反复触发
全量 CI，也容易互相冲突。批次之间只有一条硬依赖：**4.1 必须在 4.2 之前**。

| 批次 | 内容 | 时间窗 | 说明 |
| --- | --- | --- | --- |
| 0 | 1 | 1 小时 | 纯收益、零风险，可立即做 |
| 1 | 9 + 10a + 10b | 1 小时 | 纯增量文件，不碰现有逻辑 |
| 2 | 2 + 3 + 5 + 6 | 1 小时写 + 预留半天收尾 | CI 加固；**不要和发版排在同一周** |
| 3 | 7 + 8 | 1 天 | 面向外部用户与供应链 |
| 4 | 4.1 → 4.2 → 4.3 → 4.4 | 1-2 周 | 0.4 主线，单独开分支 |

批次 2 的收尾预算原本估 1 天，2026-09-01 探掉 #2 的 fish 半边和 #5 之后砍到半天：四个未知量里
两个已确认无事，剩下的不确定性几乎全集中在 #3 的 macOS 那一项上。这个批次的作用恰恰是把之前
看不见的失败变成可见的红叉，而红叉数量在动手时仍然是未知的。

## PR 与依赖图

2026-09-01 决定：从现在起每项走独立分支 + PR 合并。仓库历史里 `#5`–`#9` 本来就是
`Merge pull request` 提交，最近 6 个提交才转成直接推 main，所以这是恢复旧习惯而非新增约束。

一旦每项都是独立 PR，决定顺序的就不再是逻辑先后，而是**文件争用**——三个 PR 同改 `ci.yml`
就要处理三次 rebase 冲突。于是有一条硬规则：**并行进行的任务之间禁止共享文件**，一个文件在同一
时刻只能有一个 PR 在改；两项任务要碰同一个文件，就只能排成串行链，而不是同时开工。争用情况：

| 文件 | 争用它的任务 |
| --- | --- |
| `src/picker.rs` | #1、#4.1、#4.2、#4.3、#4.4 |
| `.github/workflows/ci.yml` | #2、#3、#5、#8 |
| `.github/workflows/release.yml` | #3、#6、#7 |
| `Justfile` | #2、控制平面（PR-10） |
| `docs/roadmap.md`、`scripts/tools/*`、`.githooks/` | 控制平面（PR-10）独占 |
| 只新增文件 | #9、#10a、#10b、#10c |

这张表能当规则用的前提是它没有漏项，而它原本漏了一个最大的：`docs/roadmap.md` 自己。旧用法要求
每完成一项就回来打勾并填状态列，那样每一项都在争用这个文件，表里却看不到它，而且争的还是「总览」
表相邻的几行。取消打勾要求、把运行时状态挪到仓库之外，正是为了让这张表说的是真话。

2026-09-02 补上第二个漏项：**控制平面自己的那几个文件**（本清单、提交信息校验脚本、`Justfile` 的
检查配方、`.githooks/`）。它们的特点是每个业务 PR 都可能顺手想动一下，而它们又恰好是判定业务 PR
能不能过的那套规则——规则和被规则约束的改动在同一个 PR 里，就没有人在把关。做法是把它们收进
一个独立节点（PR-10），业务 PR 一律不碰。`Justfile` 因此同时出现在两行里：#2 加的是 payload 的
lint 内容，PR-10 改的是检查编排本身，两者不能同时开工。

于是形成两条互不相交的串行链加两个自由节点。两条链碰的文件完全不重叠，可以真并行：

| PR | 分支 | 内容 | 改哪些文件 | 前置 |
| --- | --- | --- | --- | --- |
| PR-1 | `tui-panic-hook` | #1 | `src/picker.rs` | 无 |
| PR-2 | `docs-chores` | #9 + #10a + #10b | `Cargo.toml` + 新文件 | 无 |
| PR-3 | `ci-hardening` | #2 + #3 + #5 + #6 | `ci.yml`、`release.yml`、`Justfile` | 无 |
| PR-4 | `ci-supply-chain` | #7 + #8 | `release.yml`、`ci.yml` + 新文件 | PR-3 |
| PR-5 | `tui-render-snapshots` | #4.1 | `src/picker.rs` | PR-1 |
| PR-6 | `tui-extract-cube` | #4.2 | `src/picker.rs` + 新模块 | PR-5 |
| PR-7 | `tui-overlay-gutter-isolation` | #4.3 | `src/picker.rs` + 新模块 | PR-6 |
| PR-8 | `tui-render-state-split` | #4.4 | `src/picker.rs` | PR-7 |
| PR-9 | 待定 | #10c | 新文件 | 无（可选） |
| PR-10 | `control-plane` | 检查编排本身（不属于 1-10 任何一项） | `Justfile`、`scripts/tools/*`、`.githooks/`、本清单 | 无 |

**前置一列混了两种依赖**，得分开看：PR-5 等 PR-1 是文件争用（两者同改 `src/picker.rs`，逻辑上毫无
关系），而 PR-6 等 PR-5 是真逻辑依赖（4.1 的快照是证明 4.2 搬动没有改变渲染的唯一手段）。这个区别
决定了将来哪里还能拆——文件争用那种，只要能把改动错开到不同文件就能并行；真逻辑依赖拆不掉。

这套划分与上一节的批次一一对应：批次 0 → PR-1，批次 1 → PR-2，批次 2 → PR-3，
批次 3 → PR-4，批次 4 → PR-5 至 PR-8。#10c 两边都没排进去，单独作 PR-9；PR-10 在原始划分里
根本不存在（它改的是检查编排本身，不属于清单里的十项），也不占任何批次。

**关键路径**：PR-1 → PR-3 → PR-5 → PR-6 → PR-7 → PR-8。PR-2 随时插空，PR-4 等 PR-3 合完。

**一条软依赖**：PR-3 要在 4.1 之前落地。它们不碰同一个文件，所以不是冲突问题——理由是
4.1 到 4.4 是 1-2 周的连续改动，全压在全仓风险最高的文件上。先把 macOS job 和 1.74 job
装好，这一路重构从第一天起就在三个平台上被验证；反过来做，两周后才发现某处改动在 macOS 上
不成立，回溯成本高得多。

**分支命名**沿用仓库惯例的裸 slug（`tui-unexclude-panel`、`fix-unexclude-same-session`），
不加斜杠前缀——`feat/install-ui-progress` 是历史上唯一的例外。合并沿用 merge commit，不 squash。

**提交信息要过 `scripts/tools/commit-msg-check.sh` 的硬校验**，这里有个坑：scope 是白名单
强制（`install|bash|fish|zsh|history|paths|recommend|controller|readme|release|ci|tui`），
不在表里的一律拒，所以 `docs(roadmap):` 会被拒，无 scope 的 `docs:` 才通过。结尾也不能有句号或
感叹号（中英文标点都算）。

标题长度的上限是 **72 显示列**，不是 72 字符也不是 72 字节：ASCII 每字符算 1 列、中日韩每字符算
2 列，所以 72 列约等于 36 个汉字。原文这里写的「上限 72 字符」正是 2026-09-02 修掉的那个歧义的
一部分——脚本当时用 `${#subject}` 卡长度，而这个值在 UTF-8 locale 下是**字符数**、在 `LC_ALL=C`
下是**字节数**，同一条中文标题在两台机器上能得出不同结论，钩子却跑在别人的机器上，locale 不由
我们决定。三种度量里选显示宽度，因为 72 这个数字的来历就是「80 列终端里 `git log` 不折行」。

同一次改动还修了提示语：原本写「推荐的 scope」而行为是强制。**提示语与实现不一致时，读提示语的人
会按提示语行事然后被拒**——这和验收标准写成人工承诺（见 §5 末）是同一类问题：文字承诺的必须
正好是代码在做的那件事。

**git 钩子在 linked worktree 里曾经是死的**（2026-09-02 修）。旧装法把 `core.hooksPath` 指向
`.git/hooks`，而 linked worktree 的 `.git` 是**文件**不是目录，于是这个相对路径在每个 worktree 里
都解析到一个不存在的目录，**git 对「钩子目录不存在」是静默放过的**——上面这些规则从来没被钩子
执行过一次，全靠人自觉。现在钩子进了版本控制（`.githooks/`，`core.hooksPath = .githooks`，
用 `just hooks-install` 装），主树与 linked worktree 都实测会拦。同一个坑还有一个推论：并行开发
用的多个 worktree 里，任何「装在 `.git/` 下」的东西都不共享也不生效。

这套修法还留一个已知残留（2026-09-02 记）：相对 `hooksPath` 解析的是**当前签出的那份**
`.githooks/`，所以签出点早于引入该目录时（bisect 走到历史提交、基于旧基线的 linked worktree），
钩子同样静默失效——切回新提交即恢复，`install-hooks.sh` 的输出里有这条提醒。

## P0：会直接伤到用户

### 1. panic 后终端卡在 raw mode

**是什么** —— 已确认，不是推测。三条事实叠加：

- `Cargo.toml:34` 设了 `panic = "abort"`，release 构建不做栈展开，**`Drop` 不执行**。
- 终端恢复完全挂在 `Drop` 上：`src/picker.rs:742` 的 `impl Drop for TermGuard`，748-749 行做 `Show` + `LeaveAlternateScreen` + `disable_raw_mode`。
- 全仓没有任何 `std::panic::set_hook`（`grep -rn 'set_hook' src/` 零匹配）。

结论：release 版进入 alternate screen 之后一旦 panic，`TermGuard::drop` 永远不会被调用，
终端就留在 raw mode + alternate screen 里。

**为什么本地测不出来** —— debug 构建默认 `panic = "unwind"`，`TermGuard::drop` 正常执行，终端能恢复。
这个故障只在 release 版出现，也就是只在用户那边出现。

**触发条件** —— 进入 alternate screen 之后的任意 panic。`picker.rs` 有 121 处 `unwrap()`/`expect()`，
全仓最多。真实先例是刚修掉的两条：`cb7cdfd`（大写多字符查询 panic）、`bc6fa5a`（光标按字节切多字节路径）。
这类 bug 复现时用户看到的不是报错信息，而是终端彻底失灵——无回显、无换行，只能盲敲 `reset`。

**怎么修** —— 在进入 alternate screen 之前装 `std::panic::set_hook`：先恢复终端
（`disable_raw_mode` + `LeaveAlternateScreen` + `Show`），再调用事先 `take_hook()` 拿到的默认 hook
打印原始 panic 信息。`abort` 之前 hook 是会执行的，所以这条路有效。保留现有 `TermGuard`，
两者不冲突：hook 管 panic 路径，Drop 管正常退出和 `?` 早退路径。

**验收标准** —— 在 picker 主循环里临时插一句 `panic!("test")`，`cargo build --release` 后运行，要求
panic 信息可读地打印在正常屏幕上、且 panic 后终端仍能正常回显和换行。验证完删掉临时代码。

**落地后的修正（2026-09-02）** —— 修法成立，但上面这条验收标准**不可执行**：「终端仍能正常回显
和换行」是人眼判据，而 `cargo test` 跑在非 tty 下，根本进不了 alternate screen，也就复现不出故障。
实际验证换成了 PTY 探针——在伪终端里跑二进制，panic 之后读 termios 的 `ECHO` / `ICANON` 标志位，
用数值断言终端已复原。要点在于 `panic = "abort"` 让 `Drop` 不执行，所以「终端有没有复原」只能从
tty 状态本身读出来，不能从退出码或 stderr 推断——盯错了信号源，一个坏掉的 hook 也会显得正常。

**成本** —— 落地 1 小时；尾巴小。全表里收益/成本比最高的一条，因为它保护的是用户能直接感知的最严重故障。

### 2. fish / zsh payload 无任何检查

**是什么** —— `Justfile` 的 `shell-lint` 只做 `find scripts -type f -name '*.sh'` 加上 `docs/install.sh`，
然后 `bash -n`。以下四个文件因此从未被任何工具看过：

- `scripts/installers/fish/payload/cdh.fish`
- `scripts/installers/fish/payload/cdh_log.fish`
- `scripts/installers/zsh/payload/cdh.zsh`
- `scripts/installers/zsh/payload/cdh_log.zsh`

**探针结论（2026-09-01）** —— fish 那两个文件已在本地验过，`fish --no-execute` 全部通过，干净。
zsh 半边当时仍未验证（本地没装 zsh），留给 CI——**这一半已经补上**，见本节末的落地后修正。

同时验明了这个检查的能力边界——一个只会说 PASS 的检查等于没有检查：

| 用例 | `fish --no-execute` |
| --- | --- |
| `if` 缺 `end` | 报错并指出行号 |
| 命令名拼错 `echoo hello` | 放过 |
| 引用未定义变量 | 放过 |

所以它抓的是**结构性语法错误**：块不闭合、引号不配对、`function` / `switch` 缺 `end`。
这修正了本条最初的判断——原话说「改 payload 时打错一个字，CI 全绿」，实际上**加了这个检查之后，
打错命令名照样全绿**。防命令拼错要靠真装一遍再执行的集成测试，那是另一个量级的工程，不在本条范围内。

`zsh -n` 的价值可能比 fish 这半边更高：zsh payload 通过 `chpwd_functions` 挂载，语法比 fish 复杂，
出结构性错误的空间更大。这也是本条保持 P0 的理由——zsh payload 至今没被任何工具看过，
如果它里面有块不闭合，那现在就是坏的。

**触发条件** —— 改 fish 或 zsh payload 时打错一个字。CI 全绿、release 正常产出，用户装完后 shell
启动报错，或者 `cdh` 函数根本不存在。这些 payload 是写进用户 rc 文件的，出问题影响他们每次开终端。

**怎么修** —— `shell-lint` 里加两段：fish 文件跑 `fish --no-execute`，zsh 文件跑 `zsh -n`。
CI 已经在 ubuntu 上，加一句 `apt-get install -y fish zsh` 即可。

**范围提醒** —— 别顺手把 `bash -n` 升级成 `shellcheck`。`bash -n` 只查语法，未加引号的变量展开、
`set -e` 失效场景一概放过，确实该升级；但 shellcheck 对存量脚本一般能报出几十条 warning，
需要逐条决定修还是 `# shellcheck disable`。**单独立项**，不要混在这条里。

**验收标准** —— 故意在 `cdh.fish` 里写一处语法错误，`just shell-lint` 必须失败；改回后必须通过。zsh 同理。

**成本** —— 落地 20 分钟；尾巴不可控——这四个文件第一次被检查，冒出真实错误的概率不低，
那之后就是逐个修 shell 脚本。

**落地后的修正（2026-09-02）** —— zsh 半边不再「留给 CI」：CI 上已经装了 fish + zsh 并真跑过
`zsh -n`，四个 payload 全部通过，没有结构性错误。预估的「冒出真实错误的概率不低」**没有命中**，
尾巴实际为零。

这里留了一处刻意的不对称：本机没装 zsh 时 `just shell-lint` 打印 `shell-lint: SKIP …未被检查`，
而在 CI（`CI` 环境变量非空）上找不到解释器**直接 FAIL**。**SKIP 是会喊出来的缺口，不是通过**——
本条最初的病灶恰恰是缺口静默，所以「缺工具」绝不能表现成绿。同理 `lint_payload` 在 payload 目录下
找到 0 个文件时也 FAIL：目录哪天被改名或搬走，检查会说话，而不是变成一个永远通过的空跑。

还补上了一处同类盲区：`.githooks/` 下的钩子按约定**不带扩展名**，而三条 shell 配方都按
`-name '*.sh'` 筛文件，于是钩子本身从来不在格式化和语法检查的范围里。已改成对 `.githooks` 单独
一条不带后缀过滤的 `find`（shfmt 靠 shebang 识别语言，实测可行）。这类「检查存在，但检查不到
东西」的形态和上面 zsh 那半边是同一个病。

### 3. macOS 二进制未经测试就发布

**是什么** —— `.github/workflows/release.yml` 的 `test` job 只有 `runs-on: ubuntu-latest`，
但 `build-and-release` 矩阵产出三个目标，其中 `x86_64-apple-darwin` 和 `aarch64-apple-darwin`
从没跑过测试。`ci.yml` 同样只有 ubuntu。

**触发条件** —— 任何平台差异：XDG 路径解析（macOS 默认没有 `XDG_DATA_HOME`）、终端 key code 差异、
`strip` 参数差异。Mac 用户装上后行为不对，而本地和 CI 都看不到。

**怎么修** —— `ci.yml` 的 check job 改成 `strategy.matrix.os: [ubuntu-latest, macos-latest]`；
`release.yml` 的 `test` job 同样加 macos。构建矩阵本来就有 macOS runner，测试补上几乎不增加维护面。

**验收标准** —— 两个 workflow 都在 macos-latest 上跑完 `cargo test --locked --all` 且为绿。

**成本** —— 落地 10 分钟；尾巴不可控，留半天到一天缓冲。好消息是**这些失败本来就是已经存在的
Mac 用户 bug，只是现在才看见**，所以修它们不是额外工作，是把已经欠下的债显性化。

**落地后的修正（2026-09-02）** —— macOS job 已经上了，第一次开就红，但咬人的**不是**上面预测的
那一项，而上面这句「本来就是已经存在的 Mac 用户 bug」对实际发生的事**不成立**：

- 预测的载体是 XDG 路径解析（macOS 默认没有 `XDG_DATA_HOME`）。**这条没有命中**，一次都没红。
  保留在上面不删，标记为未命中的预测。
- 实际是 macOS 把 `/var` 软链接到 `/private/var`，撞上 `log_visit` 会 `fs::canonicalize`：测试用
  `TempDir` 建的目录落在 `/var/folders/…`，而拼出来的**期望值**没过同一道归一化，于是期望值与
  写进历史的真实路径不相等。位置在 `src/history.rs` 的测试里，不在生产路径上。
- 所以这是**测试的可移植性缺陷，不是 Mac 用户 bug**。生产代码解析软链接是对的行为，Mac 用户
  看不到任何异常；错的是测试自己带了平台假设（默认 `TempDir` 给的路径就是规范路径）。

方法论产出：**跨平台 job 第一次开时，最可能抓到的是测试的平台假设，而不是产品的平台缺陷。**
两者都值得抓，但预算和叙述要分开——把前者说成后者，会让人以为修完了一个用户故障，
实际上产品那边什么都没变，而真正的平台缺陷（如果有）仍然藏着。

## P1：迟早会咬人

### 4. `picker.rs` 体量大且无帧级测试

**是什么** —— 单文件占全仓 14097 行的 56%，里面同时装着渲染、键盘输入、分页、预览面板、
四个浮层（help / settings / excludes / 删除确认）、3D 立方体、模糊匹配高亮。与此同时 `tests/` 只有 385 行
（`install_script.rs` 208 + `tui_settings_non_tty.rs` 177），`AGENTS.md` 自己写着覆盖率集中在
frecency / history / recommend——**缺陷最密集的文件恰好是没有护栏的那个**。其余模块行数都正常
（`recommend.rs` 1349、`discover.rs` 1171、`tui_settings.rs` 886），说明这不是整体风格问题，
只是 picker 一直在往上堆。

本条标题原本写「7931 行且**几乎无测试**」，两处都要更正，见本节末的落地后修正：行数是基线快照
（`97a3e63` 上已 8400 行），而「几乎无测试」是误判——`picker.rs` 是全仓 `#[test]` 最多的文件。
浮层数量原本写「三个」，漏了删除确认那一个。

**触发条件** —— 每次改 TUI。最近两条 TUI 修复（`cb7cdfd`、`bc6fa5a`）都落在这个文件里，
就是这个结构的直接产物：改分页可能撞坏高亮，改光标可能撞坏浮层，而没有测试会告诉你。

**顺序不能反**：先铺测试网，再拆文件。4.1 结束后随时可以停，不会留下半拆状态。

- **4.1 铺测试网**（2-3 天）—— 用 ratatui 的 `TestBackend` 对固定候选集做渲染快照断言：
  选中行、高亮位置、分页边界、四个浮层、空结果态各一组。不改任何生产代码，风险为零，交付后立刻有价值
  （此后每个 picker 修复都有回归保护）。原文这里只列了四类却在验收里写「五类」，数不上——
  **补上「空结果态」这第五类**，因为落地时它确实需要单独一组（列表空掉时提示居中、其余行必须清白）。
  验收：`cargo test` 里存在覆盖上述五类场景的渲染断言，且故意改坏渲染逻辑时它们会红。
- **4.2 摘装饰性模块**（半天）—— 3D 立方体及其光照/次像素渲染是全文件里最独立的一块，
  适合当拆分的第一刀练手。原文写「与主状态几乎无耦合」，**这个判断是错的**：至少四处真实耦合——
  `App::corner_3d_enabled`（开关状态存在主结构体上）、`App::corner_anim_angle`（动画相位也存在
  主结构体上）、`reserve_corner_gutter`（布局要为它预留右下角，否则会压到内容）、以及事件循环
  超时里的 `CORNER_3D_FRAME`（动效帧率直接参与主循环的 poll 时长）。最后一条最不像耦合而最要紧：
  它意味着摘走立方体会动到**输入延迟**，不只是画面。所以「半天」这个估计偏乐观，
  真正的工作量在于决定这四处各自归谁。
  验收：立方体代码移出 `picker.rs`，`CDH_CORNER_3D=0/1` 行为不变，4.1 的快照全绿。
- **4.3 浮层各自独立**（3-5 天）—— help / settings / excludes / 删除确认四个浮层逐个搬出去，
  一个浮层一个提交，每步都有 4.1 的快照兜底。
  验收：四个浮层各自成模块，F1 / F2 / F4 以及 Ctrl-D（删除确认）行为不变。
  删除确认与前三个不同：它不由功能键打开，而是在选中行上按 Ctrl-D 进入，且带 `candidate_idx`
  状态，搬动时要连这个状态一起想清楚。
- **4.4 渲染与状态分离**（暂不估）—— 最深的一刀。做完前三步再重新评估，
  那时对实际耦合情况会清楚得多。

**成本** —— 1-2 周；分阶段可控。单独开分支，按阶段合。

**落地后的修正（2026-09-02，4.1 已落地）** —— 三处措辞要更正，最后一处是本条最有用的产出：

- **「几乎无测试」是误判。** `picker.rs` 恰恰是全仓 `#[test]` 最多的文件——立项时
  （基线 `2c789fa`）133 个，`97a3e63` 上 141 个，第二名 `tui_settings.rs` 只有 19 个。真正缺的是**帧级**测试：所有断言都停在
  「某个辅助函数返回了什么」这一层（一个 `Line` 的 spans、一个 `PageWindow` 的算术、一个
  `ScreenLayout` 的矩形），而 `draw` 把这些辅助函数**接错线**时它们全部保持绿色——那正是拆分这个
  文件会引入的失败模式。所以标题应是「无帧级测试」。
  推论：**数 `#[test]` 会高估覆盖**，它数的是断言条数，不是断言穿过了多少层。
- **行数是快照，会漂。** 7931 行是基线 `2c789fa` 上的值，`97a3e63` 上已是 8400 行。
- **浮层是四个不是三个**，漏的是删除确认（`render_confirm_delete`）。这个漏项有代价：按「三个」
  排 4.3 的工期会漏掉整整一个浮层，而它偏偏是四个里唯一带自己状态、且不由功能键打开的那个。
  落地的 4.1 覆盖了全部四个（那条测试自己的注释写的就是 "all four panels"）。

4.1 实际交付了 6 个走 `draw` + `TestBackend` 的帧级测试，覆盖上面列的五类；文件里用一条分隔注释
把「辅助函数层」和「帧层」明确切开，写明为什么前者挡不住接线错误。这道网是 4.2 唯一的安全绳。

**落地后的修正（2026-09-02，4.4 已落地）** —— 「暂不估」当时是对的：真正动手前的侦察给出了
比预估准得多的图景，而它和直觉不一样的地方在于**违规少、但藏在细处**：

- **侦察改写了预估**。渲染路径实读 `App` 37 个字段中的 20 个（`view()` 直接取的，加上经
  `page()` / `corner_3d_enabled()` / preview 判定间接读的），另 `run_ui` 每帧取 2 个
  （`corner_anim_started` 与 `theme_choice`），其余字段与渲染无关；真正的
  违规只有两处——`render_list` 在渲染中途经 `Cell` 反写鼠标命中区（渲染不是纯函数），
  `relative_time` 在渲染中读系统墙钟（同一帧的输出取决于它什么时候被跑）。
- **方案是 FrameView 借用式快照，不是重组 App**。`App::view()` 每帧把 draw 要碰的东西一次性
  借出成 `&FrameView`，draw 及全部 render_* / 浮层改吃这份快照。没走 App 重组那条路：那是
  1000+ 行、没有编译器兜底的搬动，而且重组 `App` 会顺手诱惑你动 preview 的并发结构——那是
  本条清单之外的风险，不该搭车。
- **两处违规各自的修法**：Cell 通道改成 `render_list` 返回 `ListGeometry`（在任何早退之前发布，
  顺序与原 Cell 写回一致），`run_ui` 在成功绘制后把它存进 `App` 的普通字段、`handle_mouse`
  直接读；时钟改为参数注入——`run_ui` 每个脏帧取一次，`view` 打包进快照（`now_unix`），
  `render_preview` 用既有的 `relative_time_at` 格式化，读墙钟的 `relative_time` 包装随之删除。
- **验证**：236 个测试全绿（230 单元 + 4 集成 + 2 安装脚本）；对抗验证零 P1；5 项变异验证全红。
  其中跨页点击测试专门锁住 geometry 的 `page.start` 映射——这一项是 verifier 抓到的变异幸存者
  补上的：此前的点击测试只覆盖单页候选池，把 `page.start` 清零它们照样全绿。它锁的是
  `render_list` 层（把 `start: page.start` 那行改掉测试会红），但第一跳——`run_ui` 里
  `app.last_list_start = geometry.start`（`picker.rs` 约 2367 行）——**仍是变异幸存者**：
  点击测试走的是 `render_frame` 这个测试侧镜像（它自己把 draw 的返回值存回 `App`，
  代码注释已如实标注「不是生产路径」）。roadmap 的结论不能比代码注释说得更满。
- **衔接**：有了 FrameView 这条缝，将来拆 `picker.rs` 文件或重组 `App` 的爆炸半径小得多——
  渲染侧已经只依赖一个窄接口，不再散读 19 个字段；要拆的模块只需要声明自己从快照里取什么。

行数快照随之更新：`c7ef46d`（4.4 合入）上 `picker.rs` 7666 行，拆出的 `picker_cube.rs` 1124 行、
`picker_overlays.rs` 403 行。§4 开头「单文件占全仓 56%」的比例随三刀落地而下降，但 `picker.rs`
仍是全仓最大的文件。另有一条 4.3 的未竟项在这里留痕：验收写的「四个浮层各自成模块」实际只搬了
三个（help / settings / excludes，作为 `picker_overlays.rs` 的子模块），删除确认（`render_confirm_delete`）
仍在 `picker.rs` 里——它是将来拆分的自然下一刀。

### 5. MSRV 1.74 缺 CI 固化

**是什么** —— `Cargo.toml:5` 写着 `rust-version = "1.74"`（注释说受 ratatui 0.29 约束），
CI 用的是 `dtolnay/rust-toolchain@stable`。这个数字自写下那天起没被任何构建检验过。

**探针结论（2026-09-01）** —— 已在本地用 1.74.1（2023-12-04）实测，三层全绿：

| 检查 | 结果 |
| --- | --- |
| `cargo +1.74 build --locked` | 通过，16.5s，含 ratatui 0.29 在内的依赖全部编译成功 |
| `cargo +1.74 test --no-run --locked` | 通过，测试代码也能编译 |
| `cargo +1.74 test --locked` | 216 个测试全过（210 + 4 + 2），零失败 |

**声明是准确的**，本条因此从「未验证的风险」降级成「补 CI 固化现状」。原文说「有相当概率直接
编译失败」不成立，「不要预设要迁就旧版本」那条提醒也就用不上了——这个项目依赖少、没用新语法，
跨版本兼容性比预估的好。

**顺带发现一条隐藏约束** —— `Cargo.lock` 目前是 `version = 3`。lock 文件 version 4 需要
Cargo 1.78+，所以哪天有人跑 `cargo update` 把 lock 升成 version 4，**1.74 会在读 lock 文件时就
失败**，跟代码本身能不能编译毫无关系；而报错信息指向的是 lock 文件不是代码，很难一眼看懂。
这条耦合是 1.74 job 最有价值的地方：它会在升级发生的那一刻当场抓到。

**触发条件** —— 用户在 Debian stable 之类偏旧的工具链上 `cargo build`。目前实测能过，所以真正的
风险不是「现在坏了」，而是「以后被无声改坏」——无论是代码用了新 API，还是 lock 格式被升级。

**怎么修** —— CI 加一个 `dtolnay/rust-toolchain@1.74` 的 job。既然实测 `cargo test` 能过，
这个 job 就直接跑 `cargo test --locked --all`，比只 `cargo build` 多守住 lock 格式那条线。
**仍然不要在这个 job 里跑 clippy**——旧版 clippy 的 lint 集不一样，只会制造噪音。

**验收标准** —— CI 上 1.74 的 job 为绿，且 `rust-version` 的值与 job 里的版本号一致。

**成本** —— 落地 15 分钟；无尾巴（已探明）。

**落地后的修正（2026-09-02）** —— 1.74 的 job 已经上了，而且已经真抓到两件事。**两次都不是上面
预测的那个载体**：

1. `unicode-segmentation` 在一个**补丁版本**里把 `rust-version` 提到 1.85（dependabot PR #19）。
   载体是补丁级依赖升级，既不是有人跑 `cargo update`，也不是 lock 格式。语义化版本对补丁版本的
   承诺里**不包含 MSRV**，所以这不是这个 crate 的偶然行为，而是整类依赖升级的通用形状。
2. dependabot 把 `dtolnay/rust-toolchain@1.74` 改成 `@1.100`（PR #22）。这个 action 的 tag **就是**
   Rust 版本号，于是版本更新机器人看见的只是「一个可以升的版本号」——它要动的，正好是这个 job
   用来做断言的那个值。

lock 升到 version 4 这条预测**至今没有发生**（lock 仍是 `version = 3`），保留在上面不删。它未必
错，只是排在后面：真实世界里先到的是别人替你改数字，不是你自己改格式。

**方法论产出：验收标准要写成可执行断言，不要写成人工承诺。** `ci.yml:75` 是样本。上面那条验收
「`rust-version` 的值与 job 里的版本号一致」读起来是一句人工承诺——谁来对、什么时候对、改了谁会
知道，全都没说。落地时把它写成了 job 里的一个 step：从 `cargo metadata` 取声明值、从 `rustc
--version` 取实际值，不一致就 `::error::` 加非零退出。三天后它挡住了 PR #22，也就是**挡住了一个
机器人去改它自己所断言的那个值**。同样的情形下，一句人工承诺只会静静地失效，而且失效当天没有
任何人会收到通知。这条原则对本清单里所有「验收标准」都适用，尤其是 §6 和 §7 那两条。

**落地后的修正（2026-09-03，第三战役依赖解冻）** —— MSRV 已由 1.74 抬到 **1.88**（用户裁定；
理由：MSRV 债与依赖债是同一笔复利，而 cdh 以预编译二进制发布为主，源码编译影响面小）。触发不是
计划好的：解冻的第一个 PR（#19，lock-only）就让 `unicode-segmentation 1.13.3`——一个**补丁
版本**——把 rustc 门槛抬到 1.85，上面那条「别人替你改数字」的预测提前应验，并把「要不要抬
MSRV」这个产品决策门推到了战役第一天。实证：`cargo +1.88 check` 与全量测试通过，ratatui 0.30.2
（门槛 1.88）与 toml 1.1（门槛 1.85）的要求一并越过。

CI 一致性守卫自动跟随：`ci.yml` 的 msrv job 参数化为 `toolchain: 1.88`，action 引用改为
`@master`——引用里不再带版本号，Dependabot 从此没有可误报为「过时 tag」的 pin（PR #22 那类
「把 @1.74 升成 @1.100」的误报无物可改），「Assert toolchain matches Cargo.toml
rust-version」断言原样守着新值。上面「1.74 有 CI 固化」的历史结论保留，但已被本段取代。
顺带闭环一笔：`Cargo.lock` 在战役期间升到了 `version = 4`——§5 预测的 lock 格式升级最终发生了，
只是发生在 MSRV 抬到 1.88 之后，它守护的那条线（1.74 读不了 v4 lock）已随 1.74 一起退场。

### 6. 发布流程两个硬失败点

**是什么** —— 两处都在 `.github/workflows/release.yml`：

1. `body_path: scripts/release_notes/${{ github.ref_name }}.md` 是硬依赖，文件不存在整个 release step 失败。
2. 全流程没有一处校验 `Cargo.toml` 的 `version` 与 tag 名一致。

**触发条件** —— 第一种：忘写 release notes 就打 tag，三个平台的二进制全部编译完（十几分钟）
才在最后一步炸掉。第二种更隐蔽：`v0.3.3` 的 tag 打在 `version = "0.3.2"` 的提交上会**一路成功**，
产出的 tarball 目录名是 v0.3.3、里面的二进制 `--version` 报 0.3.2，且没有任何告警。

**怎么修** —— 在 `test` job 最前面加一个前置检查 step：确认
`scripts/release_notes/${GITHUB_REF_NAME}.md` 存在，且 `cargo metadata` 取出的 version 等于
去掉 `v` 前缀的 tag 名，任一不符就 `exit 1`。几行 shell，在编译前就拦住。

**验收标准** —— 用 `workflow_dispatch` 空跑一次，确认检查逻辑本身不报假错。真正的验证要等下一次
打 tag，所以这条在下次发版前只能算「已写未验」。

**成本** —— 落地 20 分钟；尾巴小。

**落地后的修正（2026-09-02）** —— 两个检查都已落地成编译前的 step（按 §5 那条原则写成断言，不是
承诺）。「已写未验」在这里是**站得住**的用法：验证条件是「下一次打 tag」，那件事一定会发生，
只是还没发生。这跟 §7 的情况要分开——那边有一半的验收条件在当前条件下根本无法满足，
写成「已写未验」会掩盖一个不会自己消失的缺口。

## P2：补齐面向外部的部分

### 7. 缺 ARM Linux / musl 产物

**是什么** —— release 矩阵的 Linux 侧只有 `x86_64-unknown-linux-gnu`。

**触发条件** —— 树莓派、ARM 云主机（现在相当常见）的用户找不到预编译产物；
glibc 偏旧的发行版装上也跑不起来。

**怎么修** —— 矩阵各加一行 `aarch64-unknown-linux-gnu` 和 `x86_64-unknown-linux-musl`。
前者现在可以直接用 GitHub 的 ARM runner，比过去的 cross 编译省事很多；后者要装 musl 工具链，
本项目依赖很干净（没有 openssl 这类麻烦货），大概率顺利，顺便解决静态链接。

**验收标准** —— 两个新产物出现在 release assets 里，且各自在目标机器上实际跑通一次——
不能只看编译通过。

**成本** —— 落地 2-4 小时；尾巴中。

**落地后的修正（2026-09-02）：一半达成，一半明确未达成。** 这两半要分开记，写成「已写未验」会把
第二半藏起来。

达成的一半——矩阵已加上 `aarch64-unknown-linux-gnu`（用 GitHub 的原生 ARM runner
`ubuntu-24.04-arm`，不用交叉编译）和 `x86_64-unknown-linux-musl`（实测**不需要** musl-tools，
rustc 用宿主 cc 加 rustup 自带的 self-contained musl 目标）。而且这一半是被断言守住的，不是被
声明守住的：`ci.yml` 有一个 `cross` job 在**每个 PR** 上真编译这两个 target（否则问题要等到发版
当天才暴露，那是最糟的时刻），`release.yml` 有一个 `Verify artifact shape` step 用 `file -b` 断言
架构与链接方式——跨编译最常见的错不是编不过，而是编过了但产物不对（架构串了、musl 那份其实
动态链接了）。那个 step 的 `*)` 兜底分支是要点：将来给矩阵加 target 却忘了登记预期形状，它会
**失败**而不是安静放过。

明确未达成的一半——上面写的验收标准是「两个新产物出现在 release assets 里，且各自在目标机器上
实际跑通一次」，两句都还没发生：v0.3.2 之后没有打过 tag，最新 release 的 assets 仍只有 3 个目标；
「在目标机器上跑通」缺的是硬件，本地没有 ARM 机器也没有 musl-only 环境。第一句会随下次发版
自动满足，第二句不会——**它需要一台机器，不需要一次提交**，所以它是一个不会自己消失的缺口。
`file` 断言能证明产物形状对，不能证明它跑起来。这里刻意不把它记成「已验证」。

### 8. 无依赖审计与 dependabot

**是什么** —— 没有 `deny.toml`，CI 没有 `cargo-audit` 或 `cargo-deny` 步骤，`.github/` 下只有
`workflows/`，没有 `dependabot.yml`。对一个通过 `curl | bash` 装进别人 shell 的工具，
这块空白比普通 CLI 更需要补。

**怎么修** —— 加 `cargo-deny` 的 CI job，加 `.github/dependabot.yml`。

**验收标准** —— CI 上 `cargo-deny check` 为绿，且 `deny.toml` 里每条豁免都有注释说明理由。

**成本** —— 落地 1-2 小时；尾巴中。`cargo-deny` 首次运行几乎必然报一批 license 和 advisory 条目，
逐条判断并写 allowlist 占了大半时间。dependabot 配置本身十分钟，但它此后会持续开 PR，
属于长期维护面而不是一次性成本。

**落地后的修正（2026-09-02）** —— `deny.toml` + cargo-deny 的 CI job + `.github/dependabot.yml`
都已落地，但上面那条验收标准（「CI 上 `cargo-deny check` 为绿，且每条豁免都有注释说明理由」）
**本身是不充分的**，这是本条最值得记的一件事：

- cargo-deny 0.19.0 把 `advisories.unsound` / `unmaintained` 从布尔改成了作用域枚举
  （`all|workspace|transitive|none`），默认 `workspace`。本仓的 workspace 就是它自己一个 crate，
  于是 86 个传递依赖上的整个 unsound / unmaintained 类别**默认不上报**。
- 后果是 `cargo-deny check` **绿着，同时什么都没在查**——两条验收都满足：绿了，豁免也都写了注释。
  露出的马脚只有两条 `advisory-not-detected` 警告：那两条 `lru` 的豁免本身已经失效（它们要豁免的
  通告已经不再被报出来了）。**豁免变成惰性，是「检查已经不检查了」的一个可读信号。**
- 修法是显式写 `unsound = "all"` / `unmaintained = "all"`，并用变异检查证明它承重：改回
  `unsound = "workspace"` 且删掉两条 `lru` 豁免后，`cargo deny check` 依然 exit 0、全绿。
- 工具版本因此是配置的一部分：CI 用的 `cargo-deny-action@v2.1.1` 钉住的是 cargo-deny 0.20.2，
  这个版本号记在 `deny.toml` 的头注释里，两处要一起改。同一份配置在 0.19 之前会因为
  `unsound` 这个键而硬失败（`error[deprecated]`），不是静默降级——那反倒是好事。

**dependabot 落地后立刻开了 4 个 PR，其中 3 个红，而且红得都有真实理由**，这说明这套检查在替人
做判断题，不是噪音：#19（`unicode-segmentation` 补丁版本抬 MSRV，见 §5）、#20（`toml` 1.x 要
1.76/1.85，与 §9 记下的约束正面冲突）、#21（ratatui 0.30.2 在 MSRV 与 cargo-deny 上双红）。
#21 的形状值得单独记：`bans.multiple-versions = "deny"` 报 `error[duplicate]`——升 ratatui 会把
**第二个 crossterm** 拖进依赖图（0.28 与 0.29 并存），另有 hashbrown ×3、linux-raw-sys ×2、
rustix ×2、syn ×2。「升一个直接依赖会让依赖图长出重复版本」平时是看不见的，这条规则把它变成了
一个必须有人拍板的红叉；人的裁决是四个 PR 全部保持 open，代价是被有意接受的。

**仓库设置里还有一格没点**（2026-09-02 实测）：Dependabot alerts 已开（`vulnerability-alerts`
返回 204，当前 0 条 alert），私有漏洞上报也已开，但 **Dependabot security updates 仍是
`disabled`**。它和本目录下的 `dependabot.yml` 是两件事——配置文件管的是定期版本升级 PR，
security updates 管的是有通告时的即时修复 PR，后者只能在仓库设置里开，代码里做不到。
记在这里，以免以后有人以为配置漏写了。

### 9. `toml` 精确锁定原因失传

**是什么** —— `Cargo.toml:19` 是 `toml = "=0.8.23"`，精确锁定，且没有注释说明为什么。
同一个文件里其他关键行都有注释（`rust-version` 甚至写明「受 ratatui 0.29 约束」），
这一行的沉默很可能意味着当初是临时规避某个问题。

**触发条件** —— 半年后没人记得原因，它会一直挡着依赖升级，也没人敢动。

**怎么修** —— 两条路：翻 git log 找到当初钉住它的提交，补一行注释写明约束来源；
或者试着放宽成 `^0.8`，`just check` 能过就说明约束已经失效，直接放宽。

**验收标准** —— 这一行要么带上解释性注释，要么已经放宽。

**成本** —— 5 分钟；无尾巴。

**落地后的修正（2026-09-02）** —— 上面给的两条路（补注释 / 放宽）实际**两条都走了**：
`=0.8.23` 放宽成 `0.8`，同时补上注释写明真正的约束不是 `toml` 自己有 bug，而是 MSRV——
`toml >= 0.9.7` 声明 rust-version 1.76、`toml` 1.x 要 1.76/1.85，都越过本仓的 1.74。
`Cargo.lock` 一个字没动（仍解析到 0.8.23），所以这次改动对构建结果是零影响，只是把「为什么不能
往上走」从口头变成了文件里的一行。

约束写下来三天后就被用上了：dependabot PR #20 提议升到 `toml` 1.1.4，而那条注释直接给出了拒绝
理由，不需要有人重新调查一遍。**「原因失传」的成本正是每次都要重新调查一遍**，这一条的收益不在
放宽本身，在注释。

### 10. 缺失的门面文件

- **10a `CHANGELOG.md`**（30 分钟）—— 13 个版本的 notes 散在 `scripts/release_notes/`，
  没有聚合入口。几乎免费：倒序拼一次，此后每次发布追加一段。
- **10b `SECURITY.md` + issue / PR 模板**（30 分钟）—— 一个改写用户 rc 文件、
  且卸载会删历史的工具，没有漏洞上报渠道。issue 模板要收 shell 类型、终端、`cdh --version`、OS——
  现在这些信息全靠来回问。
- **10c 英文 README**（半天）—— crate 元数据（`description`、`keywords`）和 TUI 都是双语的，
  README 只有中文。现有 README 近 14 KB，含大量表格和环境变量说明，是实打实的翻译量，
  而且此后每次改文档要维护两份。**建议只译一个精简版**，细节指向中文原文。

**落地后的修正（2026-09-02）** —— 三项都已落地：`CHANGELOG.md`（13 个版本倒序聚合）、
`SECURITY.md` + issue 模板（`bug_report.yml` 收 10 个字段，比原计划的 4 个更宽，`config.yml` 把
提问引导到 discussions）+ PR 模板、`README.en.md`。

一处与计划不符，记下来而不是抹掉：**10c 没有按「只译精简版」做**，`README.en.md` 是 11.2 KB
对中文的 14.1 KB，接近全译。于是原文预警的那条成本（「此后每次改文档要维护两份」）现在是按
**全量**计价的，不是折扣价。这不是错误——双语等价对一个双语 TUI 的工具是说得过去的选择——
但它意味着「改 README」这件事的实际成本比清单里估的高，而且漂移的形式是**沉默的**：
中英不同步没有任何检查会报出来。要么接受人工同步，要么将来给它加个检查——**已解决（2026-09-22）**：新增 CI job `readme-sync`（仅 PR 触发）+ `scripts/tools/readme-sync-check.sh`，用 base...head 三点 diff 做 touch-parity 校验，只改了一个 README 而漏了另一个即报红；确有单语言正当改动时，PR 描述写一行 `readme-sync: skip` 放行。刻意只做 touch-parity 不做语义等价——译文意思有没有跟上无法自动判定，硬做只会得到不断误报、最终被无视的检查；沉默漂移里最常见的「改一个忘一个」已被稳稳抓住。原预警的「维护两份」成本仍在，但漂移不再沉默。

## 待决问题

**crates.io 要不要发？** `Cargo.toml` 的元数据完全齐备（`description`、`readme`、`repository`、
`homepage`、`license`、`keywords`、`categories`），一副准备发布的样子，但流程里没有任何
`cargo publish`，安装路径只有 install.sh 和 release tarball。如果这是有意的
（`cargo install` 装不了 shell 集成，确实说得通），那没问题，但值得在 README 里写一句说明；
如果本来打算发，这套元数据现在是白填的。

**状态（2026-09-02）：仍然未决，等仓库主人裁决。** 这里刻意不给答案——它不是技术判断题，
取决于是否愿意承担 crates.io 的版本发布节奏和「装了但没有 shell 集成」的用户支持面。
上面两条路各自的第一步都很短（写一句 README 说明 / 加一个 publish 步骤），所以拖着的成本很低，
猜错的成本不低。

**结论（2026-09-13，用户裁定）：不发。** 走的是上面第一条路——`cargo install` 装不了
shell 集成，README 安装一节已补说明。上面的分析文字按本文件惯例原样保留，作为当初的
判断过程。

## 未纳入清单的观察

- `feat/install-ui-progress`（`de43348`）和 `tui-cube-polish`（`58f4253`）是合并后遗留的分支指针：
  两个提交都已是 main 的祖先，`git log main..<branch>` 为空，diff 也为空，没有任何未合并的工作。
  **因此 4.2 没有前置阻塞**——本清单初版误判为与 `tui-cube-polish` 冲突，2026-09-01 已更正。
  陈旧指针本身是有害的（它正是那次误判的来源），建议连远端一并删除。
- 远端有个 `backup-main-20251109` 标签指向 `3d4f7f7`，是历史上的一次 main 备份，与当前发布线无关。
- 版本序列从 `v0.1.1` 开始，远端没有 `v0.1.0`。
- `v0.1.1`–`v0.2.8` 是轻量标签，`v0.3.0` 起改为附注标签，看起来是有意的切换。
- 本地已装 1.74.1 工具链（2026-09-01 探 #5 时装的），写 CI job 时可以复验；不需要时
  `rustup toolchain uninstall 1.74` 可移除。

## Deferred ledger 处置（2026-09-02，GATE-13）

DAG 收官时在案的 11 项挂起事项，在 GATE-13 逐项裁定。**A 类是「等用户点头后建 GitHub issue」
的候选——本节先不建，避免替用户决定 issue 的取舍。** B/C 类是明确继续挂起，不是待办。
2026-09-02 更新：A4、A6 已随第二战役 PR #27 销账，在案剩 9 项。
2026-09-03 更新：B10 已随第三战役销账（PR #28/#19/#20/#29），在案剩 8 项；同批补录三条 P2
缺口（12–14，A 类候选，源自 PR #29 对抗验证）与一条 C 类备注（15）。
2026-09-11 更新：A2、A3、A5 已随 PR #38 销账（issue #30/#32/#33 随合并自动关闭），
A13、A14 已随 PR #39 销账（issue #35/#36 同）。A 类在案仅剩 1（issue #31，待定 notice
过期行为）与 12（issue #34，PTY 输入路径覆盖）。
2026-09-12 更新：A1 已随 PR #40 销账（issue #31 随合并自动关闭）。A 类在案仅剩
12（issue #34，PTY 输入路径覆盖）。
2026-09-13 更新：A12 已随 PR #41 销账（issue #34 随合并自动关闭）。A 类挂账至此
清零。同日 B8、B9 经用户裁定销账（不发 crates.io / 开启 automated-security-fixes，
见 B 类各条与「待决问题」结论），B 类在案仅剩 7（双语 help，已另行立项），
C 类仍按原裁定挂起。
2026-09-13 再更新：B7 已随 PR #43 销账（-h 帮助接入 i18n 层，双语化，见 B 类该条），
B 类挂账至此清零；C 类仍按原裁定挂起。

### A 类：转 issue 候选（技术债有明确动作）

1. **`app.notice` 无过期机制** —— 已解决（PR #40，2026-09-12）：`ddbdd26` 抽出
   `dispatch_event` 缝：交互输入先经 notice 过期判定清除、再分发事件，run_ui 与
   测试同路径；配 dispatch 级测试，M4/M5 各变异均被杀。原委：notice 没有过期/
   清除路径，可能长期挂在页脚，PR-8 期间发现，按「零夹带」护栏未顺手修；现账
   已收。（issue #31，已关闭）
2. **34 处临时目录泄漏缺 `Drop` guard** —— 已解决（PR #38，2026-09-11）：`1ef2159` 新增
   `src/test_support.rs` 统一 Drop 守卫 `TempDir`（栈展开即清理，`Deref`/`AsRef<Path>` 顶替裸
   `PathBuf`），迁移全部裸 `remove_dir_all` 站点，配 panic 中途也会清目录的回归测试。
   原委：清理挂在成功路径上、断言一失败就泄漏；现账已收。（issue #30，已关闭）
3. **`App::page` 末页 clamp 与调用处重复** —— 已解决（PR #38，2026-09-11）：`c80bd2f` 让
   `page()` 委托 `PageWindow::new`，`end` 钳制只此一处，`move_page` 改读 `total_pages`。
   原委：同一不变量写两遍易漂移；现账已收。（issue #32，已关闭）
4. **`render_confirm_delete` 未搬进 `picker_overlays.rs`** —— 已解决（PR #27，2026-09-02）：
   `47c5bde` 补完 4.3 验收，浮层各自成模块。原委：4.3 验收「四个浮层各自成模块」当时实际
   只搬了三个，roadmap 留痕由本条归口收账，现账已收。
5. **`run_ui` 几何存回第一跳是变异幸存者** —— 已解决（PR #38，2026-09-11）：`f32a8eb` 把
   几何存回收敛到 `record_list_geometry` 一条 seam，run_ui 与测试同路径，配状态级测试
   （存帧、None 保留末帧）关上变异缺口。原委：`app.last_list_start = geometry.start`
   （基线 `5c9a2cd`）在 PR-10 对抗验证（P2-1）中变异全绿；现账已收。（issue #33，已关闭）
6. **`read_git_info` 测试 spawn 的 `git init` 应硬化子进程环境** —— 已解决（PR #27，2026-09-02）：
   `e3f48ce` 的 `git_command` helper 剥 9 个 `GIT_*` 变量，配两个承重测试 + bare 诱饵仓库回归
   测试。原委：2026-09-02 两次 `.git/config` 损坏的根因即此注入链，钩子侧 unset 已先行修过，
   测试侧的裸 footgun 由此关上。

以下三条为 2026-09-03 第三战役补录（源自 PR #29 对抗验证），A 类候选：

12. **crossterm 0.29 真实终端事件路径零自动化覆盖** —— 已解决
    （PR #41，2026-09-13）：`213485f` 新增 tests/pty_input.rs，spawn 真二进制、
    stdin/stderr 接 portable-pty 的 PTY slave、stdout 重定向捕获选中路径（与 shell
    集成 `$(cdh)` 的管道拓扑一致），5 个用例（回车选 top hit、Down 选次行、Esc
    无选择退出、过滤词、SGR 鼠标点击列表行）把「PTY 字节 → crossterm 解码 → 键位
    映射 → 退出语义」整条路径钉死；对抗验证 8 项变异全杀（含换恶意字节序列证明
    断言与字节语义真绑定）。原委：升级未配 PTY / 事件注入测试，changelog 核查
    排除的是已知风险、不产生对未知风险的覆盖；现账已收。（issue #34，已关闭）
13. **ratatui 0.30 布局求解器换血（cassowary → kasuari）无异常尺寸专项** —— 已解决
    （PR #39，2026-09-11）：`d714a0d` 补奇数尺寸与阈值边界矩阵（107↔108、69↔70、17↔18），
    断言由布局契约独立推导的结构不变式（chrome 行精确落位、面板两两不重叠、gutter 恰隔
    一列），不 pin 求解器输出——off-by-one 可抓、求解器再换可活。（issue #35，已关闭）
14. **渲染等价的验证层是 TestBackend buffer，终端字节流层无覆盖** —— 已解决
    （PR #39，2026-09-11）：`91a9309` 用 `CrosstermBackend<Vec<u8>>` + `Viewport::Fixed`
    捕获真实 wire 字节，pin 完整 golden（30×8 全固定 fixture，CI 双平台逐字节一致），承重
    不变式（颜色批处理、teardown 序列）先行断言——依赖 bump 令其变红正是设计目的。
    （issue #36，已关闭）

### B 类：挂起——产品/发布决策，属用户，不是代码任务（至 2026-09-13 已全部收账）

7. **`cdh -h` 输出中文-only** —— 已解决（2026-09-13，用户裁定改双语，PR #43）：
   `c5a0b00` 把 -h 帮助接入 i18n 层，语言解析与 TUI 完全同链（CDH_LANG →
   tui.toml 偏好 → locale 检测），中英两版信息量逐行等价（中文版与旧文本逐字节
   一致），三层测试（渲染 21 项逐项对等断言 / 偏好链四档 / 五场景隔离子进程
   端到端），两轮对抗验证后四路变异全红。原委：帮助文本硬编码中文，与 TUI 既有
   i18n 层脱节；现账已收。附注：`cdh log` 子命令帮助与 CLI 错误提示仍是中文
   硬编码，属将来「CLI 全界面双语化」的候选，暂无动作。
8. **crates.io publish** —— 已解决（2026-09-13，用户裁定：不发）。理由即「待决问题」
   一节里的第一条路：`cargo install` 装得上二进制、装不了 shell 集成（日志 hook 与
   包装函数），而那是 cdh 的核心用法，与其发一个功能残缺的版本不如不发；README
   安装一节已写明说明，结论见「待决问题」末尾。原委：发布流程决策属用户，挂起至
   2026-09-13 裁定，现账已收。
9. **GitHub `automated-security-fixes` 开关** —— 已解决（2026-09-13，用户裁定开启；
   开关已于同日实际开启并经 API 核验——vulnerability alerts 返回 204、
   automated security fixes enabled 为 true，代码侧无事可做）。原委一条更正：当初挂起的理由「打开
   只会复刻已挂着的 PR #21」已过期——#21 已被 PR #29 取代关闭，ratatui 0.30.2 及
   其依赖图已在 main，开关不再有那层顾虑。现账已收。
10. **Dependabot PR #19–#22 + 相关 alert 的解冻节奏** —— 已解决（2026-09-03，第三战役
    PR #28/#19/#20/#29）。原委：冻结条件（PR-7、PR-8 完成）已满足后等用户裁定；建议序
    （github-actions 组 → toml → cargo-minor-patch → ratatui 0.30.2 跨 minor 单独审）被
    实际执行大体印证，MSRV 1.74→1.88 由用户在战役首日裁定（见 §5 的 2026-09-03 修正）。
    附注：#21/#22 两个 Dependabot PR 被手制 PR 取代关闭——#21 的 ratatui 升级由 #29 完整
    承载（含 deny 配套、crossterm 0.29 与布局求解器迁移，超出机器人能力范围）；#22 的
    rust-toolchain「升级」是误报（该 action 的 tag 就是 Rust 版本号，不是 action 自身版本），
    由 #28 把 msrv job 参数化为 `toolchain: 1.88` + action 引用改 `@master` 根治——不再有
    带 Rust 版本号的 pin 可供误报。

### C 类：挂起——低优先/不可行

11. **`last_click` 双击逻辑用真实 `Instant`、不可确定性测试** —— 有据挂起：要测需时间注入
    重构，收益低，不做。
15. **cross-build job 的 apt 装交叉链接器步骤耗时方差 37s~10min** ——（2026-09-03 补录，
    C 类备注）GitHub runner 网络抖动所致，值得知道、不值得修：job 没有失败模式与它绑定，
    只是时长不可预测，重试/缓存类修法的成本大于收益。
